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,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared IR primitives — zero-dependency TF-IDF + cosine similarity.
|
|
3
|
+
*
|
|
4
|
+
* Backs IR-based traceability link recovery (feat 5): when a requirement has no
|
|
5
|
+
* exact `@req <ID>` annotation, rank candidate test/code artifacts by textual
|
|
6
|
+
* similarity (Vector Space Model, the canonical IR traceability technique —
|
|
7
|
+
* "basic linear algebra, no external services"). A requirement with NO
|
|
8
|
+
* candidate above threshold is a strong "unimplemented / untested" signal.
|
|
9
|
+
*
|
|
10
|
+
* Pure functions over token arrays; the caller tokenizes (we reuse the
|
|
11
|
+
* identifier-aware tokenizer from shared-diff so `getUserById` in code matches
|
|
12
|
+
* "get user by id" in a requirement).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** term → count for one document's tokens. */
|
|
16
|
+
export function termFreq(tokens) {
|
|
17
|
+
const tf = new Map();
|
|
18
|
+
for (const t of tokens) tf.set(t, (tf.get(t) || 0) + 1);
|
|
19
|
+
return tf;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Inverse document frequency across a corpus of token arrays.
|
|
24
|
+
* idf(t) = ln(N / (1 + df(t))) + 1 — smoothed so a term in every doc still
|
|
25
|
+
* carries a small positive weight (avoids all-zero vectors on tiny corpora).
|
|
26
|
+
*/
|
|
27
|
+
export function buildIdf(corpusTokenArrays) {
|
|
28
|
+
const N = corpusTokenArrays.length || 1;
|
|
29
|
+
const df = new Map();
|
|
30
|
+
for (const tokens of corpusTokenArrays) {
|
|
31
|
+
for (const t of new Set(tokens)) df.set(t, (df.get(t) || 0) + 1);
|
|
32
|
+
}
|
|
33
|
+
const idf = new Map();
|
|
34
|
+
for (const [t, d] of df) idf.set(t, Math.log(N / (1 + d)) + 1);
|
|
35
|
+
return idf;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** TF-IDF vector (Map term→weight) for a document, given a prebuilt idf. */
|
|
39
|
+
export function tfidfVector(tokens, idf) {
|
|
40
|
+
const tf = termFreq(tokens);
|
|
41
|
+
const vec = new Map();
|
|
42
|
+
const len = tokens.length || 1;
|
|
43
|
+
for (const [t, count] of tf) {
|
|
44
|
+
const w = idf.get(t);
|
|
45
|
+
if (w === undefined) continue; // term not in corpus idf → skip
|
|
46
|
+
vec.set(t, (count / len) * w); // normalized TF × IDF
|
|
47
|
+
}
|
|
48
|
+
return vec;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Cosine similarity of two sparse Map vectors. 0 when either is empty. */
|
|
52
|
+
export function cosineSimilarity(a, b) {
|
|
53
|
+
if (a.size === 0 || b.size === 0) return 0;
|
|
54
|
+
// iterate the smaller for the dot product
|
|
55
|
+
const [small, large] = a.size <= b.size ? [a, b] : [b, a];
|
|
56
|
+
let dot = 0;
|
|
57
|
+
for (const [t, w] of small) {
|
|
58
|
+
const w2 = large.get(t);
|
|
59
|
+
if (w2 !== undefined) dot += w * w2;
|
|
60
|
+
}
|
|
61
|
+
if (dot === 0) return 0;
|
|
62
|
+
let na = 0; for (const w of a.values()) na += w * w;
|
|
63
|
+
let nb = 0; for (const w of b.values()) nb += w * w;
|
|
64
|
+
const denom = Math.sqrt(na) * Math.sqrt(nb);
|
|
65
|
+
return denom === 0 ? 0 : dot / denom;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Rank candidate documents against a query by cosine similarity.
|
|
70
|
+
* @param queryTokens tokens of the requirement text
|
|
71
|
+
* @param candidates [{ id, tokens }]
|
|
72
|
+
* @returns [{ id, score }] sorted desc, scores in [0,1]
|
|
73
|
+
*/
|
|
74
|
+
export function rankBySimilarity(queryTokens, candidates) {
|
|
75
|
+
const corpus = [queryTokens, ...candidates.map(c => c.tokens)];
|
|
76
|
+
const idf = buildIdf(corpus);
|
|
77
|
+
const qv = tfidfVector(queryTokens, idf);
|
|
78
|
+
return candidates
|
|
79
|
+
.map(c => ({ id: c.id, score: cosineSimilarity(qv, tfidfVector(c.tokens, idf)) }))
|
|
80
|
+
.sort((x, y) => y.score - x.score);
|
|
81
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API-Doc-Smells validator (APS001 Bloated, APS002 Lazy) — v0.31.0.
|
|
3
|
+
*
|
|
4
|
+
* Research: the API-documentation-smell taxonomy (Bloated, Excess Structural
|
|
5
|
+
* Info, Tangled, Fragmented, Lazy) with a 1,000-unit benchmark. The two smells
|
|
6
|
+
* with strong DETERMINISTIC detectors are Bloated (F1 0.90) and Lazy (F1 0.95),
|
|
7
|
+
* keyed on documentation length relative to the surface documented — no ML.
|
|
8
|
+
* The three semantic smells need BERT and are deliberately left to staged agent
|
|
9
|
+
* judgment (verify --semantic), matching DocGuard's split.
|
|
10
|
+
*
|
|
11
|
+
* We apply the length signals per "API documentation unit" = a markdown section
|
|
12
|
+
* whose HEADING is a code signature (an HTTP endpoint `GET /path`, a function
|
|
13
|
+
* `foo(...)`, or a backticked symbol). Prose-only sections are ignored — that's
|
|
14
|
+
* doc-quality.mjs's job (passive voice, readability); this is API-surface-specific.
|
|
15
|
+
*
|
|
16
|
+
* Lazy — a documented endpoint/method with (almost) no explanation: ≤ N
|
|
17
|
+
* prose words of body. "Documented in name only."
|
|
18
|
+
* Bloated— a single unit that is grossly over-documented: ≥ M words.
|
|
19
|
+
*
|
|
20
|
+
* All findings confidence:'low' / soft — a nudge to right-size the doc.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
24
|
+
import { resolve } from 'node:path';
|
|
25
|
+
import { mkFinding, resultFromFindings } from '../findings.mjs';
|
|
26
|
+
|
|
27
|
+
const HEADING = /^(#{1,6})\s+(.*)$/;
|
|
28
|
+
// A heading that documents an API/code unit — NOT a prose section heading.
|
|
29
|
+
// Precision (corpus-tuned): markdown headings routinely read "Some Words
|
|
30
|
+
// (parenthetical note)", which naively looks like a call. A real signature has
|
|
31
|
+
// NO space before `(` AND a code-shaped identifier (camelCase / snake_case /
|
|
32
|
+
// dotted). This kills FPs like "Remediation Log (2026-03-17)", "Unit Tests
|
|
33
|
+
// (vitest)", "4.1 Enrollment (Base Record)".
|
|
34
|
+
const HTTP_SIG = /^`?\s*(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+\/?`?\S/i;
|
|
35
|
+
const FUNC_SIG = /(?:^|[\s`.])([A-Za-z_][A-Za-z0-9_]*)\((?:\)|[^)]*\))/; // identifier(...) no space
|
|
36
|
+
const CODEY = /[a-z][A-Z]|_|\./; // camel/snake/dotted → code-shaped
|
|
37
|
+
const BACKTICK_SIG = /^`[^`\s][^`]*`\s*$/; // heading is exactly a `symbol`
|
|
38
|
+
function isSignatureHeading(text) {
|
|
39
|
+
const t = text.trim();
|
|
40
|
+
if (HTTP_SIG.test(t)) return true;
|
|
41
|
+
if (BACKTICK_SIG.test(t)) return true;
|
|
42
|
+
const fm = t.match(FUNC_SIG);
|
|
43
|
+
if (fm && (CODEY.test(fm[1]) || /\(\s*\)/.test(t))) return true; // codey name OR empty-arg call foo()
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Prose words in a body, EXCLUDING fenced code blocks (a big code sample isn't
|
|
48
|
+
// "explanation", so a unit with only code counts as Lazy).
|
|
49
|
+
function proseWordCount(bodyLines) {
|
|
50
|
+
let inFence = false;
|
|
51
|
+
let words = 0;
|
|
52
|
+
for (const line of bodyLines) {
|
|
53
|
+
if (/^\s*```/.test(line)) { inFence = !inFence; continue; }
|
|
54
|
+
if (inFence) continue;
|
|
55
|
+
const m = line.trim().match(/[A-Za-z0-9][A-Za-z0-9'-]*/g);
|
|
56
|
+
if (m) words += m.length;
|
|
57
|
+
}
|
|
58
|
+
return words;
|
|
59
|
+
}
|
|
60
|
+
function totalWordCount(bodyLines) {
|
|
61
|
+
let words = 0;
|
|
62
|
+
for (const line of bodyLines) {
|
|
63
|
+
const m = line.match(/[A-Za-z0-9][A-Za-z0-9'-]*/g);
|
|
64
|
+
if (m) words += m.length;
|
|
65
|
+
}
|
|
66
|
+
return words;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Split markdown into signature-headed units: { heading, level, line, body[] }.
|
|
70
|
+
function extractUnits(content) {
|
|
71
|
+
const lines = content.split('\n');
|
|
72
|
+
const units = [];
|
|
73
|
+
let cur = null;
|
|
74
|
+
for (let i = 0; i < lines.length; i++) {
|
|
75
|
+
const hm = lines[i].match(HEADING);
|
|
76
|
+
if (hm) {
|
|
77
|
+
// close current unit at the next heading of same-or-higher level
|
|
78
|
+
if (cur && hm[1].length <= cur.level) { units.push(cur); cur = null; }
|
|
79
|
+
if (!cur && isSignatureHeading(hm[2])) {
|
|
80
|
+
cur = { heading: hm[2].trim(), level: hm[1].length, line: i + 1, body: [] };
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (cur) { cur.body.push(lines[i]); }
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (cur) cur.body.push(lines[i]);
|
|
87
|
+
}
|
|
88
|
+
if (cur) units.push(cur);
|
|
89
|
+
return units;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function validateApiDocSmells(projectDir, config = {}) {
|
|
93
|
+
const cfg = config.apiDocSmells || {};
|
|
94
|
+
const lazyMax = Number.isInteger(cfg.lazyMaxWords) ? cfg.lazyMaxWords : 6;
|
|
95
|
+
const bloatedMin = Number.isInteger(cfg.bloatedMinWords) ? cfg.bloatedMinWords : 300;
|
|
96
|
+
|
|
97
|
+
const docsDir = resolve(projectDir, 'docs-canonical');
|
|
98
|
+
if (!existsSync(docsDir)) {
|
|
99
|
+
return resultFromFindings([], { passed: 0, total: 0, applicable: false });
|
|
100
|
+
}
|
|
101
|
+
let docFiles = [];
|
|
102
|
+
try { docFiles = readdirSync(docsDir).filter(f => f.endsWith('.md')); } catch { /* skip */ }
|
|
103
|
+
|
|
104
|
+
const findings = [];
|
|
105
|
+
let unitCount = 0;
|
|
106
|
+
for (const f of docFiles) {
|
|
107
|
+
let content;
|
|
108
|
+
try { content = readFileSync(resolve(docsDir, f), 'utf-8'); } catch { continue; }
|
|
109
|
+
const units = extractUnits(content);
|
|
110
|
+
for (const u of units) {
|
|
111
|
+
unitCount++;
|
|
112
|
+
const prose = proseWordCount(u.body);
|
|
113
|
+
const total = totalWordCount(u.body);
|
|
114
|
+
if (prose <= lazyMax) {
|
|
115
|
+
findings.push(mkFinding({
|
|
116
|
+
code: 'APS002',
|
|
117
|
+
validator: 'api-doc-smells',
|
|
118
|
+
severity: 'warn',
|
|
119
|
+
confidence: 'low',
|
|
120
|
+
message: `${f}: "${u.heading.slice(0, 60)}" is documented in name only (${prose} words of explanation) — Lazy API doc.`,
|
|
121
|
+
location: { file: f, line: u.line },
|
|
122
|
+
suggestion: { summary: `Describe what "${u.heading.slice(0, 40)}" does, its params, return, and errors — not just its signature.` },
|
|
123
|
+
}));
|
|
124
|
+
} else if (total >= bloatedMin) {
|
|
125
|
+
findings.push(mkFinding({
|
|
126
|
+
code: 'APS001',
|
|
127
|
+
validator: 'api-doc-smells',
|
|
128
|
+
severity: 'warn',
|
|
129
|
+
confidence: 'low',
|
|
130
|
+
message: `${f}: "${u.heading.slice(0, 60)}" is ${total} words for one unit — Bloated API doc; trim to the essential contract.`,
|
|
131
|
+
location: { file: f, line: u.line },
|
|
132
|
+
suggestion: { summary: `Split or trim "${u.heading.slice(0, 40)}" — move examples/edge-cases elsewhere and keep the core contract.` },
|
|
133
|
+
}));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return resultFromFindings(findings, {
|
|
139
|
+
passed: unitCount - findings.length,
|
|
140
|
+
total: unitCount,
|
|
141
|
+
applicable: unitCount > 0,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
@@ -143,7 +143,14 @@ function validateConfigLayers(projectDir, config, layers, acc) {
|
|
|
143
143
|
|
|
144
144
|
// ── Import Graph Builder ────────────────────────────────────────────────────
|
|
145
145
|
|
|
146
|
-
|
|
146
|
+
/**
|
|
147
|
+
* Build the project's JS/TS import graph. Exported for reuse by `impact`
|
|
148
|
+
* (indirect code→doc analysis walks this graph's reverse edges) — one graph
|
|
149
|
+
* builder, not two.
|
|
150
|
+
*
|
|
151
|
+
* @returns {{files: string[], edges: {from,to,dynamic}[], fileMap: Map<string,string[]>}}
|
|
152
|
+
*/
|
|
153
|
+
export function buildImportGraph(projectDir, config) {
|
|
147
154
|
const graph = { files: [], edges: [], fileMap: new Map() };
|
|
148
155
|
|
|
149
156
|
const allFiles = getFilesRecursive(projectDir, config, projectDir);
|
|
@@ -8,6 +8,13 @@
|
|
|
8
8
|
* - Markdown relative links: [text](./OTHER.md)
|
|
9
9
|
* [text](./OTHER.md#anchor)
|
|
10
10
|
* [text](#anchor-in-same-doc)
|
|
11
|
+
* [text](<path with spaces.md>)
|
|
12
|
+
* - Obsidian wikilinks: [[OTHER]] [[OTHER#Heading]] [[OTHER|alias]]
|
|
13
|
+
* Validated only when the repo shows wikilinks-are-files evidence
|
|
14
|
+
* (`.obsidian/` exists, or at least one wikilink target resolves) — some
|
|
15
|
+
* repos use [[name]] as a non-file convention (template placeholders,
|
|
16
|
+
* memory links) and must not be flagged. Image embeds `![[x.png]]` are
|
|
17
|
+
* never treated as doc links.
|
|
11
18
|
* - Bare anchor refs: see §3.2 ARCHITECTURE.md
|
|
12
19
|
* (Section 3.2 in DATA-MODEL.md)
|
|
13
20
|
* - Bracketed section refs: [Section X.Y]
|
|
@@ -29,6 +36,8 @@
|
|
|
29
36
|
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
30
37
|
import { resolve, join, dirname, basename, relative } from 'node:path';
|
|
31
38
|
import { mkFinding, resultFromFindings } from '../findings.mjs';
|
|
39
|
+
import { resolveDocDirs } from '../shared.mjs';
|
|
40
|
+
import { walkFiles } from '../shared-ignore.mjs';
|
|
32
41
|
|
|
33
42
|
/**
|
|
34
43
|
* Slugify a heading the way GitHub's markdown anchors work.
|
|
@@ -112,8 +121,13 @@ export function extractRefs(content, sourcePath) {
|
|
|
112
121
|
// ./RELATIVE.md
|
|
113
122
|
// ../OTHER.md#anchor
|
|
114
123
|
// #intra-doc-anchor
|
|
124
|
+
// <path with spaces.md> (CommonMark angle-bracket form)
|
|
115
125
|
// We DON'T match http(s) targets here.
|
|
116
126
|
const markdownLinkRe = /\[([^\]]+)\]\(((?!https?:|mailto:)[^)]+)\)/g;
|
|
127
|
+
// [[Target]] / [[Target#Heading]] / [[Target|alias]]. The (?<!!) guard
|
|
128
|
+
// excludes Obsidian image embeds ![[img.png]] — an embed is content, not a
|
|
129
|
+
// doc cross-reference.
|
|
130
|
+
const wikiLinkRe = /(?<!!)\[\[([^\]|#\n]+)(?:#([^\]|\n]*))?(?:\|[^\]\n]*)?\]\]/g;
|
|
117
131
|
|
|
118
132
|
for (let i = 0; i < lines.length; i++) {
|
|
119
133
|
const line = lines[i];
|
|
@@ -130,8 +144,14 @@ export function extractRefs(content, sourcePath) {
|
|
|
130
144
|
markdownLinkRe.lastIndex = 0;
|
|
131
145
|
while ((m = markdownLinkRe.exec(stripped)) !== null) {
|
|
132
146
|
const target = m[2].trim();
|
|
133
|
-
|
|
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
|
|
|
@@ -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
|
+
}
|