mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-28 14:59:47 +00:00
On Windows, `core.autocrlf=true` is the default and SKILL.md files are
checked out with CRLF line endings. `extractTriggers` used regexes
anchored to `\n` (`/^---\n.../` and `/^triggers:\s*\n.../`), which
never matched `\r\n`, so the parser returned `[]` for every skill.
Result: `gbrain doctor --fast --json` on Windows reported every skill
not in `OVERLAP_WHITELIST` (39 of 42) as a false `mece_gap` warning —
even though `skill_conformance` in the same run reported "42/42 skills
pass". CI runs Ubuntu-only so the divergence never surfaced.
Fix: normalize CRLF → LF at the top of `extractTriggers`. Single-line
change preserves existing LF behavior. Function is now exported so the
test can target it directly.
Tests: added `describe("extractTriggers")` block covering LF input,
CRLF input (regression case), missing frontmatter, missing triggers
field, and quote-stripping. All 30 tests in `check-resolvable.test.ts`
pass.
Verified locally on Windows: `gbrain doctor --fast --json` now reports
`resolver_health: ok, 42 skills, all reachable` (health_score 90 → 95).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Time Attakc <89218912+time-attack@users.noreply.github.com>
454 lines
19 KiB
TypeScript
454 lines
19 KiB
TypeScript
import { describe, test, expect } from "bun:test";
|
|
import { join } from "path";
|
|
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "fs";
|
|
import { tmpdir } from "os";
|
|
import {
|
|
checkResolvable,
|
|
parseResolverEntries,
|
|
extractDelegationTargets,
|
|
extractTriggers,
|
|
} from "../src/core/check-resolvable.ts";
|
|
|
|
const SKILLS_DIR = join(import.meta.dir, "..", "skills");
|
|
|
|
describe("parseResolverEntries", () => {
|
|
test("extracts skill paths from markdown table rows", () => {
|
|
const content = `## Brain operations
|
|
| Trigger | Skill |
|
|
|---------|-------|
|
|
| "What do we know about" | \`skills/query/SKILL.md\` |
|
|
| Creating a person page | \`skills/enrich/SKILL.md\` |`;
|
|
const entries = parseResolverEntries(content);
|
|
expect(entries.length).toBe(2);
|
|
expect(entries[0].skillPath).toBe("skills/query/SKILL.md");
|
|
expect(entries[0].section).toBe("Brain operations");
|
|
expect(entries[1].skillPath).toBe("skills/enrich/SKILL.md");
|
|
});
|
|
|
|
test("handles GStack entries (external skills)", () => {
|
|
const content = `## Thinking skills
|
|
| Trigger | Skill |
|
|
|---------|-------|
|
|
| "Brainstorm" | GStack: office-hours |`;
|
|
const entries = parseResolverEntries(content);
|
|
expect(entries.length).toBe(1);
|
|
expect(entries[0].isGStack).toBe(true);
|
|
});
|
|
|
|
test("handles identity/access rows (non-skill references)", () => {
|
|
const content = `## Identity
|
|
| Trigger | Skill |
|
|
|---------|-------|
|
|
| Non-owner sends a message | Check \`ACCESS_POLICY.md\` before responding |`;
|
|
const entries = parseResolverEntries(content);
|
|
expect(entries.length).toBe(1);
|
|
expect(entries[0].isGStack).toBe(true);
|
|
});
|
|
|
|
test("skips separator and header rows", () => {
|
|
const content = `| Trigger | Skill |
|
|
|---------|-------|
|
|
| "query" | \`skills/query/SKILL.md\` |`;
|
|
const entries = parseResolverEntries(content);
|
|
expect(entries.length).toBe(1);
|
|
});
|
|
|
|
test("tracks section headings", () => {
|
|
const content = `## Always-on
|
|
| Trigger | Skill |
|
|
|---------|-------|
|
|
| Every message | \`skills/signal-detector/SKILL.md\` |
|
|
|
|
## Brain operations
|
|
| Trigger | Skill |
|
|
|---------|-------|
|
|
| "What do we know" | \`skills/query/SKILL.md\` |`;
|
|
const entries = parseResolverEntries(content);
|
|
expect(entries[0].section).toBe("Always-on");
|
|
expect(entries[1].section).toBe("Brain operations");
|
|
});
|
|
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
// v0.41.7.0 — Compact list-format support (OpenClaw-native shape)
|
|
//
|
|
// The list branch was added so `gbrain doctor` no longer reports every
|
|
// skill as unreachable on agents that write the compact `- **name**: t1 | t2`
|
|
// shape instead of the markdown table. The OpenClaw 306-skill regression
|
|
// was 238 FAIL errors → 0 errors after this fix.
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
|
|
test("[list] bold-name single trigger", () => {
|
|
const content = `## Personal
|
|
- **gift-advisor**: gift idea`;
|
|
const entries = parseResolverEntries(content);
|
|
expect(entries.length).toBe(1);
|
|
expect(entries[0].trigger).toBe("gift idea");
|
|
expect(entries[0].skillPath).toBe("skills/gift-advisor/SKILL.md");
|
|
expect(entries[0].isGStack).toBe(false);
|
|
expect(entries[0].section).toBe("Personal");
|
|
});
|
|
|
|
test("[list] bold-name multi-trigger fan-out", () => {
|
|
const content = `- **flight-tracker**: track my flight | flight status | when does my flight land`;
|
|
const entries = parseResolverEntries(content);
|
|
expect(entries.length).toBe(3);
|
|
expect(entries.map(e => e.trigger)).toEqual([
|
|
"track my flight",
|
|
"flight status",
|
|
"when does my flight land",
|
|
]);
|
|
expect(entries.every(e => e.skillPath === "skills/flight-tracker/SKILL.md")).toBe(true);
|
|
});
|
|
|
|
test("[list] plain-name fallback (no bold markers)", () => {
|
|
const content = `- gift-advisor: gift idea | birthday gift`;
|
|
const entries = parseResolverEntries(content);
|
|
expect(entries.length).toBe(2);
|
|
expect(entries[0].skillPath).toBe("skills/gift-advisor/SKILL.md");
|
|
expect(entries[1].trigger).toBe("birthday gift");
|
|
});
|
|
|
|
test("[list] D3: path suffix with Unicode → is stripped, not captured", () => {
|
|
// D3 walkback: downstream consumers (routing-eval.ts skillSlugFromPath
|
|
// and check-resolvable.ts:367 manifest derivation) assume the
|
|
// skills/<name>/SKILL.md shape. Capturing the explicit path would
|
|
// produce silent orphan_trigger warnings + routing-eval coverage
|
|
// gaps for that skill. So the suffix is stripped and the path is
|
|
// always derived from the name.
|
|
const content = "- **quality**: lint the page → `skills/conventions/quality.md`";
|
|
const entries = parseResolverEntries(content);
|
|
expect(entries.length).toBe(1);
|
|
expect(entries[0].trigger).toBe("lint the page");
|
|
expect(entries[0].skillPath).toBe("skills/quality/SKILL.md"); // derived, NOT the captured path
|
|
});
|
|
|
|
test("[list] F4: path suffix with ASCII -> is also stripped", () => {
|
|
const content = "- **quality**: lint the page -> `skills/conventions/quality.md`";
|
|
const entries = parseResolverEntries(content);
|
|
expect(entries.length).toBe(1);
|
|
expect(entries[0].trigger).toBe("lint the page");
|
|
expect(entries[0].skillPath).toBe("skills/quality/SKILL.md");
|
|
});
|
|
|
|
test("[list] ellipsis placeholder is dropped", () => {
|
|
const content = `- **foo-skill**: bar | ... | baz`;
|
|
const entries = parseResolverEntries(content);
|
|
expect(entries.length).toBe(2);
|
|
expect(entries.map(e => e.trigger)).toEqual(["bar", "baz"]);
|
|
});
|
|
|
|
test("[list] empty pipe segments are dropped", () => {
|
|
const content = `- **foo-skill**: bar | | baz`;
|
|
const entries = parseResolverEntries(content);
|
|
expect(entries.length).toBe(2);
|
|
expect(entries.map(e => e.trigger)).toEqual(["bar", "baz"]);
|
|
});
|
|
|
|
test("[mixed] table and list rows in the same file", () => {
|
|
const content = `## Always-on
|
|
| Trigger | Skill |
|
|
|---------|-------|
|
|
| Every message | \`skills/signal-detector/SKILL.md\` |
|
|
|
|
## Personal
|
|
- **gift-advisor**: gift idea | what should I bring`;
|
|
const entries = parseResolverEntries(content);
|
|
expect(entries.length).toBe(3);
|
|
expect(entries[0].skillPath).toBe("skills/signal-detector/SKILL.md");
|
|
expect(entries[0].section).toBe("Always-on");
|
|
expect(entries[1].skillPath).toBe("skills/gift-advisor/SKILL.md");
|
|
expect(entries[1].section).toBe("Personal");
|
|
expect(entries[2].section).toBe("Personal");
|
|
});
|
|
|
|
test("[list] section tracking across list entries", () => {
|
|
const content = `## Personal
|
|
- **gift-advisor**: gift idea
|
|
|
|
## Travel
|
|
- **flight-tracker**: track my flight`;
|
|
const entries = parseResolverEntries(content);
|
|
expect(entries[0].section).toBe("Personal");
|
|
expect(entries[1].section).toBe("Travel");
|
|
});
|
|
|
|
test("[list] D4 REGRESSION: prose bullets do not match as skill rows", () => {
|
|
// The kebab-lowercase name regex deliberately rejects bold names
|
|
// starting with an uppercase letter. This kills the noise class where
|
|
// real AGENTS.md files have prose bullets like `- **Note**: …` that
|
|
// would otherwise be parsed as fake skill rows pointing at
|
|
// `skills/Note/SKILL.md`. Codex F2; D4 decision.
|
|
const content = `- **Note**: this is a prose bullet
|
|
- **Convention**: see [some convention link]
|
|
- **TODO**: fix this later
|
|
- **Important**: read this carefully`;
|
|
const entries = parseResolverEntries(content);
|
|
expect(entries.length).toBe(0);
|
|
});
|
|
|
|
test("[list] D4 negative: convention-violating uppercase names are silently skipped", () => {
|
|
// Documents the trade: a real skill named `MyTool` would not parse.
|
|
// The user would notice on the first `gbrain doctor` run and lowercase it.
|
|
const content = `- **MyTool**: this trigger silently disappears
|
|
- **camelCase**: also silently dropped`;
|
|
const entries = parseResolverEntries(content);
|
|
expect(entries.length).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("extractTriggers", () => {
|
|
const LF_FRONTMATTER =
|
|
"---\nname: query\ndescription: Test\ntriggers:\n - \"what do we know\"\n - \"tell me about\"\ntools:\n - search\n---\n\n# Body\n";
|
|
|
|
test("parses triggers from LF-terminated frontmatter", () => {
|
|
const triggers = extractTriggers(LF_FRONTMATTER);
|
|
expect(triggers).toEqual(["what do we know", "tell me about"]);
|
|
});
|
|
|
|
test("parses triggers from CRLF-terminated frontmatter (Windows checkouts)", () => {
|
|
// Regression: `core.autocrlf=true` is the Windows default. Without
|
|
// CRLF→LF normalization, every Windows skill is reported as a false
|
|
// mece_gap warning because the `^---\n` regex never matches `---\r\n`.
|
|
const crlf = LF_FRONTMATTER.replace(/\n/g, "\r\n");
|
|
const triggers = extractTriggers(crlf);
|
|
expect(triggers).toEqual(["what do we know", "tell me about"]);
|
|
});
|
|
|
|
test("returns [] when frontmatter is missing", () => {
|
|
expect(extractTriggers("# Just a body, no frontmatter\n")).toEqual([]);
|
|
});
|
|
|
|
test("returns [] when triggers field is absent from frontmatter", () => {
|
|
const fm = "---\nname: query\ndescription: Test\ntools:\n - search\n---\n";
|
|
expect(extractTriggers(fm)).toEqual([]);
|
|
});
|
|
|
|
test("strips surrounding quotes from trigger values", () => {
|
|
const fm = "---\nname: x\ntriggers:\n - \"double quoted\"\n - 'single quoted'\n - unquoted\n---\n";
|
|
expect(extractTriggers(fm)).toEqual(["double quoted", "single quoted", "unquoted"]);
|
|
});
|
|
});
|
|
|
|
describe("checkResolvable — real skills directory", () => {
|
|
const report = checkResolvable(SKILLS_DIR);
|
|
|
|
test("produces a report with summary", () => {
|
|
expect(report.summary.total_skills).toBeGreaterThan(0);
|
|
expect(typeof report.ok).toBe("boolean");
|
|
expect(Array.isArray(report.issues)).toBe(true);
|
|
});
|
|
|
|
test("all manifest skills are reachable from RESOLVER.md", () => {
|
|
const unreachableIssues = report.issues.filter(i => i.type === "unreachable");
|
|
if (unreachableIssues.length > 0) {
|
|
const names = unreachableIssues.map(i => i.skill).join(", ");
|
|
console.warn(`Unreachable skills: ${names}`);
|
|
}
|
|
// Currently expect all 24 skills to be reachable
|
|
expect(report.summary.unreachable).toBe(0);
|
|
});
|
|
|
|
test("no missing files referenced by RESOLVER.md", () => {
|
|
const missingFiles = report.issues.filter(i => i.type === "missing_file");
|
|
expect(missingFiles.length).toBe(0);
|
|
});
|
|
|
|
test("no orphan triggers (in resolver but not manifest)", () => {
|
|
const orphans = report.issues.filter(i => i.type === "orphan_trigger");
|
|
expect(orphans.length).toBe(0);
|
|
});
|
|
|
|
test("action strings are specific (contain file paths)", () => {
|
|
for (const issue of report.issues) {
|
|
expect(issue.action.length).toBeGreaterThan(10);
|
|
// Action should mention a file or a specific fix
|
|
expect(
|
|
issue.action.includes("RESOLVER.md") ||
|
|
issue.action.includes("SKILL.md") ||
|
|
issue.action.includes("manifest") ||
|
|
issue.action.includes("conventions/")
|
|
).toBe(true);
|
|
}
|
|
});
|
|
|
|
test("unreachable issues have structured fix objects", () => {
|
|
const unreachable = report.issues.filter(i => i.type === "unreachable");
|
|
for (const issue of unreachable) {
|
|
expect(issue.fix).toBeDefined();
|
|
expect(issue.fix!.type).toBe("add_trigger");
|
|
expect(issue.fix!.file).toContain("RESOLVER.md");
|
|
}
|
|
});
|
|
|
|
test("whitelisted skills (ingest, signal-detector, brain-ops) don't trigger MECE overlap", () => {
|
|
const overlaps = report.issues.filter(i => i.type === "mece_overlap");
|
|
for (const issue of overlaps) {
|
|
// The skill field lists the overlapping skills
|
|
expect(issue.skill).not.toContain("signal-detector");
|
|
expect(issue.skill).not.toContain("brain-ops");
|
|
}
|
|
});
|
|
|
|
test("summary counts are consistent", () => {
|
|
expect(report.summary.reachable + report.summary.unreachable).toBe(report.summary.total_skills);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// DRY detection — proximity-based suppression
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function makeSkillsFixture(files: Record<string, string>): string {
|
|
const dir = mkdtempSync(join(tmpdir(), "gbrain-dry-"));
|
|
// Minimal RESOLVER.md and manifest.json so checkResolvable doesn't bail.
|
|
const skillNames = Object.keys(files);
|
|
const resolverRows = skillNames.map(n => `| "${n}" | \`skills/${n}/SKILL.md\` |`).join("\n");
|
|
writeFileSync(join(dir, "RESOLVER.md"), `## Test\n| Trigger | Skill |\n|-----|-----|\n${resolverRows}\n`);
|
|
writeFileSync(
|
|
join(dir, "manifest.json"),
|
|
JSON.stringify({ skills: skillNames.map(n => ({ name: n, path: `${n}/SKILL.md` })) }, null, 2)
|
|
);
|
|
for (const [name, body] of Object.entries(files)) {
|
|
mkdirSync(join(dir, name), { recursive: true });
|
|
// Skill conformance tests (elsewhere) check for frontmatter + triggers;
|
|
// checkResolvable itself only needs the body.
|
|
const frontmatter = `---\nname: ${name}\ndescription: test\ntriggers:\n - "${name}"\n---\n`;
|
|
writeFileSync(join(dir, name, "SKILL.md"), frontmatter + body);
|
|
}
|
|
return dir;
|
|
}
|
|
|
|
describe("extractDelegationTargets", () => {
|
|
test("parses > **Convention:** callouts", () => {
|
|
const refs = extractDelegationTargets(
|
|
"> **Convention:** See `skills/conventions/quality.md` for citation rules.\n"
|
|
);
|
|
expect(refs).toEqual([{ convention: "conventions/quality.md", line: 1 }]);
|
|
});
|
|
|
|
test("parses > **Filing rule:** callouts", () => {
|
|
const refs = extractDelegationTargets(
|
|
"> **Filing rule:** Read `skills/_brain-filing-rules.md` before any new page.\n"
|
|
);
|
|
expect(refs).toEqual([{ convention: "_brain-filing-rules.md", line: 1 }]);
|
|
});
|
|
|
|
test("parses inline backtick references", () => {
|
|
const refs = extractDelegationTargets(
|
|
"some prose.\nSee `skills/conventions/quality.md` for details.\n"
|
|
);
|
|
expect(refs).toEqual([{ convention: "conventions/quality.md", line: 2 }]);
|
|
});
|
|
|
|
test("ignores backticks pointing outside known delegation targets", () => {
|
|
const refs = extractDelegationTargets(
|
|
"See `skills/random/README.md` for unrelated notes.\n"
|
|
);
|
|
expect(refs).toHaveLength(0);
|
|
});
|
|
|
|
test("handles frontmatter-only skill (no body matches)", () => {
|
|
const refs = extractDelegationTargets("---\nname: foo\n---\n");
|
|
expect(refs).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe("DRY detection — checkResolvable", () => {
|
|
let dir: string;
|
|
afterEachCleanup(() => dir && rmSync(dir, { recursive: true, force: true }));
|
|
|
|
test("flags inlined notability rule with no reference", () => {
|
|
dir = makeSkillsFixture({
|
|
bad: "# BadSkill\n\nCheck the notability gate every time.\n",
|
|
});
|
|
const report = checkResolvable(dir);
|
|
const dry = report.issues.filter(i => i.type === "dry_violation");
|
|
expect(dry).toHaveLength(1);
|
|
expect(dry[0].skill).toBe("bad");
|
|
});
|
|
|
|
test("suppresses DRY when > **Convention:** callout points at quality.md (notability)", () => {
|
|
dir = makeSkillsFixture({
|
|
good: `# GoodSkill\n\n> **Convention:** See \`skills/conventions/quality.md\` for rules.\n\nCheck the notability gate.\n`,
|
|
});
|
|
const report = checkResolvable(dir);
|
|
const dry = report.issues.filter(i => i.type === "dry_violation");
|
|
expect(dry).toHaveLength(0);
|
|
});
|
|
|
|
test("suppresses DRY when _brain-filing-rules.md is referenced for notability", () => {
|
|
dir = makeSkillsFixture({
|
|
good: `# GoodSkill\n\n> **Filing rule:** Read \`skills/_brain-filing-rules.md\`.\n\nCheck the notability gate.\n`,
|
|
});
|
|
const report = checkResolvable(dir);
|
|
const dry = report.issues.filter(i => i.type === "dry_violation");
|
|
expect(dry).toHaveLength(0);
|
|
});
|
|
|
|
test("does NOT suppress when reference is >40 lines from the match", () => {
|
|
const filler = Array(50).fill("padding paragraph with no match.").join("\n");
|
|
dir = makeSkillsFixture({
|
|
distant: `> **Convention:** See \`skills/conventions/quality.md\`.\n\n${filler}\n\nCheck the notability gate now.\n`,
|
|
});
|
|
const report = checkResolvable(dir);
|
|
const dry = report.issues.filter(i => i.type === "dry_violation");
|
|
expect(dry).toHaveLength(1);
|
|
});
|
|
|
|
test("DOES suppress when reference is ~30 lines from the match", () => {
|
|
const filler = Array(20).fill("padding paragraph with no match.").join("\n");
|
|
dir = makeSkillsFixture({
|
|
near: `> **Convention:** See \`skills/conventions/quality.md\`.\n\n${filler}\n\nCheck the notability gate now.\n`,
|
|
});
|
|
const report = checkResolvable(dir);
|
|
const dry = report.issues.filter(i => i.type === "dry_violation");
|
|
expect(dry).toHaveLength(0);
|
|
});
|
|
|
|
test("iron-law pattern does NOT accept _brain-filing-rules.md as delegation", () => {
|
|
// iron-law's only accepted target is conventions/quality.md
|
|
dir = makeSkillsFixture({
|
|
filing: `> **Filing rule:** Read \`skills/_brain-filing-rules.md\`.\n\n## Iron Law: Back-Linking (MANDATORY)\n`,
|
|
});
|
|
const report = checkResolvable(dir);
|
|
const dry = report.issues.filter(i => i.type === "dry_violation");
|
|
expect(dry.length).toBeGreaterThanOrEqual(1);
|
|
});
|
|
});
|
|
|
|
describe("v0.22.4 regression — actual repo skills/ has 0 errors", () => {
|
|
test("repo skills/ pass check-resolvable cleanly (zero errors AND zero warnings)", () => {
|
|
// The v0.22.4 (Part A) contract was zero warnings AND zero errors.
|
|
// The v0.25.1 update relaxed it to allow `routing_miss` warnings as
|
|
// informational — a stop-gap because the structural matcher
|
|
// required substring-match against narrow triggers in RESOLVER.md
|
|
// and natural paraphrases legitimately missed.
|
|
//
|
|
// v0.41.11 #1451 closes the drift bug class structurally:
|
|
// frontmatter triggers + RESOLVER.md rows merge with UNION
|
|
// semantics via `loadSkillTriggerIndex`. The 7 residual
|
|
// `routing_miss` warnings on skillpack-harvest (and any future
|
|
// drift on other skills) MUST be addressed at write time, not
|
|
// tolerated at test time. The CI gate `bun run check:resolver`
|
|
// (--strict, exit-1 on any warning) enforces this for PRs.
|
|
//
|
|
// The test now asserts the FULL contract: zero errors, zero
|
|
// warnings. If a routing_miss reappears, the fix is to broaden
|
|
// the skill's frontmatter `triggers:` so the realistic fixture
|
|
// intent contains a trigger substring.
|
|
const report = checkResolvable(SKILLS_DIR);
|
|
const errors = report.issues.filter(i => i.severity === "error");
|
|
const warnings = report.issues.filter(i => i.severity === "warning");
|
|
expect(errors).toEqual([]);
|
|
expect(warnings).toEqual([]);
|
|
});
|
|
});
|
|
|
|
// bun:test has no beforeEach/afterEach at module scope cleanly interacting
|
|
// with closures; a small helper keeps cleanup readable and per-test.
|
|
function afterEachCleanup(fn: () => void) {
|
|
const { afterEach } = require("bun:test");
|
|
afterEach(fn);
|
|
}
|
|
|