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.
- package/README.md +28 -9
- package/cli/commands/explain.mjs +34 -0
- package/cli/commands/guard.mjs +8 -0
- package/cli/commands/hooks.mjs +167 -2
- package/cli/commands/impact.mjs +300 -11
- package/cli/commands/mcp.mjs +179 -53
- package/cli/commands/verify.mjs +63 -1
- package/cli/config.mjs +7 -0
- package/cli/docguard.mjs +46 -3
- package/cli/findings.mjs +32 -0
- package/cli/scanners/agent-readability.mjs +6 -1
- package/cli/scanners/semantic-claims.mjs +10 -2
- package/cli/shared-diff.mjs +209 -0
- package/cli/shared-git.mjs +93 -0
- package/cli/shared-ir.mjs +81 -0
- package/cli/validators/api-doc-smells.mjs +143 -0
- package/cli/validators/architecture.mjs +8 -1
- package/cli/validators/cross-reference.mjs +124 -3
- package/cli/validators/diff-suspicion.mjs +178 -0
- package/cli/validators/reference-existence.mjs +311 -0
- package/cli/validators/traceability.mjs +107 -4
- package/docs/quickstart.md +1 -1
- 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
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Two-Revision Reference-Existence validator (REF001) — v0.31.0.
|
|
3
|
+
*
|
|
4
|
+
* Method (arXiv 2212.01479, field-tested at ~50% maintainer acceptance):
|
|
5
|
+
* extract code-element references from a doc, then compare their existence in
|
|
6
|
+
* the source tree at TWO revisions — the commit where the doc was LAST UPDATED
|
|
7
|
+
* vs HEAD. A reference that matched source when the doc was written but matches
|
|
8
|
+
* ZERO source instances now is flagged outdated. Fully deterministic.
|
|
9
|
+
*
|
|
10
|
+
* Performance: the "present now?" gate is answered from ONE in-memory identifier
|
|
11
|
+
* set built by a single source walk (not a git grep per symbol — that was ~7s
|
|
12
|
+
* on a 167-ref repo). The walk skips dot-directories (.github, .specify), so a
|
|
13
|
+
* symbol the walk misses is CONFIRMED absent with an authoritative `git grep` at
|
|
14
|
+
* HEAD before we trust it — otherwise a symbol living in a dot-dir reads as a
|
|
15
|
+
* false "removed" (caught dogfooding DocGuard's own AGENTS.md). Only symbols
|
|
16
|
+
* absent from BOTH the walk and HEAD git grep — the rare case — pay for the
|
|
17
|
+
* historical `git grep` at the doc's last-update revision.
|
|
18
|
+
*
|
|
19
|
+
* Precision guards:
|
|
20
|
+
* - Only PURE COMPOUND identifiers (camelCase / snake_case / Pascal-multiword)
|
|
21
|
+
* from backticks are checked — never prose words ("`token`" is ignored), and
|
|
22
|
+
* dotted member/file refs are out of scope for v1 (documented).
|
|
23
|
+
* - CLI/config flags (`--foo`) excluded — the "removed but still relevant"
|
|
24
|
+
* false-positive mode (a) from the paper.
|
|
25
|
+
* - present-then-AND-absent-now is required, so a symbol that never existed
|
|
26
|
+
* at doc-time (a typo, an external lib) is not accused — false-positive
|
|
27
|
+
* mode (b) ("literal deleted but logic remains") is bounded by compound
|
|
28
|
+
* shape + the two-revision gate.
|
|
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.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
42
|
+
import { resolve, extname, relative, basename } from 'node:path';
|
|
43
|
+
import { isGitRepo, lastCommitHash, symbolExistsAtRev } from '../shared-git.mjs';
|
|
44
|
+
import { walkFiles, isNonProductPath } from '../shared-ignore.mjs';
|
|
45
|
+
import { readScannable } from '../shared-source.mjs';
|
|
46
|
+
import { resolveDocDirs } from '../shared.mjs';
|
|
47
|
+
import { mkFinding, resultFromFindings, lineSuppresses } from '../findings.mjs';
|
|
48
|
+
|
|
49
|
+
const CODE_EXT = new Set([
|
|
50
|
+
'.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.py', '.go', '.rs', '.java',
|
|
51
|
+
'.kt', '.rb', '.php', '.cs', '.swift', '.scala', '.dart', '.c', '.cpp', '.h',
|
|
52
|
+
]);
|
|
53
|
+
const IDENT = /[A-Za-z_][A-Za-z0-9_]*/g;
|
|
54
|
+
|
|
55
|
+
// Compound = clearly a code symbol, not a prose word: camelCase, snake_case,
|
|
56
|
+
// or PascalCase-multiword. Pure lowercase single words are excluded.
|
|
57
|
+
const COMPOUND = /[a-z][A-Z]|_|^[A-Z][a-z]+[A-Z]/;
|
|
58
|
+
function isCodeIdentifier(s) {
|
|
59
|
+
if (s.length < 4 || s.length > 80) return false;
|
|
60
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(s)) return false; // pure identifier (no dots)
|
|
61
|
+
return COMPOUND.test(s);
|
|
62
|
+
}
|
|
63
|
+
|
|
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;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function extractRefs(content) {
|
|
154
|
+
const refs = new Set();
|
|
155
|
+
const backtick = /`([^`\n]{2,80})`/g;
|
|
156
|
+
let m;
|
|
157
|
+
while ((m = backtick.exec(content)) !== null) {
|
|
158
|
+
let tok = m[1].trim().replace(/\(.*$/, '').replace(/[.,;:]+$/, '').trim();
|
|
159
|
+
if (/^-/.test(tok)) continue; // CLI flag → FP mode (a)
|
|
160
|
+
if (isCodeIdentifier(tok)) refs.add(tok);
|
|
161
|
+
}
|
|
162
|
+
return [...refs];
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function indexDocs(projectDir) {
|
|
166
|
+
const docs = [];
|
|
167
|
+
const push = (name, full) => {
|
|
168
|
+
try {
|
|
169
|
+
const content = readFileSync(full, 'utf-8');
|
|
170
|
+
docs.push({ name, path: full, refs: extractRefs(content) });
|
|
171
|
+
} catch { /* skip */ }
|
|
172
|
+
};
|
|
173
|
+
const docsDir = resolve(projectDir, 'docs-canonical');
|
|
174
|
+
if (existsSync(docsDir)) {
|
|
175
|
+
try {
|
|
176
|
+
for (const f of readdirSync(docsDir)) if (f.endsWith('.md')) push(f, resolve(docsDir, f));
|
|
177
|
+
} catch { /* skip */ }
|
|
178
|
+
}
|
|
179
|
+
for (const agent of ['AGENTS.md', 'CLAUDE.md', 'GEMINI.md']) {
|
|
180
|
+
const p = resolve(projectDir, agent);
|
|
181
|
+
if (existsSync(p)) push(agent, p);
|
|
182
|
+
}
|
|
183
|
+
return docs;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function validateReferenceExistence(projectDir, config = {}) {
|
|
187
|
+
const cfg = config.referenceExistence || {};
|
|
188
|
+
const maxRefsPerDoc = cfg.maxRefsPerDoc || 80;
|
|
189
|
+
const adrEnabled = cfg.adrCitations !== false;
|
|
190
|
+
|
|
191
|
+
if (!isGitRepo(projectDir)) {
|
|
192
|
+
return resultFromFindings([], { passed: 0, total: 0, applicable: false });
|
|
193
|
+
}
|
|
194
|
+
const docs = indexDocs(projectDir);
|
|
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) {
|
|
224
|
+
return resultFromFindings([], { passed: 0, total: 0, applicable: false });
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const revPresence = new Map(); // `${sym}\0${rev}` → bool
|
|
228
|
+
// Authoritative HEAD absence (covers dot-dirs the walk skipped). Cached.
|
|
229
|
+
const headAbsent = new Map();
|
|
230
|
+
const confirmedAbsentAtHead = (sym) => {
|
|
231
|
+
if (!headAbsent.has(sym)) headAbsent.set(sym, !symbolExistsAtRev(projectDir, sym, 'HEAD'));
|
|
232
|
+
return headAbsent.get(sym);
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const findings = [];
|
|
236
|
+
let totalChecked = 0;
|
|
237
|
+
let absentAtHead = 0;
|
|
238
|
+
for (const doc of docs) {
|
|
239
|
+
let rev = null; // resolve lazily — only if a ref is actually absent now
|
|
240
|
+
for (const sym of doc.refs.slice(0, maxRefsPerDoc)) {
|
|
241
|
+
totalChecked++;
|
|
242
|
+
if (headIds.has(sym)) continue; // in the walked tree → present (cheap)
|
|
243
|
+
if (!confirmedAbsentAtHead(sym)) continue; // walk missed it but git finds it (dot-dir) → present
|
|
244
|
+
absentAtHead++;
|
|
245
|
+
if (rev === null) rev = lastCommitHash(projectDir, doc.path) || '';
|
|
246
|
+
if (!rev) continue; // untracked doc → no "then" snapshot
|
|
247
|
+
const key = `${sym}\0${rev}`;
|
|
248
|
+
if (!revPresence.has(key)) revPresence.set(key, symbolExistsAtRev(projectDir, sym, rev));
|
|
249
|
+
if (!revPresence.get(key)) continue; // never existed at doc-time → not our signal
|
|
250
|
+
findings.push(mkFinding({
|
|
251
|
+
code: 'REF001',
|
|
252
|
+
validator: 'reference-existence',
|
|
253
|
+
severity: 'warn',
|
|
254
|
+
confidence: 'low',
|
|
255
|
+
message: `${doc.name} references \`${sym}\`, which existed in the code when the doc was last updated but has ZERO matches at HEAD — likely renamed or removed.`,
|
|
256
|
+
location: { file: doc.name },
|
|
257
|
+
suggestion: {
|
|
258
|
+
summary: `Update or remove the \`${sym}\` reference in ${doc.name} (or suppress if it is a still-relevant user-facing name).`,
|
|
259
|
+
},
|
|
260
|
+
}));
|
|
261
|
+
}
|
|
262
|
+
}
|
|
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);
|
|
304
|
+
const res = resultFromFindings(findings, {
|
|
305
|
+
passed: total - findings.length - overCap,
|
|
306
|
+
total,
|
|
307
|
+
applicable: true,
|
|
308
|
+
});
|
|
309
|
+
res.absentAtHead = absentAtHead; // instrumentation: proves the pipeline reaches the rev check
|
|
310
|
+
return res;
|
|
311
|
+
}
|
|
@@ -18,6 +18,75 @@ import { resolve, join, relative, basename, extname } from 'node:path';
|
|
|
18
18
|
import { TRACE_MAP, TEST_PATTERNS, isTraceableSource } from '../shared-trace-patterns.mjs';
|
|
19
19
|
import { walkFiles as sharedWalkFiles } from '../shared-ignore.mjs';
|
|
20
20
|
import { mkFinding, resultFromFindings } from '../findings.mjs';
|
|
21
|
+
import { tokenize } from '../shared-diff.mjs';
|
|
22
|
+
import { rankBySimilarity } from '../shared-ir.mjs';
|
|
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
|
+
|
|
74
|
+
// IR soft-link recovery (feat 5): tokenize test files once so an untraced
|
|
75
|
+
// requirement can be matched to the test that most likely already covers it
|
|
76
|
+
// (TF-IDF cosine, VSM). Capped so a huge test suite can't blow up guard.
|
|
77
|
+
function buildTestCorpus(projectDir, projectFiles, { maxFiles = 250, maxTokens = 400 } = {}) {
|
|
78
|
+
const testFiles = projectFiles.filter(f =>
|
|
79
|
+
TEST_PATTERNS.some(p => p.test(f)) || /__tests__\//.test(f) || /tests?\//.test(f)
|
|
80
|
+
).slice(0, maxFiles);
|
|
81
|
+
const corpus = [];
|
|
82
|
+
for (const relPath of testFiles) {
|
|
83
|
+
try {
|
|
84
|
+
const content = readFileSync(resolve(projectDir, relPath), 'utf-8');
|
|
85
|
+
corpus.push({ id: relPath, tokens: tokenize(content).slice(0, maxTokens) });
|
|
86
|
+
} catch { /* skip unreadable */ }
|
|
87
|
+
}
|
|
88
|
+
return corpus;
|
|
89
|
+
}
|
|
21
90
|
|
|
22
91
|
const IGNORE_DIRS = new Set([
|
|
23
92
|
'node_modules', '.git', '.next', 'dist', 'build', 'coverage',
|
|
@@ -79,6 +148,10 @@ export function validateTraceability(projectDir, config) {
|
|
|
79
148
|
const projectFiles = [];
|
|
80
149
|
scanDir(projectDir, projectDir, projectFiles);
|
|
81
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
|
+
|
|
82
155
|
// Scan source files for `// @doc <filename>.md` annotations. An annotation
|
|
83
156
|
// is an explicit author signal that a source file documents (or is
|
|
84
157
|
// documented by) a canonical doc. It is the user-facing escape hatch when
|
|
@@ -130,6 +203,15 @@ export function validateTraceability(projectDir, config) {
|
|
|
130
203
|
}
|
|
131
204
|
}
|
|
132
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
|
+
|
|
133
215
|
if (hasSource) {
|
|
134
216
|
passed++;
|
|
135
217
|
} else {
|
|
@@ -208,20 +290,40 @@ function validateRequirementTraceability(projectDir, config, projectFiles) {
|
|
|
208
290
|
|
|
209
291
|
// ── Step 3: Report traceability results ──
|
|
210
292
|
|
|
293
|
+
// IR soft-link recovery (feat 5): build the tokenized test corpus once, only
|
|
294
|
+
// if there are untraced requirements to match. Threshold is deliberately low
|
|
295
|
+
// — short requirement text vs a whole test file yields modest cosine scores;
|
|
296
|
+
// the hint is a suggestion, not proof.
|
|
297
|
+
const softThreshold = config.traceability?.irSoftThreshold ?? 0.10;
|
|
298
|
+
let testCorpus = null;
|
|
299
|
+
|
|
211
300
|
// Check each documented requirement has at least one test reference
|
|
212
301
|
for (const [reqId, location] of reqIds) {
|
|
213
302
|
total++;
|
|
214
303
|
if (testRefs.has(reqId)) {
|
|
215
304
|
passed++;
|
|
216
305
|
} else {
|
|
306
|
+
// Try to recover a likely-but-unannotated test via TF-IDF cosine.
|
|
307
|
+
let softHint = '';
|
|
308
|
+
let softText = `Add an @req ${reqId} comment to the test that verifies this requirement`;
|
|
309
|
+
const queryText = location.text && location.text.length > reqId.length ? location.text : reqId;
|
|
310
|
+
if (testCorpus === null) testCorpus = buildTestCorpus(projectDir, projectFiles);
|
|
311
|
+
if (testCorpus.length > 0) {
|
|
312
|
+
const ranked = rankBySimilarity(tokenize(queryText), testCorpus);
|
|
313
|
+
const top = ranked[0];
|
|
314
|
+
if (top && top.score >= softThreshold) {
|
|
315
|
+
const pct = (top.score * 100).toFixed(0);
|
|
316
|
+
softHint = ` — IR soft-match: ${top.id} (${pct}% similar) may already cover it`;
|
|
317
|
+
softText = `${top.id} looks like it already tests this (${pct}% similar) — add @req ${reqId} there, or if unrelated, write the missing test`;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
217
320
|
findings.push(mkFinding({
|
|
218
321
|
code: 'TRC004',
|
|
219
322
|
validator: 'traceability',
|
|
220
323
|
severity: 'warn',
|
|
221
|
-
message: `Requirement ${reqId} (${location.file}:${location.line}) has no test coverage
|
|
222
|
-
`Add @req ${reqId} comment to the test that verifies this requirement`,
|
|
324
|
+
message: `Requirement ${reqId} (${location.file}:${location.line}) has no test coverage.${softHint || ' Add @req ' + reqId + ' comment to the test that verifies this requirement'}`,
|
|
223
325
|
location: `${location.file}:${location.line}`,
|
|
224
|
-
suggestion: { kind: 'fix', text:
|
|
326
|
+
suggestion: { kind: 'fix', text: softText },
|
|
225
327
|
}));
|
|
226
328
|
}
|
|
227
329
|
}
|
|
@@ -269,7 +371,8 @@ function collectRequirementIds(projectDir, config, patterns) {
|
|
|
269
371
|
while ((match = pattern.exec(lines[i])) !== null) {
|
|
270
372
|
const reqId = match[0]; // e.g., "REQ-001"
|
|
271
373
|
if (!reqIds.has(reqId)) {
|
|
272
|
-
|
|
374
|
+
// capture the line text (the requirement description) for IR soft-match
|
|
375
|
+
reqIds.set(reqId, { file: docName, line: i + 1, text: lines[i].trim() });
|
|
273
376
|
}
|
|
274
377
|
}
|
|
275
378
|
}
|
package/docs/quickstart.md
CHANGED
|
@@ -68,7 +68,7 @@ diagnose → AI reads prompts → AI fixes docs → guard verifies
|
|
|
68
68
|
## Verify
|
|
69
69
|
|
|
70
70
|
```bash
|
|
71
|
-
npx docguard-cli guard # Pass/fail check (
|
|
71
|
+
npx docguard-cli guard # Pass/fail check (27 validators)
|
|
72
72
|
npx docguard-cli score # 0-100 maturity score
|
|
73
73
|
```
|
|
74
74
|
|
|
@@ -3,7 +3,7 @@ schema_version: "1.0"
|
|
|
3
3
|
extension:
|
|
4
4
|
id: "docguard"
|
|
5
5
|
name: "DocGuard — CDD Enforcement"
|
|
6
|
-
version: "0.
|
|
6
|
+
version: "0.32.0"
|
|
7
7
|
description: "Canonical-Driven Development enforcement as a true spec-kit extension. LLM-first design with automated validators, 4 AI behavior skills, spec-kit skill chaining, and workflow hooks. One pinned runtime dependency (@babel/parser); pure Node.js otherwise."
|
|
8
8
|
author: "Ricardo Accioly"
|
|
9
9
|
repository: "https://github.com/raccioly/docguard"
|
|
@@ -6,10 +6,10 @@ description: AI-driven documentation repair with structured research workflow, t
|
|
|
6
6
|
compatibility: Requires DocGuard CLI installed (npm i -g docguard-cli or npx docguard-cli)
|
|
7
7
|
metadata:
|
|
8
8
|
author: docguard
|
|
9
|
-
version: 0.
|
|
9
|
+
version: 0.32.0
|
|
10
10
|
source: extensions/spec-kit-docguard/skills/docguard-fix
|
|
11
11
|
---
|
|
12
|
-
<!-- docguard:version: 0.
|
|
12
|
+
<!-- docguard:version: 0.32.0 -->
|
|
13
13
|
|
|
14
14
|
# DocGuard Fix Skill
|
|
15
15
|
|
|
@@ -7,10 +7,10 @@ description: Run DocGuard guard validation against Canonical-Driven Development
|
|
|
7
7
|
compatibility: Requires DocGuard CLI installed (npm i -g docguard-cli or npx docguard-cli)
|
|
8
8
|
metadata:
|
|
9
9
|
author: docguard
|
|
10
|
-
version: 0.
|
|
10
|
+
version: 0.32.0
|
|
11
11
|
source: extensions/spec-kit-docguard/skills/docguard-guard
|
|
12
12
|
---
|
|
13
|
-
<!-- docguard:version: 0.
|
|
13
|
+
<!-- docguard:version: 0.32.0 -->
|
|
14
14
|
|
|
15
15
|
# DocGuard Guard Skill
|
|
16
16
|
|
|
@@ -6,10 +6,10 @@ description: Cross-document consistency analysis and quality assessment. Perform
|
|
|
6
6
|
compatibility: Requires DocGuard CLI installed (npm i -g docguard-cli or npx docguard-cli)
|
|
7
7
|
metadata:
|
|
8
8
|
author: docguard
|
|
9
|
-
version: 0.
|
|
9
|
+
version: 0.32.0
|
|
10
10
|
source: extensions/spec-kit-docguard/skills/docguard-review
|
|
11
11
|
---
|
|
12
|
-
<!-- docguard:version: 0.
|
|
12
|
+
<!-- docguard:version: 0.32.0 -->
|
|
13
13
|
|
|
14
14
|
# DocGuard Review Skill
|
|
15
15
|
|
|
@@ -6,10 +6,10 @@ description: CDD maturity assessment with category-aware improvement roadmap. Ru
|
|
|
6
6
|
compatibility: Requires DocGuard CLI installed (npm i -g docguard-cli or npx docguard-cli)
|
|
7
7
|
metadata:
|
|
8
8
|
author: docguard
|
|
9
|
-
version: 0.
|
|
9
|
+
version: 0.32.0
|
|
10
10
|
source: extensions/spec-kit-docguard/skills/docguard-score
|
|
11
11
|
---
|
|
12
|
-
<!-- docguard:version: 0.
|
|
12
|
+
<!-- docguard:version: 0.32.0 -->
|
|
13
13
|
|
|
14
14
|
# DocGuard Score Skill
|
|
15
15
|
|
|
@@ -4,10 +4,10 @@ description: Keep canonical documentation ALWAYS UP TO DATE. Refreshes code-trut
|
|
|
4
4
|
compatibility: Requires DocGuard CLI installed (npm i -g docguard-cli or npx docguard-cli)
|
|
5
5
|
metadata:
|
|
6
6
|
author: docguard
|
|
7
|
-
version: 0.
|
|
7
|
+
version: 0.32.0
|
|
8
8
|
source: extensions/spec-kit-docguard/skills/docguard-sync
|
|
9
9
|
---
|
|
10
|
-
<!-- docguard:version: 0.
|
|
10
|
+
<!-- docguard:version: 0.32.0 -->
|
|
11
11
|
|
|
12
12
|
# DocGuard Sync Skill
|
|
13
13
|
|
package/package.json
CHANGED