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>
This commit is contained in:
Garry Tan
2026-04-20 21:54:36 +08:00
committed by GitHub
parent 5fd9cd2644
commit ebfbd5e6f7
12 changed files with 1126 additions and 25 deletions

View File

@@ -2,6 +2,7 @@ import type { BrainEngine } from '../core/engine.ts';
import * as db from '../core/db.ts';
import { LATEST_VERSION } from '../core/migrate.ts';
import { checkResolvable } from '../core/check-resolvable.ts';
import { autoFixDryViolations, type AutoFixReport, type FixOutcome } from '../core/dry-fix.ts';
import { loadCompletedMigrations } from '../core/preferences.ts';
import { join } from 'path';
import { existsSync, readFileSync, readdirSync } from 'fs';
@@ -21,7 +22,10 @@ export interface Check {
export async function runDoctor(engine: BrainEngine | null, args: string[]) {
const jsonOutput = args.includes('--json');
const fastMode = args.includes('--fast');
const doFix = args.includes('--fix');
const dryRun = args.includes('--dry-run');
const checks: Check[] = [];
let autoFixReport: AutoFixReport | null = null;
// --- Filesystem checks (always run, no DB needed) ---
@@ -29,6 +33,15 @@ export async function runDoctor(engine: BrainEngine | null, args: string[]) {
const repoRoot = findRepoRoot();
if (repoRoot) {
const skillsDir = join(repoRoot, 'skills');
// --fix: run auto-repair BEFORE checkResolvable so the post-fix scan
// reflects the new state. Auto-fix only targets DRY violations today;
// other resolver issues are left to human repair.
if (doFix) {
autoFixReport = autoFixDryViolations(skillsDir, { dryRun });
printAutoFixReport(autoFixReport, dryRun, jsonOutput);
}
const report = checkResolvable(skillsDir);
if (report.ok && report.issues.length === 0) {
checks.push({
@@ -351,6 +364,36 @@ export async function runDoctor(engine: BrainEngine | null, args: string[]) {
// Helpers
// ---------------------------------------------------------------------------
/** Print the auto-fix report in human-readable form. JSON output goes through
* outputResults alongside the check list; this is the pretty-print path. */
function printAutoFixReport(report: AutoFixReport, dryRun: boolean, jsonOutput: boolean): void {
if (jsonOutput) return; // JSON consumers read autoFixReport via the check issues / caller
const verb = dryRun ? 'PROPOSED' : 'APPLIED';
for (const outcome of report.fixed) {
console.log(`[${verb}] ${outcome.skillPath} (${outcome.patternLabel})`);
if (outcome.before) {
console.log('--- before');
console.log(outcome.before);
console.log('--- after');
console.log(outcome.after ?? '');
console.log('');
}
}
const n = report.fixed.length;
const s = report.skipped.length;
if (n === 0 && s === 0) {
console.log('Doctor --fix: no DRY violations to repair.');
return;
}
const label = dryRun ? 'fixes proposed' : 'fixes applied';
console.log(`${n} ${label}${s > 0 ? `, ${s} skipped:` : '.'}`);
for (const sk of report.skipped) {
const hint = sk.reason === 'working_tree_dirty' ? ' (run `git stash` first)' : '';
console.log(` - ${sk.skillPath}: ${sk.reason}${hint}`);
}
if (dryRun && n > 0) console.log('\nRun without --dry-run to apply.');
}
/** Find the GBrain repo root by walking up from cwd looking for skills/RESOLVER.md */
function findRepoRoot(): string | null {
let dir = process.cwd();

View File

@@ -136,13 +136,67 @@ function extractTriggers(skillContent: string): string[] {
.filter(Boolean);
}
/** Scan for inlined cross-cutting rules that should reference convention files. */
const CROSS_CUTTING_PATTERNS = [
{ pattern: /iron\s*law.*back-?link/i, convention: 'conventions/quality.md', label: 'Iron Law back-linking' },
{ pattern: /citation.*format.*\[Source:/i, convention: 'conventions/quality.md', label: 'citation format rules' },
{ pattern: /notability.*gate/i, convention: 'conventions/quality.md', label: 'notability gate' },
/**
* Scan for inlined cross-cutting rules that should reference convention
* files. Each pattern can list multiple valid delegation targets — e.g.,
* notability rules live in both `conventions/quality.md` and
* `_brain-filing-rules.md`, and referencing either counts as delegation.
*/
export interface CrossCuttingPattern {
pattern: RegExp;
conventions: string[];
label: string;
}
export const CROSS_CUTTING_PATTERNS: CrossCuttingPattern[] = [
{ pattern: /iron\s*law.*back-?link/i,
conventions: ['conventions/quality.md'],
label: 'Iron Law back-linking' },
{ pattern: /citation.*format.*\[Source:/i,
conventions: ['conventions/quality.md'],
label: 'citation format rules' },
{ pattern: /notability.*gate/i,
conventions: ['conventions/quality.md', '_brain-filing-rules.md'],
label: 'notability gate' },
];
/** Proximity window (lines) within which a delegation reference suppresses
* a DRY match. Typical skill section is 20-30 lines; 40 covers header +
* section without leaking across document-length files. */
export const DRY_PROXIMITY_LINES = 40;
export interface DelegationRef {
convention: string; // normalized relative path, e.g., 'conventions/quality.md'
line: number; // 1-indexed line number of the reference
}
/**
* Extract delegation references from skill content. Recognizes three shapes:
* 1. `> **Convention:** ... \`skills/<path>\` ...`
* 2. `> **Filing rule:** ... \`skills/<path>\` ...`
* 3. Inline backtick `\`skills/conventions/*.md\`` or
* `\`skills/_brain-filing-rules.md\``
*
* Paths are normalized by stripping the leading `skills/` so they match the
* `conventions` field of CROSS_CUTTING_PATTERNS.
*/
export function extractDelegationTargets(content: string): DelegationRef[] {
const refs: DelegationRef[] = [];
const lines = content.split('\n');
// Match backtick-wrapped skills/ paths that point at a known delegation
// target. Scoped to conventions/ subtree and _brain-filing-rules.md.
const pathRe = /`skills\/((?:conventions\/[^`]+\.md)|(?:_brain-filing-rules\.md))`/g;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
pathRe.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = pathRe.exec(line)) !== null) {
refs.push({ convention: m[1], line: i + 1 });
}
}
return refs;
}
// ---------------------------------------------------------------------------
// Main function
// ---------------------------------------------------------------------------
@@ -317,24 +371,34 @@ export function checkResolvable(skillsDir: string): ResolvableReport {
}
}
// 5. DRY detection — inlined cross-cutting rules
// 5. DRY detection — inlined cross-cutting rules.
// A match is suppressed when the skill references one of the pattern's
// accepted convention files within DRY_PROXIMITY_LINES lines of the match.
// This catches the common case where a skill delegates at a section
// header but still contains prose mentioning the rule by name.
for (const skill of manifest) {
const skillPath = join(skillsDir, skill.path);
if (!existsSync(skillPath)) continue;
try {
const content = readFileSync(skillPath, 'utf-8');
for (const { pattern, convention, label } of CROSS_CUTTING_PATTERNS) {
if (pattern.test(content)) {
// Check if the skill also references the convention file
if (!content.includes(convention)) {
issues.push({
type: 'dry_violation',
severity: 'warning',
skill: skill.name,
message: `Skill '${skill.name}' inlines ${label} instead of referencing '${convention}'`,
action: `Replace inlined rules with a reference to '${convention}'`,
});
}
const delegations = extractDelegationTargets(content);
for (const { pattern, conventions, label } of CROSS_CUTTING_PATTERNS) {
const globalRe = new RegExp(pattern.source, pattern.flags.includes('g') ? pattern.flags : pattern.flags + 'g');
const matches = [...content.matchAll(globalRe)];
for (const m of matches) {
const matchLine = content.slice(0, m.index ?? 0).split('\n').length;
const suppressed = delegations.some(
d => conventions.includes(d.convention) && Math.abs(d.line - matchLine) <= DRY_PROXIMITY_LINES
);
if (suppressed) continue;
issues.push({
type: 'dry_violation',
severity: 'warning',
skill: skill.name,
message: `Skill '${skill.name}' inlines ${label} instead of delegating to a convention file`,
action: `Replace inlined rules with a reference to one of: ${conventions.join(', ')}`,
});
break; // one issue per pattern per skill
}
}
} catch {
@@ -354,3 +418,7 @@ export function checkResolvable(skillsDir: string): ResolvableReport {
},
};
}
// Re-export auto-fix so callers have one canonical entry point.
export { autoFixDryViolations } from './dry-fix.ts';
export type { AutoFixOptions, AutoFixReport, FixOutcome } from './dry-fix.ts';

382
src/core/dry-fix.ts Normal file
View File

@@ -0,0 +1,382 @@
/**
* dry-fix.ts — Auto-repair DRY violations surfaced by checkResolvable().
*
* Called by `gbrain doctor --fix`. Scans every skill in the manifest, locates
* matches of CROSS_CUTTING_PATTERNS, expands each match to its block
* boundary, and replaces the block with a `> **Convention:** ...` reference
* line. Writes are guarded:
* - working-tree-dirty → skip (preserves git-as-backup contract)
* - inside code fence → skip (don't mangle example prose)
* - already delegated → skip (idempotent re-runs)
* - multi-match → skip (ambiguous; manual edit required)
*
* Dry-run mode returns proposed edits without writing to disk.
*/
import { readFileSync, writeFileSync, existsSync } from 'fs';
import { join, dirname } from 'path';
import { execFileSync } from 'child_process';
import {
CROSS_CUTTING_PATTERNS,
DRY_PROXIMITY_LINES,
extractDelegationTargets,
type CrossCuttingPattern,
} from './check-resolvable.ts';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface AutoFixOptions {
dryRun?: boolean;
}
export type FixStatus = 'applied' | 'proposed' | 'skipped' | 'error';
export type SkipReason =
| 'working_tree_dirty'
| 'no_git_backup'
| 'inside_code_fence'
| 'already_delegated'
| 'ambiguous_multiple_matches'
| 'block_is_callout'
| 'file_missing'
| 'read_error'
| 'write_error';
export interface FixOutcome {
skill: string;
skillPath: string; // absolute
patternLabel: string;
status: FixStatus;
reason?: SkipReason | string;
before?: string; // snippet (the expanded block)
after?: string; // replacement line
}
export interface AutoFixReport {
fixed: FixOutcome[]; // applied writes (or proposals in dryRun)
skipped: FixOutcome[]; // skips and errors
}
// ---------------------------------------------------------------------------
// Block-expansion strategy map
// ---------------------------------------------------------------------------
export type BlockShape = 'bullet' | 'blockquote' | 'paragraph';
export interface Block {
startLine: number; // 0-indexed inclusive
endLine: number; // 0-indexed inclusive
}
/** Detect which block shape the line at `lineIdx` belongs to. */
export function detectBlockShape(lines: string[], lineIdx: number): BlockShape {
const line = lines[lineIdx] ?? '';
if (/^(\s*)(?:[-*]\s|\d+\.\s)/.test(line)) return 'bullet';
if (/^>\s/.test(line)) return 'blockquote';
return 'paragraph';
}
/** Expand a bullet item: start at the bullet line, end at the next sibling
* or shallower bullet (sub-bullets included). */
export function expandBullet(lines: string[], lineIdx: number): Block | null {
const line = lines[lineIdx] ?? '';
const indentMatch = line.match(/^(\s*)(?:[-*]\s|\d+\.\s)/);
if (!indentMatch) return null;
const baseIndent = indentMatch[1].length;
// Walk up to find the start of THIS bullet (in case match is on a
// continuation line of a multi-line bullet).
let start = lineIdx;
while (start > 0) {
const prev = lines[start - 1];
const prevIsBullet = /^(\s*)(?:[-*]\s|\d+\.\s)/.test(prev);
const prevIndent = prev.match(/^(\s*)/)?.[1].length ?? 0;
if (prevIsBullet && prevIndent <= baseIndent) break;
if (prev.trim() === '') break;
start--;
}
// Walk down: continue until a bullet at <= baseIndent (sibling or
// shallower), a blank line, or end of file.
let end = lineIdx;
for (let i = lineIdx + 1; i < lines.length; i++) {
const l = lines[i];
if (l.trim() === '') break;
const isBullet = /^(\s*)(?:[-*]\s|\d+\.\s)/.test(l);
const indent = l.match(/^(\s*)/)?.[1].length ?? 0;
if (isBullet && indent <= baseIndent) break;
end = i;
}
return { startLine: start, endLine: end };
}
/** Expand a blockquote: contiguous `>` lines. Returns null if the block is
* itself a `> **Convention:**` or `> **Filing rule:**` callout (don't
* rewrite a reference into a reference). */
export function expandBlockquote(lines: string[], lineIdx: number): Block | null {
if (!/^>\s/.test(lines[lineIdx] ?? '')) return null;
let start = lineIdx;
while (start > 0 && /^>\s/.test(lines[start - 1])) start--;
let end = lineIdx;
while (end + 1 < lines.length && /^>\s/.test(lines[end + 1])) end++;
const firstLine = lines[start] ?? '';
if (/\*\*(?:Convention|Filing rule):\*\*/.test(firstLine)) {
return null; // this IS a delegation callout already
}
return { startLine: start, endLine: end };
}
/** Expand a paragraph: previous blank line → next blank line. */
export function expandParagraph(lines: string[], lineIdx: number): Block | null {
let start = lineIdx;
while (start > 0 && lines[start - 1].trim() !== '') start--;
let end = lineIdx;
while (end + 1 < lines.length && lines[end + 1].trim() !== '') end++;
return { startLine: start, endLine: end };
}
export const expanders: Record<BlockShape, (lines: string[], lineIdx: number) => Block | null> = {
bullet: expandBullet,
blockquote: expandBlockquote,
paragraph: expandParagraph,
};
// ---------------------------------------------------------------------------
// Guards
// ---------------------------------------------------------------------------
/** True when the match offset sits inside a fenced code block (``` ... ```).
* Counts triple-backtick fences at line starts. Odd count = inside. */
export function isInsideCodeFence(content: string, offset: number): boolean {
const before = content.slice(0, offset);
const fenceRe = /^```/gm;
const fenceCount = (before.match(fenceRe) || []).length;
return fenceCount % 2 === 1;
}
export type WorkingTreeStatus = 'clean' | 'dirty' | 'not_a_repo';
/** Check the git state of a skill file. Three distinct outcomes — callers
* must NOT conflate "not a repo" with "clean", because the auto-fix
* contract is "git is the backup" and writing to a file outside any repo
* destroys user data with no recovery path.
*
* `execFileSync` with array args bypasses the shell entirely, so paths
* with odd characters from a manifest can't inject commands. */
export function getWorkingTreeStatus(skillPath: string): WorkingTreeStatus {
try {
const out = execFileSync('git', ['status', '--porcelain', '--', skillPath], {
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'ignore'],
cwd: dirname(skillPath),
});
return out.trim().length > 0 ? 'dirty' : 'clean';
} catch {
// git exits 128 when not inside a repo; treat any non-zero the same.
return 'not_a_repo';
}
}
/** Legacy wrapper. Callers that need to distinguish not_a_repo from clean
* should use getWorkingTreeStatus() directly. */
export function isWorkingTreeDirty(skillPath: string): boolean {
return getWorkingTreeStatus(skillPath) === 'dirty';
}
// ---------------------------------------------------------------------------
// Manifest loading (duplicated from check-resolvable.ts to avoid exporting
// that internal helper — kept in sync via tests)
// ---------------------------------------------------------------------------
interface ManifestEntry {
name: string;
path: string;
}
function loadManifest(skillsDir: string): ManifestEntry[] {
const manifestPath = join(skillsDir, 'manifest.json');
if (!existsSync(manifestPath)) return [];
try {
const content = JSON.parse(readFileSync(manifestPath, 'utf-8'));
return content.skills || [];
} catch {
return [];
}
}
// ---------------------------------------------------------------------------
// Main function
// ---------------------------------------------------------------------------
/**
* Auto-repair DRY violations across every skill in the manifest.
*
* @param skillsDir — path to the `skills/` directory
* @param opts.dryRun — if true, do not write; return proposed edits
*/
export function autoFixDryViolations(
skillsDir: string,
opts: AutoFixOptions = {}
): AutoFixReport {
const fixed: FixOutcome[] = [];
const skipped: FixOutcome[] = [];
const manifest = loadManifest(skillsDir);
for (const skill of manifest) {
const skillPath = join(skillsDir, skill.path);
if (!existsSync(skillPath)) {
// Manifest-present but file-missing is already reported by
// checkResolvable as 'missing_file'; don't double-report here.
continue;
}
let content: string;
try {
content = readFileSync(skillPath, 'utf-8');
} catch (e: any) {
skipped.push({
skill: skill.name,
skillPath,
patternLabel: '(all)',
status: 'error',
reason: 'read_error',
});
continue;
}
// Compute delegations fresh per pattern — a prior applied fix inserts
// a new Convention callout that should inform later patterns'
// idempotency checks.
let delegations = extractDelegationTargets(content);
for (const cut of CROSS_CUTTING_PATTERNS) {
const outcome = attemptFix(skill.name, skillPath, content, delegations, cut, opts);
if (!outcome) continue;
if (outcome.status === 'applied' || outcome.status === 'proposed') {
fixed.push(outcome);
if (outcome.status === 'applied') {
try {
content = readFileSync(skillPath, 'utf-8');
delegations = extractDelegationTargets(content);
} catch {
break;
}
}
} else {
skipped.push(outcome);
}
}
}
return { fixed, skipped };
}
function attemptFix(
skillName: string,
skillPath: string,
content: string,
delegations: ReturnType<typeof extractDelegationTargets>,
cut: CrossCuttingPattern,
opts: AutoFixOptions
): FixOutcome | null {
const base = {
skill: skillName,
skillPath,
patternLabel: cut.label,
};
// Find ALL matches first (for multi-match detection).
const globalRe = new RegExp(
cut.pattern.source,
cut.pattern.flags.includes('g') ? cut.pattern.flags : cut.pattern.flags + 'g'
);
const matches = [...content.matchAll(globalRe)];
if (matches.length === 0) return null;
if (matches.length > 1) {
return { ...base, status: 'skipped', reason: 'ambiguous_multiple_matches' };
}
const m = matches[0];
const offset = m.index ?? 0;
if (isInsideCodeFence(content, offset)) {
return { ...base, status: 'skipped', reason: 'inside_code_fence' };
}
// Compute match line (1-indexed) to evaluate idempotency.
// Use the same proximity window as the detector (DRY_PROXIMITY_LINES)
// so the fixer can't re-fire on blocks the detector already suppresses.
const matchLine = content.slice(0, offset).split('\n').length;
const alreadyDelegated = delegations.some(
d => cut.conventions.includes(d.convention) && Math.abs(d.line - matchLine) <= DRY_PROXIMITY_LINES
);
if (alreadyDelegated) {
return { ...base, status: 'skipped', reason: 'already_delegated' };
}
const treeStatus = getWorkingTreeStatus(skillPath);
if (treeStatus === 'dirty') {
return { ...base, status: 'skipped', reason: 'working_tree_dirty' };
}
if (treeStatus === 'not_a_repo') {
// File isn't tracked by git — writing would destroy the user's only
// copy with no rollback path. Refuse.
return { ...base, status: 'skipped', reason: 'no_git_backup' };
}
// Expand to block boundary.
const lines = content.split('\n');
const lineIdx = matchLine - 1; // 0-indexed
const shape = detectBlockShape(lines, lineIdx);
const expander = expanders[shape];
const block = expander(lines, lineIdx);
if (!block) {
return { ...base, status: 'skipped', reason: 'block_is_callout' };
}
// Build replacement line.
const canonical = cut.conventions[0];
const replacement = `> **Convention:** See \`skills/${canonical}\` for ${cut.label}.`;
// Splice: replace lines[startLine..endLine] with [replacement].
const before = lines.slice(0, block.startLine).join('\n');
const originalBlock = lines.slice(block.startLine, block.endLine + 1).join('\n');
const after = lines.slice(block.endLine + 1).join('\n');
// Preserve structure: one newline between sections, preserve the file's
// trailing newline if the original had one (POSIX convention).
const parts: string[] = [];
if (before.length > 0) parts.push(before);
parts.push(replacement);
if (after.length > 0) parts.push(after);
let next = parts.join('\n');
if (content.endsWith('\n') && !next.endsWith('\n')) {
next += '\n';
}
if (opts.dryRun) {
return {
...base,
status: 'proposed',
before: originalBlock,
after: replacement,
};
}
try {
writeFileSync(skillPath, next, 'utf-8');
} catch {
return { ...base, status: 'error', reason: 'write_error' };
}
return {
...base,
status: 'applied',
before: originalBlock,
after: replacement,
};
}