docguard-cli 0.33.1 → 0.34.7

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 (31) hide show
  1. package/cli/commands/hooks.mjs +7 -9
  2. package/cli/commands/impact.mjs +12 -12
  3. package/cli/commands/init.mjs +4 -7
  4. package/cli/commands/llms.mjs +9 -19
  5. package/cli/commands/memory.mjs +7 -8
  6. package/cli/commands/score.mjs +17 -24
  7. package/cli/commands/trace.mjs +10 -11
  8. package/cli/scanners/agent-readability.mjs +6 -10
  9. package/cli/scanners/schemas.mjs +1 -0
  10. package/cli/scanners/semantic-claims.mjs +7 -13
  11. package/cli/shared-ignore.mjs +53 -0
  12. package/cli/validator-markers.mjs +7 -11
  13. package/cli/validators/api-doc-smells.mjs +11 -8
  14. package/cli/validators/cross-reference.mjs +4 -15
  15. package/cli/validators/diff-suspicion.mjs +5 -9
  16. package/cli/validators/doc-quality.mjs +9 -20
  17. package/cli/validators/docs-coverage.mjs +2 -9
  18. package/cli/validators/docs-diff.mjs +8 -2
  19. package/cli/validators/docs-sync.mjs +6 -8
  20. package/cli/validators/generated-staleness.mjs +28 -25
  21. package/cli/validators/reference-existence.mjs +4 -7
  22. package/cli/validators/todo-tracking.mjs +1 -1
  23. package/cli/validators/traceability.mjs +23 -26
  24. package/docs/commands.md +1 -1
  25. package/extensions/spec-kit-docguard/extension.yml +1 -1
  26. package/extensions/spec-kit-docguard/skills/docguard-fix/SKILL.md +2 -2
  27. package/extensions/spec-kit-docguard/skills/docguard-guard/SKILL.md +2 -2
  28. package/extensions/spec-kit-docguard/skills/docguard-review/SKILL.md +2 -2
  29. package/extensions/spec-kit-docguard/skills/docguard-score/SKILL.md +2 -2
  30. package/extensions/spec-kit-docguard/skills/docguard-sync/SKILL.md +2 -2
  31. package/package.json +2 -2
@@ -3,7 +3,7 @@
3
3
  * Creates git hooks that run guard/score before commits.
4
4
  */
5
5
 
6
- import { existsSync, writeFileSync, mkdirSync, chmodSync, readFileSync, unlinkSync, readdirSync } from 'node:fs';
6
+ import { existsSync, writeFileSync, mkdirSync, chmodSync, readFileSync, unlinkSync } from 'node:fs';
7
7
 
8
8
  // v0.16-P3: managed-block markers. Letting users extend the hook with their
9
9
  // own commands (data-file guards, lint checks, etc.) without us clobbering
@@ -57,6 +57,7 @@ function spliceManagedBlock(existing, newBody) {
57
57
  import { resolve, relative, basename } from 'node:path';
58
58
  import { c } from '../shared.mjs';
59
59
  import { getHooksDir } from '../shared-git.mjs';
60
+ import { listCanonicalDocs } from '../shared-ignore.mjs';
60
61
 
61
62
  const HOOKS = {
62
63
  'pre-commit': {
@@ -488,14 +489,11 @@ function docsReferencingFile(projectDir, base) {
488
489
  if (readFileSync(full, 'utf-8').includes(base)) docs.push(name);
489
490
  } catch { /* unreadable */ }
490
491
  };
491
- const dir = resolve(projectDir, 'docs-canonical');
492
- if (existsSync(dir)) {
493
- try {
494
- for (const f of readdirSync(dir)) {
495
- if (f.endsWith('.md')) check(f, resolve(dir, f));
496
- }
497
- } catch { /* unreadable dir */ }
498
- }
492
+ // Recursive a nested doc mentioning the edited file must still trigger
493
+ // the post-commit nudge. `name` stays the bare basename to match this
494
+ // function's pre-existing flat-tree display (the nudge message lists
495
+ // "ARCHITECTURE.md, SECURITY.md", not full nested paths).
496
+ for (const doc of listCanonicalDocs(projectDir)) check(basename(doc.rel), doc.abs);
499
497
  for (const a of NUDGE_AGENT_FILES) {
500
498
  const p = resolve(projectDir, a);
501
499
  if (existsSync(p)) check(a, p);
@@ -40,13 +40,14 @@
40
40
  * @req SC-S11-009 — docs referencing an importer of a changed file are flagged as indirect
41
41
  */
42
42
 
43
- import { existsSync, readFileSync, readdirSync } from 'node:fs';
43
+ import { existsSync, readFileSync } from 'node:fs';
44
44
  import { resolve, basename } from 'node:path';
45
45
  import { execFileSync } from 'node:child_process';
46
46
 
47
47
  import { c } from '../shared.mjs';
48
48
  import { changedFilesSince, isGitRepo } from '../shared-git.mjs';
49
49
  import { buildImportGraph } from '../validators/architecture.mjs';
50
+ import { listCanonicalDocs } from '../shared-ignore.mjs';
50
51
 
51
52
  /**
52
53
  * File extensions we consider "code" for the purposes of impact analysis.
@@ -254,19 +255,18 @@ export function runImpact(projectDir, config, flags) {
254
255
 
255
256
  // Index canonical docs once, PLUS root agent-instruction files (they name
256
257
  // code and other docs, so they belong in both code→doc and doc→doc analysis).
257
- const docsDir = resolve(projectDir, 'docs-canonical');
258
- const docsIndex = new Map(); // docName → lines[]
258
+ const docsIndex = new Map(); // docName (basename) → lines[]
259
259
  const agentDocs = new Set(); // which indexed docs are agent-instruction files
260
- if (existsSync(docsDir)) {
260
+ // Recursive — a nested canonical doc must be indexed too. Keyed by basename
261
+ // (not full path) to match this file's existing basename-based lookups
262
+ // (`docsIndex.has(basename(f))`, `indexBasenames`); a doc in a subfolder
263
+ // with a name clashing an already-indexed doc will overwrite it in the Map,
264
+ // same pre-existing behavior as a canonical doc clashing an agent doc name.
265
+ for (const doc of listCanonicalDocs(projectDir)) {
261
266
  try {
262
- for (const f of readdirSync(docsDir)) {
263
- if (!f.endsWith('.md')) continue;
264
- try {
265
- const content = readFileSync(resolve(docsDir, f), 'utf-8');
266
- docsIndex.set(f, content.split('\n'));
267
- } catch { /* skip unreadable */ }
268
- }
269
- } catch { /* skip if dir unreadable */ }
267
+ const content = readFileSync(doc.abs, 'utf-8');
268
+ docsIndex.set(basename(doc.rel), content.split('\n'));
269
+ } catch { /* skip unreadable */ }
270
270
  }
271
271
  for (const a of AGENT_FILES) {
272
272
  const p = resolve(projectDir, a);
@@ -11,6 +11,7 @@
11
11
 
12
12
  import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync } from 'node:fs';
13
13
  import { resolve, dirname } from 'node:path';
14
+ import { listCanonicalDocs } from '../shared-ignore.mjs';
14
15
  import { fileURLToPath } from 'node:url';
15
16
  import { createInterface } from 'node:readline';
16
17
  import { execSync } from 'node:child_process';
@@ -135,13 +136,9 @@ function shouldRunGenerate(projectDir, flags) {
135
136
  if (flags.fix) return false; // --fix = deterministic create-missing-from-templates (headless)
136
137
 
137
138
  // If canonical docs already exist, this is a re-init, not a first-run.
138
- const canonicalDir = resolve(projectDir, 'docs-canonical');
139
- if (existsSync(canonicalDir)) {
140
- try {
141
- const entries = readdirSync(canonicalDir).filter(f => f.endsWith('.md'));
142
- if (entries.length > 0) return false;
143
- } catch { /* fall through */ }
144
- }
139
+ // Recursive a project whose only canonical docs are nested must still be
140
+ // detected as already-initialized, or the wizard re-triggers on every run.
141
+ if (listCanonicalDocs(projectDir).length > 0) return false;
145
142
 
146
143
  // Existing-code signals: any of cli/, src/, lib/, app/ as a directory.
147
144
  const codeDirs = ['cli', 'src', 'lib', 'app'];
@@ -15,9 +15,10 @@
15
15
  * - `docguard guard` validates llms.txt exists and is current
16
16
  */
17
17
 
18
- import { existsSync, readFileSync, writeFileSync, readdirSync } from 'node:fs';
18
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
19
19
  import { resolve, join, basename } from 'node:path';
20
20
  import { c } from '../shared.mjs';
21
+ import { listCanonicalDocs } from '../shared-ignore.mjs';
21
22
 
22
23
  // ──── Doc descriptions for llms.txt ────
23
24
  const DOC_DESCRIPTIONS = {
@@ -58,18 +59,12 @@ export function generateLlmsTxt(projectDir, config) {
58
59
  }
59
60
  lines.push('');
60
61
 
61
- // ── Canonical Docs ──
62
- const docsDir = resolve(projectDir, 'docs-canonical');
62
+ // ── Canonical Docs ── (recursive — a nested doc still belongs in llms.txt)
63
63
  const existingDocs = [];
64
-
65
- if (existsSync(docsDir)) {
66
- try {
67
- const entries = readdirSync(docsDir).filter(f => f.endsWith('.md')).sort();
68
- for (const entry of entries) {
69
- const desc = DOC_DESCRIPTIONS[entry] || `${entry.replace('.md', '')} documentation`;
70
- existingDocs.push({ path: `docs-canonical/${entry}`, name: entry, desc });
71
- }
72
- } catch { /* ignore */ }
64
+ for (const doc of listCanonicalDocs(projectDir)) {
65
+ const entry = basename(doc.rel);
66
+ const desc = DOC_DESCRIPTIONS[entry] || `${entry.replace('.md', '')} documentation`;
67
+ existingDocs.push({ path: doc.rel, name: entry, desc });
73
68
  }
74
69
 
75
70
  if (existingDocs.length > 0) {
@@ -160,13 +155,8 @@ export function generateLlmsFullTxt(projectDir, config) {
160
155
 
161
156
  // Same doc discovery as the index form: canonical docs + present optional docs.
162
157
  const docPaths = [];
163
- const docsDir = resolve(projectDir, 'docs-canonical');
164
- if (existsSync(docsDir)) {
165
- try {
166
- for (const entry of readdirSync(docsDir).filter(f => f.endsWith('.md')).sort()) {
167
- docPaths.push({ path: `docs-canonical/${entry}`, desc: DOC_DESCRIPTIONS[entry] || null });
168
- }
169
- } catch { /* ignore */ }
158
+ for (const doc of listCanonicalDocs(projectDir)) {
159
+ docPaths.push({ path: doc.rel, desc: DOC_DESCRIPTIONS[basename(doc.rel)] || null });
170
160
  }
171
161
  for (const [file, desc] of Object.entries(OPTIONAL_DOCS)) {
172
162
  if (existsSync(resolve(projectDir, file))) docPaths.push({ path: file, desc });
@@ -21,9 +21,10 @@
21
21
  * Zero NPM dependencies. Pure orchestration of existing diff helpers.
22
22
  */
23
23
 
24
- import { existsSync, readFileSync, readdirSync, mkdirSync, writeFileSync } from 'node:fs';
24
+ import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs';
25
25
  import { resolve } from 'node:path';
26
26
  import { c } from '../shared.mjs';
27
+ import { listCanonicalDocs } from '../shared-ignore.mjs';
27
28
  import { diffRoutes, diffEntities, diffEnvVars, diffTechStack } from './diff.mjs';
28
29
  import { buildMemoryPlan } from '../scanners/memory-plan.mjs';
29
30
  import { runGuardInternal } from './guard.mjs';
@@ -97,19 +98,17 @@ function runMemoryPack(projectDir, config, flags) {
97
98
  lines.push(`- Tests: ${plan.surface.tests.totalFiles} files, ${plan.surface.tests.totalCases} cases`);
98
99
  lines.push('');
99
100
 
100
- const docsDir = resolve(projectDir, 'docs-canonical');
101
- if (existsSync(docsDir)) {
101
+ const canonicalDocs = listCanonicalDocs(projectDir);
102
+ if (canonicalDocs.length > 0) {
102
103
  lines.push('## Canonical docs');
103
104
  lines.push('');
104
- let entries = [];
105
- try { entries = readdirSync(docsDir).filter(f => f.endsWith('.md')).sort(); } catch { /* ignore */ }
106
- for (const doc of entries) {
105
+ for (const doc of canonicalDocs) {
107
106
  let reviewed = '';
108
107
  try {
109
- const m = readFileSync(resolve(docsDir, doc), 'utf-8').match(/docguard:last-reviewed\s+(\d{4}-\d{2}-\d{2})/);
108
+ const m = readFileSync(doc.abs, 'utf-8').match(/docguard:last-reviewed\s+(\d{4}-\d{2}-\d{2})/);
110
109
  if (m) reviewed = ` (last-reviewed ${m[1]})`;
111
110
  } catch { /* ignore */ }
112
- lines.push(`- docs-canonical/${doc}${reviewed}`);
111
+ lines.push(`- ${doc.rel}${reviewed}`);
113
112
  }
114
113
  lines.push('');
115
114
  }
@@ -12,6 +12,7 @@ import { runGuardInternal } from './guard.mjs';
12
12
  import { extractSemanticClaims } from '../scanners/semantic-claims.mjs';
13
13
  import { assessAgentReadability } from '../scanners/agent-readability.mjs';
14
14
  import { loadHistory, sparkline } from '../writers/history.mjs';
15
+ import { listCanonicalDocs } from '../shared-ignore.mjs';
15
16
 
16
17
  /**
17
18
  * Detect whether the project configures a test runner (the "Check 3" of the
@@ -424,16 +425,16 @@ export function computeAlcoaCompliance(projectDir, config, scores) {
424
425
  // 1. Attributable — Can we trace who wrote/reviewed docs?
425
426
  const hasGit = existsSync(resolve(projectDir, '.git'));
426
427
  const docsDir = resolve(projectDir, 'docs-canonical');
428
+ // Recursive — a nested canonical tree is still the canonical tree. A flat
429
+ // read scored ALCOA against an empty doc set on those projects.
430
+ const canonicalDocs = listCanonicalDocs(projectDir);
427
431
  let hasReviewedMeta = false;
428
- if (existsSync(docsDir)) {
432
+ for (const doc of canonicalDocs) {
429
433
  try {
430
- const docs = readdirSync(docsDir).filter(f => f.endsWith('.md'));
431
- for (const doc of docs) {
432
- const content = readFileSync(join(docsDir, doc), 'utf-8');
433
- if (content.includes('docguard:last-reviewed') || content.includes('last-reviewed')) {
434
- hasReviewedMeta = true;
435
- break;
436
- }
434
+ const content = readFileSync(doc.abs, 'utf-8');
435
+ if (content.includes('docguard:last-reviewed') || content.includes('last-reviewed')) {
436
+ hasReviewedMeta = true;
437
+ break;
437
438
  }
438
439
  } catch { /* ignore */ }
439
440
  }
@@ -457,16 +458,13 @@ export function computeAlcoaCompliance(projectDir, config, scores) {
457
458
 
458
459
  // 3. Contemporaneous — Are docs kept current?
459
460
  let freshnessMet = true;
460
- if (existsSync(docsDir)) {
461
+ for (const doc of canonicalDocs) {
461
462
  try {
462
- const docs = readdirSync(docsDir).filter(f => f.endsWith('.md'));
463
- for (const doc of docs) {
464
- const stat_ = statSync(join(docsDir, doc));
465
- const daysSinceModified = (Date.now() - stat_.mtimeMs) / (1000 * 60 * 60 * 24);
466
- if (daysSinceModified > 30) {
467
- freshnessMet = false;
468
- break;
469
- }
463
+ const stat_ = statSync(doc.abs);
464
+ const daysSinceModified = (Date.now() - stat_.mtimeMs) / (1000 * 60 * 60 * 24);
465
+ if (daysSinceModified > 30) {
466
+ freshnessMet = false;
467
+ break;
470
468
  }
471
469
  } catch { /* ignore */ }
472
470
  }
@@ -1079,13 +1077,8 @@ function getSuggestion(category, score, details) {
1079
1077
  */
1080
1078
  function estimateDocTax(projectDir, config, scores) {
1081
1079
  // Count tracked docs
1082
- const canonicalDir = resolve(projectDir, 'docs-canonical');
1083
- let docCount = 0;
1084
- if (existsSync(canonicalDir)) {
1085
- try {
1086
- docCount = readdirSync(canonicalDir).filter(f => f.endsWith('.md')).length;
1087
- } catch { /* ignore */ }
1088
- }
1080
+ // Recursive nested canonical docs cost maintenance too, so they count.
1081
+ let docCount = listCanonicalDocs(projectDir).length;
1089
1082
  // Add root tracking files
1090
1083
  if (existsSync(resolve(projectDir, 'CHANGELOG.md'))) docCount++;
1091
1084
  if (existsSync(resolve(projectDir, 'DRIFT-LOG.md'))) docCount++;
@@ -10,6 +10,7 @@ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
10
10
  import { resolve, join, extname, basename, relative, dirname } from 'node:path';
11
11
  import { c } from '../shared.mjs';
12
12
  import { detectSpecKit } from '../scanners/speckit.mjs';
13
+ import { listCanonicalDocs } from '../shared-ignore.mjs';
13
14
 
14
15
  const IGNORE_DIRS = new Set([
15
16
  'node_modules', '.git', '.next', 'dist', 'build', 'coverage',
@@ -74,11 +75,11 @@ export function runTraceReverse(projectDir, config, flags) {
74
75
  const stem = base.replace(/\.[^.]+$/, '');
75
76
 
76
77
  const matches = []; // { doc, line, content, kind }
77
- for (const f of readdirSync(docsDir)) {
78
- if (!f.endsWith('.md')) continue;
79
- const docPath = resolve(docsDir, f);
78
+ // Recursive a nested doc mentioning this file must still be found, or
79
+ // `trace --reverse` falsely reports "no canonical doc references this".
80
+ for (const doc of listCanonicalDocs(projectDir)) {
80
81
  let content;
81
- try { content = readFileSync(docPath, 'utf-8'); } catch { continue; }
82
+ try { content = readFileSync(doc.abs, 'utf-8'); } catch { continue; }
82
83
  const lines = content.split('\n');
83
84
  for (let i = 0; i < lines.length; i++) {
84
85
  const line = lines[i];
@@ -87,7 +88,7 @@ export function runTraceReverse(projectDir, config, flags) {
87
88
  else if (line.includes(base)) kind = 'basename';
88
89
  else if (new RegExp(`\`${escapeRegex(stem)}\``).test(line)) kind = 'module';
89
90
  if (kind) {
90
- matches.push({ doc: f, line: i + 1, content: line.trim(), kind });
91
+ matches.push({ doc: doc.rel, line: i + 1, content: line.trim(), kind });
91
92
  }
92
93
  }
93
94
  }
@@ -155,13 +156,11 @@ export function runTrace(projectDir, config, flags) {
155
156
  );
156
157
 
157
158
  // ── 2. Inventory canonical docs ──
159
+ // NOTE: currently unused downstream (outputText ignores its 3rd param) —
160
+ // kept for API stability and future use. Recursive so it isn't ANOTHER
161
+ // silent flat-read landmine if something starts consuming it.
158
162
  const docsDir = resolve(projectDir, 'docs-canonical');
159
- const canonicalDocs = [];
160
- if (existsSync(docsDir)) {
161
- for (const f of readdirSync(docsDir)) {
162
- if (f.endsWith('.md')) canonicalDocs.push(f);
163
- }
164
- }
163
+ const canonicalDocs = listCanonicalDocs(projectDir).map(d => d.rel);
165
164
 
166
165
  // ── 3. Scan project files ──
167
166
  const projectFiles = [];
@@ -12,9 +12,10 @@
12
12
  * read that.
13
13
  */
14
14
 
15
- import { existsSync, readFileSync, readdirSync } from 'node:fs';
15
+ import { existsSync, readFileSync } from 'node:fs';
16
16
  import { resolve, dirname } from 'node:path';
17
17
  import { loadIgnorePatterns } from '../shared.mjs';
18
+ import { listCanonicalDocs } from '../shared-ignore.mjs';
18
19
 
19
20
  /** chars/4 — the standard rough token estimate; consistency matters more than precision. */
20
21
  const estTokens = (s) => Math.ceil(s.length / 4);
@@ -30,19 +31,14 @@ function readIfExists(path) {
30
31
  }
31
32
 
32
33
  function canonicalDocs(projectDir) {
33
- const dir = resolve(projectDir, 'docs-canonical');
34
- if (!existsSync(dir)) return [];
35
34
  // Honor .docguardignore — an excluded doc (e.g. a historical audit) must
36
35
  // not drag down the readability metrics either (same rule as the
37
36
  // semantic-claim extractor, bug-212).
38
37
  const isIgnored = loadIgnorePatterns(projectDir);
39
- try {
40
- return readdirSync(dir)
41
- .filter(f => f.toLowerCase().endsWith('.md') && !isIgnored(`docs-canonical/${f}`))
42
- .sort()
43
- .map(f => ({ name: `docs-canonical/${f}`, content: readIfExists(resolve(dir, f)) }))
44
- .filter(d => d.content !== null);
45
- } catch { return []; }
38
+ // Recursive: docs in subfolders are canonical docs and are scored as such.
39
+ return listCanonicalDocs(projectDir, { isIgnored })
40
+ .map(d => ({ name: d.rel, content: readIfExists(d.abs) }))
41
+ .filter(d => d.content !== null);
46
42
  }
47
43
 
48
44
  /**
@@ -715,6 +715,7 @@ function extractOpenAPIRelationships(schemas) {
715
715
  const relationships = [];
716
716
  for (const schema of schemas) {
717
717
  for (const field of schema.fields) {
718
+ if (!field.type) continue; // OpenAPI allows a field with no `type` (e.g. a bare $ref)
718
719
  if (field.type !== 'string' && field.type !== 'number' && field.type !== 'boolean' && field.type !== 'integer') {
719
720
  // Likely a reference to another schema
720
721
  const target = schemas.find(s => s.name.toLowerCase() === field.type.toLowerCase());
@@ -21,9 +21,10 @@
21
21
  * Zero npm dependencies — pure Node.js built-ins.
22
22
  */
23
23
 
24
- import { existsSync, readFileSync, readdirSync } from 'node:fs';
25
- import { resolve, join } from 'node:path';
24
+ import { existsSync, readFileSync } from 'node:fs';
25
+ import { resolve } from 'node:path';
26
26
  import { loadIgnorePatterns } from '../shared.mjs';
27
+ import { listCanonicalDocs } from '../shared-ignore.mjs';
27
28
 
28
29
  // Numbers are only claims when adjacent to a recognized unit.
29
30
  const NUMBER_PATTERNS = [
@@ -55,17 +56,10 @@ function claimSourceDocs(projectDir) {
55
56
  // "unverified claims" pool either — it inflated the count and buried the
56
57
  // claims that ARE actionable (bug-212).
57
58
  const isIgnored = loadIgnorePatterns(projectDir);
58
- const docs = [];
59
- const canonical = resolve(projectDir, 'docs-canonical');
60
- if (existsSync(canonical)) {
61
- try {
62
- for (const f of readdirSync(canonical)) {
63
- if (f.toLowerCase().endsWith('.md') && !isIgnored(`docs-canonical/${f}`)) {
64
- docs.push(`docs-canonical/${f}`);
65
- }
66
- }
67
- } catch { /* ignore */ }
68
- }
59
+ // Recursive — nested canonical docs make claims too. The ignore predicate is
60
+ // applied per-doc inside the helper against the full relative path, so a
61
+ // pattern like `docs-canonical/99-archive/**` still excludes a subtree.
62
+ const docs = listCanonicalDocs(projectDir, { isIgnored }).map(d => d.rel);
69
63
  for (const root of ['README.md', 'AGENTS.md']) {
70
64
  if (existsSync(resolve(projectDir, root)) && !isIgnored(root)) docs.push(root);
71
65
  }
@@ -350,6 +350,59 @@ export function walkFiles(dir, callback, opts = {}) {
350
350
  return complete;
351
351
  }
352
352
 
353
+ /**
354
+ * Enumerate the project's canonical docs — RECURSIVELY.
355
+ *
356
+ * Five call sites used to do this by hand with a flat
357
+ * `readdirSync('docs-canonical').filter(f => f.endsWith('.md'))`. That works
358
+ * only for a flat tree; a project that groups its canonical docs in subfolders
359
+ * (`docs-canonical/01-architecture/MODULE-MAP.md` — a common convention once a
360
+ * repo has more than a handful) was invisible to ALL of them. The failure was
361
+ * silent and pointed the wrong way: docs-sync flagged every service as
362
+ * undocumented while the docs sat right there, `docguard:validator … n/a`
363
+ * markers in nested docs were ignored, and readability/freshness scored an
364
+ * empty set. This is the single shared implementation — same rule as
365
+ * `walkFiles`: exactly one correct way to enumerate, so the answers can't drift.
366
+ *
367
+ * Contract:
368
+ * - Recursive; delegates traversal to `walkFiles`, so `ignoreDirs` and the
369
+ * skip-dot-directories rule apply. A dot-FILE ending in `.md` is kept, to
370
+ * preserve the flat behavior these call sites had.
371
+ * - `.md` matching is case-INSENSITIVE. Three of the five original call sites
372
+ * lowercased and two did not; one tool must not hold two opinions about
373
+ * what a canonical doc is. The inclusive reading wins.
374
+ * - Sorted by relative path, so output is deterministic across platforms.
375
+ * - NEVER throws. An unreadable subtree yields the files that were readable.
376
+ *
377
+ * @param {string} projectDir - Project root (absolute)
378
+ * @param {{dirName?: string, isIgnored?: ((relPath: string) => boolean) | null}} [opts]
379
+ * `isIgnored` — optional `.docguardignore` predicate from the caller (see
380
+ * `loadIgnorePatterns` in shared.mjs). Passed in rather than imported so this
381
+ * module stays a leaf with no local imports.
382
+ * @returns {Array<{abs: string, rel: string}>} `rel` is project-relative POSIX
383
+ */
384
+ export function listCanonicalDocs(projectDir, opts = {}) {
385
+ const { dirName = 'docs-canonical', isIgnored = null } = opts;
386
+ const root = resolvePath(projectDir, dirName);
387
+ if (!existsSync(root)) return [];
388
+
389
+ const isMarkdown = (name) => name.toLowerCase().endsWith('.md');
390
+ const out = [];
391
+ walkFiles(root, (abs) => {
392
+ if (!isMarkdown(abs)) return;
393
+ const rel = relPosix(projectDir, abs);
394
+ if (isIgnored && isIgnored(rel)) return;
395
+ out.push({ abs, rel });
396
+ }, {
397
+ // Keep dot-FILES that are markdown (old flat behavior included them);
398
+ // dot-DIRECTORIES still get skipped, since the predicate only matches `.md`.
399
+ keepDot: isMarkdown,
400
+ });
401
+
402
+ out.sort((a, b) => (a.rel < b.rel ? -1 : a.rel > b.rel ? 1 : 0));
403
+ return out;
404
+ }
405
+
353
406
  /**
354
407
  * Count files under `projectDir` matching an anchored glob (project-relative).
355
408
  * The code-truth side of `config.collections` (metrics-consistency).
@@ -20,8 +20,9 @@
20
20
  * Zero NPM dependencies — pure Node.js built-ins.
21
21
  */
22
22
 
23
- import { existsSync, readFileSync, readdirSync } from 'node:fs';
24
- import { resolve, join } from 'node:path';
23
+ import { existsSync, readFileSync } from 'node:fs';
24
+ import { resolve } from 'node:path';
25
+ import { listCanonicalDocs } from './shared-ignore.mjs';
25
26
 
26
27
  // `<!-- docguard:validator <key> n/a [— reason] -->`
27
28
  // Separator before the reason may be —, :, or one-or-more hyphens. Reason
@@ -30,15 +31,10 @@ const MARKER_RE = /<!--\s*docguard:validator\s+([A-Za-z0-9_-]+)\s+n\/a\b\s*(?:[
30
31
 
31
32
  /** Files where a validator marker is honored — the docs humans actually read. */
32
33
  function markerSourceFiles(projectDir) {
33
- const files = [];
34
- const canonicalDir = resolve(projectDir, 'docs-canonical');
35
- if (existsSync(canonicalDir)) {
36
- try {
37
- for (const f of readdirSync(canonicalDir)) {
38
- if (f.toLowerCase().endsWith('.md')) files.push(join(canonicalDir, f));
39
- }
40
- } catch { /* ignore */ }
41
- }
34
+ // Recursive: a marker declared in docs-canonical/01-architecture/FOO.md must
35
+ // be honored too — a flat read silently dropped the suppression and the
36
+ // validator ran anyway, which reads as "DocGuard ignored my n/a".
37
+ const files = listCanonicalDocs(projectDir).map(d => d.abs);
42
38
  for (const root of ['AGENTS.md', 'README.md', 'CLAUDE.md']) {
43
39
  const p = resolve(projectDir, root);
44
40
  if (existsSync(p)) files.push(p);
@@ -20,9 +20,10 @@
20
20
  * All findings confidence:'low' / soft — a nudge to right-size the doc.
21
21
  */
22
22
 
23
- import { existsSync, readFileSync, readdirSync } from 'node:fs';
24
- import { resolve } from 'node:path';
23
+ import { readFileSync } from 'node:fs';
24
+ import { basename } from 'node:path';
25
25
  import { mkFinding, resultFromFindings } from '../findings.mjs';
26
+ import { listCanonicalDocs } from '../shared-ignore.mjs';
26
27
 
27
28
  const HEADING = /^(#{1,6})\s+(.*)$/;
28
29
  // A heading that documents an API/code unit — NOT a prose section heading.
@@ -94,18 +95,20 @@ export function validateApiDocSmells(projectDir, config = {}) {
94
95
  const lazyMax = Number.isInteger(cfg.lazyMaxWords) ? cfg.lazyMaxWords : 6;
95
96
  const bloatedMin = Number.isInteger(cfg.bloatedMinWords) ? cfg.bloatedMinWords : 300;
96
97
 
97
- const docsDir = resolve(projectDir, 'docs-canonical');
98
- if (!existsSync(docsDir)) {
98
+ const docFiles = listCanonicalDocs(projectDir); // recursive
99
+ if (docFiles.length === 0) {
99
100
  return resultFromFindings([], { passed: 0, total: 0, applicable: false });
100
101
  }
101
- let docFiles = [];
102
- try { docFiles = readdirSync(docsDir).filter(f => f.endsWith('.md')); } catch { /* skip */ }
103
102
 
104
103
  const findings = [];
105
104
  let unitCount = 0;
106
- for (const f of docFiles) {
105
+ for (const doc of docFiles) {
107
106
  let content;
108
- try { content = readFileSync(resolve(docsDir, f), 'utf-8'); } catch { continue; }
107
+ try { content = readFileSync(doc.abs, 'utf-8'); } catch { continue; }
108
+ // `f` stays the bare basename — matches this validator's pre-existing
109
+ // flat-tree message/location format exactly (both were already bare
110
+ // filenames, not project-relative paths, before this fix).
111
+ const f = basename(doc.rel);
109
112
  const units = extractUnits(content);
110
113
  for (const u of units) {
111
114
  unitCount++;
@@ -33,11 +33,11 @@
33
33
  * @req SC-K7-004 — code-fenced examples don't trigger false positives
34
34
  */
35
35
 
36
- import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
37
- import { resolve, join, dirname, basename, relative } from 'node:path';
36
+ import { existsSync, readFileSync } from 'node:fs';
37
+ import { resolve, dirname, basename, relative } from 'node:path';
38
38
  import { mkFinding, resultFromFindings } from '../findings.mjs';
39
39
  import { resolveDocDirs } from '../shared.mjs';
40
- import { walkFiles } from '../shared-ignore.mjs';
40
+ import { walkFiles, listCanonicalDocs } from '../shared-ignore.mjs';
41
41
 
42
42
  /**
43
43
  * Slugify a heading the way GitHub's markdown anchors work.
@@ -323,18 +323,7 @@ function resolveTarget(sourcePath, targetRel, projectDir) {
323
323
  * validators — `docs-canonical/`, root tracking files, and AGENTS.md.
324
324
  */
325
325
  function collectCanonicalDocs(projectDir) {
326
- const docs = [];
327
- const cdir = resolve(projectDir, 'docs-canonical');
328
- if (existsSync(cdir)) {
329
- try {
330
- for (const f of readdirSync(cdir)) {
331
- if (f.endsWith('.md')) {
332
- const p = join(cdir, f);
333
- if (statSync(p).isFile()) docs.push(p);
334
- }
335
- }
336
- } catch {}
337
- }
326
+ const docs = listCanonicalDocs(projectDir).map(d => d.abs); // recursive
338
327
  // Standard root-level docs that are commonly cross-referenced. We index
339
328
  // them so links like [CONTRIBUTING.md](CONTRIBUTING.md#some-section) can
340
329
  // resolve. The list is conservative — adding everything would pull in
@@ -18,11 +18,12 @@
18
18
  * or no code change carries removed tokens, so it stays silent off-CI.
19
19
  */
20
20
 
21
- import { existsSync, readFileSync, readdirSync } from 'node:fs';
21
+ import { existsSync, readFileSync } from 'node:fs';
22
22
  import { resolve, basename } from 'node:path';
23
23
  import { isGitRepo, getDiffText } from '../shared-git.mjs';
24
24
  import { parseUnifiedDiff, removedTokens, tokenize, tokenOverlap } from '../shared-diff.mjs';
25
25
  import { mkFinding, resultFromFindings } from '../findings.mjs';
26
+ import { listCanonicalDocs } from '../shared-ignore.mjs';
26
27
 
27
28
  const CODE_EXTENSIONS = /\.(ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|kt|rb|php|cs|swift|scala|dart)$/;
28
29
 
@@ -64,14 +65,9 @@ function indexDocs(projectDir) {
64
65
  docs.set(name, { lines: content.split('\n'), tokens: tokenize(content) });
65
66
  } catch { /* skip unreadable */ }
66
67
  };
67
- const docsDir = resolve(projectDir, 'docs-canonical');
68
- if (existsSync(docsDir)) {
69
- try {
70
- for (const f of readdirSync(docsDir)) {
71
- if (f.endsWith('.md')) add(f, resolve(docsDir, f));
72
- }
73
- } catch { /* skip */ }
74
- }
68
+ // Recursive. Keyed by bare basename — matches this validator's pre-existing
69
+ // flat-tree DSP001 message format ("ARCHITECTURE.md describes...").
70
+ for (const doc of listCanonicalDocs(projectDir)) add(basename(doc.rel), doc.abs);
75
71
  // Agent-instruction files are documentation too — they routinely name code.
76
72
  for (const agent of ['AGENTS.md', 'CLAUDE.md', 'GEMINI.md']) {
77
73
  const p = resolve(projectDir, agent);
@@ -20,9 +20,10 @@
20
20
  * built-ins reading files only.
21
21
  */
22
22
 
23
- import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
24
- import { resolve, join, extname, relative } from 'node:path';
23
+ import { existsSync, readFileSync, statSync } from 'node:fs';
24
+ import { resolve, join, extname, relative, basename } from 'node:path';
25
25
  import { mkFinding, resultFromFindings } from '../findings.mjs';
26
+ import { listCanonicalDocs } from '../shared-ignore.mjs';
26
27
 
27
28
  // ──── Metric Thresholds ────
28
29
  // These define "good" vs "warning" boundaries for each metric.
@@ -446,24 +447,12 @@ function getGradeLabel(grade) {
446
447
  * Collect all markdown files in docs-canonical/ directory.
447
448
  */
448
449
  function getCanonicalDocs(projectDir) {
449
- const docsDir = resolve(projectDir, 'docs-canonical');
450
- const docs = [];
451
-
452
- if (!existsSync(docsDir)) return docs;
453
-
454
- try {
455
- const entries = readdirSync(docsDir);
456
- for (const entry of entries) {
457
- if (extname(entry).toLowerCase() === '.md') {
458
- docs.push({
459
- name: entry,
460
- path: join(docsDir, entry),
461
- });
462
- }
463
- }
464
- } catch {
465
- // Directory read failed silently
466
- }
450
+ // Recursive. `name` stays the bare basename (not the full nested path) to
451
+ // match this function's pre-existing flat-tree display format exactly —
452
+ // messages read "ARCHITECTURE.md: ..." not "docs-canonical/x/ARCHITECTURE.md: ...".
453
+ // Same collision tradeoff as elsewhere: two nested docs sharing a basename
454
+ // are indistinguishable by name (pre-existing risk, not new).
455
+ const docs = listCanonicalDocs(projectDir).map(d => ({ name: basename(d.rel), path: d.abs }));
467
456
 
468
457
  // Also check README.md at project root
469
458
  const readmePath = resolve(projectDir, 'README.md');
@@ -21,7 +21,7 @@
21
21
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
22
22
  import { resolve, join, relative, basename, extname } from 'node:path';
23
23
  import { resolveSourceRoots } from '../shared-source.mjs';
24
- import { shouldIgnore, walkFiles as sharedWalkFiles } from '../shared-ignore.mjs';
24
+ import { shouldIgnore, walkFiles as sharedWalkFiles, listCanonicalDocs } from '../shared-ignore.mjs';
25
25
  import { detectIaC, hasInfrastructureHeading, buildIaCWarning } from '../scanners/iac.mjs';
26
26
  import { mkFinding, resultFromFindings } from '../findings.mjs';
27
27
 
@@ -508,14 +508,7 @@ function collectDocContent(projectDir) {
508
508
  if (existsSync(p)) docPaths.push(p);
509
509
  }
510
510
 
511
- const canonDir = resolve(projectDir, 'docs-canonical');
512
- if (existsSync(canonDir)) {
513
- try {
514
- for (const entry of readdirSync(canonDir)) {
515
- if (entry.endsWith('.md')) docPaths.push(resolve(canonDir, entry));
516
- }
517
- } catch { /* skip */ }
518
- }
511
+ for (const doc of listCanonicalDocs(projectDir)) docPaths.push(doc.abs); // recursive
519
512
 
520
513
  const extDir = resolve(projectDir, 'extensions');
521
514
  if (existsSync(extDir)) {
@@ -257,7 +257,10 @@ export function collectCodeTests(dir, config = {}) {
257
257
  // 2. recursive scan of each source root (co-located + nested __tests__)
258
258
  for (const root of resolveSourceRoots(dir, config)) {
259
259
  for (const f of getFilesRecursive(root, config)) {
260
- if (isTest(f)) codeTests.add(relative(dir, f));
260
+ if (isTest(f)) {
261
+ const rel = relative(dir, f);
262
+ if (!shouldIgnore(rel, config)) codeTests.add(rel);
263
+ }
261
264
  }
262
265
  }
263
266
 
@@ -266,7 +269,10 @@ export function collectCodeTests(dir, config = {}) {
266
269
  const testDir = join(resolve(dir), td);
267
270
  if (!existsSync(testDir)) continue;
268
271
  for (const f of getFilesRecursive(testDir, config)) {
269
- if (isTest(f)) codeTests.add(relative(dir, f));
272
+ if (isTest(f)) {
273
+ const rel = relative(dir, f);
274
+ if (!shouldIgnore(rel, config)) codeTests.add(rel);
275
+ }
270
276
  }
271
277
  }
272
278
 
@@ -10,7 +10,7 @@
10
10
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
11
11
  import { resolve, join, extname, basename } from 'node:path';
12
12
  import { resolveSourceRoots } from '../shared-source.mjs';
13
- import { relPosix, walkFiles as sharedWalkFiles } from '../shared-ignore.mjs';
13
+ import { relPosix, walkFiles as sharedWalkFiles, listCanonicalDocs } from '../shared-ignore.mjs';
14
14
  import { mkFinding, resultFromFindings } from '../findings.mjs';
15
15
 
16
16
  const IGNORE_DIRS = new Set([
@@ -71,15 +71,13 @@ export function validateDocsSync(projectDir, config) {
71
71
  let passed = 0;
72
72
  let total = 0;
73
73
 
74
- // Load all canonical doc content for checking
75
- const canonicalDir = resolve(projectDir, 'docs-canonical');
74
+ // Load all canonical doc content for checking. Recursive — docs grouped in
75
+ // subfolders (docs-canonical/01-architecture/…) count as canonical too; a
76
+ // flat read made every service they documented look undocumented.
76
77
  let canonicalContent = '';
77
- if (existsSync(canonicalDir)) {
78
+ for (const doc of listCanonicalDocs(projectDir)) {
78
79
  try {
79
- const files = readdirSync(canonicalDir).filter(f => f.endsWith('.md'));
80
- for (const f of files) {
81
- canonicalContent += readFileSync(resolve(canonicalDir, f), 'utf-8') + '\n';
82
- }
80
+ canonicalContent += readFileSync(doc.abs, 'utf-8') + '\n';
83
81
  } catch {
84
82
  // Skip if can't read
85
83
  }
@@ -29,6 +29,7 @@ import { resolve, basename, join } from 'node:path';
29
29
  import { buildMemoryPlan } from '../scanners/memory-plan.mjs';
30
30
  import { getSection } from '../writers/sections.mjs';
31
31
  import { mkFinding, resultFromFindings } from '../findings.mjs';
32
+ import { listCanonicalDocs } from '../shared-ignore.mjs';
32
33
 
33
34
  /**
34
35
  * v0.18-P1 fast-path: cheap pre-flight to detect whether ANY canonical doc
@@ -41,34 +42,36 @@ import { mkFinding, resultFromFindings } from '../findings.mjs';
41
42
  */
42
43
  function _quickScan(projectDir) {
43
44
  const out = { hasMarkers: false, hasDrafts: false };
44
- const candidateDirs = [
45
- resolve(projectDir, 'docs-canonical'),
46
- projectDir, // for README.md, AGENTS.md, etc.
47
- ];
45
+ // Recursive for docs-canonical/ — a marker in a nested doc must still be
46
+ // found, or this pre-flight false-negatives and skips the ENTIRE validator
47
+ // (buildMemoryPlan never even runs), silently disabling drift detection.
48
+ // projectDir itself stays a SHALLOW, non-recursive scan by design — it's
49
+ // here only to catch root-level README.md/AGENTS.md, not to walk the repo.
50
+ const candidateFiles = listCanonicalDocs(projectDir).map(d => d.abs);
51
+ try {
52
+ for (const entry of readdirSync(projectDir)) {
53
+ if (entry.endsWith('.md')) candidateFiles.push(join(projectDir, entry));
54
+ }
55
+ } catch { /* ignore */ }
56
+
48
57
  // We only need a single match in any file to know the validator has work.
49
58
  // Short-circuit aggressively: stop the moment we find either signal.
50
- for (const dir of candidateDirs) {
51
- if (!existsSync(dir)) continue;
52
- let entries;
53
- try { entries = readdirSync(dir); } catch { continue; }
54
- for (const entry of entries) {
55
- if (!entry.endsWith('.md')) continue;
56
- // Skip very large files quickly — for canonical docs, > 200 KB is unusual
57
- // and almost certainly not the marker-heavy file we're looking for.
58
- let stat;
59
- try { stat = statSync(join(dir, entry)); } catch { continue; }
60
- if (!stat.isFile()) continue;
61
- if (stat.size > 200_000) continue;
62
- let content;
63
- try { content = readFileSync(join(dir, entry), 'utf-8'); } catch { continue; }
64
- if (!out.hasMarkers && /<!--\s*docguard:section\s+[^>]*source=code/i.test(content)) {
65
- out.hasMarkers = true;
66
- }
67
- if (!out.hasDrafts && /(?:^---\s*\n[\s\S]*?\bstatus:\s*draft\b[\s\S]*?\n---|<!--\s*status:\s*draft\s*-->)/im.test(content)) {
68
- out.hasDrafts = true;
69
- }
70
- if (out.hasMarkers && out.hasDrafts) return out;
59
+ for (const full of candidateFiles) {
60
+ // Skip very large files quickly — for canonical docs, > 200 KB is unusual
61
+ // and almost certainly not the marker-heavy file we're looking for.
62
+ let stat;
63
+ try { stat = statSync(full); } catch { continue; }
64
+ if (!stat.isFile()) continue;
65
+ if (stat.size > 200_000) continue;
66
+ let content;
67
+ try { content = readFileSync(full, 'utf-8'); } catch { continue; }
68
+ if (!out.hasMarkers && /<!--\s*docguard:section\s+[^>]*source=code/i.test(content)) {
69
+ out.hasMarkers = true;
70
+ }
71
+ if (!out.hasDrafts && /(?:^---\s*\n[\s\S]*?\bstatus:\s*draft\b[\s\S]*?\n---|<!--\s*status:\s*draft\s*-->)/im.test(content)) {
72
+ out.hasDrafts = true;
71
73
  }
74
+ if (out.hasMarkers && out.hasDrafts) return out;
72
75
  }
73
76
  return out;
74
77
  }
@@ -41,7 +41,7 @@
41
41
  import { existsSync, readFileSync, readdirSync } from 'node:fs';
42
42
  import { resolve, extname, relative, basename } from 'node:path';
43
43
  import { isGitRepo, lastCommitHash, symbolExistsAtRev } from '../shared-git.mjs';
44
- import { walkFiles, isNonProductPath } from '../shared-ignore.mjs';
44
+ import { walkFiles, isNonProductPath, listCanonicalDocs } from '../shared-ignore.mjs';
45
45
  import { readScannable } from '../shared-source.mjs';
46
46
  import { resolveDocDirs } from '../shared.mjs';
47
47
  import { mkFinding, resultFromFindings, lineSuppresses } from '../findings.mjs';
@@ -170,12 +170,9 @@ function indexDocs(projectDir) {
170
170
  docs.push({ name, path: full, refs: extractRefs(content) });
171
171
  } catch { /* skip */ }
172
172
  };
173
- const docsDir = resolve(projectDir, 'docs-canonical');
174
- if (existsSync(docsDir)) {
175
- try {
176
- for (const f of readdirSync(docsDir)) if (f.endsWith('.md')) push(f, resolve(docsDir, f));
177
- } catch { /* skip */ }
178
- }
173
+ // Recursive. `name` stays the bare basename — matches this validator's
174
+ // pre-existing flat-tree message/location format exactly.
175
+ for (const doc of listCanonicalDocs(projectDir)) push(basename(doc.rel), doc.abs);
179
176
  for (const agent of ['AGENTS.md', 'CLAUDE.md', 'GEMINI.md']) {
180
177
  const p = resolve(projectDir, agent);
181
178
  if (existsSync(p)) push(agent, p);
@@ -344,7 +344,7 @@ function findTestFiles(rootDir, dir, files, config) {
344
344
  // inside template literals is a comment marker for the regex but not a real
345
345
  // annotation to track). Set config.todoTracking.includeTestFiles = true to override.
346
346
  const TEST_FILE_RE = /(^|\/)__tests?__\//;
347
- const TEST_NAME_RE = /\.(test|spec)\.(ts|tsx|js|jsx|mjs|cjs|py|java|go)$/;
347
+ const TEST_NAME_RE = /(?:\.(?:test|spec)\.[cm]?[jt]sx?|(?:^|\/)test_[^/]*\.py|_test\.(?:py|go)|_spec\.rb|(?:^|\/)[^/]*_test\.rb)$/i;
348
348
 
349
349
  // The validator's own source file describes the keyword list in its docstring
350
350
  // and code. Skipping itself avoids self-referential false positives.
@@ -16,7 +16,7 @@
16
16
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
17
17
  import { resolve, join, relative, basename, extname } from 'node:path';
18
18
  import { TRACE_MAP, TEST_PATTERNS, isTraceableSource } from '../shared-trace-patterns.mjs';
19
- import { walkFiles as sharedWalkFiles } from '../shared-ignore.mjs';
19
+ import { walkFiles as sharedWalkFiles, listCanonicalDocs } from '../shared-ignore.mjs';
20
20
  import { mkFinding, resultFromFindings } from '../findings.mjs';
21
21
  import { tokenize } from '../shared-diff.mjs';
22
22
  import { rankBySimilarity } from '../shared-ir.mjs';
@@ -231,21 +231,25 @@ export function validateTraceability(projectDir, config) {
231
231
  }
232
232
 
233
233
  // ── Detect orphaned files (exist but not required) ──
234
- try {
235
- const existingDocs = readdirSync(docsDir).filter(f => f.endsWith('.md'));
236
- for (const docFile of existingDocs) {
237
- if (!requiredDocs.has(docFile) && TRACE_MAP[docFile]) {
238
- findings.push(mkFinding({
239
- code: 'TRC003',
240
- validator: 'traceability',
241
- severity: 'warn',
242
- message: `${docFile} file exists in docs-canonical/ but is not in your requiredFiles config. Consider deleting it or adding it to .docguard.json requiredFiles.canonical`,
243
- location: `docs-canonical/${docFile}`,
244
- suggestion: { kind: 'review', text: 'Delete the doc, or add it to requiredFiles.canonical in .docguard.json so it gets validated' },
245
- }));
246
- }
234
+ // Recursive — a nested stray doc must be visible too. TRACE_MAP/requiredDocs
235
+ // matching stays keyed by bare basename (TRACE_MAP's own keys are
236
+ // conventional top-level names); `location` uses the real path so the
237
+ // finding points at the actual file instead of a fabricated flat one — for
238
+ // a flat tree `doc.rel` already equals the old `docs-canonical/${docFile}`
239
+ // template exactly, so this is a no-op on the flat case.
240
+ for (const doc of listCanonicalDocs(projectDir)) {
241
+ const docFile = basename(doc.rel);
242
+ if (!requiredDocs.has(docFile) && TRACE_MAP[docFile]) {
243
+ findings.push(mkFinding({
244
+ code: 'TRC003',
245
+ validator: 'traceability',
246
+ severity: 'warn',
247
+ message: `${docFile} — file exists in docs-canonical/ but is not in your requiredFiles config. Consider deleting it or adding it to .docguard.json requiredFiles.canonical`,
248
+ location: doc.rel,
249
+ suggestion: { kind: 'review', text: 'Delete the doc, or add it to requiredFiles.canonical in .docguard.json so it gets validated' },
250
+ }));
247
251
  }
248
- } catch { /* ignore */ }
252
+ }
249
253
 
250
254
  // ── Part 2: Requirement ID Traceability (V-Model) ──
251
255
  const reqResult = validateRequirementTraceability(projectDir, config, projectFiles);
@@ -427,17 +431,10 @@ function scanTestFilesForReferences(projectDir, projectFiles, patterns) {
427
431
  function getRequirementDocPaths(projectDir, config) {
428
432
  const paths = [];
429
433
 
430
- // docs-canonical/ directory
431
- const docsDir = resolve(projectDir, 'docs-canonical');
432
- if (existsSync(docsDir)) {
433
- try {
434
- for (const f of readdirSync(docsDir)) {
435
- if (extname(f).toLowerCase() === '.md') {
436
- paths.push(join(docsDir, f));
437
- }
438
- }
439
- } catch { /* ignore */ }
440
- }
434
+ // docs-canonical/ directory — recursive. Consumer re-derives the display
435
+ // path via relative(projectDir, docPath), so nested docs already report
436
+ // their real path with no further change needed there.
437
+ for (const doc of listCanonicalDocs(projectDir)) paths.push(doc.abs);
441
438
 
442
439
  // Root-level docs
443
440
  const rootDocs = ['REQUIREMENTS.md', 'spec.md', 'README.md'];
package/docs/commands.md CHANGED
@@ -132,7 +132,7 @@ npx docguard-cli init --skip-prompts # No AI prompt output
132
132
 
133
133
  ```bash
134
134
  npx docguard-cli generate
135
- npx docguard-cli generate --dir /path/to/project
135
+ npx docguard-cli generate --dir ./my-project
136
136
  ```
137
137
 
138
138
  **Detects:** Next.js, React, Vue, Angular, Express, Fastify, Hono, Django, FastAPI, SvelteKit, and more.
@@ -3,7 +3,7 @@ schema_version: "1.0"
3
3
  extension:
4
4
  id: "docguard"
5
5
  name: "DocGuard — CDD Enforcement"
6
- version: "0.33.1"
6
+ version: "0.34.7"
7
7
  description: "Canonical-Driven Development enforcement as a true spec-kit extension. LLM-first design with automated validators, 4 AI behavior skills, spec-kit skill chaining, and workflow hooks. One pinned runtime dependency (@babel/parser); pure Node.js otherwise."
8
8
  author: "Ricardo Accioly"
9
9
  repository: "https://github.com/raccioly/docguard"
@@ -6,10 +6,10 @@ description: AI-driven documentation repair with structured research workflow, t
6
6
  compatibility: Requires DocGuard CLI installed (npm i -g docguard-cli or npx docguard-cli)
7
7
  metadata:
8
8
  author: docguard
9
- version: 0.33.1
9
+ version: 0.34.7
10
10
  source: extensions/spec-kit-docguard/skills/docguard-fix
11
11
  ---
12
- <!-- docguard:version: 0.33.1 -->
12
+ <!-- docguard:version: 0.34.7 -->
13
13
 
14
14
  # DocGuard Fix Skill
15
15
 
@@ -7,10 +7,10 @@ description: Run DocGuard guard validation against Canonical-Driven Development
7
7
  compatibility: Requires DocGuard CLI installed (npm i -g docguard-cli or npx docguard-cli)
8
8
  metadata:
9
9
  author: docguard
10
- version: 0.33.1
10
+ version: 0.34.7
11
11
  source: extensions/spec-kit-docguard/skills/docguard-guard
12
12
  ---
13
- <!-- docguard:version: 0.33.1 -->
13
+ <!-- docguard:version: 0.34.7 -->
14
14
 
15
15
  # DocGuard Guard Skill
16
16
 
@@ -6,10 +6,10 @@ description: Cross-document consistency analysis and quality assessment. Perform
6
6
  compatibility: Requires DocGuard CLI installed (npm i -g docguard-cli or npx docguard-cli)
7
7
  metadata:
8
8
  author: docguard
9
- version: 0.33.1
9
+ version: 0.34.7
10
10
  source: extensions/spec-kit-docguard/skills/docguard-review
11
11
  ---
12
- <!-- docguard:version: 0.33.1 -->
12
+ <!-- docguard:version: 0.34.7 -->
13
13
 
14
14
  # DocGuard Review Skill
15
15
 
@@ -6,10 +6,10 @@ description: CDD maturity assessment with category-aware improvement roadmap. Ru
6
6
  compatibility: Requires DocGuard CLI installed (npm i -g docguard-cli or npx docguard-cli)
7
7
  metadata:
8
8
  author: docguard
9
- version: 0.33.1
9
+ version: 0.34.7
10
10
  source: extensions/spec-kit-docguard/skills/docguard-score
11
11
  ---
12
- <!-- docguard:version: 0.33.1 -->
12
+ <!-- docguard:version: 0.34.7 -->
13
13
 
14
14
  # DocGuard Score Skill
15
15
 
@@ -4,10 +4,10 @@ description: Keep canonical documentation ALWAYS UP TO DATE. Refreshes code-trut
4
4
  compatibility: Requires DocGuard CLI installed (npm i -g docguard-cli or npx docguard-cli)
5
5
  metadata:
6
6
  author: docguard
7
- version: 0.33.1
7
+ version: 0.34.7
8
8
  source: extensions/spec-kit-docguard/skills/docguard-sync
9
9
  ---
10
- <!-- docguard:version: 0.33.1 -->
10
+ <!-- docguard:version: 0.34.7 -->
11
11
 
12
12
  # DocGuard Sync Skill
13
13
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "docguard-cli",
3
- "version": "0.33.1",
3
+ "version": "0.34.7",
4
4
  "description": "The enforcement tool for Canonical-Driven Development (CDD). Audit, generate, and guard your project documentation.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -47,7 +47,7 @@
47
47
  "node": ">=18.0.0"
48
48
  },
49
49
  "dependencies": {
50
- "@babel/parser": "7.29.7"
50
+ "@babel/parser": "7.29.8"
51
51
  },
52
52
  "files": [
53
53
  "cli/",