Files
gbrain/test/doctor-fix.test.ts
T
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

102 lines
4.2 KiB
TypeScript

/**
* CLI integration tests for `gbrain doctor --fix` / `--dry-run`.
* Spawns the actual CLI against tmpdir skill fixtures to prove the
* arg-parsing wiring and stdout/file-state contract hold end-to-end.
*/
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 { spawnSync, execSync } from "child_process";
const CLI = join(import.meta.dir, "..", "src", "cli.ts");
const REPO_ROOT = join(import.meta.dir, "..");
let fixtures: string[] = [];
afterEach(() => {
for (const f of fixtures) {
try { rmSync(f, { recursive: true, force: true }); } catch { /* ignore */ }
}
fixtures = [];
});
function makeGitFixture(skills: Record<string, string>): string {
// doctor finds repo root by looking for skills/RESOLVER.md — so wrap the
// fixture in a dir with skills/ inside and a RESOLVER.md stub.
const root = mkdtempSync(join(tmpdir(), "gbrain-doctorfix-"));
fixtures.push(root);
const skillsDir = join(root, "skills");
mkdirSync(skillsDir, { recursive: true });
const names = Object.keys(skills);
const rows = names.map(n => `| "${n}" | \`skills/${n}/SKILL.md\` |`).join("\n");
writeFileSync(
join(skillsDir, "RESOLVER.md"),
`## Test\n| Trigger | Skill |\n|-----|-----|\n${rows}\n`
);
writeFileSync(
join(skillsDir, "manifest.json"),
JSON.stringify({ skills: names.map(n => ({ name: n, path: `${n}/SKILL.md` })) }, null, 2)
);
for (const [name, body] of Object.entries(skills)) {
mkdirSync(join(skillsDir, name), { recursive: true });
const fm = `---\nname: ${name}\ndescription: test\ntriggers:\n - "${name}"\n---\n`;
writeFileSync(join(skillsDir, name, "SKILL.md"), fm + body);
}
execSync("git init --quiet", { cwd: root });
execSync("git config user.email t@t", { cwd: root });
execSync("git config user.name t", { cwd: root });
execSync("git add -A && git commit --quiet -m init", { cwd: root });
return root;
}
function runDoctor(cwd: string, args: string[]): { stdout: string; stderr: string; status: number } {
const res = spawnSync("bun", [CLI, "doctor", "--fast", ...args], {
cwd,
encoding: "utf-8",
env: { ...process.env, NO_COLOR: "1" },
});
return { stdout: res.stdout, stderr: res.stderr, status: res.status ?? -1 };
}
describe("gbrain doctor --fix CLI integration", () => {
test("--fix --dry-run proposes a fix and does not write", () => {
const root = makeGitFixture({
demo: "## Iron Law: Back-Linking (MANDATORY)\n\nbody paragraph.\n",
});
const before = readFileSync(join(root, "skills", "demo", "SKILL.md"), "utf-8");
const { stdout } = runDoctor(root, ["--fix", "--dry-run"]);
expect(stdout).toContain("[PROPOSED]");
expect(stdout).toContain("Iron Law back-linking");
expect(stdout).toContain("Run without --dry-run to apply.");
const after = readFileSync(join(root, "skills", "demo", "SKILL.md"), "utf-8");
expect(after).toBe(before);
});
test("--fix applies, subsequent --fast run shows no DRY violation for fixed pattern", () => {
const root = makeGitFixture({
demo: "## Iron Law: Back-Linking (MANDATORY)\n\nbody.\n",
});
const { stdout: fixOut } = runDoctor(root, ["--fix"]);
expect(fixOut).toContain("[APPLIED]");
const updated = readFileSync(join(root, "skills", "demo", "SKILL.md"), "utf-8");
expect(updated).toContain("> **Convention:** See `skills/conventions/quality.md`");
expect(updated).not.toContain("## Iron Law: Back-Linking");
// Re-run --fast (not --fix) — commit the fix first so the dirty guard
// doesn't fire and we're testing detection cleanly.
execSync("git add -A && git commit --quiet -m fixup", { cwd: root });
const { stdout: checkOut } = runDoctor(root, ["--json"]);
const dryCount = (checkOut.match(/"type":"dry_violation"/g) || []).length;
expect(dryCount).toBe(0);
});
test("--fix with nothing to fix prints no-op message", () => {
const root = makeGitFixture({
clean: "# CleanSkill\n\nNo cross-cutting patterns here.\n",
});
const { stdout } = runDoctor(root, ["--fix"]);
expect(stdout).toContain("no DRY violations to repair");
});
});