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.
- package/PHILOSOPHY.md +1 -0
- package/README.md +70 -30
- package/cli/commands/ci.mjs +52 -13
- package/cli/commands/guard.mjs +80 -0
- package/cli/commands/hooks.mjs +167 -2
- package/cli/commands/impact.mjs +213 -5
- package/cli/commands/mcp.mjs +195 -53
- package/cli/commands/report.mjs +200 -0
- package/cli/commands/score.mjs +55 -1
- package/cli/docguard.mjs +101 -13
- package/cli/findings.mjs +6 -0
- package/cli/scanners/agent-readability.mjs +6 -1
- package/cli/scanners/semantic-claims.mjs +10 -2
- package/cli/shared-git.mjs +23 -0
- package/cli/validators/architecture.mjs +8 -1
- package/cli/validators/cross-reference.mjs +124 -3
- package/cli/validators/docs-coverage.mjs +5 -0
- package/cli/validators/reference-existence.mjs +172 -18
- package/cli/validators/traceability.mjs +63 -0
- package/cli/writers/baseline.mjs +84 -0
- package/cli/writers/history.mjs +82 -0
- package/cli/writers/junit.mjs +103 -0
- package/docs/commands.md +30 -2
- package/docs/configuration.md +14 -0
- package/docs/faq.md +12 -0
- package/extensions/spec-kit-docguard/extension.yml +1 -1
- package/extensions/spec-kit-docguard/skills/docguard-fix/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-guard/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-review/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-score/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-sync/SKILL.md +2 -2
- package/package.json +1 -1
- 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
|
-
|
|
134
|
-
|
|
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,
|
|
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 {
|
|
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
|
-
//
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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:
|
|
152
|
-
total
|
|
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
|
+
}
|