* 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>
285 lines
11 KiB
TypeScript
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);
|
|
});
|
|
});
|