mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 21:19:18 +00:00
* feat(core): loadSkillTriggerIndex shared primitive (closes #1451 drift class) Single loader that unions per-skill SKILL.md frontmatter triggers: with curated RESOLVER.md / AGENTS.md rows. UNION semantics — explicit RESOLVER.md rows ADD to frontmatter triggers for the same skill (don't replace). Dedup keyed on (skillPath, normalized trigger string) so case or whitespace drift between the two surfaces collapses to one entry. This is the structural foundation for #1451: pre-fix, gbrain skills declared triggers in two places (per-skill frontmatter and a curated RESOLVER.md table) that could silently drift. Three consumers (checkResolvable, routing-eval CLI, mounts-cache.composeResolvers) each built their own resolver index from RESOLVER.md only, so fixing frontmatter would have closed doctor's warning without closing the other two surfaces. This primitive becomes the single join point for all three; consumers are wired in the next commit. Tests: 18 hermetic cases pinning frontmatter auto-registration, RESOLVER.md/AGENTS.md merge, case-insensitive dedupe, OpenClaw workspace-root layout (../AGENTS.md), graceful skip of conventions / deprecated skills / non-directory entries / missing skillsDir, plus synthesis round-trip and findPrimaryResolverPath. Plan: ~/.claude/plans/system-instruction-you-are-working-tidy-storm.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor: wire 3 consumers through loadSkillTriggerIndex (#1451) Replace the three independent resolver-content loaders with calls to the v0.41.11 shared primitive so frontmatter triggers propagate to every dispatch surface, not just doctor. Before: checkResolvable, runRoutingEvalCli, and mounts-cache each walked RESOLVER.md / AGENTS.md files separately. Adding frontmatter triggers to one consumer (e.g. checkResolvable) wouldn't have reached the routing-eval CLI or cross-brain composed dispatchers — the same drift bug class as #1451 in cross-consumer form. Codex caught this in plan-eng-review. After: all three consumers fold through loadSkillTriggerIndex. UNION semantics across both surfaces means new skills with frontmatter triggers are reachable everywhere without editing RESOLVER.md. Also updates: - check-resolvable action text on routing_miss to point at the canonical surface (SKILL.md frontmatter triggers) first, with RESOLVER.md row as secondary. - test/resolver-merge.test.ts to test BOTH the legacy RESOLVER.md-only authority path (skills with no frontmatter triggers) AND the new auto-registration path (skills reachable via frontmatter alone, no RESOLVER.md needed). - 3 routing-eval.jsonl fixtures (voice-note-ingest, brain-taxonomist, strategic-reading) gain `ambiguous_with` declarations for skill overlaps that auto-registration newly exposes. These overlaps are legitimate (voice-note vs idea-ingest on audio notes, brain-taxonomist vs repo-architecture on filing, strategic-reading vs idea-ingest on reading-through-a-lens) — the agent picks based on context. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(#1451): broaden skillpack-harvest triggers + negative fixtures + tighten gate Closes the 7 residual routing_miss warnings on skillpack-harvest that gbrain doctor reported on every fresh install (resolver_health: WARN, ~5 health-score points). Three changes: 1. Broaden skills/skillpack-harvest/SKILL.md frontmatter triggers from 5 narrow to 10 realistic phrasings. Each new trigger is a contiguous substring of one of the 7 shipped routing-eval.jsonl intents (per kylma-code's design in PR #1331; moved from RESOLVER.md to frontmatter under the v0.41.11 frontmatter- authoritative contract). Existing RESOLVER.md row stays for human-readability of the dispatcher map. 2. Add 4 negative-fixture cases to skills/skillpack-harvest/ routing-eval.jsonl with expected_skill=null to defend against false positives the broader triggers might introduce ("publish this report to the team", "promote my role on LinkedIn", "bundle these screenshots into a deck", "lift weights at the gym"). Two candidate negatives ("save this report as PDF", "share this article with the channel") were excluded — they trip idea-ingest's existing "save this"/"share" triggers, a real overlap but a separate v0.42+ concern. 3. Tighten test/check-resolvable.test.ts's "repo skills/ pass cleanly" assertion: the v0.25.1 carve-out that allowed routing_miss as informational is removed. The contract is back to zero errors AND zero warnings — the CI gate (next commit) enforces this for PRs so future drift fails the build instead of degrading user-install resolver_health silently. Co-Authored-By: kylma-code <noreply@github.com> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(cli): register reindex in CLI_ONLY so --help works (closes part of #1354) Pre-fix: src/cli.ts had a `case 'reindex':` handler at line 1334 that dispatched to reindex-multimodal or reindex.ts based on flags, but 'reindex' was missing from the CLI_ONLY Set at line 38. The dispatcher rejected the command with "Unknown command: reindex" before the handler ever ran. Post-fix: 'reindex' is in CLI_ONLY (recognized as a registered command). NOT added to CLI_ONLY_SELF_HELP — the handler doesn't have its own --help branch, so the dispatcher's generic printCliOnlyHelp() shows "gbrain reindex - run gbrain --help for the full command list." Polishing this to per-flag help text (--multimodal, --markdown, --code) is a follow-up TODO. Regression test in test/cli.test.ts asserts `'reindex'` is in the CLI_ONLY Set source string. Mirrors the existing pattern for 'reinit-pglite' in test/v0_37_fix_wave.serial.test.ts:284 and 'book-mirror' in test/book-mirror.test.ts:73. Cherry-picked from lost9999's PR #1354 (which bundled this fix with their fixture-rewrite approach to #1451 — the routing-eval half of that PR was superseded by kylma-code's trigger-broadening direction in #1331, which we took structurally in the previous commits). Co-Authored-By: lost9999 <noreply@github.com> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(ci): wire check:resolver into bun run verify Adds `bun run check:resolver` (= `bun src/cli.ts check-resolvable --strict --skills-dir skills/`) to package.json scripts and registers it in scripts/run-verify-parallel.sh's CHECKS array. This gates PR CI on resolver health: any future drift between a skill's frontmatter triggers and its routing-eval.jsonl fixtures fails the build, instead of silently degrading the resolver_health score on user installs after merge. The --strict flag exits non-zero on warnings (not just errors), so routing_miss / routing_ambiguous / routing_false_positive all block. Closes the CI half of #1451's structural fix: doctor catches drift at runtime, this gate catches drift at PR time. Local pre-flight: `bun run check:resolver`. Codex finding #9 from plan review: scripts/run-verify-parallel.sh invokes entries as `bun run <script-name>`, not raw shell. The package.json script name + CHECKS-array entry is the correct shape. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(test): update CLI unreachable test for v0.41.11 contract change The prior fixture used `triggers: ['alpha']` + `inResolver: false` to simulate an unreachable skill. Under v0.41.11's structural fix, frontmatter triggers auto-register the skill independently of RESOLVER.md, so this skill is reachable now — the assertion `errors.length > 0` failed. Drop the `triggers:` array from the fixture so the skill is genuinely unreachable (neither frontmatter nor RESOLVER.md row), preserving the test's regression-guard intent: doctor/check-resolvable still exits 1 when a manifest skill is truly unreachable. Caught by the full unit test suite after the merge. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: CLAUDE.md Key Files note for skill-trigger-index + regen llms.txt Document the v0.41.11 shared primitive (loadSkillTriggerIndex) in the Key Files section so future contributors find it before they reach for parseResolverEntries directly. Notes the 3 consumers (checkResolvable, runRoutingEvalCli, mounts-cache.composeResolvers), the UNION semantics, skip rules, parseSkillFrontmatter dependency, test coverage, and the CI gate wiring. Regenerated llms.txt + llms-full.txt per the CLAUDE.md edit rule. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * v0.41.14.0 fix(#1451): close RESOLVER.md drift bug class structurally Frontmatter triggers + RESOLVER.md / AGENTS.md rows now union into one unified index via the new loadSkillTriggerIndex primitive, consumed by all three dispatch surfaces (checkResolvable, routing-eval CLI, mounts-cache.composeResolvers). Closes the 7 residual routing_miss warnings #1451 reported on every fresh install, and the drift bug class that produced them. Highlights: - New shared primitive src/core/skill-trigger-index.ts (252 lines + 361 lines of tests across 18 cases). UNION semantics, case-insensitive dedupe keyed on (skillPath, normalized trigger). - Three consumers wired through the primitive — fixing frontmatter triggers for doctor now also fixes routing-eval CLI and cross-brain mounted dispatch (codex outside-voice catch). - skillpack-harvest frontmatter broadened from 5 to 10 triggers per kylma-code's design in #1331, plus 4 negative-fixture cases for false-positive defense. - reindex CLI added to CLI_ONLY set so `gbrain reindex --help` works instead of "Unknown command: reindex" (lost9999's #1354 hunk). - check:resolver wired into bun run verify CI gate so future drift fails PR CI instead of silently degrading user-install resolver_health. - check-resolvable's repo skills/ test tightened from "warn-tolerant" to "zero errors AND zero warnings" — the carve-out was a stop-gap pre-structural-fix. Plan + 5 decisions + codex outside-voice recalibration captured at ~/.claude/plans/system-instruction-you-are-working-tidy-storm.md. Co-Authored-By: kylma-code <noreply@github.com> Co-Authored-By: lost9999 <noreply@github.com> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: update project documentation for v0.41.14.0 - CLAUDE.md: tag skill-trigger-index entry with correct shipped version (v0.41.14.0, closes #1451) instead of the stale v0.41.11 draft tag. - CONTRIBUTING.md: list the new `check:resolver` gate in the `bun run verify` chain so contributors know to expect resolver-drift failures in PR CI. - llms-full.txt: regenerated from updated CLAUDE.md (mandatory per CLAUDE.md's auto-derived files rule; CI shard 1 fails the build otherwise). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: kylma-code <noreply@github.com>
402 lines
16 KiB
TypeScript
402 lines
16 KiB
TypeScript
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 });
|
|
}
|
|
});
|
|
});
|