docguard-cli 0.31.0 → 0.33.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.
Files changed (33) hide show
  1. package/PHILOSOPHY.md +1 -0
  2. package/README.md +70 -30
  3. package/cli/commands/ci.mjs +52 -13
  4. package/cli/commands/guard.mjs +80 -0
  5. package/cli/commands/hooks.mjs +167 -2
  6. package/cli/commands/impact.mjs +213 -5
  7. package/cli/commands/mcp.mjs +195 -53
  8. package/cli/commands/report.mjs +200 -0
  9. package/cli/commands/score.mjs +55 -1
  10. package/cli/docguard.mjs +101 -13
  11. package/cli/findings.mjs +6 -0
  12. package/cli/scanners/agent-readability.mjs +6 -1
  13. package/cli/scanners/semantic-claims.mjs +10 -2
  14. package/cli/shared-git.mjs +23 -0
  15. package/cli/validators/architecture.mjs +8 -1
  16. package/cli/validators/cross-reference.mjs +124 -3
  17. package/cli/validators/docs-coverage.mjs +5 -0
  18. package/cli/validators/reference-existence.mjs +172 -18
  19. package/cli/validators/traceability.mjs +63 -0
  20. package/cli/writers/baseline.mjs +84 -0
  21. package/cli/writers/history.mjs +82 -0
  22. package/cli/writers/junit.mjs +103 -0
  23. package/docs/commands.md +30 -2
  24. package/docs/configuration.md +14 -0
  25. package/docs/faq.md +12 -0
  26. package/extensions/spec-kit-docguard/extension.yml +1 -1
  27. package/extensions/spec-kit-docguard/skills/docguard-fix/SKILL.md +2 -2
  28. package/extensions/spec-kit-docguard/skills/docguard-guard/SKILL.md +2 -2
  29. package/extensions/spec-kit-docguard/skills/docguard-review/SKILL.md +2 -2
  30. package/extensions/spec-kit-docguard/skills/docguard-score/SKILL.md +2 -2
  31. package/extensions/spec-kit-docguard/skills/docguard-sync/SKILL.md +2 -2
  32. package/package.json +1 -1
  33. package/schemas/docguard-config.schema.json +5 -0
@@ -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
 
@@ -43,6 +43,11 @@ const COMMON_DOTFILES = new Set([
43
43
  '.babelrc', '.browserslistrc', '.stylelintrc',
44
44
  '.dockerignore', '.python-version', '.tool-versions', '.ruby-version',
45
45
  '.gitkeep', '.keep',
46
+ // DocGuard's own files — self-explanatory (embedded _comment / schema),
47
+ // and flagging them creates a warning the moment a team adopts the tool
48
+ // (e.g. `guard --update-baseline` writing the baseline instantly produced
49
+ // a DCV001 about the baseline file itself).
50
+ '.docguard.json', '.docguardignore', '.docguard.baseline.json',
46
51
  ]);
47
52
 
48
53
  // Generated tool artifacts (caches, coverage data, lock-data) that land at the
@@ -27,14 +27,24 @@
27
27
  * mode (b) ("literal deleted but logic remains") is bounded by compound
28
28
  * shape + the two-revision gate.
29
29
  * All findings are confidence:'low' / soft — "verify", never a hard failure.
30
+ *
31
+ * REF002 (ADR citations, the code→doc direction): a code comment citing
32
+ * `ADR-NNN` is a reference into the docs — if no ADR document defines that
33
+ * number, the citation is stale (renumbered, removed, or never written).
34
+ * ADRs have no external registry, so a missing number is a real signal.
35
+ * RFC citations are deliberately OUT of scope: `RFC 793` in a comment almost
36
+ * always cites the IETF registry (external, unverifiable) and would
37
+ * false-positive on every network stack. Numbers compare as integers, so
38
+ * `ADR-00NN` in code matches `ADR-NN` in docs.
30
39
  */
31
40
 
32
41
  import { existsSync, readFileSync, readdirSync } from 'node:fs';
33
- import { resolve, extname } from 'node:path';
42
+ import { resolve, extname, relative, basename } from 'node:path';
34
43
  import { isGitRepo, lastCommitHash, symbolExistsAtRev } from '../shared-git.mjs';
35
- import { walkFiles } from '../shared-ignore.mjs';
44
+ import { walkFiles, isNonProductPath } from '../shared-ignore.mjs';
36
45
  import { readScannable } from '../shared-source.mjs';
37
- import { mkFinding, resultFromFindings } from '../findings.mjs';
46
+ import { resolveDocDirs } from '../shared.mjs';
47
+ import { mkFinding, resultFromFindings, lineSuppresses } from '../findings.mjs';
38
48
 
39
49
  const CODE_EXT = new Set([
40
50
  '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.py', '.go', '.rs', '.java',
@@ -51,17 +61,93 @@ function isCodeIdentifier(s) {
51
61
  return COMPOUND.test(s);
52
62
  }
53
63
 
54
- // One pass over the current source tree → Set of every whole identifier present.
55
- function buildHeadIdentifierSet(projectDir) {
56
- const set = new Set();
57
- walkFiles(projectDir, (full) => {
58
- if (!CODE_EXT.has(extname(full))) return;
59
- const content = readScannable(full);
60
- if (!content) return;
61
- const m = content.match(IDENT);
62
- if (m) for (const id of m) set.add(id);
63
- });
64
- return set;
64
+ // ── REF002 helpers ──────────────────────────────────────────────────────────
65
+
66
+ // Uppercase-only by design: teams cite decision records as "ADR-NNN" / "ADR NN";
67
+ // lowercase "adr" is too often an abbreviation for something else.
68
+ const ADR_CITE_SRC = String.raw`\bADR[- ]?(\d{1,5})\b`;
69
+
70
+ /**
71
+ * Index of the comment portion of a code line, or -1 when the line has no
72
+ * comment. A citation only counts when it sits inside a comment — `ADR-NN`
73
+ * in a string literal or identifier is data, not a citation. Whole-line
74
+ * comments (incl. `*` block-comment continuations) count from column 0;
75
+ * trailing comments are recognised by their marker. ` # ` requires spacing so
76
+ * `"#fff"`-style literals don't read as Python/shell comments.
77
+ */
78
+ function commentIndex(line) {
79
+ const t = line.trimStart();
80
+ if (/^(\/\/|\/\*|\*|#|--|<!--)/.test(t)) return 0;
81
+ let idx = -1;
82
+ for (const marker of ['//', '/*', '<!--', ' # ', ' -- ']) {
83
+ const i = line.indexOf(marker);
84
+ if (i >= 0 && (idx < 0 || i < idx)) idx = i;
85
+ }
86
+ return idx;
87
+ }
88
+
89
+ /** Collect ADR citations found in comments of one source file. */
90
+ function collectAdrCitations(content, relPath, out) {
91
+ const lines = content.split('\n');
92
+ for (let i = 0; i < lines.length; i++) {
93
+ const line = lines[i];
94
+ if (!line.includes('ADR')) continue;
95
+ const ci = commentIndex(line);
96
+ if (ci < 0) continue;
97
+ const re = new RegExp(ADR_CITE_SRC, 'g'); // local: shared stateful g-regexes are a footgun
98
+ let m;
99
+ while ((m = re.exec(line)) !== null) {
100
+ if (m.index < ci) continue;
101
+ if (lineSuppresses('REF002', line, lines[i - 1] || '')) continue;
102
+ out.push({ num: parseInt(m[1], 10), raw: m[0], file: relPath, line: i + 1 });
103
+ }
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Discover which ADR numbers the project's docs actually define.
109
+ * Sources, in decreasing specificity:
110
+ * - files/dirs named after ADRs (`ADR.md`, `docs/adr/`, `docs/decisions/`):
111
+ * every ADR-NNN mention counts, plus madr-style numeric filenames
112
+ * (`0001-use-postgres.md`);
113
+ * - any other markdown in a doc home: heading lines only (`## ADR-NNN: …`),
114
+ * so a prose mention ("see ADR-NN") never counts as a definition.
115
+ */
116
+ function collectAdrNumbers(projectDir, config) {
117
+ const nums = new Set();
118
+ const addAll = (text) => {
119
+ for (const m of text.matchAll(new RegExp(ADR_CITE_SRC, 'g'))) nums.add(parseInt(m[1], 10));
120
+ };
121
+ const files = new Set();
122
+ try {
123
+ for (const f of readdirSync(projectDir)) {
124
+ if (f.endsWith('.md')) files.add(resolve(projectDir, f));
125
+ }
126
+ } catch { /* unreadable root */ }
127
+ for (const d of resolveDocDirs(projectDir, config)) {
128
+ const abs = resolve(projectDir, d);
129
+ if (!existsSync(abs)) continue;
130
+ walkFiles(abs, (full) => { if (full.endsWith('.md')) files.add(full); });
131
+ }
132
+ for (const full of files) {
133
+ let content;
134
+ try { content = readFileSync(full, 'utf-8'); } catch { continue; }
135
+ const base = basename(full);
136
+ const norm = full.replace(/\\/g, '/');
137
+ const isAdrHome = /adr/i.test(base) || /\/(adrs?|decisions?)\//i.test(norm);
138
+ const numericName = base.match(/^(\d{1,5})[-_.]/);
139
+ if (isAdrHome && numericName) nums.add(parseInt(numericName[1], 10));
140
+ const adrInName = base.match(/adr[-_ ]?(\d{1,5})/i);
141
+ if (adrInName) nums.add(parseInt(adrInName[1], 10));
142
+ if (isAdrHome) {
143
+ addAll(content);
144
+ } else {
145
+ for (const line of content.split('\n')) {
146
+ if (/^#{1,6}\s/.test(line)) addAll(line);
147
+ }
148
+ }
149
+ }
150
+ return nums;
65
151
  }
66
152
 
67
153
  function extractRefs(content) {
@@ -100,16 +186,44 @@ function indexDocs(projectDir) {
100
186
  export function validateReferenceExistence(projectDir, config = {}) {
101
187
  const cfg = config.referenceExistence || {};
102
188
  const maxRefsPerDoc = cfg.maxRefsPerDoc || 80;
189
+ const adrEnabled = cfg.adrCitations !== false;
103
190
 
104
191
  if (!isGitRepo(projectDir)) {
105
192
  return resultFromFindings([], { passed: 0, total: 0, applicable: false });
106
193
  }
107
194
  const docs = indexDocs(projectDir);
108
- if (docs.length === 0 || docs.every(d => d.refs.length === 0)) {
195
+ const needIds = docs.some(d => d.refs.length > 0);
196
+ if (!needIds && !adrEnabled) {
197
+ return resultFromFindings([], { passed: 0, total: 0, applicable: false });
198
+ }
199
+
200
+ // ONE walk over the source tree serves both checks: the REF001 identifier
201
+ // set and the REF002 ADR-citation scan. Each part is skipped when its check
202
+ // has nothing to do, so the walk stays as cheap as before for either alone.
203
+ const headIds = new Set();
204
+ const citations = [];
205
+ walkFiles(projectDir, (full) => {
206
+ if (!CODE_EXT.has(extname(full))) return;
207
+ const content = readScannable(full);
208
+ if (!content) return;
209
+ if (needIds) {
210
+ const m = content.match(IDENT);
211
+ if (m) for (const id of m) headIds.add(id);
212
+ }
213
+ if (adrEnabled && content.includes('ADR')) {
214
+ // Tests/fixtures/examples cite ADRs as fixture data, not as real
215
+ // citations — same non-product scoping the surface scanners use.
216
+ const rel = relative(projectDir, full);
217
+ if (!isNonProductPath(rel.replace(/\\/g, '/'), config)) {
218
+ collectAdrCitations(content, rel, citations);
219
+ }
220
+ }
221
+ });
222
+
223
+ if (!needIds && citations.length === 0) {
109
224
  return resultFromFindings([], { passed: 0, total: 0, applicable: false });
110
225
  }
111
226
 
112
- const headIds = buildHeadIdentifierSet(projectDir); // cheap gate, built once
113
227
  const revPresence = new Map(); // `${sym}\0${rev}` → bool
114
228
  // Authoritative HEAD absence (covers dot-dirs the walk skipped). Cached.
115
229
  const headAbsent = new Map();
@@ -147,9 +261,49 @@ export function validateReferenceExistence(projectDir, config = {}) {
147
261
  }
148
262
  }
149
263
 
264
+ // ── REF002: every distinct cited ADR number is one check ──
265
+ const MAX_ADR_FINDINGS = 10; // calm cap — a flood means a systemic numbering change, not 40 separate problems
266
+ let adrDistinct = 0;
267
+ let adrMissing = 0;
268
+ if (adrEnabled && citations.length > 0) {
269
+ const known = collectAdrNumbers(projectDir, config);
270
+ const byNum = new Map(); // num → [{raw, file, line}]
271
+ for (const c of citations) {
272
+ if (!byNum.has(c.num)) byNum.set(c.num, []);
273
+ byNum.get(c.num).push(c);
274
+ }
275
+ adrDistinct = byNum.size;
276
+ for (const [num, locs] of byNum) {
277
+ if (known.has(num)) continue;
278
+ adrMissing++;
279
+ if (adrMissing > MAX_ADR_FINDINGS) continue;
280
+ const first = locs[0];
281
+ const where = `${first.file}:${first.line}${locs.length > 1 ? ` (+${locs.length - 1} more)` : ''}`;
282
+ const message = known.size > 0
283
+ ? `Code cites ${first.raw} (${where}) but no ADR document defines that number — renumbered, removed, or never written.`
284
+ : `Code cites ${first.raw} (${where}) but the repo has no ADR documents — the decision record it points to is missing.`;
285
+ findings.push(mkFinding({
286
+ code: 'REF002',
287
+ validator: 'reference-existence',
288
+ severity: 'warn',
289
+ confidence: 'low',
290
+ message,
291
+ location: { file: first.file, line: first.line },
292
+ suggestion: {
293
+ summary: known.size > 0
294
+ ? `Fix the number or write the missing ADR entry (or suppress with // docguard:ignore REF002 on the citation line).`
295
+ : `Create an ADR doc (docguard init writes templates/ADR.md) or suppress with // docguard:ignore REF002.`,
296
+ },
297
+ }));
298
+ }
299
+ }
300
+
301
+ // A missing number beyond the finding cap is still a failed check.
302
+ const total = totalChecked + adrDistinct;
303
+ const overCap = Math.max(0, adrMissing - MAX_ADR_FINDINGS);
150
304
  const res = resultFromFindings(findings, {
151
- passed: totalChecked - findings.length,
152
- total: totalChecked,
305
+ passed: total - findings.length - overCap,
306
+ total,
153
307
  applicable: true,
154
308
  });
155
309
  res.absentAtHead = absentAtHead; // instrumentation: proves the pipeline reaches the rev check
@@ -21,6 +21,56 @@ import { mkFinding, resultFromFindings } from '../findings.mjs';
21
21
  import { tokenize } from '../shared-diff.mjs';
22
22
  import { rankBySimilarity } from '../shared-ir.mjs';
23
23
 
24
+ /**
25
+ * Optional graphify interop (github.com/Graphify-Labs/graphify, MIT).
26
+ * Teams that commit `graphify-out/graph.json` already carry a knowledge graph
27
+ * whose CODE side is deterministic tree-sitter extraction. If it's there, its
28
+ * doc↔code edges are one more linkage-evidence source for traceability.
29
+ *
30
+ * Trust rules (the determinism anchor):
31
+ * - ONLY edges tagged confidence:"EXTRACTED" count — INFERRED/AMBIGUOUS
32
+ * edges can come from graphify's LLM pass and must not vouch for a doc.
33
+ * - Evidence-only: this can turn a would-be "unlinked doc" into a pass;
34
+ * it never produces a finding.
35
+ * - Zero-dep: one JSON read. Any parse/shape mismatch → null (no evidence).
36
+ *
37
+ * @returns {Map<string, Set<string>>|null} doc basename → linked code files
38
+ */
39
+ function loadGraphifyDocLinks(projectDir) {
40
+ const p = resolve(projectDir, 'graphify-out', 'graph.json');
41
+ if (!existsSync(p)) return null;
42
+ try {
43
+ const raw = JSON.parse(readFileSync(p, 'utf-8'));
44
+ const nodes = Array.isArray(raw.nodes) ? raw.nodes : [];
45
+ // networkx serializes edges as "links"; older graphify exports used "edges".
46
+ const links = Array.isArray(raw.links) ? raw.links
47
+ : Array.isArray(raw.edges) ? raw.edges : [];
48
+ const nodeFile = new Map(); // node id → source_file
49
+ for (const n of nodes) {
50
+ if (n && n.id !== undefined && typeof n.source_file === 'string') {
51
+ nodeFile.set(n.id, n.source_file);
52
+ }
53
+ }
54
+ const docLinks = new Map();
55
+ for (const l of links) {
56
+ if (!l || l.confidence !== 'EXTRACTED') continue;
57
+ const sf = nodeFile.get(l.source);
58
+ const tf = nodeFile.get(l.target);
59
+ if (!sf || !tf) continue;
60
+ for (const [a, b] of [[sf, tf], [tf, sf]]) {
61
+ if (a.endsWith('.md') && !b.endsWith('.md') && isTraceableSource(b)) {
62
+ const doc = basename(a);
63
+ if (!docLinks.has(doc)) docLinks.set(doc, new Set());
64
+ docLinks.get(doc).add(b);
65
+ }
66
+ }
67
+ }
68
+ return docLinks.size > 0 ? docLinks : null;
69
+ } catch {
70
+ return null; // malformed graph = no evidence, never a finding
71
+ }
72
+ }
73
+
24
74
  // IR soft-link recovery (feat 5): tokenize test files once so an untraced
25
75
  // requirement can be matched to the test that most likely already covers it
26
76
  // (TF-IDF cosine, VSM). Capped so a huge test suite can't blow up guard.
@@ -98,6 +148,10 @@ export function validateTraceability(projectDir, config) {
98
148
  const projectFiles = [];
99
149
  scanDir(projectDir, projectDir, projectFiles);
100
150
 
151
+ // Optional graphify interop: a committed knowledge graph is one more
152
+ // deterministic evidence source for doc↔code linkage.
153
+ const graphifyLinks = loadGraphifyDocLinks(projectDir);
154
+
101
155
  // Scan source files for `// @doc <filename>.md` annotations. An annotation
102
156
  // is an explicit author signal that a source file documents (or is
103
157
  // documented by) a canonical doc. It is the user-facing escape hatch when
@@ -149,6 +203,15 @@ export function validateTraceability(projectDir, config) {
149
203
  }
150
204
  }
151
205
 
206
+ // Graphify interop: an EXTRACTED doc↔code edge in a committed
207
+ // graphify-out/graph.json is author-grade linkage evidence (the graph's
208
+ // code side is deterministic AST extraction). Only trusted when at least
209
+ // one linked code file still exists — a stale graph must not vouch.
210
+ if (!hasSource && graphifyLinks && graphifyLinks.has(docName)) {
211
+ hasSource = [...graphifyLinks.get(docName)]
212
+ .some(f => existsSync(resolve(projectDir, f)) || existsSync(f));
213
+ }
214
+
152
215
  if (hasSource) {
153
216
  passed++;
154
217
  } else {
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Adoption Baseline — `.docguard.baseline.json` (repo root, COMMITTED).
3
+ *
4
+ * The brownfield-adoption pattern (ESLint/semgrep-style): a legacy repo
5
+ * freezes its existing findings once (`guard --update-baseline`), commits the
6
+ * file, and from then on guard/ci gate only NEW drift. Suppressed findings
7
+ * are counted and displayed — never silently hidden — and the baseline is a
8
+ * reviewable diff in every PR that updates it.
9
+ *
10
+ * Root, not `.docguard/`: the state dir is gitignored, and a baseline only
11
+ * works if the whole team and CI share it.
12
+ *
13
+ * Fingerprints are content-addressed, not line-addressed: `code | location
14
+ * path (line numbers stripped) | message with digit-runs normalized to #`.
15
+ * Line numbers churn on every edit and messages embed volatile counts
16
+ * ("21 commits since…") — both would rot the baseline in a week.
17
+ */
18
+
19
+ import { createHash } from 'node:crypto';
20
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
21
+ import { resolve } from 'node:path';
22
+
23
+ export const BASELINE_FILE = '.docguard.baseline.json';
24
+
25
+ /** Stable fingerprint for one finding. */
26
+ export function fingerprintFinding(f) {
27
+ const code = f.code || 'UNCODED';
28
+ const path = typeof f.location === 'string' ? f.location.replace(/:\d+$/, '') : '';
29
+ const msg = String(f.message || '').replace(/\d+/g, '#').replace(/\s+/g, ' ').trim();
30
+ return createHash('sha256').update(`${code}|${path}|${msg}`).digest('hex').slice(0, 16);
31
+ }
32
+
33
+ /**
34
+ * Load the committed baseline as a Map of fingerprint → allowed occurrence
35
+ * count, or null when the project has none (the common case — zero overhead).
36
+ *
37
+ * Occurrence counts matter (review finding H2): two findings with the same
38
+ * code + file + message shape — e.g. two hardcoded passwords in one file —
39
+ * share a fingerprint. A count-less set would let one frozen instance
40
+ * suppress every FUTURE instance of that class in that file, a
41
+ * security-relevant false negative. With counts, freezing 1 suppresses 1;
42
+ * a second appearance surfaces and gates.
43
+ */
44
+ export function loadBaseline(projectDir) {
45
+ const p = resolve(projectDir, BASELINE_FILE);
46
+ if (!existsSync(p)) return null;
47
+ try {
48
+ const data = JSON.parse(readFileSync(p, 'utf-8'));
49
+ if (!data || typeof data.fingerprints !== 'object' || data.fingerprints === null) return null;
50
+ const map = new Map();
51
+ for (const [fp, n] of Object.entries(data.fingerprints)) {
52
+ const count = Number.isInteger(n) && n > 0 ? n : 0;
53
+ if (count > 0) map.set(fp, count);
54
+ }
55
+ return map.size > 0 ? map : null;
56
+ } catch {
57
+ // A malformed baseline must not silently un-gate CI: treat as absent so
58
+ // every finding surfaces (fail-open on visibility, fail-closed on hiding).
59
+ return null;
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Write the baseline from the current findings: fingerprint → occurrence
65
+ * count, keys sorted so the committed file diffs cleanly. Returns the number
66
+ * of distinct fingerprints.
67
+ */
68
+ export function saveBaseline(projectDir, findings) {
69
+ const counts = {};
70
+ for (const f of findings) {
71
+ const fp = fingerprintFinding(f);
72
+ counts[fp] = (counts[fp] || 0) + 1;
73
+ }
74
+ const fingerprints = Object.fromEntries(Object.keys(counts).sort().map(k => [k, counts[k]]));
75
+ const doc = {
76
+ _comment: 'DocGuard adoption baseline — existing findings frozen at adoption time (fingerprint → occurrence count). Guard suppresses up to that many instances of each and gates everything new. Regenerate with: docguard guard --update-baseline',
77
+ version: 2,
78
+ generatedAt: new Date().toISOString(),
79
+ count: Object.keys(fingerprints).length,
80
+ fingerprints,
81
+ };
82
+ writeFileSync(resolve(projectDir, BASELINE_FILE), JSON.stringify(doc, null, 2) + '\n');
83
+ return Object.keys(fingerprints).length;
84
+ }