Files
gbrain/test/dry-fix.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

285 lines
11 KiB
TypeScript

import { describe, test, expect, afterEach } from "bun:test";
import { join } from "path";
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, existsSync } from "fs";
import { tmpdir } from "os";
import { execSync } from "child_process";
import {
autoFixDryViolations,
isInsideCodeFence,
detectBlockShape,
expandBullet,
expandBlockquote,
expandParagraph,
} from "../src/core/dry-fix.ts";
// ---------------------------------------------------------------------------
// Fixture helpers
// ---------------------------------------------------------------------------
let fixtures: string[] = [];
afterEach(() => {
for (const f of fixtures) {
try { rmSync(f, { recursive: true, force: true }); } catch { /* ignore */ }
}
fixtures = [];
});
function makeSkillsFixture(files: Record<string, string>, opts: { gitInit?: boolean } = {}): string {
const dir = mkdtempSync(join(tmpdir(), "gbrain-dryfix-"));
fixtures.push(dir);
const skillNames = Object.keys(files);
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 });
writeFileSync(join(dir, name, "SKILL.md"), body);
}
if (opts.gitInit) {
execSync("git init --quiet", { cwd: dir });
execSync("git config user.email test@test", { cwd: dir });
execSync("git config user.name test", { cwd: dir });
execSync("git add -A && git commit --quiet -m init", { cwd: dir });
}
return dir;
}
// ---------------------------------------------------------------------------
// Pure function tests: expanders and guards
// ---------------------------------------------------------------------------
describe("detectBlockShape", () => {
test("bullet with dash", () => {
expect(detectBlockShape(["- a bullet"], 0)).toBe("bullet");
});
test("bullet with numeric", () => {
expect(detectBlockShape(["1. numbered"], 0)).toBe("bullet");
});
test("indented bullet", () => {
expect(detectBlockShape([" - nested"], 0)).toBe("bullet");
});
test("blockquote", () => {
expect(detectBlockShape(["> quoted"], 0)).toBe("blockquote");
});
test("paragraph default", () => {
expect(detectBlockShape(["plain text"], 0)).toBe("paragraph");
});
});
describe("expandBullet", () => {
test("single-line bullet", () => {
const lines = ["before", "", "- single bullet", "", "after"];
const block = expandBullet(lines, 2);
expect(block).toEqual({ startLine: 2, endLine: 2 });
});
test("bullet with sub-bullets", () => {
const lines = [
"- top-level bullet",
" - sub one",
" - sub two",
"- next sibling",
];
const block = expandBullet(lines, 0);
expect(block).toEqual({ startLine: 0, endLine: 2 });
});
test("stops at blank line", () => {
const lines = ["- item", "continuation", "", "- next"];
const block = expandBullet(lines, 0);
expect(block).toEqual({ startLine: 0, endLine: 1 });
});
});
describe("expandBlockquote", () => {
test("contiguous quote lines", () => {
const lines = ["> line 1", "> line 2", "not quote"];
const block = expandBlockquote(lines, 0);
expect(block).toEqual({ startLine: 0, endLine: 1 });
});
test("returns null for Convention callout (don't rewrite reference)", () => {
const lines = ["> **Convention:** See `skills/conventions/quality.md`."];
expect(expandBlockquote(lines, 0)).toBeNull();
});
test("returns null for Filing rule callout", () => {
const lines = ["> **Filing rule:** Read `skills/_brain-filing-rules.md`."];
expect(expandBlockquote(lines, 0)).toBeNull();
});
});
describe("expandParagraph", () => {
test("expands to blank boundaries", () => {
const lines = ["", "line a", "line b", "", "other"];
const block = expandParagraph(lines, 1);
expect(block).toEqual({ startLine: 1, endLine: 2 });
});
test("handles start of file", () => {
const lines = ["first line", "second", ""];
const block = expandParagraph(lines, 0);
expect(block).toEqual({ startLine: 0, endLine: 1 });
});
});
describe("isInsideCodeFence", () => {
test("inside fenced block", () => {
const content = "pre\n```\nfenced notability gate\n```\npost\n";
const offset = content.indexOf("fenced notability");
expect(isInsideCodeFence(content, offset)).toBe(true);
});
test("outside fenced block", () => {
const content = "notability gate\n```\ncode\n```\n";
const offset = content.indexOf("notability");
expect(isInsideCodeFence(content, offset)).toBe(false);
});
test("after closed fence (regression guard)", () => {
const content = "```\nexample\n```\n\nreal notability gate here\n";
const offset = content.indexOf("real notability");
expect(isInsideCodeFence(content, offset)).toBe(false);
});
});
// ---------------------------------------------------------------------------
// Integration tests: autoFixDryViolations
// ---------------------------------------------------------------------------
describe("autoFixDryViolations", () => {
test("replaces paragraph-form heading (Iron Law)", () => {
const dir = makeSkillsFixture({
a: "# A\n\n## Iron Law: Back-Linking (MANDATORY)\n\nbody text.\n",
}, { gitInit: true });
const report = autoFixDryViolations(dir);
expect(report.fixed).toHaveLength(1);
expect(report.fixed[0].status).toBe("applied");
const updated = readFileSync(join(dir, "a", "SKILL.md"), "utf-8");
expect(updated).toContain("> **Convention:** See `skills/conventions/quality.md`");
expect(updated).not.toContain("## Iron Law: Back-Linking");
});
test("replaces bullet-item inlined rule", () => {
const dir = makeSkillsFixture({
b: "# B\n\n- First\n- Check the notability gate before creating a page\n- Last\n",
}, { gitInit: true });
const report = autoFixDryViolations(dir);
expect(report.fixed).toHaveLength(1);
const updated = readFileSync(join(dir, "b", "SKILL.md"), "utf-8");
expect(updated).toContain("> **Convention:** See `skills/conventions/quality.md`");
expect(updated).toContain("- First"); // surrounding bullets preserved
expect(updated).toContain("- Last");
});
test("does NOT rewrite a Convention callout (block_is_callout)", () => {
const dir = makeSkillsFixture({
c: "> **Convention:** See `skills/conventions/quality.md` for Iron Law back-linking rules.\n",
}, { gitInit: true });
const report = autoFixDryViolations(dir);
// proximity suppression means no violation to fix in the first place
expect(report.fixed).toHaveLength(0);
});
test("skips match inside fenced code block", () => {
const dir = makeSkillsFixture({
d: "# D\n\nExample:\n```\n## Iron Law: Back-Linking (MANDATORY)\n```\ntext.\n",
}, { gitInit: true });
const report = autoFixDryViolations(dir);
const sk = report.skipped.find(s => s.reason === "inside_code_fence");
expect(sk).toBeDefined();
expect(report.fixed).toHaveLength(0);
});
test("skips when pattern matches more than once", () => {
const dir = makeSkillsFixture({
e: "## Iron Law: Back-Linking (MANDATORY)\n\nThe Iron Law Back-Link applies to every entity.\n",
}, { gitInit: true });
const report = autoFixDryViolations(dir);
const sk = report.skipped.find(s => s.reason === "ambiguous_multiple_matches");
expect(sk).toBeDefined();
});
test("skips when delegation already within 10 lines (idempotent)", () => {
const dir = makeSkillsFixture({
f: "> **Convention:** See `skills/conventions/quality.md`.\n\nCheck the notability gate.\n",
}, { gitInit: true });
const report = autoFixDryViolations(dir);
const sk = report.skipped.find(s => s.reason === "already_delegated");
expect(sk).toBeDefined();
expect(report.fixed).toHaveLength(0);
});
test("skips when working tree is dirty", () => {
const dir = makeSkillsFixture({
g: "## Iron Law: Back-Linking (MANDATORY)\n\nbody.\n",
}, { gitInit: true });
// dirty the file: add another line post-commit
const p = join(dir, "g", "SKILL.md");
writeFileSync(p, readFileSync(p, "utf-8") + "\nextra edit\n");
const report = autoFixDryViolations(dir);
const sk = report.skipped.find(s => s.reason === "working_tree_dirty");
expect(sk).toBeDefined();
// file unchanged
expect(readFileSync(p, "utf-8")).toContain("## Iron Law: Back-Linking");
});
test("refuses to write when skill is NOT inside a git repo (no_git_backup)", () => {
// no gitInit — writing would destroy user data with no rollback
const dir = makeSkillsFixture({
ng: "## Iron Law: Back-Linking (MANDATORY)\n\nbody.\n",
}, { gitInit: false });
const p = join(dir, "ng", "SKILL.md");
const before = readFileSync(p, "utf-8");
const report = autoFixDryViolations(dir);
const sk = report.skipped.find(s => s.reason === "no_git_backup");
expect(sk).toBeDefined();
expect(report.fixed).toHaveLength(0);
expect(readFileSync(p, "utf-8")).toBe(before);
});
test("preserves trailing newline when block is at EOF", () => {
const dir = makeSkillsFixture({
eof: "## Iron Law: Back-Linking (MANDATORY)\n",
}, { gitInit: true });
const report = autoFixDryViolations(dir);
expect(report.fixed).toHaveLength(1);
const after = readFileSync(join(dir, "eof", "SKILL.md"), "utf-8");
expect(after.endsWith("\n")).toBe(true);
});
test("dry-run mode does not write files", () => {
const dir = makeSkillsFixture({
h: "# H\n\n## Iron Law: Back-Linking (MANDATORY)\n\nbody.\n",
}, { gitInit: true });
const before = readFileSync(join(dir, "h", "SKILL.md"), "utf-8");
const report = autoFixDryViolations(dir, { dryRun: true });
expect(report.fixed).toHaveLength(1);
expect(report.fixed[0].status).toBe("proposed");
const after = readFileSync(join(dir, "h", "SKILL.md"), "utf-8");
expect(after).toBe(before);
});
test("ENOENT on skill file does not crash", () => {
const dir = makeSkillsFixture({
i: "## Iron Law: Back-Linking (MANDATORY)\n",
}, { gitInit: true });
// remove the skill file after fixture creation but before fix runs
rmSync(join(dir, "i", "SKILL.md"));
const report = autoFixDryViolations(dir);
// file_missing is silently skipped (already reported as missing_file elsewhere)
expect(report.fixed).toHaveLength(0);
});
test("notability gate accepts _brain-filing-rules.md as delegation", () => {
const dir = makeSkillsFixture({
j: "> **Filing rule:** Read `skills/_brain-filing-rules.md`.\n\nCheck the notability gate.\n",
}, { gitInit: true });
const report = autoFixDryViolations(dir);
// suppressed by proximity + filing-rule delegation
expect(report.fixed).toHaveLength(0);
});
});