docguard-cli 0.27.0 → 0.29.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.es.md +102 -0
- package/README.md +65 -31
- package/README.pt-BR.md +101 -0
- package/STANDARD.md +20 -10
- package/cli/commands/agents.mjs +149 -0
- package/cli/commands/diff.mjs +6 -15
- package/cli/commands/explain.mjs +8 -6
- package/cli/commands/generate.mjs +14 -1001
- package/cli/commands/guard.mjs +149 -15
- package/cli/commands/init.mjs +23 -1
- package/cli/commands/llms.mjs +67 -5
- package/cli/commands/mcp.mjs +263 -0
- package/cli/commands/memory.mjs +115 -0
- package/cli/commands/score.mjs +76 -12
- package/cli/commands/sync-tests.mjs +272 -0
- package/cli/commands/sync.mjs +6 -0
- package/cli/commands/verify.mjs +67 -0
- package/cli/docguard.mjs +62 -5
- package/cli/findings.mjs +499 -0
- package/cli/scanners/agent-readability.mjs +202 -0
- package/cli/scanners/semantic-claims.mjs +160 -0
- package/cli/scanners/speckit.mjs +98 -28
- package/cli/shared-ignore.mjs +148 -16
- package/cli/shared.mjs +45 -1
- package/cli/validators/api-surface.mjs +182 -29
- package/cli/validators/architecture.mjs +91 -56
- package/cli/validators/canonical-sync.mjs +59 -28
- package/cli/validators/changelog.mjs +41 -17
- package/cli/validators/cross-reference.mjs +28 -11
- package/cli/validators/doc-quality.mjs +78 -44
- package/cli/validators/docs-coverage.mjs +90 -63
- package/cli/validators/docs-diff.mjs +63 -64
- package/cli/validators/docs-sync.mjs +48 -33
- package/cli/validators/drift.mjs +40 -34
- package/cli/validators/environment.mjs +67 -27
- package/cli/validators/freshness.mjs +12 -5
- package/cli/validators/generated-staleness.mjs +26 -10
- package/cli/validators/metadata-sync.mjs +28 -25
- package/cli/validators/metrics-consistency.mjs +89 -47
- package/cli/validators/schema-sync.mjs +37 -32
- package/cli/validators/security.mjs +7 -20
- package/cli/validators/spec-kit.mjs +3 -0
- package/cli/validators/structure.mjs +58 -23
- package/cli/validators/surface-sync.mjs +34 -15
- package/cli/validators/test-spec.mjs +87 -29
- package/cli/validators/todo-tracking.mjs +83 -74
- package/cli/validators/traceability.mjs +67 -39
- package/cli/writers/doc-generators.mjs +853 -0
- package/cli/writers/generate-io.mjs +142 -0
- package/cli/writers/sarif.mjs +129 -0
- package/commands/docguard.fix.md +56 -53
- package/commands/docguard.guard.md +53 -47
- package/commands/docguard.review.md +49 -31
- package/docs/ai-integration.md +133 -134
- package/docs/commands.md +49 -3
- package/docs/configuration.md +38 -0
- package/docs/faq.md +15 -0
- 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
- package/schemas/docguard-config.schema.json +17 -0
- package/templates/ENVIRONMENT.md.template +5 -0
- package/templates/REQUIREMENTS.md.template +2 -0
- package/templates/SECURITY.md.template +6 -1
- package/templates/TEST-SPEC.md.template +5 -0
- package/templates/commands/docguard.fix.md +33 -10
- package/templates/commands/docguard.guard.md +40 -26
- package/templates/commands/docguard.init.md +23 -11
- package/templates/commands/docguard.review.md +25 -8
- package/templates/commands/docguard.update.md +14 -4
package/cli/shared-ignore.mjs
CHANGED
|
@@ -89,8 +89,8 @@ export function isNonProductPath(relPath, config = {}) {
|
|
|
89
89
|
*
|
|
90
90
|
* Returns [] if the file is missing or unreadable — never throws.
|
|
91
91
|
*/
|
|
92
|
-
import { readFileSync, existsSync } from 'node:fs';
|
|
93
|
-
import { resolve as resolvePath, relative as relativePath, sep } from 'node:path';
|
|
92
|
+
import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
|
|
93
|
+
import { resolve as resolvePath, relative as relativePath, join as joinPath, sep } from 'node:path';
|
|
94
94
|
|
|
95
95
|
/**
|
|
96
96
|
* Project-relative path with POSIX (`/`) separators — the canonical form that
|
|
@@ -212,26 +212,62 @@ export function shouldIgnore(relPath, config, validatorKey) {
|
|
|
212
212
|
}
|
|
213
213
|
|
|
214
214
|
/**
|
|
215
|
-
*
|
|
216
|
-
* Unlike globToRegex (used for ignore filtering), this anchors the match
|
|
217
|
-
* to the full relative path from the project root.
|
|
215
|
+
* THE canonical anchored glob compiler (v0.29 consolidation).
|
|
218
216
|
*
|
|
219
|
-
*
|
|
217
|
+
* The repo previously carried THREE glob→regex implementations (ignore-side
|
|
218
|
+
* `globToRegex` above, the old `globToMatchRegex` here, and a third private
|
|
219
|
+
* copy in metrics-consistency for collection counting) with subtly different
|
|
220
|
+
* feature sets — a maintenance hazard for exactly the drift class this tool
|
|
221
|
+
* detects in others. This is now the single anchored compiler; the ignore-side
|
|
222
|
+
* `globToRegex` deliberately stays separate because its UNanchored,
|
|
223
|
+
* boundary-substring semantics ("dir" matches at any depth) are a different
|
|
224
|
+
* contract, documented above, with its own bug history.
|
|
225
|
+
*
|
|
226
|
+
* Supports (superset of all prior anchored variants):
|
|
227
|
+
* `**\/` → zero or more path segments → (?:.*\/)?
|
|
228
|
+
* `**` → any chars (incl. /) → .*
|
|
229
|
+
* `*` → any chars except / → [^/]*
|
|
230
|
+
* `?` → one char except / → [^/]
|
|
231
|
+
* `{a,b}` → alternation (non-nested) → (?:a|b)
|
|
232
|
+
* Everything else is regex-escaped. Fully anchored: ^...$.
|
|
220
233
|
*
|
|
221
|
-
* @param {string} pattern - Glob pattern (e.g., "backend/**\/__tests__/**\/*.test.ts")
|
|
234
|
+
* @param {string} pattern - Glob pattern (e.g., "backend/**\/__tests__/**\/*.test.{ts,js}")
|
|
222
235
|
* @returns {RegExp}
|
|
223
236
|
*/
|
|
224
|
-
function
|
|
225
|
-
|
|
226
|
-
let
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
237
|
+
export function compileGlob(pattern) {
|
|
238
|
+
const glob = String(pattern);
|
|
239
|
+
let re = '';
|
|
240
|
+
for (let i = 0; i < glob.length; i++) {
|
|
241
|
+
const ch = glob[i];
|
|
242
|
+
if (ch === '*') {
|
|
243
|
+
if (glob[i + 1] === '*') {
|
|
244
|
+
i++;
|
|
245
|
+
if (glob[i + 1] === '/') { re += '(?:.*/)?'; i++; }
|
|
246
|
+
else re += '.*';
|
|
247
|
+
} else {
|
|
248
|
+
re += '[^/]*';
|
|
249
|
+
}
|
|
250
|
+
} else if (ch === '?') {
|
|
251
|
+
re += '[^/]';
|
|
252
|
+
} else if (ch === '{') {
|
|
253
|
+
const end = glob.indexOf('}', i);
|
|
254
|
+
if (end > i) {
|
|
255
|
+
re += '(?:' + glob.slice(i + 1, end).split(',')
|
|
256
|
+
.map(s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|') + ')';
|
|
257
|
+
i = end;
|
|
258
|
+
} else {
|
|
259
|
+
re += '\\{';
|
|
260
|
+
}
|
|
261
|
+
} else {
|
|
262
|
+
re += ch.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return new RegExp(`^${re}$`);
|
|
233
266
|
}
|
|
234
267
|
|
|
268
|
+
// Back-compat internal alias — globMatch below always used the anchored form.
|
|
269
|
+
const globToMatchRegex = compileGlob;
|
|
270
|
+
|
|
235
271
|
/**
|
|
236
272
|
* Check if a relative path matches ANY of the given glob patterns.
|
|
237
273
|
* Purpose-built for POSITIVE matching (e.g., "is this a test file?").
|
|
@@ -255,3 +291,99 @@ export function globMatch(relPath, patterns) {
|
|
|
255
291
|
const regexes = patterns.map(p => globToMatchRegex(p));
|
|
256
292
|
return regexes.some(r => r.test(relPath));
|
|
257
293
|
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* THE canonical recursive file walker (v0.29 consolidation).
|
|
297
|
+
*
|
|
298
|
+
* ~13 validators each carried a private recursive walker with its own copied
|
|
299
|
+
* IGNORE_DIRS set and its own error handling — thirteen chances for skip logic
|
|
300
|
+
* to disagree. This is the single shared implementation.
|
|
301
|
+
*
|
|
302
|
+
* Contract:
|
|
303
|
+
* - Skips directory names in `ignoreDirs` (default: DEFAULT_IGNORE_DIRS) and,
|
|
304
|
+
* by default, every dot-prefixed entry (files AND dirs — matches the
|
|
305
|
+
* dominant prior behavior).
|
|
306
|
+
* - Calls `callback(absPath)` for every regular file reached.
|
|
307
|
+
* - NEVER throws. Unreadable entries invoke `onError(err, path)` if given.
|
|
308
|
+
* - Returns `true` iff the walk was COMPLETE (no unreadable entries). Callers
|
|
309
|
+
* computing counts MUST check this: a partial walk that silently under-
|
|
310
|
+
* counts is how a "code has N" assertion becomes confidently wrong — the
|
|
311
|
+
* tool's own worst failure mode.
|
|
312
|
+
*
|
|
313
|
+
* @param {string} dir - Absolute directory to walk
|
|
314
|
+
* @param {(absPath: string) => void} callback
|
|
315
|
+
* @param {{ignoreDirs?: Set<string>, skipDotEntries?: boolean, keepDot?: (entry: string) => boolean, onError?: (err: Error, path: string) => void}} [opts]
|
|
316
|
+
* `keepDot` — exception predicate for dot entries that MUST be walked even
|
|
317
|
+
* with skipDotEntries on. Load-bearing for e.g. the security validator
|
|
318
|
+
* (must scan `.env`) and traceability (`.env*`, `.gitignore`, `.github/`).
|
|
319
|
+
* @returns {boolean} - true if every entry was readable
|
|
320
|
+
*/
|
|
321
|
+
export function walkFiles(dir, callback, opts = {}) {
|
|
322
|
+
const {
|
|
323
|
+
ignoreDirs = DEFAULT_IGNORE_DIRS,
|
|
324
|
+
skipDotEntries = true,
|
|
325
|
+
keepDot = null,
|
|
326
|
+
onError = null,
|
|
327
|
+
} = opts;
|
|
328
|
+
let entries;
|
|
329
|
+
try { entries = readdirSync(dir); } catch (err) {
|
|
330
|
+
if (onError) onError(err, dir);
|
|
331
|
+
return false;
|
|
332
|
+
}
|
|
333
|
+
let complete = true;
|
|
334
|
+
for (const entry of entries) {
|
|
335
|
+
if (ignoreDirs.has(entry)) continue;
|
|
336
|
+
if (skipDotEntries && entry.startsWith('.') && !(keepDot && keepDot(entry))) continue;
|
|
337
|
+
const full = joinPath(dir, entry);
|
|
338
|
+
let stat;
|
|
339
|
+
try { stat = statSync(full); } catch (err) {
|
|
340
|
+
if (onError) onError(err, full);
|
|
341
|
+
complete = false;
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
if (stat.isDirectory()) {
|
|
345
|
+
if (!walkFiles(full, callback, opts)) complete = false;
|
|
346
|
+
} else if (stat.isFile()) {
|
|
347
|
+
callback(full);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
return complete;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Count files under `projectDir` matching an anchored glob (project-relative).
|
|
355
|
+
* The code-truth side of `config.collections` (metrics-consistency).
|
|
356
|
+
*
|
|
357
|
+
* Walks only from the glob's literal prefix — never the whole repo for a deep
|
|
358
|
+
* pattern. FAIL-SAFE BY CONTRACT:
|
|
359
|
+
* - returns 0 when the base path doesn't exist (unresolved glob — caller skips);
|
|
360
|
+
* - returns -1 when the walk was INCOMPLETE (permission-denied subtree, bad
|
|
361
|
+
* pattern). Previously a partial walk silently under-counted, so a doc
|
|
362
|
+
* saying "19 extractors" could be "corrected" to a wrong lower number.
|
|
363
|
+
* Callers must treat any value <= 0 as "don't assert".
|
|
364
|
+
*
|
|
365
|
+
* @param {string} projectDir
|
|
366
|
+
* @param {string} pattern - e.g. "src/extractors/*.py"
|
|
367
|
+
* @returns {number} match count, 0 = unresolved, -1 = unreliable
|
|
368
|
+
*/
|
|
369
|
+
export function countGlobFiles(projectDir, pattern) {
|
|
370
|
+
const norm = String(pattern).replace(/\\/g, '/').replace(/^\.\//, '');
|
|
371
|
+
if (!norm) return -1;
|
|
372
|
+
const baseSegs = [];
|
|
373
|
+
for (const seg of norm.split('/')) {
|
|
374
|
+
if (/[*?{]/.test(seg)) break;
|
|
375
|
+
baseSegs.push(seg);
|
|
376
|
+
}
|
|
377
|
+
const baseDir = resolvePath(projectDir, baseSegs.join('/') || '.');
|
|
378
|
+
if (!existsSync(baseDir)) return 0;
|
|
379
|
+
let re;
|
|
380
|
+
try { re = compileGlob(norm); } catch { return -1; }
|
|
381
|
+
try {
|
|
382
|
+
if (statSync(baseDir).isFile()) return re.test(norm) ? 1 : 0; // literal file pattern
|
|
383
|
+
} catch { return -1; }
|
|
384
|
+
let n = 0;
|
|
385
|
+
const complete = walkFiles(baseDir, (full) => {
|
|
386
|
+
if (re.test(relPosix(projectDir, full))) n++;
|
|
387
|
+
});
|
|
388
|
+
return complete ? n : -1;
|
|
389
|
+
}
|
package/cli/shared.mjs
CHANGED
|
@@ -253,9 +253,53 @@ export const PROFILES = {
|
|
|
253
253
|
};
|
|
254
254
|
|
|
255
255
|
// ── .docguardignore Support ───────────────────────────────────────────────
|
|
256
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
256
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
257
257
|
import { resolve, relative } from 'node:path';
|
|
258
258
|
|
|
259
|
+
/**
|
|
260
|
+
* Conventional documentation-home directory names. A folder named one of these
|
|
261
|
+
* is unambiguously "docs DocGuard governs" — distinct from arbitrary markdown
|
|
262
|
+
* buried in a non-doc subdir (security/wolf-archive/, vendored toolkits), which
|
|
263
|
+
* the wu-whatsappinbox scoping fix deliberately excludes. We auto-track the
|
|
264
|
+
* former and never blanket-walk the latter.
|
|
265
|
+
*/
|
|
266
|
+
export const DEFAULT_DOC_DIRS = [
|
|
267
|
+
'docs', 'doc', 'documentation', 'docs-canonical', 'docs-implementation',
|
|
268
|
+
'guides', 'guide', 'handbook', 'manual', 'wiki', 'extensions',
|
|
269
|
+
];
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Resolve the documentation-home directories for a project (relative dir paths,
|
|
273
|
+
* no trailing slash). Single source of truth so the claim scanner and the
|
|
274
|
+
* coverage map agree — "tracked" must mean "actually scanned," never a label
|
|
275
|
+
* the scanner ignores.
|
|
276
|
+
*
|
|
277
|
+
* Auto-detects the conventional doc-home names that actually exist at the root,
|
|
278
|
+
* plus the Docusaurus-style `website/docs`. `config.docs.dirs` EXTENDS that set
|
|
279
|
+
* (adds non-standard homes like a project's `wiki/`) rather than replacing it —
|
|
280
|
+
* the least-surprising model, since the whole point is to track MORE clearly-doc
|
|
281
|
+
* folders automatically. To EXCLUDE a conventional dir, use `.docguardignore`.
|
|
282
|
+
* NAMED dirs only — this never walks arbitrary subdirectories (that was the
|
|
283
|
+
* false-positive flood the scoping fix removed).
|
|
284
|
+
*
|
|
285
|
+
* @param {string} projectDir
|
|
286
|
+
* @param {object} [config]
|
|
287
|
+
* @returns {string[]} relative directory paths (e.g. ['docs', 'documentation'])
|
|
288
|
+
*/
|
|
289
|
+
export function resolveDocDirs(projectDir, config = {}) {
|
|
290
|
+
const isDir = (rel) => {
|
|
291
|
+
try { return statSync(resolve(projectDir, rel)).isDirectory(); } catch { return false; }
|
|
292
|
+
};
|
|
293
|
+
const out = new Set(DEFAULT_DOC_DIRS.filter(isDir));
|
|
294
|
+
if (isDir('website/docs')) out.add('website/docs');
|
|
295
|
+
const declared = config && config.docs && Array.isArray(config.docs.dirs) ? config.docs.dirs : [];
|
|
296
|
+
for (const d of declared) {
|
|
297
|
+
const norm = String(d).replace(/\\/g, '/').replace(/\/+$/, '');
|
|
298
|
+
if (norm) out.add(norm);
|
|
299
|
+
}
|
|
300
|
+
return [...out];
|
|
301
|
+
}
|
|
302
|
+
|
|
259
303
|
/**
|
|
260
304
|
* Load ignore patterns from .docguardignore (like .gitignore).
|
|
261
305
|
* Returns a function that checks if a relative path should be ignored.
|
|
@@ -30,6 +30,7 @@ import { scanRoutesDeep } from '../scanners/routes.mjs';
|
|
|
30
30
|
import { parseApiReferenceDoc, compareEndpoints, endpointKey } from '../scanners/api-doc.mjs';
|
|
31
31
|
import { collectPackageJsons, getWorkspaceDirs } from '../shared-source.mjs';
|
|
32
32
|
import { relPosix } from '../shared-ignore.mjs';
|
|
33
|
+
import { mkFinding, resultFromFindings } from '../findings.mjs';
|
|
33
34
|
|
|
34
35
|
const MAX_REPORTED = 15;
|
|
35
36
|
const API_DOC = 'docs-canonical/API-REFERENCE.md';
|
|
@@ -208,10 +209,59 @@ export function computeApiSurfaceDrift(projectDir, config) {
|
|
|
208
209
|
};
|
|
209
210
|
}
|
|
210
211
|
|
|
212
|
+
/**
|
|
213
|
+
* v0.28 (field report #4): diff the OpenAPI spec against the routes actually
|
|
214
|
+
* REGISTERED in code. When a spec exists, resolveApiSurface treats it as ground
|
|
215
|
+
* truth and the API-REFERENCE doc reconciles against it — so a spec that declares
|
|
216
|
+
* a phantom endpoint (no Express/Fastify route registers it) passes doc-vs-spec
|
|
217
|
+
* clean while the spec itself is wrong. This catches that.
|
|
218
|
+
*
|
|
219
|
+
* Conservative on purpose: only runs when code routes are actually scannable.
|
|
220
|
+
* If the scanner finds zero routes (unsupported framework, dynamically-registered
|
|
221
|
+
* routes), we can't tell "no route" from "scanner blind", so we skip rather than
|
|
222
|
+
* flag every spec endpoint as phantom. Reuses compareEndpoints so path-param /
|
|
223
|
+
* mount-prefix normalization matches the rest of the validator.
|
|
224
|
+
*
|
|
225
|
+
* @returns {{ applicable:boolean, specPath:string|null, routeCount:number,
|
|
226
|
+
* matched:object[], specDeclaredNoRoute:object[], reason?:string }}
|
|
227
|
+
*/
|
|
228
|
+
export function computeSpecVsRouteDrift(projectDir, config) {
|
|
229
|
+
const specs = findAllOpenApiSpecs(projectDir, config);
|
|
230
|
+
if (specs.length === 0) {
|
|
231
|
+
return { applicable: false, specPath: null, routeCount: 0, matched: [], specDeclaredNoRoute: [], reason: 'no openapi spec' };
|
|
232
|
+
}
|
|
233
|
+
const spec = specs[0]; // authoritative (sourceRoot first, root last)
|
|
234
|
+
const framework = detectFramework(projectDir, config);
|
|
235
|
+
const routes = scanRoutesDeep(projectDir, { framework }, { openapi: { found: false } }, { config });
|
|
236
|
+
if (routes.length === 0) {
|
|
237
|
+
return { applicable: false, specPath: spec.relPath, routeCount: 0, matched: [], specDeclaredNoRoute: [], reason: 'no routes scannable' };
|
|
238
|
+
}
|
|
239
|
+
// documentedButAbsent = in the SPEC (first arg) but absent from the ROUTES
|
|
240
|
+
// (second arg) = spec-declares-but-no-route.
|
|
241
|
+
const cmp = compareEndpoints(
|
|
242
|
+
spec.endpoints.map(e => ({ method: e.method, path: e.path })),
|
|
243
|
+
routes.map(r => ({ method: r.method, path: r.path }))
|
|
244
|
+
);
|
|
245
|
+
return {
|
|
246
|
+
applicable: true,
|
|
247
|
+
specPath: spec.relPath,
|
|
248
|
+
routeCount: routes.length,
|
|
249
|
+
matched: cmp.matched,
|
|
250
|
+
specDeclaredNoRoute: cmp.documentedButAbsent,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// v0.29: migrated to structured findings (API001–API005). Messages are
|
|
255
|
+
// byte-identical to the legacy strings — resultFromFindings derives the
|
|
256
|
+
// errors/warnings arrays from the same findings; `fixes` and
|
|
257
|
+
// `authoritativeSpec` are preserved.
|
|
211
258
|
export function validateApiSurface(projectDir, config) {
|
|
212
|
-
const
|
|
213
|
-
const warnings = [];
|
|
259
|
+
const findings = [];
|
|
214
260
|
const fixes = [];
|
|
261
|
+
const trim = (arr) => {
|
|
262
|
+
const shown = arr.slice(0, MAX_REPORTED);
|
|
263
|
+
return { shown, extra: arr.length - shown.length };
|
|
264
|
+
};
|
|
215
265
|
|
|
216
266
|
// v0.14-P2: when --changed-only scoping is active and NONE of the changed
|
|
217
267
|
// files look like route/spec/controller files, this validator has nothing
|
|
@@ -222,7 +272,7 @@ export function validateApiSurface(projectDir, config) {
|
|
|
222
272
|
const anyRouteFile = config.changedFiles.some(f => ROUTE_RE.test(f));
|
|
223
273
|
if (!anyRouteFile) {
|
|
224
274
|
return {
|
|
225
|
-
errors, warnings, passed: 0, total: 0, fixes,
|
|
275
|
+
errors: [], warnings: [], passed: 0, total: 0, fixes,
|
|
226
276
|
applicable: false,
|
|
227
277
|
note: 'no route/spec files in changed set',
|
|
228
278
|
};
|
|
@@ -234,12 +284,17 @@ export function validateApiSurface(projectDir, config) {
|
|
|
234
284
|
// choked on it. We fall back to code scanning (below), but the parse failure
|
|
235
285
|
// is surfaced here rather than silently producing a clean "no surface" pass.
|
|
236
286
|
for (const specPath of findUnparseableSpecs(projectDir, config)) {
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
`
|
|
242
|
-
|
|
287
|
+
findings.push(mkFinding({
|
|
288
|
+
code: 'API001',
|
|
289
|
+
validator: 'apiSurface',
|
|
290
|
+
severity: 'warn',
|
|
291
|
+
message: `OpenAPI spec ${specPath} declares paths but DocGuard parsed 0 endpoints from it ` +
|
|
292
|
+
`(likely an unsupported YAML feature — $ref, anchors, or folded scalars). ` +
|
|
293
|
+
`Falling back to code scanning; the spec's own endpoint list is unavailable. ` +
|
|
294
|
+
`Validate it with a full OpenAPI linter.`,
|
|
295
|
+
location: specPath,
|
|
296
|
+
suggestion: { kind: 'review', text: 'Validate the spec with a full OpenAPI linter (e.g. spectral) and simplify unsupported YAML features' },
|
|
297
|
+
}));
|
|
243
298
|
}
|
|
244
299
|
|
|
245
300
|
const drift = computeApiSurfaceDrift(projectDir, config);
|
|
@@ -250,38 +305,120 @@ export function validateApiSurface(projectDir, config) {
|
|
|
250
305
|
const others = divergence.specs.slice(1).map(s => s.relPath).join(', ');
|
|
251
306
|
const sample = divergence.divergent.slice(0, 8).join(', ');
|
|
252
307
|
const more = divergence.divergent.length > 8 ? ` (+${divergence.divergent.length - 8} more)` : '';
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
308
|
+
findings.push(mkFinding({
|
|
309
|
+
code: 'API002',
|
|
310
|
+
validator: 'apiSurface',
|
|
311
|
+
severity: 'warn',
|
|
312
|
+
message: `Multiple OpenAPI specs disagree on ${divergence.divergent.length} endpoint(s): ` +
|
|
313
|
+
`${divergence.authoritative} (treated as authoritative) vs ${others}. Divergent: ${sample}${more}`,
|
|
314
|
+
location: divergence.authoritative,
|
|
315
|
+
suggestion: { kind: 'review', text: 'Regenerate or delete the stale spec copy so every spec agrees on the endpoint set' },
|
|
316
|
+
}));
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// ── #4: spec declares an endpoint with no registered route ──
|
|
320
|
+
// Independent of the API-REFERENCE doc — it checks the spec against code, so it
|
|
321
|
+
// runs even when no doc exists. Conservative (only when routes are scannable).
|
|
322
|
+
const specRoute = computeSpecVsRouteDrift(projectDir, config);
|
|
323
|
+
let specRouteTotal = 0;
|
|
324
|
+
let specRoutePassed = 0;
|
|
325
|
+
if (specRoute.applicable) {
|
|
326
|
+
specRouteTotal = specRoute.matched.length + specRoute.specDeclaredNoRoute.length;
|
|
327
|
+
specRoutePassed = specRoute.matched.length;
|
|
328
|
+
if (specRoute.specDeclaredNoRoute.length) {
|
|
329
|
+
const { shown, extra } = trim(specRoute.specDeclaredNoRoute);
|
|
330
|
+
for (const e of shown) {
|
|
331
|
+
findings.push(mkFinding({
|
|
332
|
+
code: 'API003',
|
|
333
|
+
validator: 'apiSurface',
|
|
334
|
+
severity: 'warn',
|
|
335
|
+
// "may be wrong" — the route scanner can be blind to dynamic
|
|
336
|
+
// registration, so this is a candidate false positive by design.
|
|
337
|
+
confidence: 'low',
|
|
338
|
+
message: `OpenAPI spec (${specRoute.specPath}) declares ${e.method} ${e.path} but no route registers it in code — ` +
|
|
339
|
+
`the spec may be wrong, and the API-REFERENCE doc reconciles clean against it, hiding the gap.`,
|
|
340
|
+
location: specRoute.specPath,
|
|
341
|
+
suggestion: { kind: 'review', text: 'Verify the endpoint: remove it from the spec if it no longer exists, or check whether the route is registered dynamically' },
|
|
342
|
+
}));
|
|
343
|
+
}
|
|
344
|
+
if (extra > 0) {
|
|
345
|
+
findings.push(mkFinding({
|
|
346
|
+
code: 'API003',
|
|
347
|
+
validator: 'apiSurface',
|
|
348
|
+
severity: 'warn',
|
|
349
|
+
confidence: 'low',
|
|
350
|
+
message: `…and ${extra} more spec-declared endpoint(s) with no registered route`,
|
|
351
|
+
location: specRoute.specPath,
|
|
352
|
+
suggestion: { kind: 'review', text: 'Verify each spec-declared endpoint against the registered routes' },
|
|
353
|
+
}));
|
|
354
|
+
}
|
|
355
|
+
}
|
|
257
356
|
}
|
|
258
357
|
|
|
259
358
|
if (!drift.applicable) {
|
|
260
|
-
// Nothing to validate against the API-REFERENCE doc
|
|
261
|
-
|
|
359
|
+
// Nothing to validate against the API-REFERENCE doc — but the spec-vs-route
|
|
360
|
+
// check above may still have produced findings.
|
|
361
|
+
return {
|
|
362
|
+
...resultFromFindings(findings, { passed: specRoutePassed, total: specRouteTotal }),
|
|
363
|
+
fixes,
|
|
364
|
+
authoritativeSpec: drift.source || specRoute.specPath,
|
|
365
|
+
};
|
|
262
366
|
}
|
|
263
367
|
|
|
264
368
|
const { documentedButAbsent, presentButUndocumented, matched, confidence, source } = drift;
|
|
265
|
-
const total = matched.length + documentedButAbsent.length + presentButUndocumented.length;
|
|
266
|
-
const passed = matched.length;
|
|
267
|
-
|
|
268
|
-
const trim = (arr) => {
|
|
269
|
-
const shown = arr.slice(0, MAX_REPORTED);
|
|
270
|
-
return { shown, extra: arr.length - shown.length };
|
|
271
|
-
};
|
|
369
|
+
const total = matched.length + documentedButAbsent.length + presentButUndocumented.length + specRouteTotal;
|
|
370
|
+
const passed = matched.length + specRoutePassed;
|
|
272
371
|
|
|
273
372
|
// documented-but-absent → deterministic remove-endpoint fixes
|
|
274
373
|
if (documentedButAbsent.length) {
|
|
275
374
|
const { shown, extra } = trim(documentedButAbsent);
|
|
276
375
|
for (const e of shown) {
|
|
277
376
|
const msg = `Documented endpoint not found in code: ${e.method} ${e.path} (${API_DOC})`;
|
|
278
|
-
if (confidence === 'spec')
|
|
279
|
-
|
|
377
|
+
if (confidence === 'spec') {
|
|
378
|
+
findings.push(mkFinding({
|
|
379
|
+
code: 'API004',
|
|
380
|
+
validator: 'apiSurface',
|
|
381
|
+
severity: 'error',
|
|
382
|
+
message: msg,
|
|
383
|
+
location: API_DOC,
|
|
384
|
+
suggestion: { kind: 'fix', text: 'Remove the dead endpoint from the doc', command: 'docguard fix --write' },
|
|
385
|
+
}));
|
|
386
|
+
} else {
|
|
387
|
+
findings.push(mkFinding({
|
|
388
|
+
code: 'API004',
|
|
389
|
+
validator: 'apiSurface',
|
|
390
|
+
severity: 'warn',
|
|
391
|
+
// The "[code-scan — verify]" suffix marks this as heuristic-only:
|
|
392
|
+
// the route scanner may simply not see the endpoint's registration.
|
|
393
|
+
confidence: 'low',
|
|
394
|
+
message: `${msg} [code-scan — verify]`,
|
|
395
|
+
location: API_DOC,
|
|
396
|
+
suggestion: { kind: 'review', text: 'Verify the endpoint really is gone from the code, then remove it from the doc' },
|
|
397
|
+
}));
|
|
398
|
+
}
|
|
280
399
|
}
|
|
281
400
|
if (extra > 0) {
|
|
282
401
|
const tail = `…and ${extra} more documented endpoint(s) not found in code`;
|
|
283
|
-
if (confidence === 'spec')
|
|
284
|
-
|
|
402
|
+
if (confidence === 'spec') {
|
|
403
|
+
findings.push(mkFinding({
|
|
404
|
+
code: 'API004',
|
|
405
|
+
validator: 'apiSurface',
|
|
406
|
+
severity: 'error',
|
|
407
|
+
message: tail,
|
|
408
|
+
location: API_DOC,
|
|
409
|
+
suggestion: { kind: 'fix', text: 'Remove the dead endpoints from the doc', command: 'docguard fix --write' },
|
|
410
|
+
}));
|
|
411
|
+
} else {
|
|
412
|
+
findings.push(mkFinding({
|
|
413
|
+
code: 'API004',
|
|
414
|
+
validator: 'apiSurface',
|
|
415
|
+
severity: 'warn',
|
|
416
|
+
confidence: 'low',
|
|
417
|
+
message: tail,
|
|
418
|
+
location: API_DOC,
|
|
419
|
+
suggestion: { kind: 'review', text: 'Verify each documented endpoint against the code, then prune the doc' },
|
|
420
|
+
}));
|
|
421
|
+
}
|
|
285
422
|
}
|
|
286
423
|
// Only spec-confirmed absences are safe to auto-remove.
|
|
287
424
|
if (confidence === 'spec') {
|
|
@@ -295,10 +432,26 @@ export function validateApiSurface(projectDir, config) {
|
|
|
295
432
|
if (presentButUndocumented.length) {
|
|
296
433
|
const { shown, extra } = trim(presentButUndocumented);
|
|
297
434
|
for (const e of shown) {
|
|
298
|
-
|
|
435
|
+
findings.push(mkFinding({
|
|
436
|
+
code: 'API005',
|
|
437
|
+
validator: 'apiSurface',
|
|
438
|
+
severity: 'warn',
|
|
439
|
+
message: `Undocumented endpoint in code: ${e.method} ${e.path} — add it to ${API_DOC}`,
|
|
440
|
+
location: API_DOC,
|
|
441
|
+
suggestion: { kind: 'fix', text: `Document the endpoint in ${API_DOC}` },
|
|
442
|
+
}));
|
|
443
|
+
}
|
|
444
|
+
if (extra > 0) {
|
|
445
|
+
findings.push(mkFinding({
|
|
446
|
+
code: 'API005',
|
|
447
|
+
validator: 'apiSurface',
|
|
448
|
+
severity: 'warn',
|
|
449
|
+
message: `…and ${extra} more undocumented endpoint(s) in code`,
|
|
450
|
+
location: API_DOC,
|
|
451
|
+
suggestion: { kind: 'fix', text: `Document the remaining endpoints in ${API_DOC}` },
|
|
452
|
+
}));
|
|
299
453
|
}
|
|
300
|
-
if (extra > 0) warnings.push(`…and ${extra} more undocumented endpoint(s) in code`);
|
|
301
454
|
}
|
|
302
455
|
|
|
303
|
-
return {
|
|
456
|
+
return { ...resultFromFindings(findings, { passed, total }), fixes, authoritativeSpec: source };
|
|
304
457
|
}
|