docguard-cli 0.30.1 → 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 +9 -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/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 +93 -0
- 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,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared Unified-Diff Parser + Tokenizer — zero-dependency foundation for the
|
|
3
|
+
* change-driven detectors added in v0.31.0.
|
|
4
|
+
*
|
|
5
|
+
* ONE parser, consumed by four features so they never re-implement diff
|
|
6
|
+
* scraping (each previously would have grepped `git diff` output ad hoc):
|
|
7
|
+
* - diff-overlap suspicion (validators/diff-suspicion.mjs): does a doc's
|
|
8
|
+
* wording overlap tokens that were DELETED/REPLACED-OLD in the code diff?
|
|
9
|
+
* - reference-existence (validators/reference-existence.mjs): which symbols
|
|
10
|
+
* left the tree between two revisions.
|
|
11
|
+
* - impact blast-radius (commands/impact.mjs): which docs cite changed code.
|
|
12
|
+
* - structured-diff staging (commands/verify.mjs): hand agents an ordered
|
|
13
|
+
* replace/delete/add representation (the CARL-CCI "activity-labeled diff"
|
|
14
|
+
* shown to beat raw-text diffs — arXiv 2512.19883) instead of a raw patch.
|
|
15
|
+
*
|
|
16
|
+
* Pure Node built-ins. No git here — this operates on diff TEXT a caller
|
|
17
|
+
* already produced (see shared-git.getDiffSpans). That keeps it unit-testable
|
|
18
|
+
* without a repo and reusable on any unified-diff string.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
// ── Tokenizer ────────────────────────────────────────────────────────────────
|
|
22
|
+
// Identifier-aware: keeps `getUserById`, `user_id`, `UserService` whole, then
|
|
23
|
+
// also emits their sub-words so a doc saying "user id" still overlaps code
|
|
24
|
+
// token `user_id`. Deterministic, lowercase, stopword-filtered.
|
|
25
|
+
|
|
26
|
+
const STOPWORDS = new Set([
|
|
27
|
+
'the', 'a', 'an', 'and', 'or', 'but', 'if', 'then', 'else', 'for', 'of', 'to',
|
|
28
|
+
'in', 'on', 'at', 'by', 'as', 'is', 'are', 'was', 'were', 'be', 'been', 'this',
|
|
29
|
+
'that', 'these', 'those', 'it', 'its', 'with', 'from', 'not', 'no', 'we', 'you',
|
|
30
|
+
'return', 'returns', 'const', 'let', 'var', 'function', 'class', 'import',
|
|
31
|
+
'export', 'default', 'new', 'true', 'false', 'null', 'void', 'public', 'private',
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Split identifiers into sub-words: getUserById → [get,user,by,id];
|
|
36
|
+
* user_id → [user,id]; HTTPServer → [http,server].
|
|
37
|
+
*/
|
|
38
|
+
export function splitIdentifier(id) {
|
|
39
|
+
return String(id)
|
|
40
|
+
// camelCase / PascalCase / ACRONYMBoundary
|
|
41
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
42
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
|
|
43
|
+
// snake_case, kebab-case, dot.paths
|
|
44
|
+
.replace(/[_\-.]+/g, ' ')
|
|
45
|
+
.toLowerCase()
|
|
46
|
+
.split(/\s+/)
|
|
47
|
+
.filter(Boolean);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Tokenize free text OR code into a lowercase word set. Keeps whole identifiers
|
|
52
|
+
* AND their sub-words. `min` drops tokens shorter than it (default 3) to cut
|
|
53
|
+
* noise; identifiers below `min` after splitting are still dropped.
|
|
54
|
+
*
|
|
55
|
+
* Returns an array (call-site decides Set vs list). Deduped, order-preserving.
|
|
56
|
+
*/
|
|
57
|
+
export function tokenize(text, { min = 3, keepStopwords = false } = {}) {
|
|
58
|
+
const out = [];
|
|
59
|
+
const seen = new Set();
|
|
60
|
+
const raw = String(text).match(/[A-Za-z_][A-Za-z0-9_.-]*/g) || [];
|
|
61
|
+
for (const word of raw) {
|
|
62
|
+
// the whole identifier (lowercased) …
|
|
63
|
+
const whole = word.toLowerCase();
|
|
64
|
+
for (const t of [whole, ...splitIdentifier(word)]) {
|
|
65
|
+
if (t.length < min) continue;
|
|
66
|
+
if (!keepStopwords && STOPWORDS.has(t)) continue;
|
|
67
|
+
if (seen.has(t)) continue;
|
|
68
|
+
seen.add(t);
|
|
69
|
+
out.push(t);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ── Unified-diff parser ──────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Parse a unified diff (git diff / diff -u) into structured file entries.
|
|
79
|
+
* Handles multi-file diffs, adds/deletes (/dev/null), renames, and the
|
|
80
|
+
* "" marker.
|
|
81
|
+
*
|
|
82
|
+
* Returns: [{ oldPath, newPath, status, hunks: [{ oldStart, newStart,
|
|
83
|
+
* lines: [{ op: ' '|'-'|'+', text }] }] }]
|
|
84
|
+
* status ∈ 'modified' | 'added' | 'deleted' | 'renamed'.
|
|
85
|
+
*/
|
|
86
|
+
export function parseUnifiedDiff(diffText) {
|
|
87
|
+
const files = [];
|
|
88
|
+
if (!diffText) return files;
|
|
89
|
+
const lines = String(diffText).split('\n');
|
|
90
|
+
let cur = null;
|
|
91
|
+
let hunk = null;
|
|
92
|
+
|
|
93
|
+
const pushHunkHeader = (m) => {
|
|
94
|
+
hunk = { oldStart: parseInt(m[1], 10) || 0, newStart: parseInt(m[2], 10) || 0, lines: [] };
|
|
95
|
+
cur.hunks.push(hunk);
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
for (const line of lines) {
|
|
99
|
+
if (line.startsWith('diff --git')) {
|
|
100
|
+
// a/<old> b/<new> — quotes possible but rare; keep it simple.
|
|
101
|
+
const m = line.match(/^diff --git a\/(.+?) b\/(.+)$/);
|
|
102
|
+
cur = { oldPath: m ? m[1] : null, newPath: m ? m[2] : null, status: 'modified', hunks: [] };
|
|
103
|
+
files.push(cur);
|
|
104
|
+
hunk = null;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (!cur) continue; // ignore any preamble before the first file
|
|
108
|
+
if (line.startsWith('rename from ')) { cur.status = 'renamed'; cur.oldPath = line.slice(12); continue; }
|
|
109
|
+
if (line.startsWith('rename to ')) { cur.status = 'renamed'; cur.newPath = line.slice(10); continue; }
|
|
110
|
+
if (line.startsWith('--- ')) {
|
|
111
|
+
const p = line.slice(4);
|
|
112
|
+
if (p === '/dev/null') cur.status = 'added';
|
|
113
|
+
else cur.oldPath = p.replace(/^a\//, '');
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (line.startsWith('+++ ')) {
|
|
117
|
+
const p = line.slice(4);
|
|
118
|
+
if (p === '/dev/null') cur.status = 'deleted';
|
|
119
|
+
else cur.newPath = p.replace(/^b\//, '');
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
const hm = line.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
|
|
123
|
+
if (hm) { pushHunkHeader(hm); continue; }
|
|
124
|
+
if (!hunk) continue;
|
|
125
|
+
if (line.startsWith('\\')) continue; // ""
|
|
126
|
+
const op = line[0];
|
|
127
|
+
if (op === ' ' || op === '+' || op === '-') {
|
|
128
|
+
hunk.lines.push({ op, text: line.slice(1) });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return files;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Decompose a file's hunks into an ORDERED activity list — the CARL-CCI
|
|
136
|
+
* "activity-labeled" representation. Consecutive removed/added runs are grouped:
|
|
137
|
+
* run of '-' then '+' → { type: 'replace', del: [...], add: [...] }
|
|
138
|
+
* run of '-' only → { type: 'delete', del: [...] }
|
|
139
|
+
* run of '+' only → { type: 'add', add: [...] }
|
|
140
|
+
* Context lines are the separators and are not emitted (they're not a change).
|
|
141
|
+
*
|
|
142
|
+
* This is what makes staged agent tasks legible: the agent sees "these lines
|
|
143
|
+
* were replaced by those", not a flat blob.
|
|
144
|
+
*/
|
|
145
|
+
export function activityLabeledDiff(fileDiff) {
|
|
146
|
+
const acts = [];
|
|
147
|
+
for (const h of fileDiff.hunks || []) {
|
|
148
|
+
let del = [];
|
|
149
|
+
let add = [];
|
|
150
|
+
const flush = () => {
|
|
151
|
+
if (del.length && add.length) acts.push({ type: 'replace', del, add });
|
|
152
|
+
else if (del.length) acts.push({ type: 'delete', del });
|
|
153
|
+
else if (add.length) acts.push({ type: 'add', add });
|
|
154
|
+
del = []; add = [];
|
|
155
|
+
};
|
|
156
|
+
for (const ln of h.lines) {
|
|
157
|
+
if (ln.op === '-') {
|
|
158
|
+
if (add.length) flush(); // an add run ended before this delete → boundary
|
|
159
|
+
del.push(ln.text);
|
|
160
|
+
} else if (ln.op === '+') {
|
|
161
|
+
add.push(ln.text);
|
|
162
|
+
} else {
|
|
163
|
+
flush(); // context terminates the current activity
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
flush();
|
|
167
|
+
}
|
|
168
|
+
return acts;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Tokens that LEFT the code in this file diff — union over every '-' line
|
|
173
|
+
* (both pure deletes and the "old" side of replaces). This is the
|
|
174
|
+
* `deleted ∪ replaceOld` span the outdated-comment research keys on: a doc
|
|
175
|
+
* that still talks about these tokens is a drift suspect.
|
|
176
|
+
*/
|
|
177
|
+
export function removedTokens(fileDiff, opts) {
|
|
178
|
+
const set = new Set();
|
|
179
|
+
for (const h of fileDiff.hunks || []) {
|
|
180
|
+
for (const ln of h.lines) {
|
|
181
|
+
if (ln.op === '-') for (const t of tokenize(ln.text, opts)) set.add(t);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return set;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Tokens that were ADDED ('+' lines) — the new-side span. */
|
|
188
|
+
export function addedTokens(fileDiff, opts) {
|
|
189
|
+
const set = new Set();
|
|
190
|
+
for (const h of fileDiff.hunks || []) {
|
|
191
|
+
for (const ln of h.lines) {
|
|
192
|
+
if (ln.op === '+') for (const t of tokenize(ln.text, opts)) set.add(t);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return set;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Overlap score between a doc's tokens and a set of change tokens: the count
|
|
200
|
+
* and the shared tokens themselves (for explainable findings). Deterministic.
|
|
201
|
+
*/
|
|
202
|
+
export function tokenOverlap(docTokens, changeTokenSet) {
|
|
203
|
+
const shared = [];
|
|
204
|
+
const seen = new Set();
|
|
205
|
+
for (const t of docTokens) {
|
|
206
|
+
if (changeTokenSet.has(t) && !seen.has(t)) { seen.add(t); shared.push(t); }
|
|
207
|
+
}
|
|
208
|
+
return { count: shared.length, shared };
|
|
209
|
+
}
|
package/cli/shared-git.mjs
CHANGED
|
@@ -148,6 +148,99 @@ export function changedFilesSince(dir, ref = 'HEAD~1') {
|
|
|
148
148
|
}
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
+
/**
|
|
152
|
+
* Return the raw unified-diff TEXT between `ref` and HEAD, restricted to code
|
|
153
|
+
* files (docs are excluded — a doc changing is not a code change that could
|
|
154
|
+
* make OTHER docs stale). Consumed by shared-diff.parseUnifiedDiff.
|
|
155
|
+
*
|
|
156
|
+
* `-U0`? No — we want a few lines of context so the parser can group activities
|
|
157
|
+
* and callers can see surrounding tokens; default 3 is fine. Returns '' on
|
|
158
|
+
* error / no diff. Caps output at ~5MB so a giant refactor can't OOM the CLI.
|
|
159
|
+
*/
|
|
160
|
+
export function getDiffText(dir, ref = 'HEAD~1', pathspec = null) {
|
|
161
|
+
try {
|
|
162
|
+
const args = ['diff', '--no-color', '--no-ext-diff', ref, 'HEAD'];
|
|
163
|
+
if (pathspec && pathspec.length) args.push('--', ...pathspec);
|
|
164
|
+
const raw = execFileSync('git', args, {
|
|
165
|
+
cwd: dir, encoding: 'utf-8',
|
|
166
|
+
stdio: ['pipe', 'pipe', 'ignore'],
|
|
167
|
+
maxBuffer: 1024 * 1024 * 5,
|
|
168
|
+
});
|
|
169
|
+
return raw || '';
|
|
170
|
+
} catch {
|
|
171
|
+
return '';
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Read a file's contents AS OF a given revision (e.g. the commit where a doc
|
|
177
|
+
* was last touched), following the `<rev>:<path>` git addressing. Returns null
|
|
178
|
+
* when the path didn't exist at that rev, the rev is unknown, or git is
|
|
179
|
+
* unavailable — callers treat null as "no prior snapshot to compare".
|
|
180
|
+
*
|
|
181
|
+
* This is the backbone of the two-revision reference-existence check: read the
|
|
182
|
+
* source at the doc's last-updated commit vs HEAD and diff symbol presence.
|
|
183
|
+
*/
|
|
184
|
+
export function fileContentAtRev(dir, rev, filePath) {
|
|
185
|
+
try {
|
|
186
|
+
const raw = execFileSync(
|
|
187
|
+
'git',
|
|
188
|
+
['show', `${rev}:${filePath}`],
|
|
189
|
+
{ cwd: dir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'], maxBuffer: 1024 * 1024 * 10 }
|
|
190
|
+
);
|
|
191
|
+
return raw;
|
|
192
|
+
} catch {
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Default source globs for symbol-existence grep (any-language).
|
|
198
|
+
export const CODE_GLOBS = [
|
|
199
|
+
'*.ts', '*.tsx', '*.js', '*.jsx', '*.mjs', '*.cjs', '*.py', '*.go', '*.rs',
|
|
200
|
+
'*.java', '*.kt', '*.rb', '*.php', '*.cs', '*.swift', '*.scala', '*.dart', '*.c', '*.cpp', '*.h',
|
|
201
|
+
];
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* True if `symbol` appears as a whole word in the source tree AS OF `rev`.
|
|
205
|
+
* Uses `git grep -w -F` (fixed string, word boundary) at the given revision —
|
|
206
|
+
* exactly the "whole-word, case-sensitive, exact string match" the two-revision
|
|
207
|
+
* outdated-reference method specifies (arXiv 2212.01479). Restricted to code
|
|
208
|
+
* globs so a symbol still named in prose/docs doesn't count as "present".
|
|
209
|
+
*
|
|
210
|
+
* Returns false when absent, the rev is unknown, or git is unavailable — the
|
|
211
|
+
* caller pairs two calls (doc's last-update rev vs HEAD) to detect present→gone.
|
|
212
|
+
*/
|
|
213
|
+
export function symbolExistsAtRev(dir, symbol, rev, pathspecs = CODE_GLOBS) {
|
|
214
|
+
try {
|
|
215
|
+
execFileSync(
|
|
216
|
+
'git',
|
|
217
|
+
['grep', '-q', '-w', '-F', '-e', symbol, rev, '--', ...pathspecs],
|
|
218
|
+
{ cwd: dir, stdio: ['pipe', 'ignore', 'ignore'] }
|
|
219
|
+
);
|
|
220
|
+
return true; // exit 0 → at least one match
|
|
221
|
+
} catch {
|
|
222
|
+
return false; // exit 1 → no match (or bad rev / no git)
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Resolve the commit hash that last touched `filePath` (following renames), or
|
|
228
|
+
* null. Used to anchor "the revision when this doc was last updated" for the
|
|
229
|
+
* two-revision check without re-parsing getFileHistory at every call site.
|
|
230
|
+
*/
|
|
231
|
+
export function lastCommitHash(dir, filePath) {
|
|
232
|
+
try {
|
|
233
|
+
const raw = execFileSync(
|
|
234
|
+
'git',
|
|
235
|
+
['log', '--follow', '-1', '--format=%H', '--', filePath],
|
|
236
|
+
{ cwd: dir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] }
|
|
237
|
+
).trim();
|
|
238
|
+
return raw || null;
|
|
239
|
+
} catch {
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
151
244
|
/**
|
|
152
245
|
* Resolve the absolute path to this repo's git hooks directory.
|
|
153
246
|
*
|
|
@@ -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
|
+
}
|