docguard-cli 0.9.11 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/PHILOSOPHY.md +59 -106
  2. package/README.md +26 -3
  3. package/cli/commands/diagnose.mjs +171 -58
  4. package/cli/commands/diff.mjs +110 -137
  5. package/cli/commands/fix.mjs +152 -4
  6. package/cli/commands/generate.mjs +148 -27
  7. package/cli/commands/guard.mjs +45 -24
  8. package/cli/commands/hooks.mjs +40 -2
  9. package/cli/commands/score.mjs +22 -0
  10. package/cli/commands/sync.mjs +123 -0
  11. package/cli/docguard.mjs +22 -0
  12. package/cli/scanners/api-doc.mjs +122 -0
  13. package/cli/scanners/doc-tools.mjs +1 -1
  14. package/cli/scanners/frontend.mjs +438 -0
  15. package/cli/scanners/integrations.mjs +116 -0
  16. package/cli/scanners/memory-plan.mjs +242 -0
  17. package/cli/scanners/project-type.mjs +310 -0
  18. package/cli/scanners/routes.mjs +194 -32
  19. package/cli/scanners/schemas.mjs +174 -1
  20. package/cli/shared-source.mjs +247 -0
  21. package/cli/validators/api-surface.mjs +254 -0
  22. package/cli/validators/architecture.mjs +4 -3
  23. package/cli/validators/changelog.mjs +45 -4
  24. package/cli/validators/doc-quality.mjs +3 -2
  25. package/cli/validators/docs-coverage.mjs +9 -14
  26. package/cli/validators/docs-diff.mjs +117 -66
  27. package/cli/validators/docs-sync.mjs +30 -24
  28. package/cli/validators/drift.mjs +6 -2
  29. package/cli/validators/environment.mjs +43 -3
  30. package/cli/validators/freshness.mjs +4 -3
  31. package/cli/validators/metadata-sync.mjs +17 -7
  32. package/cli/validators/metrics-consistency.mjs +9 -4
  33. package/cli/validators/schema-sync.mjs +19 -10
  34. package/cli/validators/security.mjs +20 -7
  35. package/cli/validators/structure.mjs +8 -1
  36. package/cli/validators/test-spec.mjs +26 -17
  37. package/cli/validators/todo-tracking.mjs +21 -8
  38. package/cli/validators/traceability.mjs +61 -36
  39. package/cli/writers/api-reference.mjs +101 -0
  40. package/cli/writers/mechanical.mjs +116 -0
  41. package/cli/writers/sections.mjs +148 -0
  42. package/commands/docguard.fix.md +19 -3
  43. package/commands/docguard.guard.md +5 -4
  44. package/docs/doc-sections.md +37 -0
  45. package/docs/quickstart.md +1 -1
  46. package/extensions/spec-kit-docguard/README.md +8 -5
  47. package/extensions/spec-kit-docguard/commands/fix.md +74 -0
  48. package/extensions/spec-kit-docguard/commands/generate.md +25 -2
  49. package/extensions/spec-kit-docguard/commands/guard.md +6 -5
  50. package/extensions/spec-kit-docguard/commands/sync.md +62 -0
  51. package/extensions/spec-kit-docguard/skills/docguard-fix/SKILL.md +11 -1
  52. package/extensions/spec-kit-docguard/skills/docguard-guard/SKILL.md +3 -2
  53. package/extensions/spec-kit-docguard/skills/docguard-sync/SKILL.md +111 -0
  54. package/package.json +1 -1
  55. package/templates/commands/docguard.guard.md +3 -3
@@ -12,6 +12,7 @@
12
12
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
13
13
  import { resolve, join, extname, basename, relative } from 'node:path';
14
14
  import { shouldIgnore, globMatch } from '../shared-ignore.mjs';
15
+ import { collectPackageJsons, detectDocker, resolveSourceRoots } from '../shared-source.mjs';
15
16
 
16
17
  const IGNORE_DIRS = new Set([
17
18
  'node_modules', '.git', '.next', 'dist', 'build',
@@ -33,9 +34,11 @@ export function validateDocsDiff(projectDir, config) {
33
34
  let passed = 0;
34
35
  let total = 0;
35
36
 
37
+ // NOTE: env-var drift is owned by the Environment validator (which compares
38
+ // documented vars against real process.env / import.meta.env usage). Docs-Diff
39
+ // covers tech-stack and test-file drift to avoid double-reporting.
36
40
  const checks = [
37
- diffTechStack(projectDir),
38
- diffEnvVars(projectDir),
41
+ diffTechStack(projectDir, config),
39
42
  diffTests(projectDir, config),
40
43
  ];
41
44
 
@@ -61,14 +64,17 @@ export function validateDocsDiff(projectDir, config) {
61
64
 
62
65
  // ── Diff Functions (lightweight versions for validator) ──────────────────
63
66
 
64
- function diffTechStack(dir) {
67
+ export function diffTechStack(dir, config = {}) {
65
68
  const archPath = resolve(dir, 'docs-canonical/ARCHITECTURE.md');
66
- const pkgPath = resolve(dir, 'package.json');
67
- if (!existsSync(archPath) || !existsSync(pkgPath)) return null;
69
+ if (!existsSync(archPath)) return null;
70
+
71
+ // Monorepo-aware: merge dependencies across the root package + the source-root
72
+ // package + any workspace packages. A repo with no parseable package.json
73
+ // anywhere yields no code-side truth → return null (graceful, like before).
74
+ const pkgs = collectPackageJsons(dir, config);
75
+ if (pkgs.length === 0) return null;
68
76
 
69
77
  const archContent = readFileSync(archPath, 'utf-8');
70
- let pkg;
71
- try { pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')); } catch { return null; }
72
78
 
73
79
  const docTech = new Set();
74
80
  const techPatterns = ['React', 'Next.js', 'Vue', 'Angular', 'Svelte', 'Express', 'Fastify', 'Hono',
@@ -82,7 +88,10 @@ function diffTechStack(dir) {
82
88
  }
83
89
 
84
90
  const codeTech = new Set();
85
- const allDeps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
91
+ const allDeps = {};
92
+ for (const { pkg } of pkgs) {
93
+ Object.assign(allDeps, pkg.dependencies || {}, pkg.devDependencies || {});
94
+ }
86
95
  const depMap = {
87
96
  'react': 'React', 'next': 'Next.js', 'vue': 'Vue', 'express': 'Express',
88
97
  'fastify': 'Fastify', 'hono': 'Hono', 'prisma': 'Prisma', '@prisma/client': 'Prisma',
@@ -95,6 +104,11 @@ function diffTechStack(dir) {
95
104
  if (allDeps[dep]) codeTech.add(tech);
96
105
  }
97
106
 
107
+ // Docker is not an npm dependency — detect it via a Dockerfile/compose file.
108
+ if (detectDocker(dir, config)) codeTech.add('Docker');
109
+ // Terraform: detect via .tf files anywhere in the project (non-npm artifact).
110
+ if (hasFileWithExt(dir, '.tf', config)) codeTech.add('Terraform');
111
+
98
112
  if (docTech.size === 0 && codeTech.size === 0) return null;
99
113
 
100
114
  return {
@@ -104,37 +118,6 @@ function diffTechStack(dir) {
104
118
  };
105
119
  }
106
120
 
107
- function diffEnvVars(dir) {
108
- const envDocPath = resolve(dir, 'docs-canonical/ENVIRONMENT.md');
109
- if (!existsSync(envDocPath)) return null;
110
-
111
- const content = readFileSync(envDocPath, 'utf-8');
112
- const docVars = new Set();
113
- const varRegex = /`([A-Z][A-Z0-9_]{2,})`/g;
114
- let match;
115
- while ((match = varRegex.exec(content)) !== null) {
116
- docVars.add(match[1]);
117
- }
118
-
119
- const codeVars = new Set();
120
- const envExamplePath = resolve(dir, '.env.example');
121
- if (existsSync(envExamplePath)) {
122
- const envContent = readFileSync(envExamplePath, 'utf-8');
123
- const envRegex = /^([A-Z][A-Z0-9_]+)\s*=/gm;
124
- while ((match = envRegex.exec(envContent)) !== null) {
125
- codeVars.add(match[1]);
126
- }
127
- }
128
-
129
- if (docVars.size === 0 && codeVars.size === 0) return null;
130
-
131
- return {
132
- title: 'Environment Variables',
133
- onlyInDocs: [...docVars].filter(v => !codeVars.has(v)),
134
- onlyInCode: [...codeVars].filter(v => !docVars.has(v)),
135
- };
136
- }
137
-
138
121
  /**
139
122
  * Diff test files between TEST-SPEC.md and actual code.
140
123
  * Uses config.testPatterns if available, otherwise falls back to
@@ -145,52 +128,95 @@ function diffTests(dir, config) {
145
128
  const testSpecPath = resolve(dir, 'docs-canonical/TEST-SPEC.md');
146
129
  if (!existsSync(testSpecPath)) return null;
147
130
 
148
- const content = readFileSync(testSpecPath, 'utf-8');
131
+ // Strip fenced code blocks first — they contain shell commands like
132
+ // `npx playwright test login.spec.ts` whose tokens were being mis-extracted
133
+ // as documented test files.
134
+ const content = readFileSync(testSpecPath, 'utf-8').replace(/```[\s\S]*?```/g, '');
149
135
  const docTests = new Set();
150
- const testFileRegex = /`([^`]*\.(test|spec)\.[^`]+)`/g;
136
+ // A documented test reference: a single whitespace-free token ending in
137
+ // .test.<ext> or .spec.<ext>, optionally containing glob '*'.
138
+ const testFileRegex = /`([^`\s]*\.(?:test|spec)\.[a-zA-Z0-9]+)`/g;
151
139
  let match;
152
140
  while ((match = testFileRegex.exec(content)) !== null) {
153
141
  docTests.add(match[1]);
154
142
  }
155
143
 
156
- // Collect test files from disk using globMatch (always excludes node_modules)
157
- const codeTests = new Set();
158
- const testPatterns = config?.testPatterns || [];
159
-
160
- if (testPatterns.length > 0) {
161
- // Use configured patterns — globMatch handles node_modules exclusion
162
- const allTestFiles = getTestFilesFromPatterns(dir, testPatterns, config);
163
- for (const f of allTestFiles) {
164
- codeTests.add(f);
165
- }
166
- } else {
167
- // Fall back to standard test directories
168
- const testDirs = ['tests', 'test', '__tests__', 'spec', 'e2e'];
169
- for (const td of testDirs) {
170
- const testDir = resolve(dir, td);
171
- if (!existsSync(testDir)) continue;
172
- const files = getFilesRecursive(testDir, config);
173
- for (const f of files) {
174
- codeTests.add(f.replace(dir + '/', ''));
175
- }
176
- }
177
- }
144
+ // Collect ALL test files from disk: configured patterns + every *.test.* /
145
+ // *.spec.* file found recursively under each source root (catches co-located
146
+ // and nested __tests__ dirs) + root-level conventional test dirs (e2e/, tests/).
147
+ const codeTests = collectCodeTests(dir, config);
178
148
 
179
149
  if (docTests.size === 0 && codeTests.size === 0) return null;
180
150
 
151
+ // TEST-SPEC.md frequently documents tests as GLOB PATTERNS
152
+ // (`backend/src/*/__tests__/*.test.ts`, `e2e/*.spec.ts`), and entries may be
153
+ // bare basenames or full paths. Treat each documented entry as a glob and
154
+ // match it against code test paths (or basenames when the entry has no slash).
155
+ // Exact-string comparison produced the false "N documented but not found".
156
+ const codeArr = [...codeTests];
157
+ const docArr = [...docTests];
158
+
159
+ const matches = (docEntry, codeRel) => {
160
+ const entry = String(docEntry).trim();
161
+ const hasSlash = entry.includes('/');
162
+ const target = hasSlash ? entry : basename(entry);
163
+ const subject = hasSlash ? codeRel : basename(codeRel);
164
+ // Glob -> regex: escape regex specials, then any run of '*' becomes '.*'.
165
+ const rx = new RegExp('^' + target
166
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
167
+ .replace(/\*+/g, '.*') + '$');
168
+ return rx.test(subject);
169
+ };
170
+
181
171
  return {
182
172
  title: 'Test Files',
183
- onlyInDocs: [...docTests].filter(t => !codeTests.has(t)),
184
- onlyInCode: [...codeTests].filter(t => !docTests.has(t)),
173
+ onlyInDocs: docArr.filter(d => !codeArr.some(c => matches(d, c))),
174
+ onlyInCode: codeArr.filter(c => !docArr.some(d => matches(d, c))),
185
175
  };
186
176
  }
187
177
 
178
+ /**
179
+ * Collect every test file in the project (relative paths), monorepo-aware:
180
+ * - configured config.testPatterns
181
+ * - any *.test.* / *.spec.* found recursively under each source root
182
+ * (catches co-located and deeply-nested __tests__ directories)
183
+ * - root-level conventional test dirs (tests/, e2e/, cypress/, etc.)
184
+ * @returns {Set<string>} relative test file paths
185
+ */
186
+ export function collectCodeTests(dir, config = {}) {
187
+ const codeTests = new Set();
188
+ const isTest = (f) => /\.(test|spec)\./.test(f);
189
+
190
+ // 1. configured patterns
191
+ for (const f of getTestFilesFromPatterns(dir, config?.testPatterns || [], config)) {
192
+ codeTests.add(f);
193
+ }
194
+
195
+ // 2. recursive scan of each source root (co-located + nested __tests__)
196
+ for (const root of resolveSourceRoots(dir, config)) {
197
+ for (const f of getFilesRecursive(root, config)) {
198
+ if (isTest(f)) codeTests.add(relative(dir, f));
199
+ }
200
+ }
201
+
202
+ // 3. root-level conventional test dirs (e2e lives outside any source root)
203
+ for (const td of ['tests', 'test', '__tests__', 'spec', 'e2e', 'cypress']) {
204
+ const testDir = join(resolve(dir), td);
205
+ if (!existsSync(testDir)) continue;
206
+ for (const f of getFilesRecursive(testDir, config)) {
207
+ if (isTest(f)) codeTests.add(relative(dir, f));
208
+ }
209
+ }
210
+
211
+ return codeTests;
212
+ }
213
+
188
214
  /**
189
215
  * Find test files matching configured testPatterns.
190
216
  * Uses globMatch() for pattern matching — always excludes node_modules.
191
217
  * Results are deduplicated via Set (handles overlapping patterns).
192
218
  */
193
- function getTestFilesFromPatterns(dir, patterns, config) {
219
+ export function getTestFilesFromPatterns(dir, patterns, config) {
194
220
  const results = new Set();
195
221
 
196
222
  function walk(currentDir) {
@@ -222,6 +248,31 @@ function getTestFilesFromPatterns(dir, patterns, config) {
222
248
  return [...results];
223
249
  }
224
250
 
251
+ /** Returns true if any file with the given extension exists under dir (ignoring vendor dirs). */
252
+ function hasFileWithExt(dir, ext, config) {
253
+ let found = false;
254
+ const walk = (d) => {
255
+ if (found) return;
256
+ let entries;
257
+ try { entries = readdirSync(d); } catch { return; }
258
+ for (const entry of entries) {
259
+ if (found) return;
260
+ if (IGNORE_DIRS.has(entry) || entry.startsWith('.')) continue;
261
+ const full = join(d, entry);
262
+ try {
263
+ const stat = statSync(full);
264
+ if (stat.isDirectory()) walk(full);
265
+ else if (stat.isFile() && extname(full) === ext) {
266
+ const rel = relative(dir, full);
267
+ if (!config || !shouldIgnore(rel, config)) found = true;
268
+ }
269
+ } catch { /* skip */ }
270
+ }
271
+ };
272
+ walk(dir);
273
+ return found;
274
+ }
275
+
225
276
  function getFilesRecursive(dir, config) {
226
277
  const results = [];
227
278
  if (!existsSync(dir)) return results;
@@ -4,23 +4,36 @@
4
4
 
5
5
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
6
6
  import { resolve, join, extname, basename } from 'node:path';
7
+ import { resolveSourceRoots } from '../shared-source.mjs';
7
8
 
8
9
  const IGNORE_DIRS = new Set([
9
10
  'node_modules', '.git', '.next', 'dist', 'build',
10
11
  'coverage', '.cache', '__pycache__', '.venv', 'vendor',
11
12
  ]);
12
13
 
14
+ /**
15
+ * Expand sub-path patterns (e.g. 'routes', 'src/routes') against the project
16
+ * root AND every configured source root, returning de-duplicated existing dirs.
17
+ * Makes route/service discovery monorepo-aware (e.g. backend/src/routes).
18
+ */
19
+ function expandDirs(projectDir, config, subPaths) {
20
+ const bases = [resolve(projectDir), ...resolveSourceRoots(projectDir, config)];
21
+ const out = [];
22
+ const seen = new Set();
23
+ for (const base of bases) {
24
+ for (const sub of subPaths) {
25
+ const dir = resolve(base, sub);
26
+ if (seen.has(dir) || !existsSync(dir)) continue;
27
+ seen.add(dir);
28
+ out.push(dir);
29
+ }
30
+ }
31
+ return out;
32
+ }
33
+
13
34
  export function validateDocsSync(projectDir, config) {
14
35
  const results = { name: 'docs-sync', errors: [], warnings: [], passed: 0, total: 0 };
15
36
 
16
- // Find route/API files and check they're mentioned in canonical docs
17
- const routePatterns = [
18
- { dir: 'src/routes', label: 'route' },
19
- { dir: 'src/app/api', label: 'API route' },
20
- { dir: 'api', label: 'API route' },
21
- { dir: 'routes', label: 'route' },
22
- ];
23
-
24
37
  // Load all canonical doc content for checking
25
38
  const canonicalDir = resolve(projectDir, 'docs-canonical');
26
39
  let canonicalContent = '';
@@ -39,10 +52,9 @@ export function validateDocsSync(projectDir, config) {
39
52
  return results; // No canonical docs to check against
40
53
  }
41
54
 
42
- for (const { dir, label } of routePatterns) {
43
- const routeDir = resolve(projectDir, dir);
44
- if (!existsSync(routeDir)) continue;
45
-
55
+ // Find route/API files (monorepo-aware) and check they're mentioned in docs.
56
+ const routeDirs = expandDirs(projectDir, config, ['src/routes', 'src/app/api', 'routes', 'api']);
57
+ for (const routeDir of routeDirs) {
46
58
  const files = getFilesRecursive(routeDir);
47
59
  for (const file of files) {
48
60
  const ext = extname(file);
@@ -56,17 +68,14 @@ export function validateDocsSync(projectDir, config) {
56
68
  if (canonicalContent.includes(relPath) || canonicalContent.includes(name)) {
57
69
  results.passed++;
58
70
  } else {
59
- results.warnings.push(`${label} ${relPath} not referenced in any canonical doc`);
71
+ results.warnings.push(`route ${relPath} not referenced in any canonical doc`);
60
72
  }
61
73
  }
62
74
  }
63
75
 
64
- // Find service files and check they're documented
65
- const serviceDirs = ['src/services', 'services', 'src/lib'];
66
- for (const dir of serviceDirs) {
67
- const serviceDir = resolve(projectDir, dir);
68
- if (!existsSync(serviceDir)) continue;
69
-
76
+ // Find service files (monorepo-aware) and check they're documented.
77
+ const serviceDirs = expandDirs(projectDir, config, ['src/services', 'services', 'src/lib']);
78
+ for (const serviceDir of serviceDirs) {
70
79
  const files = getFilesRecursive(serviceDir);
71
80
  for (const file of files) {
72
81
  const ext = extname(file);
@@ -107,11 +116,8 @@ export function validateDocsSync(projectDir, config) {
107
116
  }
108
117
 
109
118
  if (openapiContent && openapiFile) {
110
- // Check that route files have corresponding paths in OpenAPI spec
111
- for (const { dir } of routePatterns) {
112
- const routeDir = resolve(projectDir, dir);
113
- if (!existsSync(routeDir)) continue;
114
-
119
+ // Check that route files have corresponding paths in OpenAPI spec (monorepo-aware)
120
+ for (const routeDir of expandDirs(projectDir, config, ['src/routes', 'src/app/api', 'routes', 'api'])) {
115
121
  const files = getFilesRecursive(routeDir);
116
122
  for (const file of files) {
117
123
  const ext = extname(file);
@@ -27,6 +27,10 @@ export function validateDrift(projectDir, config) {
27
27
  if (!CODE_EXTENSIONS.has(ext)) return;
28
28
 
29
29
  const content = readFileSync(filePath, 'utf-8');
30
+
31
+ // Fast early-return: skip expensive string split if no comment exists
32
+ if (!content.includes('DRIFT:')) return;
33
+
30
34
  const lines = content.split('\n');
31
35
 
32
36
  lines.forEach((line, i) => {
@@ -43,8 +47,8 @@ export function validateDrift(projectDir, config) {
43
47
  });
44
48
 
45
49
  if (driftComments.length === 0) {
46
- results.total = 1;
47
- results.passed = 1;
50
+ // No // DRIFT: comments to reconcile — not applicable (NOT a pass).
51
+ results.note = 'no // DRIFT: comments in code';
48
52
  return results;
49
53
  }
50
54
 
@@ -5,6 +5,7 @@
5
5
 
6
6
  import { existsSync, readFileSync } from 'node:fs';
7
7
  import { resolve } from 'node:path';
8
+ import { grepEnvUsage } from '../shared-source.mjs';
8
9
 
9
10
  export function validateEnvironment(projectDir, config) {
10
11
  const results = { name: 'environment', errors: [], warnings: [], passed: 0, total: 0 };
@@ -17,21 +18,60 @@ export function validateEnvironment(projectDir, config) {
17
18
 
18
19
  const content = readFileSync(envDocPath, 'utf-8');
19
20
 
20
- // Check for required sections
21
+ // Check for required sections (anchored headings — not substring matches that
22
+ // could hit a TOC entry or code block).
23
+ const hasHeading = (re) => re.test(content);
21
24
  results.total++;
22
- if (content.includes('## Prerequisites') || content.includes('## Setup Steps')) {
25
+ if (hasHeading(/^#{2,3}\s+(Prerequisites|Setup Steps)\b/m)) {
23
26
  results.passed++;
24
27
  } else {
25
28
  results.warnings.push('ENVIRONMENT.md: missing "## Prerequisites" or "## Setup Steps" section');
26
29
  }
27
30
 
28
31
  results.total++;
29
- if (content.includes('## Environment Variables') || content.includes('## Setup Steps')) {
32
+ if (hasHeading(/^#{2,3}\s+Environment Variables\b/m)) {
30
33
  results.passed++;
31
34
  } else {
32
35
  results.warnings.push('ENVIRONMENT.md: missing "## Environment Variables" section');
33
36
  }
34
37
 
38
+ // ── Real code-truth check: env vars USED in code but documented nowhere ──
39
+ // (Replaces the old pure section-presence heuristic with an actual comparison
40
+ // against process.env / import.meta.env usage. .env.example counts as docs.
41
+ // CLI/library projects that declare no env vars skip this.)
42
+ if (ptc.needsEnvVars !== false) {
43
+ const documented = new Set();
44
+ const varRe = /`([A-Z][A-Z0-9_]{2,})`/g;
45
+ let m;
46
+ while ((m = varRe.exec(content)) !== null) documented.add(m[1]);
47
+ for (const envFile of ['.env.example', '.env.template']) {
48
+ const p = resolve(projectDir, envFile);
49
+ if (!existsSync(p)) continue;
50
+ const re = /^([A-Z][A-Z0-9_]+)\s*=/gm;
51
+ const ex = readFileSync(p, 'utf-8');
52
+ let em;
53
+ while ((em = re.exec(ex)) !== null) documented.add(em[1]);
54
+ }
55
+
56
+ const codeUsed = grepEnvUsage(projectDir, config);
57
+
58
+ // Only assess when code actually reads env vars — otherwise the check is
59
+ // vacuous (always passes) and would just inflate the count.
60
+ if (codeUsed.size > 0) {
61
+ const usedButUndocumented = [...codeUsed].filter(v => !documented.has(v));
62
+ results.total++;
63
+ if (usedButUndocumented.length === 0) {
64
+ results.passed++;
65
+ } else {
66
+ const shown = usedButUndocumented.slice(0, 10).join(', ');
67
+ const more = usedButUndocumented.length > 10 ? ` (+${usedButUndocumented.length - 10} more)` : '';
68
+ results.warnings.push(
69
+ `${usedButUndocumented.length} env var(s) used in code but not documented in ENVIRONMENT.md / .env.example: ${shown}${more}`
70
+ );
71
+ }
72
+ }
73
+ }
74
+
35
75
  // Only check .env.example if the project type needs it
36
76
  if (ptc.needsEnvExample !== false && ptc.needsEnvVars !== false) {
37
77
  // Check if .env.example is referenced and exists
@@ -8,7 +8,7 @@
8
8
 
9
9
  import { existsSync, readdirSync, statSync } from 'node:fs';
10
10
  import { resolve, join, extname } from 'node:path';
11
- import { execSync } from 'node:child_process';
11
+ import { execSync, execFileSync } from 'node:child_process';
12
12
 
13
13
  const IGNORE_DIRS = new Set([
14
14
  'node_modules', '.git', '.next', 'dist', 'build',
@@ -22,8 +22,9 @@ const IGNORE_DIRS = new Set([
22
22
  */
23
23
  function getLastGitDate(filePath, dir) {
24
24
  try {
25
- const result = execSync(
26
- `git log -1 --format="%aI" -- "${filePath}"`,
25
+ const result = execFileSync(
26
+ 'git',
27
+ ['log', '-1', '--format=%aI', '--', filePath],
27
28
  { cwd: dir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
28
29
  ).trim();
29
30
  return result ? new Date(result) : null;
@@ -8,6 +8,7 @@
8
8
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
9
9
  import { resolve, join, relative, extname } from 'node:path';
10
10
  import { loadIgnorePatterns } from '../shared.mjs';
11
+ import { collectPackageJsons } from '../shared-source.mjs';
11
12
 
12
13
  const IGNORE_DIRS = new Set([
13
14
  'node_modules', '.git', '.next', 'dist', 'build', 'coverage',
@@ -22,18 +23,23 @@ const IGNORE_DIRS = new Set([
22
23
  */
23
24
  export function validateMetadataSync(projectDir, config) {
24
25
  const warnings = [];
26
+ const fixes = [];
25
27
  let passed = 0;
26
28
  let total = 0;
27
29
 
28
30
  // ── Get source of truth: package.json version ──
31
+ // Prefer the root package version; in a monorepo where the root is just a
32
+ // workspace manifest with no version, fall back to a source-root package.
29
33
  const pkgPath = resolve(projectDir, 'package.json');
30
- if (!existsSync(pkgPath)) {
31
- return { errors: [], warnings, passed: 0, total: 0 };
34
+ let currentVersion = null;
35
+ if (existsSync(pkgPath)) {
36
+ try { currentVersion = JSON.parse(readFileSync(pkgPath, 'utf-8')).version || null; } catch { /* ignore */ }
37
+ }
38
+ if (!currentVersion) {
39
+ for (const { pkg } of collectPackageJsons(projectDir, config)) {
40
+ if (pkg.version) { currentVersion = pkg.version; break; }
41
+ }
32
42
  }
33
-
34
- let pkg;
35
- try { pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')); } catch { return { errors: [], warnings, passed: 0, total: 0 }; }
36
- const currentVersion = pkg.version;
37
43
  if (!currentVersion) return { errors: [], warnings, passed: 0, total: 0 };
38
44
 
39
45
  // Parse into components for smart comparison
@@ -55,6 +61,7 @@ export function validateMetadataSync(projectDir, config) {
55
61
  warnings.push(
56
62
  `${relPath} has version "${versionMatch[1]}" but package.json is "${currentVersion}"`
57
63
  );
64
+ fixes.push({ type: 'replace-version', file: relPath, found: versionMatch[1], actual: currentVersion });
58
65
  } else {
59
66
  passed++;
60
67
  }
@@ -115,6 +122,9 @@ export function validateMetadataSync(projectDir, config) {
115
122
  warnings.push(
116
123
  `${relPath} references "v${foundVersion}" in an actionable context (URL/install/declaration) but current version is "${currentVersion}"`
117
124
  );
125
+ if (!fixes.some(f => f.file === relPath && f.found === foundVersion)) {
126
+ fixes.push({ type: 'replace-version', file: relPath, found: foundVersion, actual: currentVersion });
127
+ }
118
128
  } else if (fMajor === major && fMinor === minor && foundVersion === currentVersion) {
119
129
  total++;
120
130
  passed++;
@@ -123,7 +133,7 @@ export function validateMetadataSync(projectDir, config) {
123
133
  }
124
134
  }
125
135
 
126
- return { errors: [], warnings, passed, total };
136
+ return { errors: [], warnings, passed, total, fixes };
127
137
  }
128
138
 
129
139
  // ── Helpers ──────────────────────────────────────────────────────────────────
@@ -8,7 +8,7 @@
8
8
 
9
9
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
10
10
  import { resolve, join, relative } from 'node:path';
11
- import { loadIgnorePatterns } from '../shared.mjs';
11
+ import { loadIgnorePatterns, c } from '../shared.mjs';
12
12
 
13
13
  const IGNORE_DIRS = new Set([
14
14
  'node_modules', '.git', '.next', 'dist', 'build', 'coverage',
@@ -24,6 +24,7 @@ const IGNORE_DIRS = new Set([
24
24
  */
25
25
  export function validateMetricsConsistency(projectDir, config, guardResults) {
26
26
  const warnings = [];
27
+ const fixes = [];
27
28
  let passed = 0;
28
29
  let total = 0;
29
30
 
@@ -83,8 +84,10 @@ export function validateMetricsConsistency(projectDir, config, guardResults) {
83
84
  const found = parseInt(match[1], 10);
84
85
  if (found !== actuals[key] && found > 0) {
85
86
  warnings.push(
86
- `${relPath} says "${found} ${label}" but actual count is ${actuals[key]}. Update the doc or run \`docguard generate --force\``
87
+ `${relPath} says "${found} ${label}" but actual count is ${actuals[key]}. Fix with \`docguard fix --write\``
87
88
  );
89
+ // Deterministic, surgical token replacement — safe to auto-apply.
90
+ fixes.push({ type: 'replace-count', file: relPath, label, found, actual: actuals[key] });
88
91
  } else {
89
92
  passed++;
90
93
  }
@@ -92,7 +95,7 @@ export function validateMetricsConsistency(projectDir, config, guardResults) {
92
95
  }
93
96
  }
94
97
 
95
- return { errors: [], warnings, passed, total };
98
+ return { errors: [], warnings, passed, total, fixes };
96
99
  }
97
100
 
98
101
  // ── Helpers ──────────────────────────────────────────────────────────────────
@@ -162,6 +165,8 @@ function walkFiles(dir, callback) {
162
165
  } else if (stat.isFile()) {
163
166
  callback(fullPath);
164
167
  }
165
- } catch { /* skip */ }
168
+ } catch (err) {
169
+ console.error(`${c.red}Error reading file or directory: ${err.message}${c.reset}`);
170
+ }
166
171
  }
167
172
  }
@@ -11,6 +11,7 @@
11
11
 
12
12
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
13
13
  import { resolve, join, relative, extname, basename } from 'node:path';
14
+ import { resolveSourceRoots } from '../shared-source.mjs';
14
15
 
15
16
  const IGNORE_DIRS = new Set([
16
17
  'node_modules', '.git', '.next', 'dist', 'build', 'coverage',
@@ -85,7 +86,7 @@ export function validateSchemaSync(projectDir, config) {
85
86
  if (!existsSync(dataModelPath)) {
86
87
  // No DATA-MODEL.md — nothing to sync against
87
88
  // Only warn if we detect schema files
88
- const detectedModels = detectAllModels(projectDir);
89
+ const detectedModels = detectAllModels(projectDir, config);
89
90
  if (detectedModels.length > 0) {
90
91
  results.total++;
91
92
  results.warnings.push(
@@ -99,7 +100,7 @@ export function validateSchemaSync(projectDir, config) {
99
100
  const dataModelContent = readFileSync(dataModelPath, 'utf-8').toLowerCase();
100
101
 
101
102
  // Detect all models/tables across schemas
102
- const detectedModels = detectAllModels(projectDir);
103
+ const detectedModels = detectAllModels(projectDir, config);
103
104
 
104
105
  if (detectedModels.length === 0) {
105
106
  // No schema files found — silently pass
@@ -136,11 +137,11 @@ export function validateSchemaSync(projectDir, config) {
136
137
  /**
137
138
  * Detect all database models/tables across all supported frameworks.
138
139
  */
139
- function detectAllModels(projectDir) {
140
+ function detectAllModels(projectDir, config = {}) {
140
141
  const models = [];
141
142
 
142
143
  for (const detector of SCHEMA_DETECTORS) {
143
- const files = findSchemaFiles(projectDir, detector);
144
+ const files = findSchemaFiles(projectDir, detector, config);
144
145
 
145
146
  for (const filePath of files) {
146
147
  let content;
@@ -171,14 +172,22 @@ function detectAllModels(projectDir) {
171
172
  /**
172
173
  * Find schema files for a given detector configuration.
173
174
  */
174
- function findSchemaFiles(projectDir, detector) {
175
+ function findSchemaFiles(projectDir, detector, config = {}) {
175
176
  const files = [];
176
177
 
177
- for (const searchDir of detector.searchDirs) {
178
- const dir = resolve(projectDir, searchDir);
179
- if (!existsSync(dir)) continue;
180
-
181
- scanSchemaDir(dir, detector.filePattern, files);
178
+ // Monorepo-aware: resolve each searchDir against the project root AND every
179
+ // configured source root (config.sourceRoot + workspaces), so schemas under
180
+ // e.g. backend/src/models are found — not just root-relative paths.
181
+ const bases = [resolve(projectDir), ...resolveSourceRoots(projectDir, config)];
182
+ const seenDirs = new Set();
183
+
184
+ for (const base of bases) {
185
+ for (const searchDir of detector.searchDirs) {
186
+ const dir = resolve(base, searchDir);
187
+ if (seenDirs.has(dir) || !existsSync(dir)) continue;
188
+ seenDirs.add(dir);
189
+ scanSchemaDir(dir, detector.filePattern, files);
190
+ }
182
191
  }
183
192
 
184
193
  return files;