docguard-cli 0.28.0 → 0.30.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.
Files changed (69) hide show
  1. package/README.es.md +102 -0
  2. package/README.md +80 -32
  3. package/README.pt-BR.md +101 -0
  4. package/STANDARD.md +20 -10
  5. package/cli/commands/agents.mjs +149 -0
  6. package/cli/commands/diff.mjs +6 -15
  7. package/cli/commands/generate.mjs +14 -1001
  8. package/cli/commands/guard.mjs +136 -8
  9. package/cli/commands/llms.mjs +67 -5
  10. package/cli/commands/mcp.mjs +263 -0
  11. package/cli/commands/memory.mjs +115 -0
  12. package/cli/commands/score.mjs +76 -12
  13. package/cli/commands/trace.mjs +364 -1
  14. package/cli/commands/verify.mjs +93 -6
  15. package/cli/docguard.mjs +42 -5
  16. package/cli/findings.mjs +511 -0
  17. package/cli/scanners/agent-readability.mjs +202 -0
  18. package/cli/scanners/instruction-audit.mjs +320 -0
  19. package/cli/scanners/semantic-claims.mjs +7 -1
  20. package/cli/scanners/speckit.mjs +443 -28
  21. package/cli/shared-ignore.mjs +148 -16
  22. package/cli/shared.mjs +45 -1
  23. package/cli/validators/api-surface.mjs +113 -26
  24. package/cli/validators/architecture.mjs +66 -43
  25. package/cli/validators/canonical-sync.mjs +59 -28
  26. package/cli/validators/changelog.mjs +41 -17
  27. package/cli/validators/cross-reference.mjs +28 -11
  28. package/cli/validators/doc-quality.mjs +78 -44
  29. package/cli/validators/docs-coverage.mjs +90 -63
  30. package/cli/validators/docs-diff.mjs +63 -64
  31. package/cli/validators/docs-sync.mjs +48 -33
  32. package/cli/validators/drift.mjs +40 -34
  33. package/cli/validators/environment.mjs +67 -27
  34. package/cli/validators/freshness.mjs +12 -5
  35. package/cli/validators/generated-staleness.mjs +26 -10
  36. package/cli/validators/metadata-sync.mjs +28 -25
  37. package/cli/validators/metrics-consistency.mjs +89 -47
  38. package/cli/validators/schema-sync.mjs +37 -32
  39. package/cli/validators/security.mjs +7 -20
  40. package/cli/validators/spec-kit.mjs +3 -0
  41. package/cli/validators/structure.mjs +58 -23
  42. package/cli/validators/surface-sync.mjs +34 -15
  43. package/cli/validators/test-spec.mjs +87 -29
  44. package/cli/validators/todo-tracking.mjs +83 -74
  45. package/cli/validators/traceability.mjs +67 -39
  46. package/cli/writers/doc-generators.mjs +853 -0
  47. package/cli/writers/generate-io.mjs +142 -0
  48. package/cli/writers/sarif.mjs +129 -0
  49. package/commands/docguard.fix.md +56 -53
  50. package/commands/docguard.guard.md +53 -47
  51. package/commands/docguard.review.md +49 -31
  52. package/docs/ai-integration.md +133 -134
  53. package/docs/commands.md +49 -3
  54. package/docs/configuration.md +38 -0
  55. package/docs/faq.md +15 -0
  56. package/extensions/spec-kit-docguard/extension.yml +1 -1
  57. package/extensions/spec-kit-docguard/skills/docguard-fix/SKILL.md +2 -2
  58. package/extensions/spec-kit-docguard/skills/docguard-guard/SKILL.md +2 -2
  59. package/extensions/spec-kit-docguard/skills/docguard-review/SKILL.md +2 -2
  60. package/extensions/spec-kit-docguard/skills/docguard-score/SKILL.md +2 -2
  61. package/extensions/spec-kit-docguard/skills/docguard-sync/SKILL.md +2 -2
  62. package/package.json +2 -1
  63. package/schemas/docguard-config.schema.json +28 -0
  64. package/templates/ci/gitlab-component.yml +90 -0
  65. package/templates/commands/docguard.fix.md +33 -10
  66. package/templates/commands/docguard.guard.md +40 -26
  67. package/templates/commands/docguard.init.md +23 -11
  68. package/templates/commands/docguard.review.md +25 -8
  69. package/templates/commands/docguard.update.md +14 -4
@@ -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
- * Convert a glob pattern to a RegExp for POSITIVE matching.
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
- * Supports: * (any chars except /), ** (any path segments), . (literal dot).
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 globToMatchRegex(pattern) {
225
- // Normalize: replace **/ with a placeholder that means "zero or more path segments"
226
- let escaped = pattern
227
- .replace(/\./g, '\\.')
228
- .replace(/\*\*\//g, '§STARSTAR§') // **/ → zero-or-more segments
229
- .replace(/\*\*/g, '.*') // standalone ** → any chars
230
- .replace(/\*/g, '[^/]*') // single * → any chars except /
231
- .replace(/§STARSTAR§/g, '(.*/)?'); // **/ → optional path prefix
232
- return new RegExp(`^${escaped}$`);
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';
@@ -250,9 +251,12 @@ export function computeSpecVsRouteDrift(projectDir, config) {
250
251
  };
251
252
  }
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.
253
258
  export function validateApiSurface(projectDir, config) {
254
- const errors = [];
255
- const warnings = [];
259
+ const findings = [];
256
260
  const fixes = [];
257
261
  const trim = (arr) => {
258
262
  const shown = arr.slice(0, MAX_REPORTED);
@@ -268,7 +272,7 @@ export function validateApiSurface(projectDir, config) {
268
272
  const anyRouteFile = config.changedFiles.some(f => ROUTE_RE.test(f));
269
273
  if (!anyRouteFile) {
270
274
  return {
271
- errors, warnings, passed: 0, total: 0, fixes,
275
+ errors: [], warnings: [], passed: 0, total: 0, fixes,
272
276
  applicable: false,
273
277
  note: 'no route/spec files in changed set',
274
278
  };
@@ -280,12 +284,17 @@ export function validateApiSurface(projectDir, config) {
280
284
  // choked on it. We fall back to code scanning (below), but the parse failure
281
285
  // is surfaced here rather than silently producing a clean "no surface" pass.
282
286
  for (const specPath of findUnparseableSpecs(projectDir, config)) {
283
- warnings.push(
284
- `OpenAPI spec ${specPath} declares paths but DocGuard parsed 0 endpoints from it ` +
285
- `(likely an unsupported YAML feature — $ref, anchors, or folded scalars). ` +
286
- `Falling back to code scanning; the spec's own endpoint list is unavailable. ` +
287
- `Validate it with a full OpenAPI linter.`
288
- );
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
+ }));
289
298
  }
290
299
 
291
300
  const drift = computeApiSurfaceDrift(projectDir, config);
@@ -296,10 +305,15 @@ export function validateApiSurface(projectDir, config) {
296
305
  const others = divergence.specs.slice(1).map(s => s.relPath).join(', ');
297
306
  const sample = divergence.divergent.slice(0, 8).join(', ');
298
307
  const more = divergence.divergent.length > 8 ? ` (+${divergence.divergent.length - 8} more)` : '';
299
- warnings.push(
300
- `Multiple OpenAPI specs disagree on ${divergence.divergent.length} endpoint(s): ` +
301
- `${divergence.authoritative} (treated as authoritative) vs ${others}. Divergent: ${sample}${more}`
302
- );
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
+ }));
303
317
  }
304
318
 
305
319
  // ── #4: spec declares an endpoint with no registered route ──
@@ -314,12 +328,30 @@ export function validateApiSurface(projectDir, config) {
314
328
  if (specRoute.specDeclaredNoRoute.length) {
315
329
  const { shown, extra } = trim(specRoute.specDeclaredNoRoute);
316
330
  for (const e of shown) {
317
- warnings.push(
318
- `OpenAPI spec (${specRoute.specPath}) declares ${e.method} ${e.path} but no route registers it in code — ` +
319
- `the spec may be wrong, and the API-REFERENCE doc reconciles clean against it, hiding the gap.`
320
- );
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
+ }));
321
354
  }
322
- if (extra > 0) warnings.push(`…and ${extra} more spec-declared endpoint(s) with no registered route`);
323
355
  }
324
356
  }
325
357
 
@@ -327,7 +359,8 @@ export function validateApiSurface(projectDir, config) {
327
359
  // Nothing to validate against the API-REFERENCE doc — but the spec-vs-route
328
360
  // check above may still have produced findings.
329
361
  return {
330
- errors, warnings, passed: specRoutePassed, total: specRouteTotal, fixes,
362
+ ...resultFromFindings(findings, { passed: specRoutePassed, total: specRouteTotal }),
363
+ fixes,
331
364
  authoritativeSpec: drift.source || specRoute.specPath,
332
365
  };
333
366
  }
@@ -341,13 +374,51 @@ export function validateApiSurface(projectDir, config) {
341
374
  const { shown, extra } = trim(documentedButAbsent);
342
375
  for (const e of shown) {
343
376
  const msg = `Documented endpoint not found in code: ${e.method} ${e.path} (${API_DOC})`;
344
- if (confidence === 'spec') errors.push(msg);
345
- else warnings.push(`${msg} [code-scan — verify]`);
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
+ }
346
399
  }
347
400
  if (extra > 0) {
348
401
  const tail = `…and ${extra} more documented endpoint(s) not found in code`;
349
- if (confidence === 'spec') errors.push(tail);
350
- else warnings.push(tail);
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
+ }
351
422
  }
352
423
  // Only spec-confirmed absences are safe to auto-remove.
353
424
  if (confidence === 'spec') {
@@ -361,10 +432,26 @@ export function validateApiSurface(projectDir, config) {
361
432
  if (presentButUndocumented.length) {
362
433
  const { shown, extra } = trim(presentButUndocumented);
363
434
  for (const e of shown) {
364
- warnings.push(`Undocumented endpoint in code: ${e.method} ${e.path} — add it to ${API_DOC}`);
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
+ }));
365
453
  }
366
- if (extra > 0) warnings.push(`…and ${extra} more undocumented endpoint(s) in code`);
367
454
  }
368
455
 
369
- return { errors, warnings, passed, total, fixes, authoritativeSpec: source };
456
+ return { ...resultFromFindings(findings, { passed, total }), fixes, authoritativeSpec: source };
370
457
  }
@@ -17,7 +17,8 @@
17
17
 
18
18
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
19
19
  import { resolve, join, extname, relative, dirname, basename } from 'node:path';
20
- import { shouldIgnore } from '../shared-ignore.mjs';
20
+ import { shouldIgnore, walkFiles as sharedWalkFiles } from '../shared-ignore.mjs';
21
+ import { mkFinding, resultFromFindings } from '../findings.mjs';
21
22
 
22
23
  const IGNORE_DIRS = new Set([
23
24
  'node_modules', '.git', '.next', 'dist', 'build',
@@ -27,24 +28,42 @@ const IGNORE_DIRS = new Set([
27
28
 
28
29
  const CODE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.mjs', '.cjs', '.jsx']);
29
30
 
31
+ // v0.29: migrated to structured findings (ARC001–ARC003). Messages are
32
+ // byte-identical to the legacy strings — resultFromFindings derives the
33
+ // errors/warnings arrays from the same findings array (acc), which the
34
+ // helpers below mutate in place.
30
35
  export function validateArchitecture(projectDir, config) {
31
- const results = { name: 'architecture', errors: [], warnings: [], passed: 0, total: 0 };
36
+ const acc = { findings: [], passed: 0, total: 0 };
37
+ const compose = () => ({
38
+ name: 'architecture',
39
+ ...resultFromFindings(acc.findings, { passed: acc.passed, total: acc.total }),
40
+ });
32
41
 
33
42
  // ── 1. Config-driven layer validation ──
34
43
  const layers = config.layers;
35
44
  if (layers && Object.keys(layers).length > 0) {
36
- validateConfigLayers(projectDir, config, layers, results);
45
+ validateConfigLayers(projectDir, config, layers, acc);
37
46
  }
38
47
 
39
48
  // ── 2. Auto-detect import graph ──
40
49
  const importGraph = buildImportGraph(projectDir, config);
41
- if (importGraph.files.length === 0) return results;
50
+ if (importGraph.files.length === 0) return compose();
42
51
 
43
52
  // ── 3. Detect circular dependencies ──
44
53
  const circles = detectCircularDeps(importGraph);
45
54
  for (const circle of circles) {
46
- results.total++;
47
- results.warnings.push(`Circular dependency: ${circle.join(' → ')}`);
55
+ acc.total++;
56
+ acc.findings.push(mkFinding({
57
+ code: 'ARC002',
58
+ validator: 'architecture',
59
+ severity: 'warn',
60
+ message: `Circular dependency: ${circle.join(' → ')}`,
61
+ location: circle[0],
62
+ suggestion: {
63
+ kind: 'fix',
64
+ text: 'Break the cycle — convert one edge to a dynamic import() or extract the shared code into a third module',
65
+ },
66
+ }));
48
67
  }
49
68
 
50
69
  // ── 4. Check layer boundaries from ARCHITECTURE.md ──
@@ -54,13 +73,14 @@ export function validateArchitecture(projectDir, config) {
54
73
  const declaredLayers = parseLayerBoundaries(archContent);
55
74
 
56
75
  if (declaredLayers.length > 0) {
57
- validateLayerBoundaries(projectDir, importGraph, declaredLayers, results);
76
+ validateLayerBoundaries(projectDir, importGraph, declaredLayers, acc);
58
77
  }
59
78
  }
60
79
 
61
80
  // ── 5. No boundaries declared and no circular deps to check → not applicable.
62
81
  // (Previously this returned a fake 1/1 pass, rendering a confident green ✅
63
82
  // for projects that declared no layer boundaries — it validated nothing.)
83
+ const results = compose();
64
84
  if (results.total === 0) {
65
85
  results.note = 'no layer boundaries declared in ARCHITECTURE.md';
66
86
  }
@@ -70,7 +90,7 @@ export function validateArchitecture(projectDir, config) {
70
90
 
71
91
  // ── Config-driven validation (existing behavior) ────────────────────────────
72
92
 
73
- function validateConfigLayers(projectDir, config, layers, results) {
93
+ function validateConfigLayers(projectDir, config, layers, acc) {
74
94
  const layerMap = {};
75
95
  for (const [layerName, layerConfig] of Object.entries(layers)) {
76
96
  if (layerConfig.dir && layerConfig.canImport) {
@@ -102,10 +122,18 @@ function validateConfigLayers(projectDir, config, layers, results) {
102
122
 
103
123
  for (const forbiddenDir of layer.forbidden) {
104
124
  if (spec.includes(forbiddenDir) || spec.includes(`/${forbiddenDir}/`)) {
105
- results.total++;
106
- results.errors.push(
107
- `${relPath}: ${layer.name} layer imports from forbidden layer (${forbiddenDir})`
108
- );
125
+ acc.total++;
126
+ acc.findings.push(mkFinding({
127
+ code: 'ARC001',
128
+ validator: 'architecture',
129
+ severity: 'error',
130
+ message: `${relPath}: ${layer.name} layer imports from forbidden layer (${forbiddenDir})`,
131
+ location: relPath,
132
+ suggestion: {
133
+ kind: 'fix',
134
+ text: 'Remove the import or route it through an allowed layer (see the layers config in .docguard.json)',
135
+ },
136
+ }));
109
137
  }
110
138
  }
111
139
  }
@@ -307,7 +335,7 @@ function parseLayerBoundaries(archContent) {
307
335
  return layers;
308
336
  }
309
337
 
310
- function validateLayerBoundaries(projectDir, graph, declaredLayers, results) {
338
+ function validateLayerBoundaries(projectDir, graph, declaredLayers, acc) {
311
339
  // Map directory patterns to layer names
312
340
  const layerDirMap = new Map();
313
341
  for (const layer of declaredLayers) {
@@ -327,13 +355,21 @@ function validateLayerBoundaries(projectDir, graph, declaredLayers, results) {
327
355
 
328
356
  // Check if this import is forbidden
329
357
  if (fromLayer.cannotImport.some(l => l.includes(toLayer.name) || toLayer.name.includes(l))) {
330
- results.total++;
331
- results.errors.push(
332
- `${edge.from}: ${fromLayer.name} → ${toLayer.name} (forbidden by ARCHITECTURE.md)`
333
- );
358
+ acc.total++;
359
+ acc.findings.push(mkFinding({
360
+ code: 'ARC003',
361
+ validator: 'architecture',
362
+ severity: 'error',
363
+ message: `${edge.from}: ${fromLayer.name} → ${toLayer.name} (forbidden by ARCHITECTURE.md)`,
364
+ location: edge.from,
365
+ suggestion: {
366
+ kind: 'review',
367
+ text: 'Remove or invert the import — or update the Layer Boundaries table in ARCHITECTURE.md if the rule changed',
368
+ },
369
+ }));
334
370
  } else {
335
- results.total++;
336
- results.passed++;
371
+ acc.total++;
372
+ acc.passed++;
337
373
  }
338
374
  }
339
375
  }
@@ -376,33 +412,20 @@ function getFileLayer(filePath, layerDirMap) {
376
412
 
377
413
  // ── Utilities ───────────────────────────────────────────────────────────────
378
414
 
415
+ // v0.29 consolidation: traversal delegates to the shared canonical walker.
416
+ // The old version pruned config-ignored DIRECTORIES before descending; the
417
+ // per-file check below yields the same result set (ignore-glob semantics match
418
+ // any path under the dir — see globToRegex's `^pattern/` alternation), at the
419
+ // cost of descending then filtering. Correctness-equivalent, verified by the
420
+ // ignore-validator specs.
379
421
  function getFilesRecursive(dir, config, projectDir) {
380
422
  const results = [];
381
- if (!existsSync(dir)) return results;
382
-
383
- let entries;
384
- try {
385
- entries = readdirSync(dir);
386
- } catch { return results; }
387
-
388
- for (const entry of entries) {
389
- if (IGNORE_DIRS.has(entry) || entry.startsWith('.')) continue;
390
-
391
- // Check config.ignore for this directory
423
+ sharedWalkFiles(dir, (fullPath) => {
392
424
  if (config && projectDir) {
393
- const relPath = relative(projectDir, join(dir, entry));
394
- if (shouldIgnore(relPath, config)) continue;
425
+ const relPath = relative(projectDir, fullPath);
426
+ if (shouldIgnore(relPath, config)) return;
395
427
  }
396
-
397
- const fullPath = join(dir, entry);
398
- try {
399
- const stat = statSync(fullPath);
400
- if (stat.isDirectory()) {
401
- results.push(...getFilesRecursive(fullPath, config, projectDir));
402
- } else {
403
- results.push(fullPath);
404
- }
405
- } catch { /* skip */ }
406
- }
428
+ results.push(fullPath);
429
+ }, { ignoreDirs: IGNORE_DIRS });
407
430
  return results;
408
431
  }