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.
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Shared IR primitives — zero-dependency TF-IDF + cosine similarity.
3
+ *
4
+ * Backs IR-based traceability link recovery (feat 5): when a requirement has no
5
+ * exact `@req <ID>` annotation, rank candidate test/code artifacts by textual
6
+ * similarity (Vector Space Model, the canonical IR traceability technique —
7
+ * "basic linear algebra, no external services"). A requirement with NO
8
+ * candidate above threshold is a strong "unimplemented / untested" signal.
9
+ *
10
+ * Pure functions over token arrays; the caller tokenizes (we reuse the
11
+ * identifier-aware tokenizer from shared-diff so `getUserById` in code matches
12
+ * "get user by id" in a requirement).
13
+ */
14
+
15
+ /** term → count for one document's tokens. */
16
+ export function termFreq(tokens) {
17
+ const tf = new Map();
18
+ for (const t of tokens) tf.set(t, (tf.get(t) || 0) + 1);
19
+ return tf;
20
+ }
21
+
22
+ /**
23
+ * Inverse document frequency across a corpus of token arrays.
24
+ * idf(t) = ln(N / (1 + df(t))) + 1 — smoothed so a term in every doc still
25
+ * carries a small positive weight (avoids all-zero vectors on tiny corpora).
26
+ */
27
+ export function buildIdf(corpusTokenArrays) {
28
+ const N = corpusTokenArrays.length || 1;
29
+ const df = new Map();
30
+ for (const tokens of corpusTokenArrays) {
31
+ for (const t of new Set(tokens)) df.set(t, (df.get(t) || 0) + 1);
32
+ }
33
+ const idf = new Map();
34
+ for (const [t, d] of df) idf.set(t, Math.log(N / (1 + d)) + 1);
35
+ return idf;
36
+ }
37
+
38
+ /** TF-IDF vector (Map term→weight) for a document, given a prebuilt idf. */
39
+ export function tfidfVector(tokens, idf) {
40
+ const tf = termFreq(tokens);
41
+ const vec = new Map();
42
+ const len = tokens.length || 1;
43
+ for (const [t, count] of tf) {
44
+ const w = idf.get(t);
45
+ if (w === undefined) continue; // term not in corpus idf → skip
46
+ vec.set(t, (count / len) * w); // normalized TF × IDF
47
+ }
48
+ return vec;
49
+ }
50
+
51
+ /** Cosine similarity of two sparse Map vectors. 0 when either is empty. */
52
+ export function cosineSimilarity(a, b) {
53
+ if (a.size === 0 || b.size === 0) return 0;
54
+ // iterate the smaller for the dot product
55
+ const [small, large] = a.size <= b.size ? [a, b] : [b, a];
56
+ let dot = 0;
57
+ for (const [t, w] of small) {
58
+ const w2 = large.get(t);
59
+ if (w2 !== undefined) dot += w * w2;
60
+ }
61
+ if (dot === 0) return 0;
62
+ let na = 0; for (const w of a.values()) na += w * w;
63
+ let nb = 0; for (const w of b.values()) nb += w * w;
64
+ const denom = Math.sqrt(na) * Math.sqrt(nb);
65
+ return denom === 0 ? 0 : dot / denom;
66
+ }
67
+
68
+ /**
69
+ * Rank candidate documents against a query by cosine similarity.
70
+ * @param queryTokens tokens of the requirement text
71
+ * @param candidates [{ id, tokens }]
72
+ * @returns [{ id, score }] sorted desc, scores in [0,1]
73
+ */
74
+ export function rankBySimilarity(queryTokens, candidates) {
75
+ const corpus = [queryTokens, ...candidates.map(c => c.tokens)];
76
+ const idf = buildIdf(corpus);
77
+ const qv = tfidfVector(queryTokens, idf);
78
+ return candidates
79
+ .map(c => ({ id: c.id, score: cosineSimilarity(qv, tfidfVector(c.tokens, idf)) }))
80
+ .sort((x, y) => y.score - x.score);
81
+ }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * API-Doc-Smells validator (APS001 Bloated, APS002 Lazy) — v0.31.0.
3
+ *
4
+ * Research: the API-documentation-smell taxonomy (Bloated, Excess Structural
5
+ * Info, Tangled, Fragmented, Lazy) with a 1,000-unit benchmark. The two smells
6
+ * with strong DETERMINISTIC detectors are Bloated (F1 0.90) and Lazy (F1 0.95),
7
+ * keyed on documentation length relative to the surface documented — no ML.
8
+ * The three semantic smells need BERT and are deliberately left to staged agent
9
+ * judgment (verify --semantic), matching DocGuard's split.
10
+ *
11
+ * We apply the length signals per "API documentation unit" = a markdown section
12
+ * whose HEADING is a code signature (an HTTP endpoint `GET /path`, a function
13
+ * `foo(...)`, or a backticked symbol). Prose-only sections are ignored — that's
14
+ * doc-quality.mjs's job (passive voice, readability); this is API-surface-specific.
15
+ *
16
+ * Lazy — a documented endpoint/method with (almost) no explanation: ≤ N
17
+ * prose words of body. "Documented in name only."
18
+ * Bloated— a single unit that is grossly over-documented: ≥ M words.
19
+ *
20
+ * All findings confidence:'low' / soft — a nudge to right-size the doc.
21
+ */
22
+
23
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
24
+ import { resolve } from 'node:path';
25
+ import { mkFinding, resultFromFindings } from '../findings.mjs';
26
+
27
+ const HEADING = /^(#{1,6})\s+(.*)$/;
28
+ // A heading that documents an API/code unit — NOT a prose section heading.
29
+ // Precision (corpus-tuned): markdown headings routinely read "Some Words
30
+ // (parenthetical note)", which naively looks like a call. A real signature has
31
+ // NO space before `(` AND a code-shaped identifier (camelCase / snake_case /
32
+ // dotted). This kills FPs like "Remediation Log (2026-03-17)", "Unit Tests
33
+ // (vitest)", "4.1 Enrollment (Base Record)".
34
+ const HTTP_SIG = /^`?\s*(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+\/?`?\S/i;
35
+ const FUNC_SIG = /(?:^|[\s`.])([A-Za-z_][A-Za-z0-9_]*)\((?:\)|[^)]*\))/; // identifier(...) no space
36
+ const CODEY = /[a-z][A-Z]|_|\./; // camel/snake/dotted → code-shaped
37
+ const BACKTICK_SIG = /^`[^`\s][^`]*`\s*$/; // heading is exactly a `symbol`
38
+ function isSignatureHeading(text) {
39
+ const t = text.trim();
40
+ if (HTTP_SIG.test(t)) return true;
41
+ if (BACKTICK_SIG.test(t)) return true;
42
+ const fm = t.match(FUNC_SIG);
43
+ if (fm && (CODEY.test(fm[1]) || /\(\s*\)/.test(t))) return true; // codey name OR empty-arg call foo()
44
+ return false;
45
+ }
46
+
47
+ // Prose words in a body, EXCLUDING fenced code blocks (a big code sample isn't
48
+ // "explanation", so a unit with only code counts as Lazy).
49
+ function proseWordCount(bodyLines) {
50
+ let inFence = false;
51
+ let words = 0;
52
+ for (const line of bodyLines) {
53
+ if (/^\s*```/.test(line)) { inFence = !inFence; continue; }
54
+ if (inFence) continue;
55
+ const m = line.trim().match(/[A-Za-z0-9][A-Za-z0-9'-]*/g);
56
+ if (m) words += m.length;
57
+ }
58
+ return words;
59
+ }
60
+ function totalWordCount(bodyLines) {
61
+ let words = 0;
62
+ for (const line of bodyLines) {
63
+ const m = line.match(/[A-Za-z0-9][A-Za-z0-9'-]*/g);
64
+ if (m) words += m.length;
65
+ }
66
+ return words;
67
+ }
68
+
69
+ // Split markdown into signature-headed units: { heading, level, line, body[] }.
70
+ function extractUnits(content) {
71
+ const lines = content.split('\n');
72
+ const units = [];
73
+ let cur = null;
74
+ for (let i = 0; i < lines.length; i++) {
75
+ const hm = lines[i].match(HEADING);
76
+ if (hm) {
77
+ // close current unit at the next heading of same-or-higher level
78
+ if (cur && hm[1].length <= cur.level) { units.push(cur); cur = null; }
79
+ if (!cur && isSignatureHeading(hm[2])) {
80
+ cur = { heading: hm[2].trim(), level: hm[1].length, line: i + 1, body: [] };
81
+ continue;
82
+ }
83
+ if (cur) { cur.body.push(lines[i]); }
84
+ continue;
85
+ }
86
+ if (cur) cur.body.push(lines[i]);
87
+ }
88
+ if (cur) units.push(cur);
89
+ return units;
90
+ }
91
+
92
+ export function validateApiDocSmells(projectDir, config = {}) {
93
+ const cfg = config.apiDocSmells || {};
94
+ const lazyMax = Number.isInteger(cfg.lazyMaxWords) ? cfg.lazyMaxWords : 6;
95
+ const bloatedMin = Number.isInteger(cfg.bloatedMinWords) ? cfg.bloatedMinWords : 300;
96
+
97
+ const docsDir = resolve(projectDir, 'docs-canonical');
98
+ if (!existsSync(docsDir)) {
99
+ return resultFromFindings([], { passed: 0, total: 0, applicable: false });
100
+ }
101
+ let docFiles = [];
102
+ try { docFiles = readdirSync(docsDir).filter(f => f.endsWith('.md')); } catch { /* skip */ }
103
+
104
+ const findings = [];
105
+ let unitCount = 0;
106
+ for (const f of docFiles) {
107
+ let content;
108
+ try { content = readFileSync(resolve(docsDir, f), 'utf-8'); } catch { continue; }
109
+ const units = extractUnits(content);
110
+ for (const u of units) {
111
+ unitCount++;
112
+ const prose = proseWordCount(u.body);
113
+ const total = totalWordCount(u.body);
114
+ if (prose <= lazyMax) {
115
+ findings.push(mkFinding({
116
+ code: 'APS002',
117
+ validator: 'api-doc-smells',
118
+ severity: 'warn',
119
+ confidence: 'low',
120
+ message: `${f}: "${u.heading.slice(0, 60)}" is documented in name only (${prose} words of explanation) — Lazy API doc.`,
121
+ location: { file: f, line: u.line },
122
+ suggestion: { summary: `Describe what "${u.heading.slice(0, 40)}" does, its params, return, and errors — not just its signature.` },
123
+ }));
124
+ } else if (total >= bloatedMin) {
125
+ findings.push(mkFinding({
126
+ code: 'APS001',
127
+ validator: 'api-doc-smells',
128
+ severity: 'warn',
129
+ confidence: 'low',
130
+ message: `${f}: "${u.heading.slice(0, 60)}" is ${total} words for one unit — Bloated API doc; trim to the essential contract.`,
131
+ location: { file: f, line: u.line },
132
+ suggestion: { summary: `Split or trim "${u.heading.slice(0, 40)}" — move examples/edge-cases elsewhere and keep the core contract.` },
133
+ }));
134
+ }
135
+ }
136
+ }
137
+
138
+ return resultFromFindings(findings, {
139
+ passed: unitCount - findings.length,
140
+ total: unitCount,
141
+ applicable: unitCount > 0,
142
+ });
143
+ }
@@ -143,7 +143,14 @@ function validateConfigLayers(projectDir, config, layers, acc) {
143
143
 
144
144
  // ── Import Graph Builder ────────────────────────────────────────────────────
145
145
 
146
- function buildImportGraph(projectDir, config) {
146
+ /**
147
+ * Build the project's JS/TS import graph. Exported for reuse by `impact`
148
+ * (indirect code→doc analysis walks this graph's reverse edges) — one graph
149
+ * builder, not two.
150
+ *
151
+ * @returns {{files: string[], edges: {from,to,dynamic}[], fileMap: Map<string,string[]>}}
152
+ */
153
+ export function buildImportGraph(projectDir, config) {
147
154
  const graph = { files: [], edges: [], fileMap: new Map() };
148
155
 
149
156
  const allFiles = getFilesRecursive(projectDir, config, projectDir);
@@ -8,6 +8,13 @@
8
8
  * - Markdown relative links: [text](./OTHER.md)
9
9
  * [text](./OTHER.md#anchor)
10
10
  * [text](#anchor-in-same-doc)
11
+ * [text](<path with spaces.md>)
12
+ * - Obsidian wikilinks: [[OTHER]] [[OTHER#Heading]] [[OTHER|alias]]
13
+ * Validated only when the repo shows wikilinks-are-files evidence
14
+ * (`.obsidian/` exists, or at least one wikilink target resolves) — some
15
+ * repos use [[name]] as a non-file convention (template placeholders,
16
+ * memory links) and must not be flagged. Image embeds `![[x.png]]` are
17
+ * never treated as doc links.
11
18
  * - Bare anchor refs: see §3.2 ARCHITECTURE.md
12
19
  * (Section 3.2 in DATA-MODEL.md)
13
20
  * - Bracketed section refs: [Section X.Y]
@@ -29,6 +36,8 @@
29
36
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
30
37
  import { resolve, join, dirname, basename, relative } from 'node:path';
31
38
  import { mkFinding, resultFromFindings } from '../findings.mjs';
39
+ import { resolveDocDirs } from '../shared.mjs';
40
+ import { walkFiles } from '../shared-ignore.mjs';
32
41
 
33
42
  /**
34
43
  * Slugify a heading the way GitHub's markdown anchors work.
@@ -112,8 +121,13 @@ export function extractRefs(content, sourcePath) {
112
121
  // ./RELATIVE.md
113
122
  // ../OTHER.md#anchor
114
123
  // #intra-doc-anchor
124
+ // <path with spaces.md> (CommonMark angle-bracket form)
115
125
  // We DON'T match http(s) targets here.
116
126
  const markdownLinkRe = /\[([^\]]+)\]\(((?!https?:|mailto:)[^)]+)\)/g;
127
+ // [[Target]] / [[Target#Heading]] / [[Target|alias]]. The (?<!!) guard
128
+ // excludes Obsidian image embeds ![[img.png]] — an embed is content, not a
129
+ // doc cross-reference.
130
+ const wikiLinkRe = /(?<!!)\[\[([^\]|#\n]+)(?:#([^\]|\n]*))?(?:\|[^\]\n]*)?\]\]/g;
117
131
 
118
132
  for (let i = 0; i < lines.length; i++) {
119
133
  const line = lines[i];
@@ -130,8 +144,14 @@ export function extractRefs(content, sourcePath) {
130
144
  markdownLinkRe.lastIndex = 0;
131
145
  while ((m = markdownLinkRe.exec(stripped)) !== null) {
132
146
  const target = m[2].trim();
133
- // Drop any title text: [foo](bar "title") → bar
134
- const cleanTarget = target.split(/\s+/)[0];
147
+ let cleanTarget;
148
+ if (target.startsWith('<') && target.includes('>')) {
149
+ // Angle-bracket form: the target is everything inside <…>, spaces allowed.
150
+ cleanTarget = target.slice(1, target.indexOf('>'));
151
+ } else {
152
+ // Drop any title text: [foo](bar "title") → bar
153
+ cleanTarget = target.split(/\s+/)[0];
154
+ }
135
155
  const hashIdx = cleanTarget.indexOf('#');
136
156
  let file, anchor;
137
157
  if (hashIdx === 0) {
@@ -145,13 +165,55 @@ export function extractRefs(content, sourcePath) {
145
165
  file = cleanTarget;
146
166
  anchor = null;
147
167
  }
168
+ // `./repo.md?x=1#setup` targets repo.md — the query never names a file.
169
+ if (file) file = file.split('?')[0];
148
170
  refs.push({ source: sourcePath, file, anchor, raw: m[0], line: i + 1 });
149
171
  }
172
+
173
+ wikiLinkRe.lastIndex = 0;
174
+ while ((m = wikiLinkRe.exec(stripped)) !== null) {
175
+ const target = m[1].trim();
176
+ if (!target) continue;
177
+ const anchor = m[2] !== undefined ? m[2].trim() : null;
178
+ refs.push({ source: sourcePath, file: target, anchor: anchor || null, raw: m[0], line: i + 1, wiki: true });
179
+ }
150
180
  }
151
181
 
152
182
  return refs;
153
183
  }
154
184
 
185
+ /**
186
+ * Basename-stem → path index of every markdown file in the project's doc
187
+ * homes plus the already-collected canonical docs. Obsidian resolves
188
+ * wikilinks vault-wide by basename; this is the named-doc-dirs equivalent
189
+ * (never an arbitrary-subdir walk).
190
+ */
191
+ function buildWikiIndex(projectDir, config, docs) {
192
+ const idx = new Map();
193
+ const add = (p) => {
194
+ const stem = basename(p).replace(/\.mdx?$/i, '').toLowerCase();
195
+ if (!idx.has(stem)) idx.set(stem, p);
196
+ };
197
+ for (const d of docs) add(d);
198
+ for (const d of resolveDocDirs(projectDir, config)) {
199
+ const abs = resolve(projectDir, d);
200
+ if (!existsSync(abs)) continue;
201
+ walkFiles(abs, (full) => { if (/\.mdx?$/i.test(full)) add(full); });
202
+ }
203
+ return idx;
204
+ }
205
+
206
+ /** Resolve a wikilink target: sibling path, project root, then vault index. */
207
+ function resolveWikiTarget(sourcePath, target, projectDir, wikiIndex) {
208
+ const withExt = /\.mdx?$/i.test(target) ? target : `${target}.md`;
209
+ for (const base of [dirname(sourcePath), projectDir]) {
210
+ const p = resolve(base, withExt);
211
+ if (existsSync(p)) return p;
212
+ }
213
+ const stem = basename(withExt).replace(/\.mdx?$/i, '').toLowerCase();
214
+ return wikiIndex.get(stem) || null;
215
+ }
216
+
155
217
  /**
156
218
  * Resolve a target file path relative to a source markdown file.
157
219
  * Returns the absolute path or null if the file doesn't exist.
@@ -297,9 +359,10 @@ function collectCanonicalDocs(projectDir) {
297
359
  * errors/warnings arrays from the same findings, so counts, exit codes, and
298
360
  * existing tests are unaffected; guard just renders richer output.
299
361
  */
300
- export function validateCrossReferences(projectDir, _config = {}) {
362
+ export function validateCrossReferences(projectDir, config = {}) {
301
363
  const findings = [];
302
364
  const fixes = [];
365
+ const wikiRefs = []; // validated in a second pass — evidence gate needs the full set
303
366
  let passed = 0;
304
367
  let total = 0;
305
368
 
@@ -328,6 +391,10 @@ export function validateCrossReferences(projectDir, _config = {}) {
328
391
  const docName = basename(docPath);
329
392
 
330
393
  for (const ref of refs) {
394
+ if (ref.wiki) {
395
+ wikiRefs.push({ ...ref, docPath, docName });
396
+ continue;
397
+ }
331
398
  total++;
332
399
 
333
400
  // Resolve the target file (if any)
@@ -411,6 +478,60 @@ export function validateCrossReferences(projectDir, _config = {}) {
411
478
  }
412
479
  }
413
480
 
481
+ // ── Wikilink pass — only when the repo demonstrably uses [[x]] as FILE
482
+ // links: `.obsidian/` exists, or at least one wikilink target resolves.
483
+ // Repos using [[name]] as a non-file convention are skipped silently.
484
+ if (wikiRefs.length > 0) {
485
+ const wikiIndex = buildWikiIndex(projectDir, config, docs);
486
+ const resolved = wikiRefs.map(r => ({
487
+ r,
488
+ path: resolveWikiTarget(r.docPath, r.file, projectDir, wikiIndex),
489
+ }));
490
+ const evidence = existsSync(resolve(projectDir, '.obsidian')) || resolved.some(x => x.path);
491
+ if (evidence) {
492
+ for (const { r, path } of resolved) {
493
+ total++;
494
+ if (!path) {
495
+ findings.push(mkFinding({
496
+ code: 'XRF001',
497
+ validator: 'crossReference',
498
+ severity: 'warn',
499
+ message: `${r.docName}:${r.line} — broken wikilink: target "[[${r.file}]]" not found`,
500
+ location: `${relative(projectDir, r.docPath)}:${r.line}`,
501
+ suggestion: { kind: 'fix', text: 'Fix the wikilink target (or remove the dead link)' },
502
+ }));
503
+ continue;
504
+ }
505
+ if (r.anchor) {
506
+ let anchors = anchorIndex.get(path);
507
+ if (!anchors) {
508
+ try {
509
+ anchors = new Set(extractHeadings(readFileSync(path, 'utf-8')).map(h => h.anchor));
510
+ } catch { anchors = new Set(); }
511
+ anchorIndex.set(path, anchors);
512
+ }
513
+ // Obsidian anchors are heading TEXT ([[Doc#Quick Start]]); compare
514
+ // through the same slug pipeline as inline links.
515
+ const normalized = slugifyHeading(r.anchor);
516
+ if (!anchors.has(normalized) && !anchors.has(r.anchor)) {
517
+ const suggestion = suggestAnchor(normalized, anchors);
518
+ const hint = suggestion ? ` (did you mean #${suggestion}?)` : '';
519
+ findings.push(mkFinding({
520
+ code: 'XRF002',
521
+ validator: 'crossReference',
522
+ severity: 'warn',
523
+ message: `${r.docName}:${r.line} — broken anchor: "[[${r.file}#${r.anchor}]]" doesn't match any heading in ${basename(path)}${hint}`,
524
+ location: `${relative(projectDir, r.docPath)}:${r.line}`,
525
+ suggestion: { kind: 'review', text: 'Update the wikilink heading to match a real heading in the target doc' },
526
+ }));
527
+ continue;
528
+ }
529
+ }
530
+ passed++;
531
+ }
532
+ }
533
+ }
534
+
414
535
  return { ...resultFromFindings(findings, { passed, total }), fixes };
415
536
  }
416
537
 
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Diff-Overlap Suspicion validator (DSP) — v0.31.0.
3
+ *
4
+ * Research basis: the outdated-comment work (arXiv 2010.01625) found that a
5
+ * purely deterministic rule — "flag the prose as suspect if its tokens overlap
6
+ * a Delete/ReplaceOld span of the code change" — hits F1 74.7, beating every
7
+ * post-hoc neural model. We apply it to DOCS instead of comments.
8
+ *
9
+ * Precision-first pairing (two independent signals must BOTH hold):
10
+ * 1. the doc REFERENCES the changed code file (path / basename / `module`),
11
+ * 2. the doc's wording OVERLAPS tokens that were REMOVED from that file.
12
+ * Requiring both is what keeps this from firing on every doc that happens to
13
+ * share a common word with a diff. All findings are confidence:'low' (soft /
14
+ * reportable) — this is a "review this" signal, never a hard failure.
15
+ *
16
+ * Change-driven: reads `config.changedSinceRef` (set by `guard --changed-only`)
17
+ * or falls back to HEAD~1. Returns applicable:false when there's no git history
18
+ * or no code change carries removed tokens, so it stays silent off-CI.
19
+ */
20
+
21
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
22
+ import { resolve, basename } from 'node:path';
23
+ import { isGitRepo, getDiffText } from '../shared-git.mjs';
24
+ import { parseUnifiedDiff, removedTokens, tokenize, tokenOverlap } from '../shared-diff.mjs';
25
+ import { mkFinding, resultFromFindings } from '../findings.mjs';
26
+
27
+ const CODE_EXTENSIONS = /\.(ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|kt|rb|php|cs|swift|scala|dart)$/;
28
+
29
+ // Generic framework / language noise that overlaps between ANY React/TS doc and
30
+ // ANY diff — empirically the residual false-positive source (v0.31.0 corpus:
31
+ // "SECURITY.md ↔ page.tsx: page,set,use,state,active"). We require the shared
32
+ // tokens to contain DOMAIN identifiers, so these are stripped before counting.
33
+ const GENERIC_TOKENS = new Set([
34
+ 'page', 'components', 'component', 'shared', 'nav', 'state', 'use', 'set',
35
+ 'active', 'tab', 'react', 'props', 'prop', 'string', 'type', 'types', 'value',
36
+ 'values', 'data', 'status', 'config', 'client', 'none', 'all', 'com', 'https',
37
+ 'http', 'url', 'text', 'download', 'request', 'response', 'error', 'index',
38
+ 'item', 'items', 'list', 'name', 'key', 'map', 'log', 'update', 'updated',
39
+ 'version', 'content', 'document', 'description', 'service', 'services',
40
+ 'object', 'array', 'number', 'boolean', 'async', 'await', 'promise', 'void',
41
+ 'render', 'component', 'element', 'style', 'styles', 'class', 'div', 'span',
42
+ 'button', 'input', 'form', 'label', 'title', 'header', 'footer', 'main',
43
+ // presentational / CSS — styling churn is not API-contract drift
44
+ 'font', 'color', 'colors', 'tracking', 'surface', 'auto', 'full', 'next',
45
+ 'body', 'sans', 'blue', 'accent', 'size', 'spacing', 'margin', 'padding',
46
+ 'width', 'height', 'flex', 'grid', 'bold', 'bg', 'rounded',
47
+ // HTTP / REST / handler plumbing — generic across any API route (globalshares
48
+ // corpus: a route-inventory doc + heavy rewrites flooded findings with these)
49
+ 'code', 'json', 'err', 'error', 'message', 'auth', 'get', 'put', 'post',
50
+ 'patch', 'delete', 'req', 'res', 'route', 'routes', 'handler', 'endpoint',
51
+ 'method', 'headers', 'query', 'params', 'param', 'cron', 'process', 'api',
52
+ 'path', 'lib', 'util', 'utils', 'helper', 'helpers', 'admin', 'roles', 'role',
53
+ 'user', 'users', 'email', 'price', 'json', 'fetch', 'axios', 'send', 'call',
54
+ ]);
55
+
56
+ function escapeRegex(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
57
+
58
+ // Index canonical docs + root agent-instruction files → Map<name, {lines, tokens}>.
59
+ function indexDocs(projectDir) {
60
+ const docs = new Map();
61
+ const add = (name, full) => {
62
+ try {
63
+ const content = readFileSync(full, 'utf-8');
64
+ docs.set(name, { lines: content.split('\n'), tokens: tokenize(content) });
65
+ } catch { /* skip unreadable */ }
66
+ };
67
+ const docsDir = resolve(projectDir, 'docs-canonical');
68
+ if (existsSync(docsDir)) {
69
+ try {
70
+ for (const f of readdirSync(docsDir)) {
71
+ if (f.endsWith('.md')) add(f, resolve(docsDir, f));
72
+ }
73
+ } catch { /* skip */ }
74
+ }
75
+ // Agent-instruction files are documentation too — they routinely name code.
76
+ for (const agent of ['AGENTS.md', 'CLAUDE.md', 'GEMINI.md']) {
77
+ const p = resolve(projectDir, agent);
78
+ if (existsSync(p)) add(agent, p);
79
+ }
80
+ return docs;
81
+ }
82
+
83
+ // Does the doc reference this file? We deliberately accept ONLY `path` (the
84
+ // doc wrote the real file path) and `module` (the doc backticked the module
85
+ // stem) references. We DROP bare `basename` matches: empirical corpus testing
86
+ // (v0.31.0) showed basename refs pair an architecture doc that lists every
87
+ // `page.tsx` with framework-noise tokens (page/components/state/use/nav),
88
+ // producing false positives. path/module refs are intentional and high-signal.
89
+ function referenceKind(docLines, file) {
90
+ const normalized = file.replace(/^\.\//, '');
91
+ const base = basename(normalized);
92
+ const stem = base.replace(/\.[^.]+$/, '');
93
+ const stemRe = new RegExp(`\`${escapeRegex(stem)}\``);
94
+ for (const line of docLines) {
95
+ if (line.includes(normalized)) return 'path';
96
+ if (stemRe.test(line)) return 'module';
97
+ }
98
+ return null;
99
+ }
100
+
101
+ export function validateDiffSuspicion(projectDir, config = {}) {
102
+ const cfg = config.diffSuspicion || {};
103
+ const minOverlap = Number.isInteger(cfg.minOverlap) ? cfg.minOverlap : 2;
104
+ const ref = cfg.since || config.changedSinceRef || 'HEAD~1';
105
+
106
+ if (!isGitRepo(projectDir)) {
107
+ return resultFromFindings([], { passed: 0, total: 0, applicable: false });
108
+ }
109
+
110
+ const diffText = getDiffText(projectDir, ref);
111
+ const changedFiles = parseUnifiedDiff(diffText).filter(
112
+ f => f.newPath && CODE_EXTENSIONS.test(f.newPath) && f.status !== 'deleted'
113
+ );
114
+ // Precompute removed-token sets; drop files whose change removed nothing.
115
+ const changed = changedFiles
116
+ .map(f => ({ path: f.newPath, removed: removedTokens(f) }))
117
+ .filter(f => f.removed.size > 0);
118
+
119
+ if (changed.length === 0) {
120
+ return resultFromFindings([], { passed: 0, total: 0, applicable: false });
121
+ }
122
+
123
+ const docs = indexDocs(projectDir);
124
+ if (docs.size === 0) {
125
+ return resultFromFindings([], { passed: 0, total: 0, applicable: false });
126
+ }
127
+
128
+ const maxPerDoc = Number.isInteger(cfg.maxPerDoc) ? cfg.maxPerDoc : 5;
129
+ const findings = [];
130
+ let pairsChecked = 0;
131
+ for (const [docName, doc] of docs) {
132
+ // Collect this doc's suspect pairs, strongest overlap first, so a doc that
133
+ // inventories many changed files (e.g. an API-route reference) is capped
134
+ // with an elision note rather than flooding — the same discipline SPK008 uses.
135
+ const hits = [];
136
+ for (const cf of changed) {
137
+ const kind = referenceKind(doc.lines, cf.path);
138
+ if (!kind) continue; // signal 1: doc must reference the changed file (path|module)
139
+ pairsChecked++;
140
+ const { shared: rawShared } = tokenOverlap(doc.tokens, cf.removed);
141
+ // signal 2: doc must share DOMAIN (non-generic) removed tokens
142
+ const shared = rawShared.filter(t => !GENERIC_TOKENS.has(t));
143
+ if (shared.length < minOverlap) continue;
144
+ hits.push({ path: cf.path, kind, shared });
145
+ }
146
+ hits.sort((a, b) => b.shared.length - a.shared.length);
147
+ for (const h of hits.slice(0, maxPerDoc)) {
148
+ findings.push(mkFinding({
149
+ code: 'DSP001',
150
+ validator: 'diff-suspicion',
151
+ severity: 'warn',
152
+ confidence: 'low',
153
+ message: `${docName} describes ${h.path} (${h.kind} ref), which just had ${h.shared.slice(0, 5).join(', ')}${h.shared.length > 5 ? '…' : ''} removed/changed (${ref}..HEAD) — verify the doc still matches.`,
154
+ location: { file: docName },
155
+ suggestion: {
156
+ summary: `Re-read ${docName} against the current ${h.path}; the removed symbols (${h.shared.slice(0, 8).join(', ')}) may now be wrong.`,
157
+ },
158
+ }));
159
+ }
160
+ if (hits.length > maxPerDoc) {
161
+ findings.push(mkFinding({
162
+ code: 'DSP001',
163
+ validator: 'diff-suspicion',
164
+ severity: 'warn',
165
+ confidence: 'low',
166
+ message: `${docName} references ${hits.length - maxPerDoc} more changed file(s) with removed domain symbols (${ref}..HEAD) — a broad change; review ${docName} as a whole.`,
167
+ location: { file: docName },
168
+ suggestion: { summary: `${docName} looks broadly affected by this change set — review it end-to-end rather than line by line.` },
169
+ }));
170
+ }
171
+ }
172
+
173
+ return resultFromFindings(findings, {
174
+ passed: pairsChecked - findings.length,
175
+ total: pairsChecked,
176
+ applicable: true,
177
+ });
178
+ }