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.
@@ -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
+ }
@@ -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
  *
@@ -167,7 +260,14 @@ export function getHooksDir(dir) {
167
260
  ).trim();
168
261
  // --git-path returns a path relative to `dir` (cwd) or an absolute path;
169
262
  // resolve() handles both.
170
- if (out) return resolve(dir, out);
263
+ //
264
+ // Guard `/dev/null`: when `core.hooksPath` is set to /dev/null (a common
265
+ // "disable all hooks" convention — and what Jules's sandbox VM does),
266
+ // git returns the literal `/dev/null`. resolve()-ing it and then writing
267
+ // `<hooksDir>/pre-commit` gives `ENOTDIR: /dev/null/pre-commit`. Treat it
268
+ // as "no usable hooks dir" and fall through to the `.git/hooks` check so
269
+ // hook install/list still works in that environment. (bug-200)
270
+ if (out && out !== '/dev/null') return resolve(dir, out);
171
271
  } catch {
172
272
  // git unavailable or not a repo — fall through to the literal-path check.
173
273
  }
@@ -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
+ }