Files
gbrain/test/check-resolvable.test.ts
Garry Tan ebfbd5e6f7 feat(doctor): proximity-based DRY detection + --fix auto-repair (v0.14.1) (#254)
* feat(doctor): proximity-based DRY detection + --fix auto-repair

Fixes false-positive DRY violations on skills that properly delegate
notability/filing rules to `skills/_brain-filing-rules.md`. The old
check only accepted `conventions/quality.md` as a valid delegation
target, leaving 9 skills flagged every run even though they delegate
correctly.

- CROSS_CUTTING_PATTERNS.conventions is now an array; notability gate
  accepts both `conventions/quality.md` AND `_brain-filing-rules.md`
- New extractDelegationTargets() parses `> **Convention:**`,
  `> **Filing rule:**`, and inline backtick references
- DRY suppression is proximity-based (K=40 lines) via DRY_PROXIMITY_LINES
- New src/core/dry-fix.ts module with autoFixDryViolations:
  - expanders strategy map (bullet / blockquote / paragraph)
  - 5 guards: working-tree-dirty, no-git-backup, inside-code-fence,
    already-delegated, ambiguous-multi-match, block-is-callout
  - execFileSync array args (no shell-injection surface)
  - EOF newline preservation
- `gbrain doctor --fix` and `--dry-run` flags wire in via doctor.ts
- 31 new tests across dry-fix.test.ts (28 unit), check-resolvable.test.ts
  (13 DRY detection + extraction), doctor-fix.test.ts (3 CLI integration)

* chore: bump version and changelog (v0.14.1)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: update project documentation for v0.14.1

CLAUDE.md:
- Added src/core/dry-fix.ts entry under Key files (expanders, guards,
  execFileSync safety, EOF newline preservation).
- Updated src/commands/doctor.ts entry to cover --fix/--dry-run flags.
- Updated src/core/check-resolvable.ts entry to reflect array-valued
  CROSS_CUTTING_PATTERNS.conventions, extractDelegationTargets(), and
  proximity-based DRY suppression via DRY_PROXIMITY_LINES = 40.
- Added test/dry-fix.test.ts and test/doctor-fix.test.ts to the test
  list, and annotated test/check-resolvable.test.ts with v0.14.1 cases.

README.md:
- ADMIN block: --fix now names what it actually fixes (DRY violations
  via conventions delegation) and documents --dry-run.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 21:54:36 +08:00

265 lines
10 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,
} 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");
});
});
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);
});
});
// 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);
}