docguard-cli 0.33.0 → 0.34.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/PRIVACY.md ADDED
@@ -0,0 +1,45 @@
1
+ # Privacy Policy — DocGuard
2
+
3
+ **Effective: 2026-07-16**
4
+
5
+ DocGuard is a local-first command-line tool. This policy is short because the
6
+ honest answer is short: **DocGuard collects nothing.**
7
+
8
+ ## What DocGuard does with your data
9
+
10
+ - **All analysis runs locally.** Validators, scoring, reports, the MCP server —
11
+ everything reads files on your machine and writes output to your machine.
12
+ Nothing is uploaded, sampled, or "improved" with your code or docs.
13
+ - **No telemetry, no analytics, no crash reporting.** There is no phone-home
14
+ code path. The deterministic core makes no network calls at all.
15
+ - **No accounts.** DocGuard has no sign-up, no API keys of its own, and no
16
+ server-side component operated by us.
17
+
18
+ ## The explicit, user-initiated exceptions
19
+
20
+ Three commands can *prepare* outbound actions — each is opt-in, visible, and
21
+ executed by you or your own tooling, never silently by DocGuard:
22
+
23
+ | Command | What happens |
24
+ |---------|--------------|
25
+ | `docguard feedback` | Builds a **prefilled GitHub issue URL** (redacted and length-capped) and saves a local record. Nothing is sent unless you open the URL and submit it yourself. |
26
+ | `docguard upgrade --pr` / `impact --prs` | Shell out to **your** locally-authenticated `gh` CLI to interact with **your** repositories. DocGuard never holds credentials. |
27
+ | `docguard mcp --transport http` | Serves read-only tools over HTTP. Binds to loopback by default; binding a non-loopback address **refuses to start** without an `--api-key`. |
28
+
29
+ ## Data written to disk (yours, locally)
30
+
31
+ State lives under `.docguard/` in your repo (fix history, score history,
32
+ caches) and `.docguard.baseline.json` if you create one. All of it is plain
33
+ text, in your repository, under your version control — delete it any time.
34
+
35
+ ## Dependencies
36
+
37
+ One pinned runtime dependency (`@babel/parser`). The npm package is published
38
+ from GitHub Actions with provenance attestation, so you can verify the tarball
39
+ was built from this repository.
40
+
41
+ ## Changes & contact
42
+
43
+ Changes to this policy land in this file with a dated entry in
44
+ [CHANGELOG.md](CHANGELOG.md). Questions: open an issue at
45
+ <https://github.com/raccioly/docguard/issues> (see [SUPPORT.md](SUPPORT.md)).
package/README.md CHANGED
@@ -454,6 +454,15 @@ DocGuard ships **18 professional templates** with metadata, badges, and revision
454
454
 
455
455
  ## 🤖 AI Agent Support
456
456
 
457
+ ### One-click MCP install
458
+
459
+ [![Add to Cursor](https://img.shields.io/badge/Cursor-Add_MCP_Server-000000?logo=cursor)](cursor://anysphere.cursor-deeplink/mcp/install?name=docguard&config=eyJjb21tYW5kIjogIm5weCIsICJhcmdzIjogWyIteSIsICJkb2NndWFyZC1jbGkiLCAibWNwIl19)
460
+ [![Install in VS Code](https://img.shields.io/badge/VS_Code-Install_MCP_Server-0098FF?logo=githubcopilot)](vscode:mcp/install?%7B%22name%22%3A%22docguard%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22docguard-cli%22%2C%22mcp%22%5D%7D)
461
+
462
+ - **Claude Code**: `claude mcp add docguard -- npx docguard-cli mcp`
463
+ - **Claude Desktop**: download `docguard-v<version>.mcpb` from the [latest release](https://github.com/raccioly/docguard/releases/latest) and drag it into Settings → Extensions — you'll be asked which project folder to analyze. No npm, no JSON editing.
464
+ - **Anything MCP**: DocGuard is a verified namespace on the [official MCP registry](https://registry.modelcontextprotocol.io/v0/servers?search=docguard) (`io.github.raccioly/docguard`).
465
+
457
466
  DocGuard works with **every major AI coding agent**. All canonical docs are plain markdown — no vendor lock-in.
458
467
 
459
468
  | Agent | Compatibility | Auto-Generate Config |
@@ -769,6 +778,13 @@ See [CONTRIBUTING.md](CONTRIBUTING.md#research--academic-credits) for full citat
769
778
 
770
779
  ---
771
780
 
781
+ ## 🔒 Privacy & Supply Chain
782
+
783
+ DocGuard is local-first: no telemetry, no analytics, no phone-home — the full
784
+ (short) policy is in [PRIVACY.md](PRIVACY.md). npm releases are published with
785
+ [provenance attestation](https://docs.npmjs.com/generating-provenance-statements),
786
+ so you can verify each tarball was built by GitHub Actions from this repository.
787
+
772
788
  ## 📄 License
773
789
 
774
790
  [MIT](LICENSE) — Free to use, modify, and distribute.
@@ -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
  /**
@@ -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)) {
@@ -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);
@@ -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/faq.md CHANGED
@@ -119,6 +119,25 @@ Exit code 0 = pass, 1 = fail. Use `--threshold` to set minimum score.
119
119
 
120
120
  Yes — DocGuard ships a template at `templates/ci/github-actions.yml`. Copy it to `.github/workflows/` or use the reusable action in `action.yml`.
121
121
 
122
+ ### Claude (or my AI agent) says DocGuard is "unknown" and wants a legitimacy check. Why?
123
+
124
+ That's the agent's default posture toward **any** third-party MCP server or
125
+ npx package — MCP servers run code with your permissions, so unknown ones get
126
+ a trust prompt. It's not a DocGuard-specific flag. What DocGuard provides so
127
+ the check can pass:
128
+
129
+ - **npm provenance** — releases are signed by GitHub Actions (Sigstore), so
130
+ the tarball is verifiably built from this repository.
131
+ - **Official MCP registry** — `io.github.raccioly/docguard` is a verified
132
+ namespace at registry.modelcontextprotocol.io.
133
+ - **Read-only annotations** — every MCP tool declares `readOnlyHint`; the
134
+ server never writes or reaches the network.
135
+ - **[PRIVACY.md](../PRIVACY.md)** — no telemetry, no data collection.
136
+
137
+ To stop the prompt on your own machines: approve with "Always allow", or
138
+ pre-trust it for a whole team via a project-scoped `.mcp.json` /
139
+ enterprise managed-settings allowlist.
140
+
122
141
  ### Does DocGuard block commits?
123
142
 
124
143
  Only if you install hooks (`docguard hooks`). Without hooks, it's advisory only.
@@ -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.0"
6
+ version: "0.34.0"
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.0
9
+ version: 0.34.0
10
10
  source: extensions/spec-kit-docguard/skills/docguard-fix
11
11
  ---
12
- <!-- docguard:version: 0.33.0 -->
12
+ <!-- docguard:version: 0.34.0 -->
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.0
10
+ version: 0.34.0
11
11
  source: extensions/spec-kit-docguard/skills/docguard-guard
12
12
  ---
13
- <!-- docguard:version: 0.33.0 -->
13
+ <!-- docguard:version: 0.34.0 -->
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.0
9
+ version: 0.34.0
10
10
  source: extensions/spec-kit-docguard/skills/docguard-review
11
11
  ---
12
- <!-- docguard:version: 0.33.0 -->
12
+ <!-- docguard:version: 0.34.0 -->
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.0
9
+ version: 0.34.0
10
10
  source: extensions/spec-kit-docguard/skills/docguard-score
11
11
  ---
12
- <!-- docguard:version: 0.33.0 -->
12
+ <!-- docguard:version: 0.34.0 -->
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.0
7
+ version: 0.34.0
8
8
  source: extensions/spec-kit-docguard/skills/docguard-sync
9
9
  ---
10
- <!-- docguard:version: 0.33.0 -->
10
+ <!-- docguard:version: 0.34.0 -->
11
11
 
12
12
  # DocGuard Sync Skill
13
13
 
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "docguard-cli",
3
- "version": "0.33.0",
3
+ "version": "0.34.0",
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": {
7
- "docguard": "./cli/docguard.mjs"
7
+ "docguard": "cli/docguard.mjs"
8
8
  },
9
9
  "scripts": {
10
10
  "docguard": "node cli/docguard.mjs",
@@ -36,7 +36,7 @@
36
36
  "license": "MIT",
37
37
  "repository": {
38
38
  "type": "git",
39
- "url": "https://github.com/raccioly/docguard"
39
+ "url": "git+https://github.com/raccioly/docguard.git"
40
40
  },
41
41
  "homepage": "https://github.com/raccioly/docguard#readme",
42
42
  "mcpName": "io.github.raccioly/docguard",
@@ -57,6 +57,7 @@
57
57
  "docs/",
58
58
  "schemas/",
59
59
  "STANDARD.md",
60
+ "PRIVACY.md",
60
61
  "PHILOSOPHY.md",
61
62
  "README.md",
62
63
  "LICENSE"