docguard-cli 0.9.10 → 0.10.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 (38) hide show
  1. package/README.md +3 -2
  2. package/cli/commands/diagnose.mjs +23 -15
  3. package/cli/commands/diff.mjs +110 -137
  4. package/cli/commands/fix.mjs +39 -3
  5. package/cli/commands/generate.mjs +57 -27
  6. package/cli/commands/guard.mjs +45 -24
  7. package/cli/commands/score.mjs +24 -2
  8. package/cli/docguard.mjs +0 -0
  9. package/cli/scanners/api-doc.mjs +122 -0
  10. package/cli/scanners/doc-tools.mjs +1 -1
  11. package/cli/scanners/routes.mjs +45 -32
  12. package/cli/shared-ignore.mjs +43 -0
  13. package/cli/shared-source.mjs +247 -0
  14. package/cli/validators/api-surface.mjs +179 -0
  15. package/cli/validators/architecture.mjs +4 -3
  16. package/cli/validators/changelog.mjs +42 -2
  17. package/cli/validators/doc-quality.mjs +3 -2
  18. package/cli/validators/docs-coverage.mjs +9 -14
  19. package/cli/validators/docs-diff.mjs +128 -85
  20. package/cli/validators/docs-sync.mjs +30 -24
  21. package/cli/validators/drift.mjs +6 -2
  22. package/cli/validators/environment.mjs +43 -3
  23. package/cli/validators/freshness.mjs +4 -3
  24. package/cli/validators/metadata-sync.mjs +11 -6
  25. package/cli/validators/metrics-consistency.mjs +4 -2
  26. package/cli/validators/schema-sync.mjs +19 -10
  27. package/cli/validators/security.mjs +20 -7
  28. package/cli/validators/structure.mjs +8 -1
  29. package/cli/validators/test-spec.mjs +26 -17
  30. package/cli/validators/todo-tracking.mjs +21 -8
  31. package/cli/validators/traceability.mjs +61 -36
  32. package/commands/docguard.guard.md +5 -4
  33. package/docs/quickstart.md +1 -1
  34. package/extensions/spec-kit-docguard/README.md +1 -1
  35. package/extensions/spec-kit-docguard/commands/guard.md +6 -5
  36. package/extensions/spec-kit-docguard/skills/docguard-guard/SKILL.md +3 -2
  37. package/package.json +1 -1
  38. package/templates/commands/docguard.guard.md +3 -3
@@ -11,7 +11,8 @@
11
11
 
12
12
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
13
13
  import { resolve, join, extname, basename, relative } from 'node:path';
14
- import { shouldIgnore, buildIgnoreFilter } from '../shared-ignore.mjs';
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,105 +118,113 @@ 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
141
124
  * scanning standard test directories.
142
- * Always ignores node_modules via shared ignore filter.
125
+ * Always ignores node_modules via globMatch().
143
126
  */
144
127
  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
- const codeTests = new Set();
157
-
158
- // Use testPatterns from config if available
159
- const testPatterns = config?.testPatterns || [];
160
- if (testPatterns.length > 0) {
161
- // Use configured patterns to find test files
162
- const patternFilter = buildIgnoreFilter(testPatterns.map(p => {
163
- // Invert the pattern: we WANT files matching these patterns
164
- return p;
165
- }));
166
- // Walk the project and collect matching test files
167
- const allTestFiles = getTestFilesFromPatterns(dir, testPatterns, config);
168
- for (const f of allTestFiles) {
169
- codeTests.add(f);
170
- }
171
- } else {
172
- // Fall back to standard test directories
173
- const testDirs = ['tests', 'test', '__tests__', 'spec', 'e2e'];
174
- for (const td of testDirs) {
175
- const testDir = resolve(dir, td);
176
- if (!existsSync(testDir)) continue;
177
- const files = getFilesRecursive(testDir, config);
178
- for (const f of files) {
179
- codeTests.add(f.replace(dir + '/', ''));
180
- }
181
- }
182
- }
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);
183
148
 
184
149
  if (docTests.size === 0 && codeTests.size === 0) return null;
185
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
+
186
171
  return {
187
172
  title: 'Test Files',
188
- onlyInDocs: [...docTests].filter(t => !codeTests.has(t)),
189
- 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))),
190
175
  };
191
176
  }
192
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
+
193
214
  /**
194
215
  * Find test files matching configured testPatterns.
195
- * Walks the project tree, skipping node_modules and ignored dirs.
216
+ * Uses globMatch() for pattern matching always excludes node_modules.
217
+ * Results are deduplicated via Set (handles overlapping patterns).
196
218
  */
197
- function getTestFilesFromPatterns(dir, patterns, config) {
198
- const results = [];
199
- const testFileRegex = /\.(test|spec)\.(mjs|cjs|[jt]sx?)$/;
219
+ export function getTestFilesFromPatterns(dir, patterns, config) {
220
+ const results = new Set();
200
221
 
201
222
  function walk(currentDir) {
202
223
  let entries;
203
224
  try { entries = readdirSync(currentDir); } catch { return; }
204
225
 
205
226
  for (const entry of entries) {
227
+ // Skip node_modules and other ignored dirs at directory level (fast path)
206
228
  if (IGNORE_DIRS.has(entry) || entry.startsWith('.')) continue;
207
229
  const fullPath = join(currentDir, entry);
208
230
  try {
@@ -211,15 +233,11 @@ function getTestFilesFromPatterns(dir, patterns, config) {
211
233
  walk(fullPath);
212
234
  } else if (stat.isFile()) {
213
235
  const relPath = relative(dir, fullPath);
214
- // Skip files in ignored paths
236
+ // Skip files in globally ignored paths
215
237
  if (config && shouldIgnore(relPath, config)) continue;
216
- // Check if it matches test file naming patterns
217
- if (testFileRegex.test(entry) || /__(tests|test)__/.test(relPath)) {
218
- // Check if it matches any of the configured test patterns
219
- const patternFilter = buildIgnoreFilter(patterns);
220
- if (patternFilter(relPath)) {
221
- results.push(relPath);
222
- }
238
+ // Use globMatch for positive pattern matching (rejects node_modules internally)
239
+ if (globMatch(relPath, patterns)) {
240
+ results.add(relPath);
223
241
  }
224
242
  }
225
243
  } catch { /* skip */ }
@@ -227,7 +245,32 @@ function getTestFilesFromPatterns(dir, patterns, config) {
227
245
  }
228
246
 
229
247
  walk(dir);
230
- return results;
248
+ return [...results];
249
+ }
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;
231
274
  }
232
275
 
233
276
  function getFilesRecursive(dir, config) {
@@ -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',
@@ -26,14 +27,18 @@ export function validateMetadataSync(projectDir, config) {
26
27
  let total = 0;
27
28
 
28
29
  // ── Get source of truth: package.json version ──
30
+ // Prefer the root package version; in a monorepo where the root is just a
31
+ // workspace manifest with no version, fall back to a source-root package.
29
32
  const pkgPath = resolve(projectDir, 'package.json');
30
- if (!existsSync(pkgPath)) {
31
- return { errors: [], warnings, passed: 0, total: 0 };
33
+ let currentVersion = null;
34
+ if (existsSync(pkgPath)) {
35
+ try { currentVersion = JSON.parse(readFileSync(pkgPath, 'utf-8')).version || null; } catch { /* ignore */ }
36
+ }
37
+ if (!currentVersion) {
38
+ for (const { pkg } of collectPackageJsons(projectDir, config)) {
39
+ if (pkg.version) { currentVersion = pkg.version; break; }
40
+ }
32
41
  }
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
42
  if (!currentVersion) return { errors: [], warnings, passed: 0, total: 0 };
38
43
 
39
44
  // Parse into components for smart comparison
@@ -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',
@@ -162,6 +162,8 @@ function walkFiles(dir, callback) {
162
162
  } else if (stat.isFile()) {
163
163
  callback(fullPath);
164
164
  }
165
- } catch { /* skip */ }
165
+ } catch (err) {
166
+ console.error(`${c.red}Error reading file or directory: ${err.message}${c.reset}`);
167
+ }
166
168
  }
167
169
  }
@@ -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;