docguard-cli 0.30.0 → 0.31.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 +11 -6
- package/cli/commands/explain.mjs +34 -0
- package/cli/commands/guard.mjs +8 -0
- package/cli/commands/impact.mjs +93 -12
- package/cli/commands/mcp.mjs +20 -0
- package/cli/commands/score.mjs +9 -4
- package/cli/commands/setup.mjs +0 -13
- package/cli/commands/verify.mjs +63 -1
- package/cli/config.mjs +7 -0
- package/cli/findings.mjs +26 -0
- package/cli/shared-diff.mjs +209 -0
- package/cli/shared-git.mjs +101 -1
- package/cli/shared-ir.mjs +81 -0
- package/cli/validators/api-doc-smells.mjs +143 -0
- package/cli/validators/diff-suspicion.mjs +178 -0
- package/cli/validators/reference-existence.mjs +157 -0
- package/cli/validators/traceability.mjs +44 -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,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
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
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
|
+
|
|
32
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
33
|
+
import { resolve, extname } from 'node:path';
|
|
34
|
+
import { isGitRepo, lastCommitHash, symbolExistsAtRev } from '../shared-git.mjs';
|
|
35
|
+
import { walkFiles } from '../shared-ignore.mjs';
|
|
36
|
+
import { readScannable } from '../shared-source.mjs';
|
|
37
|
+
import { mkFinding, resultFromFindings } from '../findings.mjs';
|
|
38
|
+
|
|
39
|
+
const CODE_EXT = new Set([
|
|
40
|
+
'.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.py', '.go', '.rs', '.java',
|
|
41
|
+
'.kt', '.rb', '.php', '.cs', '.swift', '.scala', '.dart', '.c', '.cpp', '.h',
|
|
42
|
+
]);
|
|
43
|
+
const IDENT = /[A-Za-z_][A-Za-z0-9_]*/g;
|
|
44
|
+
|
|
45
|
+
// Compound = clearly a code symbol, not a prose word: camelCase, snake_case,
|
|
46
|
+
// or PascalCase-multiword. Pure lowercase single words are excluded.
|
|
47
|
+
const COMPOUND = /[a-z][A-Z]|_|^[A-Z][a-z]+[A-Z]/;
|
|
48
|
+
function isCodeIdentifier(s) {
|
|
49
|
+
if (s.length < 4 || s.length > 80) return false;
|
|
50
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(s)) return false; // pure identifier (no dots)
|
|
51
|
+
return COMPOUND.test(s);
|
|
52
|
+
}
|
|
53
|
+
|
|
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;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function extractRefs(content) {
|
|
68
|
+
const refs = new Set();
|
|
69
|
+
const backtick = /`([^`\n]{2,80})`/g;
|
|
70
|
+
let m;
|
|
71
|
+
while ((m = backtick.exec(content)) !== null) {
|
|
72
|
+
let tok = m[1].trim().replace(/\(.*$/, '').replace(/[.,;:]+$/, '').trim();
|
|
73
|
+
if (/^-/.test(tok)) continue; // CLI flag → FP mode (a)
|
|
74
|
+
if (isCodeIdentifier(tok)) refs.add(tok);
|
|
75
|
+
}
|
|
76
|
+
return [...refs];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function indexDocs(projectDir) {
|
|
80
|
+
const docs = [];
|
|
81
|
+
const push = (name, full) => {
|
|
82
|
+
try {
|
|
83
|
+
const content = readFileSync(full, 'utf-8');
|
|
84
|
+
docs.push({ name, path: full, refs: extractRefs(content) });
|
|
85
|
+
} catch { /* skip */ }
|
|
86
|
+
};
|
|
87
|
+
const docsDir = resolve(projectDir, 'docs-canonical');
|
|
88
|
+
if (existsSync(docsDir)) {
|
|
89
|
+
try {
|
|
90
|
+
for (const f of readdirSync(docsDir)) if (f.endsWith('.md')) push(f, resolve(docsDir, f));
|
|
91
|
+
} catch { /* skip */ }
|
|
92
|
+
}
|
|
93
|
+
for (const agent of ['AGENTS.md', 'CLAUDE.md', 'GEMINI.md']) {
|
|
94
|
+
const p = resolve(projectDir, agent);
|
|
95
|
+
if (existsSync(p)) push(agent, p);
|
|
96
|
+
}
|
|
97
|
+
return docs;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function validateReferenceExistence(projectDir, config = {}) {
|
|
101
|
+
const cfg = config.referenceExistence || {};
|
|
102
|
+
const maxRefsPerDoc = cfg.maxRefsPerDoc || 80;
|
|
103
|
+
|
|
104
|
+
if (!isGitRepo(projectDir)) {
|
|
105
|
+
return resultFromFindings([], { passed: 0, total: 0, applicable: false });
|
|
106
|
+
}
|
|
107
|
+
const docs = indexDocs(projectDir);
|
|
108
|
+
if (docs.length === 0 || docs.every(d => d.refs.length === 0)) {
|
|
109
|
+
return resultFromFindings([], { passed: 0, total: 0, applicable: false });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const headIds = buildHeadIdentifierSet(projectDir); // cheap gate, built once
|
|
113
|
+
const revPresence = new Map(); // `${sym}\0${rev}` → bool
|
|
114
|
+
// Authoritative HEAD absence (covers dot-dirs the walk skipped). Cached.
|
|
115
|
+
const headAbsent = new Map();
|
|
116
|
+
const confirmedAbsentAtHead = (sym) => {
|
|
117
|
+
if (!headAbsent.has(sym)) headAbsent.set(sym, !symbolExistsAtRev(projectDir, sym, 'HEAD'));
|
|
118
|
+
return headAbsent.get(sym);
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const findings = [];
|
|
122
|
+
let totalChecked = 0;
|
|
123
|
+
let absentAtHead = 0;
|
|
124
|
+
for (const doc of docs) {
|
|
125
|
+
let rev = null; // resolve lazily — only if a ref is actually absent now
|
|
126
|
+
for (const sym of doc.refs.slice(0, maxRefsPerDoc)) {
|
|
127
|
+
totalChecked++;
|
|
128
|
+
if (headIds.has(sym)) continue; // in the walked tree → present (cheap)
|
|
129
|
+
if (!confirmedAbsentAtHead(sym)) continue; // walk missed it but git finds it (dot-dir) → present
|
|
130
|
+
absentAtHead++;
|
|
131
|
+
if (rev === null) rev = lastCommitHash(projectDir, doc.path) || '';
|
|
132
|
+
if (!rev) continue; // untracked doc → no "then" snapshot
|
|
133
|
+
const key = `${sym}\0${rev}`;
|
|
134
|
+
if (!revPresence.has(key)) revPresence.set(key, symbolExistsAtRev(projectDir, sym, rev));
|
|
135
|
+
if (!revPresence.get(key)) continue; // never existed at doc-time → not our signal
|
|
136
|
+
findings.push(mkFinding({
|
|
137
|
+
code: 'REF001',
|
|
138
|
+
validator: 'reference-existence',
|
|
139
|
+
severity: 'warn',
|
|
140
|
+
confidence: 'low',
|
|
141
|
+
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.`,
|
|
142
|
+
location: { file: doc.name },
|
|
143
|
+
suggestion: {
|
|
144
|
+
summary: `Update or remove the \`${sym}\` reference in ${doc.name} (or suppress if it is a still-relevant user-facing name).`,
|
|
145
|
+
},
|
|
146
|
+
}));
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const res = resultFromFindings(findings, {
|
|
151
|
+
passed: totalChecked - findings.length,
|
|
152
|
+
total: totalChecked,
|
|
153
|
+
applicable: true,
|
|
154
|
+
});
|
|
155
|
+
res.absentAtHead = absentAtHead; // instrumentation: proves the pipeline reaches the rev check
|
|
156
|
+
return res;
|
|
157
|
+
}
|
|
@@ -18,6 +18,25 @@ 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
|
+
// IR soft-link recovery (feat 5): tokenize test files once so an untraced
|
|
25
|
+
// requirement can be matched to the test that most likely already covers it
|
|
26
|
+
// (TF-IDF cosine, VSM). Capped so a huge test suite can't blow up guard.
|
|
27
|
+
function buildTestCorpus(projectDir, projectFiles, { maxFiles = 250, maxTokens = 400 } = {}) {
|
|
28
|
+
const testFiles = projectFiles.filter(f =>
|
|
29
|
+
TEST_PATTERNS.some(p => p.test(f)) || /__tests__\//.test(f) || /tests?\//.test(f)
|
|
30
|
+
).slice(0, maxFiles);
|
|
31
|
+
const corpus = [];
|
|
32
|
+
for (const relPath of testFiles) {
|
|
33
|
+
try {
|
|
34
|
+
const content = readFileSync(resolve(projectDir, relPath), 'utf-8');
|
|
35
|
+
corpus.push({ id: relPath, tokens: tokenize(content).slice(0, maxTokens) });
|
|
36
|
+
} catch { /* skip unreadable */ }
|
|
37
|
+
}
|
|
38
|
+
return corpus;
|
|
39
|
+
}
|
|
21
40
|
|
|
22
41
|
const IGNORE_DIRS = new Set([
|
|
23
42
|
'node_modules', '.git', '.next', 'dist', 'build', 'coverage',
|
|
@@ -208,20 +227,40 @@ function validateRequirementTraceability(projectDir, config, projectFiles) {
|
|
|
208
227
|
|
|
209
228
|
// ── Step 3: Report traceability results ──
|
|
210
229
|
|
|
230
|
+
// IR soft-link recovery (feat 5): build the tokenized test corpus once, only
|
|
231
|
+
// if there are untraced requirements to match. Threshold is deliberately low
|
|
232
|
+
// — short requirement text vs a whole test file yields modest cosine scores;
|
|
233
|
+
// the hint is a suggestion, not proof.
|
|
234
|
+
const softThreshold = config.traceability?.irSoftThreshold ?? 0.10;
|
|
235
|
+
let testCorpus = null;
|
|
236
|
+
|
|
211
237
|
// Check each documented requirement has at least one test reference
|
|
212
238
|
for (const [reqId, location] of reqIds) {
|
|
213
239
|
total++;
|
|
214
240
|
if (testRefs.has(reqId)) {
|
|
215
241
|
passed++;
|
|
216
242
|
} else {
|
|
243
|
+
// Try to recover a likely-but-unannotated test via TF-IDF cosine.
|
|
244
|
+
let softHint = '';
|
|
245
|
+
let softText = `Add an @req ${reqId} comment to the test that verifies this requirement`;
|
|
246
|
+
const queryText = location.text && location.text.length > reqId.length ? location.text : reqId;
|
|
247
|
+
if (testCorpus === null) testCorpus = buildTestCorpus(projectDir, projectFiles);
|
|
248
|
+
if (testCorpus.length > 0) {
|
|
249
|
+
const ranked = rankBySimilarity(tokenize(queryText), testCorpus);
|
|
250
|
+
const top = ranked[0];
|
|
251
|
+
if (top && top.score >= softThreshold) {
|
|
252
|
+
const pct = (top.score * 100).toFixed(0);
|
|
253
|
+
softHint = ` — IR soft-match: ${top.id} (${pct}% similar) may already cover it`;
|
|
254
|
+
softText = `${top.id} looks like it already tests this (${pct}% similar) — add @req ${reqId} there, or if unrelated, write the missing test`;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
217
257
|
findings.push(mkFinding({
|
|
218
258
|
code: 'TRC004',
|
|
219
259
|
validator: 'traceability',
|
|
220
260
|
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`,
|
|
261
|
+
message: `Requirement ${reqId} (${location.file}:${location.line}) has no test coverage.${softHint || ' Add @req ' + reqId + ' comment to the test that verifies this requirement'}`,
|
|
223
262
|
location: `${location.file}:${location.line}`,
|
|
224
|
-
suggestion: { kind: 'fix', text:
|
|
263
|
+
suggestion: { kind: 'fix', text: softText },
|
|
225
264
|
}));
|
|
226
265
|
}
|
|
227
266
|
}
|
|
@@ -269,7 +308,8 @@ function collectRequirementIds(projectDir, config, patterns) {
|
|
|
269
308
|
while ((match = pattern.exec(lines[i])) !== null) {
|
|
270
309
|
const reqId = match[0]; // e.g., "REQ-001"
|
|
271
310
|
if (!reqIds.has(reqId)) {
|
|
272
|
-
|
|
311
|
+
// capture the line text (the requirement description) for IR soft-match
|
|
312
|
+
reqIds.set(reqId, { file: docName, line: i + 1, text: lines[i].trim() });
|
|
273
313
|
}
|
|
274
314
|
}
|
|
275
315
|
}
|
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.31.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.31.0
|
|
10
10
|
source: extensions/spec-kit-docguard/skills/docguard-fix
|
|
11
11
|
---
|
|
12
|
-
<!-- docguard:version: 0.
|
|
12
|
+
<!-- docguard:version: 0.31.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.31.0
|
|
11
11
|
source: extensions/spec-kit-docguard/skills/docguard-guard
|
|
12
12
|
---
|
|
13
|
-
<!-- docguard:version: 0.
|
|
13
|
+
<!-- docguard:version: 0.31.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.31.0
|
|
10
10
|
source: extensions/spec-kit-docguard/skills/docguard-review
|
|
11
11
|
---
|
|
12
|
-
<!-- docguard:version: 0.
|
|
12
|
+
<!-- docguard:version: 0.31.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.31.0
|
|
10
10
|
source: extensions/spec-kit-docguard/skills/docguard-score
|
|
11
11
|
---
|
|
12
|
-
<!-- docguard:version: 0.
|
|
12
|
+
<!-- docguard:version: 0.31.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.31.0
|
|
8
8
|
source: extensions/spec-kit-docguard/skills/docguard-sync
|
|
9
9
|
---
|
|
10
|
-
<!-- docguard:version: 0.
|
|
10
|
+
<!-- docguard:version: 0.31.0 -->
|
|
11
11
|
|
|
12
12
|
# DocGuard Sync Skill
|
|
13
13
|
|
package/package.json
CHANGED