import { describe, it, expect, afterEach } from 'bun:test'; import { spawnSync } from 'child_process'; import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'fs'; import { tmpdir } from 'os'; import { join, resolve } from 'path'; import { parseFlags, resolveSkillsDir, DEFERRED, } from '../src/commands/check-resolvable.ts'; // Path to the CLI entry point. Runs through bun directly so tests don't // require a pre-built binary. Always invoked from the repo root so bun can // resolve transitive node_modules (the top-level cli.ts imports pull in // @anthropic-ai/sdk which walks from the file path, but some internal // shim resolution requires node_modules to be reachable from cwd too). const CLI = resolve(import.meta.dir, '..', 'src', 'cli.ts'); const REPO_ROOT = resolve(import.meta.dir, '..'); // --------------------------------------------------------------------------- // Fixture builders // --------------------------------------------------------------------------- interface SkillSpec { name: string; triggers?: string[]; /** Register in manifest.json — defaults true. */ inManifest?: boolean; /** Add a RESOLVER.md row pointing at this skill — defaults true. */ inResolver?: boolean; } function makeFixture(skills: SkillSpec[], created: string[]): string { const root = mkdtempSync(join(tmpdir(), 'check-resolvable-cli-')); created.push(root); const skillsDir = join(root, 'skills'); mkdirSync(skillsDir, { recursive: true }); const manifest = { skills: skills .filter(s => s.inManifest !== false) .map(s => ({ name: s.name, path: `${s.name}/SKILL.md` })), }; writeFileSync(join(skillsDir, 'manifest.json'), JSON.stringify(manifest, null, 2)); for (const s of skills) { const skillDir = join(skillsDir, s.name); mkdirSync(skillDir, { recursive: true }); const fm = ['---', `name: ${s.name}`]; if (s.triggers && s.triggers.length) { fm.push('triggers:'); for (const t of s.triggers) fm.push(` - "${t}"`); } fm.push('---'); fm.push(`# ${s.name}\n\nA test skill.\n`); writeFileSync(join(skillDir, 'SKILL.md'), fm.join('\n')); } const rows = skills .filter(s => s.inResolver !== false) .map(s => `| "${s.name} trigger" | \`skills/${s.name}/SKILL.md\` |`); const resolver = [ '# RESOLVER', '', '## Brain operations', '| Trigger | Skill |', '|---------|-------|', ...rows, '', ].join('\n'); writeFileSync(join(skillsDir, 'RESOLVER.md'), resolver); return skillsDir; } interface RunResult { status: number; stdout: string; stderr: string; json: any; } function run(args: string[]): RunResult { const res = spawnSync('bun', [CLI, 'check-resolvable', ...args], { encoding: 'utf-8', cwd: REPO_ROOT, maxBuffer: 10 * 1024 * 1024, }); let json: any = null; if (args.includes('--json')) { try { json = JSON.parse(res.stdout); } catch { /* leave null */ } } return { status: res.status ?? -1, stdout: res.stdout, stderr: res.stderr, json, }; } // --------------------------------------------------------------------------- // Unit tests: direct helpers (fast, no subprocess) // --------------------------------------------------------------------------- describe('check-resolvable — unit: parseFlags', () => { it('parses all known flags', () => { const f = parseFlags(['--json', '--fix', '--dry-run', '--verbose', '--skills-dir', '/x']); expect(f.json).toBe(true); expect(f.fix).toBe(true); expect(f.dryRun).toBe(true); expect(f.verbose).toBe(true); expect(f.skillsDir).toBe('/x'); }); it('supports --skills-dir=PATH syntax', () => { const f = parseFlags(['--skills-dir=/x/y']); expect(f.skillsDir).toBe('/x/y'); }); it('silently ignores unknown flags (permissive, matches lint/orphans convention)', () => { const f = parseFlags(['--json', '--bogus', '--another-unknown']); expect(f.json).toBe(true); expect(f.help).toBe(false); }); }); describe('check-resolvable — unit: resolveSkillsDir', () => { it('resolves absolute --skills-dir unchanged', () => { const r = resolveSkillsDir({ help: false, json: false, fix: false, dryRun: false, verbose: false, strict: false, skillsDir: '/tmp/absolute-path' }); expect(r.dir).toBe('/tmp/absolute-path'); expect(r.error).toBeNull(); }); it('resolves relative --skills-dir against cwd', () => { const r = resolveSkillsDir({ help: false, json: false, fix: false, dryRun: false, verbose: false, strict: false, skillsDir: 'skills' }); expect(r.dir).toMatch(/\/skills$/); expect(r.error).toBeNull(); expect(r.source).toBe('explicit'); }); it('v0.31.7: empty cwd falls back to install-path (finds bundled skills/)', () => { // Temporarily chdir to a guaranteed-empty tmpdir. findRepoRoot from cwd // walks up and fails — but autoDetectSkillsDirReadOnly's tier-5 // install-path fallback then walks up from the gbrain module's own // location and finds the bundled skills/ dir. This is the v0.31.7 // capability: doctor and check-resolvable work from anywhere. // // To test the underlying no_skills_dir error path, see the unit tests // in test/repo-root.test.ts that drive autoDetectSkillsDirReadOnly // with mocked env to suppress the install-path success. const empty = mkdtempSync(join(tmpdir(), 'empty-for-resolve-')); const original = process.cwd(); try { process.chdir(empty); const r = resolveSkillsDir({ help: false, json: false, fix: false, dryRun: false, verbose: false, strict: false, skillsDir: null }); // Install-path fallback succeeds when test runs inside the gbrain repo. expect(r.error).toBeNull(); expect(r.dir).toMatch(/\/skills$/); expect(r.source).toBe('install_path'); } finally { process.chdir(original); rmSync(empty, { recursive: true, force: true }); } }); it('finds skills via cwd_walk_up when cwd is inside a repo (no --skills-dir)', () => { // Running from this test file — we're inside the real gbrain repo. // v0.33 added the cwd_walk_up tier ahead of repo_root, so the same // skills/ dir is matched via the broader (no gbrain-shape gate) // path. Behavior unchanged — source label updated. The repo_root // tier is now functionally subsumed; kept in the type union for // back-compat. See src/core/repo-root.ts. const r = resolveSkillsDir({ help: false, json: false, fix: false, dryRun: false, verbose: false, strict: false, skillsDir: null }); expect(r.error).toBeNull(); expect(r.dir).toMatch(/\/skills$/); expect(r.source).toBe('cwd_walk_up'); }); it('REGRESSION-GATE: --skills-dir override takes precedence over OpenClaw env auto-detection', () => { const explicit = mkdtempSync(join(tmpdir(), 'explicit-skills-')); mkdirSync(explicit, { recursive: true }); writeFileSync(join(explicit, 'RESOLVER.md'), '# RESOLVER\n'); const workspace = mkdtempSync(join(tmpdir(), 'openclaw-ws-')); mkdirSync(join(workspace, 'skills'), { recursive: true }); writeFileSync(join(workspace, 'skills', 'RESOLVER.md'), '# RESOLVER\n'); const prev = process.env.OPENCLAW_WORKSPACE; process.env.OPENCLAW_WORKSPACE = workspace; try { const r = resolveSkillsDir({ help: false, json: false, fix: false, dryRun: false, verbose: false, strict: false, skillsDir: explicit, }); expect(r.error).toBeNull(); expect(r.dir).toBe(explicit); expect(r.source).toBe('explicit'); } finally { if (prev === undefined) delete process.env.OPENCLAW_WORKSPACE; else process.env.OPENCLAW_WORKSPACE = prev; rmSync(explicit, { recursive: true, force: true }); rmSync(workspace, { recursive: true, force: true }); } }); }); describe('check-resolvable — unit: DEFERRED', () => { it('v0.17 ships Checks 5 and 6 — DEFERRED is empty', () => { // Pre-v0.17: both Check 5 (routing eval) and Check 6 (brain filing) // were deferred. v0.17 W2 shipped Check 5; v0.17 W3 shipped Check 6. // The DEFERRED export stays (stable --json field) for future checks. expect(DEFERRED.length).toBe(0); }); }); // --------------------------------------------------------------------------- // Integration tests: subprocess via bun src/cli.ts (cwd = repo root) // --------------------------------------------------------------------------- describe('gbrain check-resolvable CLI — integration', () => { const created: string[] = []; afterEach(() => { while (created.length) { const p = created.pop()!; try { rmSync(p, { recursive: true, force: true }); } catch { /* ignore */ } } }); it('prints usage and exits 0 on --help', () => { const r = run(['--help']); expect(r.status).toBe(0); expect(r.stdout).toContain('gbrain check-resolvable'); expect(r.stdout).toContain('--json'); expect(r.stdout).toContain('--fix'); expect(r.stdout).toContain('--strict'); // Check 5 shipped in v0.17 (mentioned in the body); Check 6 is // still deferred and must appear under "Deferred to separate issues". expect(r.stdout).toContain('Check 5'); expect(r.stdout).toContain('Check 6'); }); it('--json success envelope has all seven stable keys', () => { const skillsDir = makeFixture([{ name: 'alpha', triggers: ['alpha'] }], created); const r = run(['--json', '--skills-dir', skillsDir]); expect(r.json).not.toBeNull(); const keys = Object.keys(r.json).sort(); expect(keys).toEqual(['autoFix', 'deferred', 'error', 'message', 'ok', 'report', 'skillsDir']); expect(r.json.ok).toBe(true); // v0.17 ships Checks 5 and 6; DEFERRED is empty. The key remains // stable for future checks. expect(Array.isArray(r.json.deferred)).toBe(true); expect(r.json.deferred.length).toBe(0); }); it('--json success: autoFix is null when --fix was not passed', () => { const skillsDir = makeFixture([{ name: 'alpha', triggers: ['alpha'] }], created); const r = run(['--json', '--skills-dir', skillsDir]); expect(r.json.autoFix).toBeNull(); }); it('exits 0 on clean fixture with zero issues', () => { const skillsDir = makeFixture([{ name: 'alpha', triggers: ['alpha'] }], created); const r = run(['--skills-dir', skillsDir]); expect(r.status).toBe(0); expect(r.stdout).toContain('resolver_health: OK'); }); it('D-CX-3: warnings-only fixture exits 0 in default mode', () => { // "alpha" is in resolver but not manifest → orphan_trigger (warning) // Per D-CX-3 (codex review outside voice): warnings alone do not flip // the exit code. This is the new contract; callers who want strict // behavior pass --strict. Prior contract (exit 1 on any warning) broke // CI for workspaces emitting warning-level advisories like filing-audit. const skillsDir = makeFixture( [{ name: 'alpha', triggers: ['alpha'], inManifest: false }], created, ); const r = run(['--json', '--skills-dir', skillsDir]); expect(r.json).not.toBeNull(); expect(r.json.report.warnings.length).toBeGreaterThan(0); expect(r.json.report.errors.length).toBe(0); // warnings.length > 0 but errors.length === 0 → exit 0 (advisory) expect(r.status).toBe(0); expect(r.json.ok).toBe(true); }); it('D-CX-3: --strict promotes warnings to exit 1', () => { const skillsDir = makeFixture( [{ name: 'alpha', triggers: ['alpha'], inManifest: false }], created, ); const r = run(['--json', '--strict', '--skills-dir', skillsDir]); expect(r.json).not.toBeNull(); expect(r.json.report.warnings.length).toBeGreaterThan(0); expect(r.json.report.errors.length).toBe(0); // --strict flips ok to false and exit to 1 when warnings exist expect(r.json.ok).toBe(false); expect(r.status).toBe(1); }); it('D-CX-3: report has separate errors[] and warnings[] arrays alongside issues[]', () => { const skillsDir = makeFixture( [{ name: 'alpha', triggers: ['alpha'], inManifest: false }], created, ); const r = run(['--json', '--skills-dir', skillsDir]); expect(r.json).not.toBeNull(); const rep = r.json.report; expect(Array.isArray(rep.errors)).toBe(true); expect(Array.isArray(rep.warnings)).toBe(true); // Deprecated flat `issues` still present for one-release backcompat expect(Array.isArray(rep.issues)).toBe(true); expect(rep.issues.length).toBe(rep.errors.length + rep.warnings.length); }); it('exits 1 when fixture has an error-level unreachable skill', () => { // v0.41.11 contract change: a skill is unreachable only when BOTH // surfaces are empty — no frontmatter `triggers:` AND no RESOLVER.md // row. The prior fixture (`triggers: ['alpha']`, `inResolver: false`) // is now reachable via frontmatter auto-registration, so we drop // triggers and the resolver row to genuinely simulate unreachable. const skillsDir = makeFixture( [{ name: 'alpha', inResolver: false }], created, ); const r = run(['--json', '--skills-dir', skillsDir]); expect(r.json).not.toBeNull(); const errors = r.json.report.issues.filter((i: any) => i.severity === 'error'); expect(errors.length).toBeGreaterThan(0); expect(r.status).toBe(1); }); it('--fix --dry-run includes an autoFix object in the JSON envelope', () => { const skillsDir = makeFixture([{ name: 'alpha', triggers: ['alpha'] }], created); const r = run(['--json', '--fix', '--dry-run', '--skills-dir', skillsDir]); expect(r.json).not.toBeNull(); expect(r.json.autoFix).not.toBeNull(); expect(Array.isArray(r.json.autoFix.fixed)).toBe(true); expect(Array.isArray(r.json.autoFix.skipped)).toBe(true); }); it('--verbose prints the deferred checks note in human mode', () => { const skillsDir = makeFixture([{ name: 'alpha', triggers: ['alpha'] }], created); const r = run(['--verbose', '--skills-dir', skillsDir]); // v0.17 ships Checks 5 and 6 → DEFERRED is empty. Verbose still // prints the "Deferred:" header for stable UX; downstream content // is empty until a future release adds a new deferred check. expect(r.stdout).toContain('Deferred:'); expect(r.stdout).not.toContain('trigger_routing_eval'); expect(r.stdout).not.toContain('brain_filing'); }); it('clean fixture human output says all skills reachable', () => { const skillsDir = makeFixture( [ { name: 'alpha', triggers: ['alpha'] }, { name: 'beta', triggers: ['beta'] }, ], created, ); const r = run(['--skills-dir', skillsDir]); expect(r.stdout).toContain('resolver_health: OK'); expect(r.stdout).toContain('2 skills'); expect(r.status).toBe(0); }); it('logs the auto-detected skills directory path in human mode', () => { const r = run([]); expect(r.status === 0 || r.status === 1).toBe(true); expect(r.stdout).toContain('Auto-detected skills directory'); expect(r.stdout).toContain('/skills'); }); // v0.31.7 D6 regression guard: --fix must refuse install-path fallback. // Without this gate, `cd ~ && gbrain check-resolvable --fix` would silently // mutate SKILL.md files in the bundled gbrain repo via autoFixDryViolations. // Codex caught this leak in the v0.31.7 ship review. it('v0.31.7 D6: --fix refuses when source is install_path', () => { // Run from a guaranteed-empty tempdir so the install-path fallback fires. const empty = mkdtempSync(join(tmpdir(), 'cr-fix-installpath-')); try { // Pass --fix; expect refusal exit + clear error message. const r = spawnSync('bun', ['run', CLI, 'check-resolvable', '--fix'], { cwd: empty, env: { ...process.env, OPENCLAW_WORKSPACE: '', GBRAIN_SKILLS_DIR: '' }, encoding: 'utf-8', }); expect(r.status).toBe(1); expect(r.stderr).toContain('install-path fallback'); expect(r.stderr).toContain('refused'); expect(r.stderr).toMatch(/GBRAIN_SKILLS_DIR|OPENCLAW_WORKSPACE|--skills-dir/); } finally { rmSync(empty, { recursive: true, force: true }); } }); });