docguard-cli 0.30.1 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -22,10 +22,50 @@
22
22
  * docguard verify [--semantic | --instructions] [--format json]
23
23
  */
24
24
 
25
+ import { basename } from 'node:path';
25
26
  import { c } from '../shared.mjs';
26
27
  import { detectAgentMode } from '../ensure-skills.mjs';
27
28
  import { extractSemanticClaims, buildSemanticVerifyTasks } from '../scanners/semantic-claims.mjs';
28
29
  import { auditInstructions } from '../scanners/instruction-audit.mjs';
30
+ import { isGitRepo, getDiffText } from '../shared-git.mjs';
31
+ import { parseUnifiedDiff, activityLabeledDiff } from '../shared-diff.mjs';
32
+
33
+ const CHANGE_CODE_EXT = /\.(ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|kt|rb|php|cs|swift|scala|dart)$/;
34
+
35
+ /**
36
+ * Structured change context for staged agent tasks (feat 6). When `--since` is
37
+ * given, decompose the code diff into activity-labeled spans (ordered
38
+ * replace/delete/add) — the CARL-CCI representation shown to beat raw-text
39
+ * diffs (arXiv 2512.19883). The agent judging a claim then sees WHAT changed,
40
+ * not just the claim. Returns null when there's no ref / git / diff.
41
+ *
42
+ * Bounded: caps files and per-activity lines so the JSON stays agent-sized.
43
+ */
44
+ function buildChangeContext(projectDir, since, { maxFiles = 40, maxLines = 6 } = {}) {
45
+ if (!since || !isGitRepo(projectDir)) return null;
46
+ const files = parseUnifiedDiff(getDiffText(projectDir, since))
47
+ .filter(f => f.newPath && CHANGE_CODE_EXT.test(f.newPath) && f.status !== 'deleted');
48
+ if (files.length === 0) return null;
49
+ const clip = (arr) => arr.slice(0, maxLines).map(s => s.length > 200 ? s.slice(0, 200) + '…' : s);
50
+ const activities = files.slice(0, maxFiles).map(f => ({
51
+ file: f.newPath,
52
+ activities: activityLabeledDiff(f).map(a => ({
53
+ type: a.type,
54
+ ...(a.del ? { del: clip(a.del) } : {}),
55
+ ...(a.add ? { add: clip(a.add) } : {}),
56
+ })),
57
+ }));
58
+ return { since, changedFiles: files.map(f => f.newPath), activities };
59
+ }
60
+
61
+ // Best-effort: is this semantic-verify task about code that just changed?
62
+ function taskTouchesChange(task, changedSet, changedBasenames) {
63
+ const cite = task.citedCode || '';
64
+ if (!cite) return false;
65
+ if (changedSet.has(cite)) return true;
66
+ const b = basename(cite);
67
+ return changedBasenames.has(b) || [...changedSet].some(p => cite.includes(p) || p.includes(cite));
68
+ }
29
69
 
30
70
  export function runVerify(projectDir, config, flags) {
31
71
  if (flags.instructions) {
@@ -37,13 +77,27 @@ export function runVerify(projectDir, config, flags) {
37
77
  const claims = extractSemanticClaims(projectDir, config);
38
78
  const tasks = buildSemanticVerifyTasks(claims);
39
79
 
80
+ // Change-aware staging (feat 6): if --since given, attach the structured diff
81
+ // and flag which claims are about just-changed code (verify those first).
82
+ const changeContext = buildChangeContext(projectDir, flags.since);
83
+ if (changeContext) {
84
+ const changedSet = new Set(changeContext.changedFiles);
85
+ const changedBasenames = new Set(changeContext.changedFiles.map(f => basename(f)));
86
+ for (const t of tasks) t.aboutChangedCode = taskTouchesChange(t, changedSet, changedBasenames);
87
+ // Prioritize changed-code claims first.
88
+ tasks.sort((a, b) => (b.aboutChangedCode ? 1 : 0) - (a.aboutChangedCode ? 1 : 0));
89
+ }
90
+
40
91
  if (isJson) {
41
92
  console.log(JSON.stringify({
42
93
  command: 'verify --semantic',
43
94
  project: config.projectName,
44
95
  claimCount: tasks.length,
45
96
  // How to act on this: each task is a claim to confirm against the code.
46
- howToVerify: 'For each task, read the cited code (or grep for the constant/config), compare it to the documented value, and report any mismatch with both values. DocGuard cannot judge these — they require reading the code.',
97
+ howToVerify: changeContext
98
+ ? 'Claims flagged aboutChangedCode are about code that changed since the ref — verify those FIRST using changeContext.activities (the ordered replace/delete/add spans show exactly what changed). For each task, read the cited code, compare to the documented value, report mismatches with both values.'
99
+ : 'For each task, read the cited code (or grep for the constant/config), compare it to the documented value, and report any mismatch with both values. DocGuard cannot judge these — they require reading the code.',
100
+ ...(changeContext ? { changeContext } : {}),
47
101
  tasks,
48
102
  }, null, 2));
49
103
  return;
@@ -66,6 +120,10 @@ export function runVerify(projectDir, config, flags) {
66
120
  }
67
121
 
68
122
  console.log(` ${c.yellow}${tasks.length} claim(s) to verify against the code:${c.reset}\n`);
123
+ if (changeContext) {
124
+ const nChanged = tasks.filter(t => t.aboutChangedCode).length;
125
+ console.log(` ${c.cyan}⚡ ${nChanged} claim(s) are about code changed since ${flags.since}${c.reset} ${c.dim}— verify these first (structured diff in --format json).${c.reset}\n`);
126
+ }
69
127
  for (const [doc, ts] of byDoc) {
70
128
  console.log(` ${c.bold}${doc}${c.reset}`);
71
129
  for (const t of ts) {
@@ -89,6 +147,9 @@ function runInstructionAudit(projectDir, config, flags) {
89
147
  const { rules, deterministic, tasks } = auditInstructions(projectDir, config);
90
148
  const { duplicates, negations, stalePointers, staleCommands } = deterministic;
91
149
  const findingCount = duplicates.length + negations.length + stalePointers.length + staleCommands.length;
150
+ // Structured change context helps the agent judge whether a rule about code
151
+ // has been invalidated by a recent change (feat 6).
152
+ const changeContext = buildChangeContext(projectDir, flags.since);
92
153
 
93
154
  if (isJson) {
94
155
  console.log(JSON.stringify({
@@ -100,6 +161,7 @@ function runInstructionAudit(projectDir, config, flags) {
100
161
  taskCount: tasks.length,
101
162
  // How to act on this: findings are proven; tasks need judgment.
102
163
  howToVerify: 'The findings are deterministic — fix them directly (delete the duplicate copy, resolve the negation in favour of one rule, repoint or remove stale paths/commands). For each task, read both rules in context and judge whether they contradict in practice; if so, report which should win, why, and which file to edit. DocGuard cannot judge the tasks — they require understanding intent.',
164
+ ...(changeContext ? { changeContext } : {}),
103
165
  tasks,
104
166
  }, null, 2));
105
167
  return;
package/cli/config.mjs CHANGED
@@ -77,6 +77,13 @@ export function loadConfig(projectDir) {
77
77
  security: false,
78
78
  environment: true,
79
79
  freshness: true,
80
+ // v0.31.0 — all three default ON. Soft (confidence:low, never break CI),
81
+ // precise (zero false positives across the 6-repo corpus), and quiet when
82
+ // not applicable (no diff / no API-reference doc). api-doc-smells is
83
+ // low-yield but zero-FP, so on-by-default beats a self-counting split.
84
+ diffSuspicion: true,
85
+ referenceExistence: true,
86
+ apiDocSmells: true,
80
87
  },
81
88
  };
82
89
 
package/cli/docguard.mjs CHANGED
@@ -29,7 +29,7 @@ import { runScore } from './commands/score.mjs';
29
29
  import { runDiff } from './commands/diff.mjs';
30
30
  import { runAgents } from './commands/agents.mjs';
31
31
  import { runGenerate } from './commands/generate.mjs';
32
- import { runHooks } from './commands/hooks.mjs';
32
+ import { runHooks, runNudgeHook } from './commands/hooks.mjs';
33
33
  import { runBadge } from './commands/badge.mjs';
34
34
  import { runCI } from './commands/ci.mjs';
35
35
  import { runFix } from './commands/fix.mjs';
@@ -495,6 +495,32 @@ async function main() {
495
495
  i++;
496
496
  } else if (args[i] === '--no-fix') {
497
497
  flags.noFix = true;
498
+ } else if (args[i] === '--no-indirect') {
499
+ // impact: skip the reverse-import-graph (indirect code→doc) analysis.
500
+ flags.indirect = false;
501
+ } else if (args[i] === '--prs') {
502
+ // impact: open-PR doc-conflict analysis (needs the gh CLI).
503
+ flags.prs = true;
504
+ } else if (args[i] === '--claude') {
505
+ // hooks: install/remove the Claude Code agent nudge hook.
506
+ flags.claude = true;
507
+ } else if (args[i] === '--transport' && args[i + 1]) {
508
+ // mcp: stdio (default) or http (Streamable HTTP, team-shared server).
509
+ flags.transport = args[i + 1];
510
+ i++;
511
+ } else if (args[i] === '--port' && args[i + 1]) {
512
+ flags.port = args[i + 1];
513
+ i++;
514
+ } else if (args[i] === '--host' && args[i + 1]) {
515
+ flags.host = args[i + 1];
516
+ i++;
517
+ } else if (args[i] === '--api-key' && args[i + 1]) {
518
+ flags.apiKey = args[i + 1];
519
+ i++;
520
+ } else if (args[i] === '--path' && args[i + 1]) {
521
+ // mcp --transport http: HTTP mount path (default /mcp).
522
+ flags.path = args[i + 1];
523
+ i++;
498
524
  } else if (args[i] === '--signals') {
499
525
  flags.signals = true;
500
526
  } else if (args[i] === '--debate') {
@@ -545,7 +571,9 @@ async function main() {
545
571
  // `agent` emits a machine task graph (JSON by default) — it must be banner-
546
572
  // free and side-effect-free like the other read-only commands.
547
573
  // `mcp`: stdout IS the JSON-RPC transport — any banner byte corrupts the stream.
548
- const headless = jsonMode || flags.write || flags.checkOnly || flags.changedOnly || flags.quiet || flags.plan || command === 'agent' || command === 'mcp';
574
+ // `nudge-hook`: stdout is the Claude Code hook feedback channel any banner
575
+ // byte corrupts the JSON the hook runner parses.
576
+ const headless = jsonMode || flags.write || flags.checkOnly || flags.changedOnly || flags.quiet || flags.plan || command === 'agent' || command === 'mcp' || command === 'nudge-hook';
549
577
 
550
578
  if (!headless) printBanner();
551
579
 
@@ -572,6 +600,9 @@ async function main() {
572
600
  'verify',
573
601
  // mcp serves read-only tools over stdio — scaffolding writes are off-limits.
574
602
  'mcp',
603
+ // nudge-hook runs inside an agent's PostToolUse hook — it may write only
604
+ // its own .docguard/nudge-state.json throttle file, never scaffold skills.
605
+ 'nudge-hook',
575
606
  ]);
576
607
 
577
608
  // Silent auto-check: install skills/commands if missing. Skip entirely in
@@ -624,7 +655,9 @@ async function main() {
624
655
  process.exit(1);
625
656
  }
626
657
 
627
- if (DEPRECATED_COMMANDS[command] && !flags.quiet) {
658
+ // `hooks --claude` is a first-class new surface (agent nudge hook), not the
659
+ // deprecated git-hooks alias — no deprecation warning for it.
660
+ if (DEPRECATED_COMMANDS[command] && !flags.quiet && !(command === 'hooks' && flags.claude)) {
628
661
  const { since, replacement } = DEPRECATED_COMMANDS[command];
629
662
  console.error(`${c.yellow}⚠ Deprecated since v${since}:${c.reset} ${c.cyan}docguard ${command}${c.reset} → use ${c.cyan}${replacement}${c.reset}`);
630
663
  console.error(`${c.dim} The old form still works in v0.20.x but will be removed in v1.0. See MIGRATION-v0.20.md.${c.reset}`);
@@ -671,8 +704,18 @@ async function main() {
671
704
  runAgent(projectDir, config, flags);
672
705
  break;
673
706
  case 'hooks':
707
+ if (flags.claude) {
708
+ // Agent nudge hook (.claude/settings.json) — direct path, no wizard.
709
+ runHooks(projectDir, config, flags);
710
+ break;
711
+ }
674
712
  await runInit(projectDir, config, { ...flags, with: ['hooks'], skipPrompts: true });
675
713
  break;
714
+ case 'nudge-hook':
715
+ // Runtime for the Claude Code PostToolUse hook. stdout is the machine
716
+ // channel (headless — see the jsonMode/banner gate above).
717
+ runNudgeHook(projectDir);
718
+ break;
676
719
  case 'badge':
677
720
  await runInit(projectDir, config, { ...flags, with: ['badge'], skipPrompts: true });
678
721
  break;
package/cli/findings.mjs CHANGED
@@ -600,6 +600,38 @@ export const CODES = {
600
600
  help: 'Over 30% of sentences are conditional (if/unless/when…). Split conditionals into separate, unconditional requirements.',
601
601
  suppress: null,
602
602
  },
603
+
604
+ // ── v0.31.0 change-driven + IR detectors (all confidence:'low' / soft) ──
605
+ DSP001: {
606
+ validator: 'diff-suspicion',
607
+ title: 'Doc describes code that just changed',
608
+ help: 'A canonical doc (or agent-instruction file) references a code file AND shares wording with symbols removed/changed in that file since the compared revision. Deterministic diff-overlap rule (arXiv 2010.01625, F1 74.7). Low-confidence by design — re-read the doc against the current code; suppress the pairing if it is a false positive.',
609
+ suppress: null,
610
+ },
611
+ REF001: {
612
+ validator: 'reference-existence',
613
+ title: 'Doc references a code symbol that no longer exists',
614
+ help: 'A code-element reference in the doc matched source when the doc was last updated, but matches ZERO source instances at HEAD (two-revision check, arXiv 2212.01479). Excludes the two documented false-positive modes (removed-but-config-relevant flags, and symbols whose literal string was deleted while logic remains). Verify and update the reference.',
615
+ suppress: '<!-- docguard:ignore REF001 — still relevant, e.g. user-facing flag -->',
616
+ },
617
+ REF002: {
618
+ validator: 'reference-existence',
619
+ title: 'Code cites an ADR that has no document',
620
+ help: 'A code comment cites an Architecture Decision Record (e.g. ADR-012) that no ADR document defines — the citation is stale (renumbered, removed) or the ADR was never written. Numbers compare as integers, so ADR-0011 matches ADR-11. IETF RFC citations are deliberately not checked (external registry). Write the ADR, fix the number, or suppress on the citation line.',
621
+ suppress: '// docguard:ignore REF002 — your reason',
622
+ },
623
+ APS001: {
624
+ validator: 'api-doc-smells',
625
+ title: 'Bloated API documentation',
626
+ help: 'An API doc unit is excessively long / over-structured relative to the surface it documents (smell taxonomy, arXiv API-doc-smells; deterministic Bloated detector F1 0.90). Trim to the essential contract.',
627
+ suppress: '<!-- docguard:quality api-smell off — your reason -->',
628
+ },
629
+ APS002: {
630
+ validator: 'api-doc-smells',
631
+ title: 'Lazy API documentation',
632
+ help: 'An API doc unit is vague/generic or barely exceeds the signature it documents (deterministic Lazy detector F1 0.95). Document parameters, return, and errors concretely.',
633
+ suppress: '<!-- docguard:quality api-smell off — your reason -->',
634
+ },
603
635
  };
604
636
 
605
637
  /**
@@ -14,6 +14,7 @@
14
14
 
15
15
  import { existsSync, readFileSync, readdirSync } from 'node:fs';
16
16
  import { resolve, dirname } from 'node:path';
17
+ import { loadIgnorePatterns } from '../shared.mjs';
17
18
 
18
19
  /** chars/4 — the standard rough token estimate; consistency matters more than precision. */
19
20
  const estTokens = (s) => Math.ceil(s.length / 4);
@@ -31,9 +32,13 @@ function readIfExists(path) {
31
32
  function canonicalDocs(projectDir) {
32
33
  const dir = resolve(projectDir, 'docs-canonical');
33
34
  if (!existsSync(dir)) return [];
35
+ // Honor .docguardignore — an excluded doc (e.g. a historical audit) must
36
+ // not drag down the readability metrics either (same rule as the
37
+ // semantic-claim extractor, bug-212).
38
+ const isIgnored = loadIgnorePatterns(projectDir);
34
39
  try {
35
40
  return readdirSync(dir)
36
- .filter(f => f.toLowerCase().endsWith('.md'))
41
+ .filter(f => f.toLowerCase().endsWith('.md') && !isIgnored(`docs-canonical/${f}`))
37
42
  .sort()
38
43
  .map(f => ({ name: `docs-canonical/${f}`, content: readIfExists(resolve(dir, f)) }))
39
44
  .filter(d => d.content !== null);
@@ -23,6 +23,7 @@
23
23
 
24
24
  import { existsSync, readFileSync, readdirSync } from 'node:fs';
25
25
  import { resolve, join } from 'node:path';
26
+ import { loadIgnorePatterns } from '../shared.mjs';
26
27
 
27
28
  // Numbers are only claims when adjacent to a recognized unit.
28
29
  const NUMBER_PATTERNS = [
@@ -49,17 +50,24 @@ const MAX_CLAIMS = 80;
49
50
 
50
51
  /** Canonical docs + the root docs where limits/counts commonly live. */
51
52
  function claimSourceDocs(projectDir) {
53
+ // Honor .docguardignore: a doc the user explicitly excluded from validation
54
+ // (e.g. a historical audit full of point-in-time counts) must not feed the
55
+ // "unverified claims" pool either — it inflated the count and buried the
56
+ // claims that ARE actionable (bug-212).
57
+ const isIgnored = loadIgnorePatterns(projectDir);
52
58
  const docs = [];
53
59
  const canonical = resolve(projectDir, 'docs-canonical');
54
60
  if (existsSync(canonical)) {
55
61
  try {
56
62
  for (const f of readdirSync(canonical)) {
57
- if (f.toLowerCase().endsWith('.md')) docs.push(`docs-canonical/${f}`);
63
+ if (f.toLowerCase().endsWith('.md') && !isIgnored(`docs-canonical/${f}`)) {
64
+ docs.push(`docs-canonical/${f}`);
65
+ }
58
66
  }
59
67
  } catch { /* ignore */ }
60
68
  }
61
69
  for (const root of ['README.md', 'AGENTS.md']) {
62
- if (existsSync(resolve(projectDir, root))) docs.push(root);
70
+ if (existsSync(resolve(projectDir, root)) && !isIgnored(root)) docs.push(root);
63
71
  }
64
72
  return docs;
65
73
  }
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Shared Unified-Diff Parser + Tokenizer — zero-dependency foundation for the
3
+ * change-driven detectors added in v0.31.0.
4
+ *
5
+ * ONE parser, consumed by four features so they never re-implement diff
6
+ * scraping (each previously would have grepped `git diff` output ad hoc):
7
+ * - diff-overlap suspicion (validators/diff-suspicion.mjs): does a doc's
8
+ * wording overlap tokens that were DELETED/REPLACED-OLD in the code diff?
9
+ * - reference-existence (validators/reference-existence.mjs): which symbols
10
+ * left the tree between two revisions.
11
+ * - impact blast-radius (commands/impact.mjs): which docs cite changed code.
12
+ * - structured-diff staging (commands/verify.mjs): hand agents an ordered
13
+ * replace/delete/add representation (the CARL-CCI "activity-labeled diff"
14
+ * shown to beat raw-text diffs — arXiv 2512.19883) instead of a raw patch.
15
+ *
16
+ * Pure Node built-ins. No git here — this operates on diff TEXT a caller
17
+ * already produced (see shared-git.getDiffSpans). That keeps it unit-testable
18
+ * without a repo and reusable on any unified-diff string.
19
+ */
20
+
21
+ // ── Tokenizer ────────────────────────────────────────────────────────────────
22
+ // Identifier-aware: keeps `getUserById`, `user_id`, `UserService` whole, then
23
+ // also emits their sub-words so a doc saying "user id" still overlaps code
24
+ // token `user_id`. Deterministic, lowercase, stopword-filtered.
25
+
26
+ const STOPWORDS = new Set([
27
+ 'the', 'a', 'an', 'and', 'or', 'but', 'if', 'then', 'else', 'for', 'of', 'to',
28
+ 'in', 'on', 'at', 'by', 'as', 'is', 'are', 'was', 'were', 'be', 'been', 'this',
29
+ 'that', 'these', 'those', 'it', 'its', 'with', 'from', 'not', 'no', 'we', 'you',
30
+ 'return', 'returns', 'const', 'let', 'var', 'function', 'class', 'import',
31
+ 'export', 'default', 'new', 'true', 'false', 'null', 'void', 'public', 'private',
32
+ ]);
33
+
34
+ /**
35
+ * Split identifiers into sub-words: getUserById → [get,user,by,id];
36
+ * user_id → [user,id]; HTTPServer → [http,server].
37
+ */
38
+ export function splitIdentifier(id) {
39
+ return String(id)
40
+ // camelCase / PascalCase / ACRONYMBoundary
41
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
42
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
43
+ // snake_case, kebab-case, dot.paths
44
+ .replace(/[_\-.]+/g, ' ')
45
+ .toLowerCase()
46
+ .split(/\s+/)
47
+ .filter(Boolean);
48
+ }
49
+
50
+ /**
51
+ * Tokenize free text OR code into a lowercase word set. Keeps whole identifiers
52
+ * AND their sub-words. `min` drops tokens shorter than it (default 3) to cut
53
+ * noise; identifiers below `min` after splitting are still dropped.
54
+ *
55
+ * Returns an array (call-site decides Set vs list). Deduped, order-preserving.
56
+ */
57
+ export function tokenize(text, { min = 3, keepStopwords = false } = {}) {
58
+ const out = [];
59
+ const seen = new Set();
60
+ const raw = String(text).match(/[A-Za-z_][A-Za-z0-9_.-]*/g) || [];
61
+ for (const word of raw) {
62
+ // the whole identifier (lowercased) …
63
+ const whole = word.toLowerCase();
64
+ for (const t of [whole, ...splitIdentifier(word)]) {
65
+ if (t.length < min) continue;
66
+ if (!keepStopwords && STOPWORDS.has(t)) continue;
67
+ if (seen.has(t)) continue;
68
+ seen.add(t);
69
+ out.push(t);
70
+ }
71
+ }
72
+ return out;
73
+ }
74
+
75
+ // ── Unified-diff parser ──────────────────────────────────────────────────────
76
+
77
+ /**
78
+ * Parse a unified diff (git diff / diff -u) into structured file entries.
79
+ * Handles multi-file diffs, adds/deletes (/dev/null), renames, and the
80
+ * "" marker.
81
+ *
82
+ * Returns: [{ oldPath, newPath, status, hunks: [{ oldStart, newStart,
83
+ * lines: [{ op: ' '|'-'|'+', text }] }] }]
84
+ * status ∈ 'modified' | 'added' | 'deleted' | 'renamed'.
85
+ */
86
+ export function parseUnifiedDiff(diffText) {
87
+ const files = [];
88
+ if (!diffText) return files;
89
+ const lines = String(diffText).split('\n');
90
+ let cur = null;
91
+ let hunk = null;
92
+
93
+ const pushHunkHeader = (m) => {
94
+ hunk = { oldStart: parseInt(m[1], 10) || 0, newStart: parseInt(m[2], 10) || 0, lines: [] };
95
+ cur.hunks.push(hunk);
96
+ };
97
+
98
+ for (const line of lines) {
99
+ if (line.startsWith('diff --git')) {
100
+ // a/<old> b/<new> — quotes possible but rare; keep it simple.
101
+ const m = line.match(/^diff --git a\/(.+?) b\/(.+)$/);
102
+ cur = { oldPath: m ? m[1] : null, newPath: m ? m[2] : null, status: 'modified', hunks: [] };
103
+ files.push(cur);
104
+ hunk = null;
105
+ continue;
106
+ }
107
+ if (!cur) continue; // ignore any preamble before the first file
108
+ if (line.startsWith('rename from ')) { cur.status = 'renamed'; cur.oldPath = line.slice(12); continue; }
109
+ if (line.startsWith('rename to ')) { cur.status = 'renamed'; cur.newPath = line.slice(10); continue; }
110
+ if (line.startsWith('--- ')) {
111
+ const p = line.slice(4);
112
+ if (p === '/dev/null') cur.status = 'added';
113
+ else cur.oldPath = p.replace(/^a\//, '');
114
+ continue;
115
+ }
116
+ if (line.startsWith('+++ ')) {
117
+ const p = line.slice(4);
118
+ if (p === '/dev/null') cur.status = 'deleted';
119
+ else cur.newPath = p.replace(/^b\//, '');
120
+ continue;
121
+ }
122
+ const hm = line.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
123
+ if (hm) { pushHunkHeader(hm); continue; }
124
+ if (!hunk) continue;
125
+ if (line.startsWith('\\')) continue; // ""
126
+ const op = line[0];
127
+ if (op === ' ' || op === '+' || op === '-') {
128
+ hunk.lines.push({ op, text: line.slice(1) });
129
+ }
130
+ }
131
+ return files;
132
+ }
133
+
134
+ /**
135
+ * Decompose a file's hunks into an ORDERED activity list — the CARL-CCI
136
+ * "activity-labeled" representation. Consecutive removed/added runs are grouped:
137
+ * run of '-' then '+' → { type: 'replace', del: [...], add: [...] }
138
+ * run of '-' only → { type: 'delete', del: [...] }
139
+ * run of '+' only → { type: 'add', add: [...] }
140
+ * Context lines are the separators and are not emitted (they're not a change).
141
+ *
142
+ * This is what makes staged agent tasks legible: the agent sees "these lines
143
+ * were replaced by those", not a flat blob.
144
+ */
145
+ export function activityLabeledDiff(fileDiff) {
146
+ const acts = [];
147
+ for (const h of fileDiff.hunks || []) {
148
+ let del = [];
149
+ let add = [];
150
+ const flush = () => {
151
+ if (del.length && add.length) acts.push({ type: 'replace', del, add });
152
+ else if (del.length) acts.push({ type: 'delete', del });
153
+ else if (add.length) acts.push({ type: 'add', add });
154
+ del = []; add = [];
155
+ };
156
+ for (const ln of h.lines) {
157
+ if (ln.op === '-') {
158
+ if (add.length) flush(); // an add run ended before this delete → boundary
159
+ del.push(ln.text);
160
+ } else if (ln.op === '+') {
161
+ add.push(ln.text);
162
+ } else {
163
+ flush(); // context terminates the current activity
164
+ }
165
+ }
166
+ flush();
167
+ }
168
+ return acts;
169
+ }
170
+
171
+ /**
172
+ * Tokens that LEFT the code in this file diff — union over every '-' line
173
+ * (both pure deletes and the "old" side of replaces). This is the
174
+ * `deleted ∪ replaceOld` span the outdated-comment research keys on: a doc
175
+ * that still talks about these tokens is a drift suspect.
176
+ */
177
+ export function removedTokens(fileDiff, opts) {
178
+ const set = new Set();
179
+ for (const h of fileDiff.hunks || []) {
180
+ for (const ln of h.lines) {
181
+ if (ln.op === '-') for (const t of tokenize(ln.text, opts)) set.add(t);
182
+ }
183
+ }
184
+ return set;
185
+ }
186
+
187
+ /** Tokens that were ADDED ('+' lines) — the new-side span. */
188
+ export function addedTokens(fileDiff, opts) {
189
+ const set = new Set();
190
+ for (const h of fileDiff.hunks || []) {
191
+ for (const ln of h.lines) {
192
+ if (ln.op === '+') for (const t of tokenize(ln.text, opts)) set.add(t);
193
+ }
194
+ }
195
+ return set;
196
+ }
197
+
198
+ /**
199
+ * Overlap score between a doc's tokens and a set of change tokens: the count
200
+ * and the shared tokens themselves (for explainable findings). Deterministic.
201
+ */
202
+ export function tokenOverlap(docTokens, changeTokenSet) {
203
+ const shared = [];
204
+ const seen = new Set();
205
+ for (const t of docTokens) {
206
+ if (changeTokenSet.has(t) && !seen.has(t)) { seen.add(t); shared.push(t); }
207
+ }
208
+ return { count: shared.length, shared };
209
+ }
@@ -148,6 +148,99 @@ export function changedFilesSince(dir, ref = 'HEAD~1') {
148
148
  }
149
149
  }
150
150
 
151
+ /**
152
+ * Return the raw unified-diff TEXT between `ref` and HEAD, restricted to code
153
+ * files (docs are excluded — a doc changing is not a code change that could
154
+ * make OTHER docs stale). Consumed by shared-diff.parseUnifiedDiff.
155
+ *
156
+ * `-U0`? No — we want a few lines of context so the parser can group activities
157
+ * and callers can see surrounding tokens; default 3 is fine. Returns '' on
158
+ * error / no diff. Caps output at ~5MB so a giant refactor can't OOM the CLI.
159
+ */
160
+ export function getDiffText(dir, ref = 'HEAD~1', pathspec = null) {
161
+ try {
162
+ const args = ['diff', '--no-color', '--no-ext-diff', ref, 'HEAD'];
163
+ if (pathspec && pathspec.length) args.push('--', ...pathspec);
164
+ const raw = execFileSync('git', args, {
165
+ cwd: dir, encoding: 'utf-8',
166
+ stdio: ['pipe', 'pipe', 'ignore'],
167
+ maxBuffer: 1024 * 1024 * 5,
168
+ });
169
+ return raw || '';
170
+ } catch {
171
+ return '';
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Read a file's contents AS OF a given revision (e.g. the commit where a doc
177
+ * was last touched), following the `<rev>:<path>` git addressing. Returns null
178
+ * when the path didn't exist at that rev, the rev is unknown, or git is
179
+ * unavailable — callers treat null as "no prior snapshot to compare".
180
+ *
181
+ * This is the backbone of the two-revision reference-existence check: read the
182
+ * source at the doc's last-updated commit vs HEAD and diff symbol presence.
183
+ */
184
+ export function fileContentAtRev(dir, rev, filePath) {
185
+ try {
186
+ const raw = execFileSync(
187
+ 'git',
188
+ ['show', `${rev}:${filePath}`],
189
+ { cwd: dir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'], maxBuffer: 1024 * 1024 * 10 }
190
+ );
191
+ return raw;
192
+ } catch {
193
+ return null;
194
+ }
195
+ }
196
+
197
+ // Default source globs for symbol-existence grep (any-language).
198
+ export const CODE_GLOBS = [
199
+ '*.ts', '*.tsx', '*.js', '*.jsx', '*.mjs', '*.cjs', '*.py', '*.go', '*.rs',
200
+ '*.java', '*.kt', '*.rb', '*.php', '*.cs', '*.swift', '*.scala', '*.dart', '*.c', '*.cpp', '*.h',
201
+ ];
202
+
203
+ /**
204
+ * True if `symbol` appears as a whole word in the source tree AS OF `rev`.
205
+ * Uses `git grep -w -F` (fixed string, word boundary) at the given revision —
206
+ * exactly the "whole-word, case-sensitive, exact string match" the two-revision
207
+ * outdated-reference method specifies (arXiv 2212.01479). Restricted to code
208
+ * globs so a symbol still named in prose/docs doesn't count as "present".
209
+ *
210
+ * Returns false when absent, the rev is unknown, or git is unavailable — the
211
+ * caller pairs two calls (doc's last-update rev vs HEAD) to detect present→gone.
212
+ */
213
+ export function symbolExistsAtRev(dir, symbol, rev, pathspecs = CODE_GLOBS) {
214
+ try {
215
+ execFileSync(
216
+ 'git',
217
+ ['grep', '-q', '-w', '-F', '-e', symbol, rev, '--', ...pathspecs],
218
+ { cwd: dir, stdio: ['pipe', 'ignore', 'ignore'] }
219
+ );
220
+ return true; // exit 0 → at least one match
221
+ } catch {
222
+ return false; // exit 1 → no match (or bad rev / no git)
223
+ }
224
+ }
225
+
226
+ /**
227
+ * Resolve the commit hash that last touched `filePath` (following renames), or
228
+ * null. Used to anchor "the revision when this doc was last updated" for the
229
+ * two-revision check without re-parsing getFileHistory at every call site.
230
+ */
231
+ export function lastCommitHash(dir, filePath) {
232
+ try {
233
+ const raw = execFileSync(
234
+ 'git',
235
+ ['log', '--follow', '-1', '--format=%H', '--', filePath],
236
+ { cwd: dir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] }
237
+ ).trim();
238
+ return raw || null;
239
+ } catch {
240
+ return null;
241
+ }
242
+ }
243
+
151
244
  /**
152
245
  * Resolve the absolute path to this repo's git hooks directory.
153
246
  *