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
@@ -74,3 +74,46 @@ export function shouldIgnore(relPath, config, validatorKey) {
74
74
 
75
75
  return false;
76
76
  }
77
+
78
+ /**
79
+ * Convert a glob pattern to a RegExp for POSITIVE matching.
80
+ * Unlike globToRegex (used for ignore filtering), this anchors the match
81
+ * to the full relative path from the project root.
82
+ *
83
+ * Supports: * (any chars except /), ** (any path segments), . (literal dot).
84
+ *
85
+ * @param {string} pattern - Glob pattern (e.g., "backend/**\/__tests__/**\/*.test.ts")
86
+ * @returns {RegExp}
87
+ */
88
+ function globToMatchRegex(pattern) {
89
+ // Normalize: replace **/ with a placeholder that means "zero or more path segments"
90
+ let escaped = pattern
91
+ .replace(/\./g, '\\.')
92
+ .replace(/\*\*\//g, '§STARSTAR§') // **/ → zero-or-more segments
93
+ .replace(/\*\*/g, '.*') // standalone ** → any chars
94
+ .replace(/\*/g, '[^/]*') // single * → any chars except /
95
+ .replace(/§STARSTAR§/g, '(.*/)?'); // **/ → optional path prefix
96
+ return new RegExp(`^${escaped}$`);
97
+ }
98
+
99
+ /**
100
+ * Check if a relative path matches ANY of the given glob patterns.
101
+ * Purpose-built for POSITIVE matching (e.g., "is this a test file?").
102
+ *
103
+ * ALWAYS rejects paths containing node_modules at any depth.
104
+ * This is the correct function for test file discovery — do NOT use
105
+ * buildIgnoreFilter() for this purpose.
106
+ *
107
+ * @param {string} relPath - Relative path from project root
108
+ * @param {string[]} patterns - Array of glob patterns to match against
109
+ * @returns {boolean} - true if path matches a pattern AND is not in node_modules
110
+ */
111
+ export function globMatch(relPath, patterns) {
112
+ if (!relPath || !patterns || patterns.length === 0) return false;
113
+
114
+ // Always reject paths containing node_modules at any depth
115
+ if (/(?:^|[/\\])node_modules(?:[/\\]|$)/.test(relPath)) return false;
116
+
117
+ const regexes = patterns.map(p => globToMatchRegex(p));
118
+ return regexes.some(r => r.test(relPath));
119
+ }
@@ -0,0 +1,247 @@
1
+ /**
2
+ * Shared Source Resolution — Monorepo-aware source discovery.
3
+ *
4
+ * Single source of truth for "where is the code?" so validators and
5
+ * scanners stop assuming a single package rooted at projectDir.
6
+ *
7
+ * Honors:
8
+ * - config.sourceRoot (string | string[], e.g. "backend/src")
9
+ * - root package.json workspaces ("packages/*", { packages: [...] })
10
+ * - pnpm-workspace.yaml (packages:)
11
+ * - turbo.json (presence → trust package.json workspaces)
12
+ *
13
+ * Zero NPM dependencies — pure Node.js built-ins only.
14
+ */
15
+
16
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
17
+ import { resolve, join, dirname, relative, extname } from 'node:path';
18
+ import { shouldIgnore } from './shared-ignore.mjs';
19
+
20
+ const IGNORE_DIRS = new Set([
21
+ 'node_modules', '.git', '.next', 'dist', 'build',
22
+ 'coverage', '.cache', '__pycache__', '.venv', 'vendor', '.turbo',
23
+ ]);
24
+
25
+ const CODE_EXTENSIONS = new Set([
26
+ '.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx',
27
+ '.py', '.java', '.go', '.rs', '.rb', '.php',
28
+ ]);
29
+
30
+ /** Normalize config.sourceRoot into an array of relative paths. */
31
+ function sourceRootList(config) {
32
+ const sr = config?.sourceRoot;
33
+ if (!sr) return [];
34
+ return Array.isArray(sr) ? sr : [sr];
35
+ }
36
+
37
+ function safeReadJson(path) {
38
+ try { return JSON.parse(readFileSync(path, 'utf-8')); } catch { return null; }
39
+ }
40
+
41
+ /**
42
+ * Expand a workspace glob (e.g. "packages/*") into concrete directories
43
+ * that contain a package.json. Only the trailing single-level "/*" glob is
44
+ * expanded — explicit paths are returned as-is when they exist.
45
+ */
46
+ function expandWorkspaceGlob(projectDir, pattern) {
47
+ const dirs = [];
48
+ if (pattern.endsWith('/*')) {
49
+ const base = resolve(projectDir, pattern.slice(0, -2));
50
+ if (existsSync(base)) {
51
+ let entries;
52
+ try { entries = readdirSync(base, { withFileTypes: true }); } catch { return dirs; }
53
+ for (const e of entries) {
54
+ if (!e.isDirectory() || IGNORE_DIRS.has(e.name) || e.name.startsWith('.')) continue;
55
+ const full = join(base, e.name);
56
+ if (existsSync(join(full, 'package.json'))) dirs.push(full);
57
+ }
58
+ }
59
+ } else {
60
+ const full = resolve(projectDir, pattern);
61
+ if (existsSync(full)) dirs.push(full);
62
+ }
63
+ return dirs;
64
+ }
65
+
66
+ /**
67
+ * Discover workspace package directories declared in the monorepo manifests.
68
+ * @returns {string[]} absolute directories
69
+ */
70
+ export function getWorkspaceDirs(projectDir) {
71
+ const patterns = [];
72
+
73
+ // 1. root package.json "workspaces"
74
+ const rootPkg = safeReadJson(resolve(projectDir, 'package.json'));
75
+ if (rootPkg?.workspaces) {
76
+ const ws = Array.isArray(rootPkg.workspaces)
77
+ ? rootPkg.workspaces
78
+ : (rootPkg.workspaces.packages || []);
79
+ patterns.push(...ws);
80
+ }
81
+
82
+ // 2. pnpm-workspace.yaml — extract simple " - 'packages/*'" entries
83
+ const pnpmPath = resolve(projectDir, 'pnpm-workspace.yaml');
84
+ if (existsSync(pnpmPath)) {
85
+ const content = readFileSync(pnpmPath, 'utf-8');
86
+ const re = /^\s*-\s*['"]?([^'"\n]+?)['"]?\s*$/gm;
87
+ let m;
88
+ let inPackages = false;
89
+ for (const line of content.split('\n')) {
90
+ if (/^packages:/.test(line.trim())) { inPackages = true; continue; }
91
+ if (inPackages) {
92
+ const mm = line.match(/^\s*-\s*['"]?([^'"\n]+?)['"]?\s*$/);
93
+ if (mm) patterns.push(mm[1]);
94
+ else if (line.trim() && !line.startsWith(' ')) inPackages = false;
95
+ }
96
+ }
97
+ void re; void m;
98
+ }
99
+
100
+ const dirs = new Set();
101
+ for (const p of patterns) {
102
+ for (const d of expandWorkspaceGlob(projectDir, p)) dirs.add(d);
103
+ }
104
+ return [...dirs];
105
+ }
106
+
107
+ /** Walk up from a directory to find the nearest enclosing package.json dir. */
108
+ function nearestPackageDir(projectDir, startDir) {
109
+ let cur = startDir;
110
+ const root = resolve(projectDir);
111
+ while (cur && cur.startsWith(root)) {
112
+ if (existsSync(join(cur, 'package.json'))) return cur;
113
+ const parent = dirname(cur);
114
+ if (parent === cur) break;
115
+ cur = parent;
116
+ }
117
+ return null;
118
+ }
119
+
120
+ /**
121
+ * Resolve the set of directories that should be treated as source roots
122
+ * for scanning (routes, env usage, source files).
123
+ *
124
+ * Precedence: explicit config.sourceRoot → workspace packages → conventional
125
+ * roots that exist on disk. projectDir is always included as a fallback so
126
+ * single-package repos keep working.
127
+ *
128
+ * @returns {string[]} absolute directories, de-duplicated, existing only
129
+ */
130
+ export function resolveSourceRoots(projectDir, config = {}) {
131
+ const out = new Set();
132
+ const add = (abs) => { if (abs && existsSync(abs)) out.add(abs); };
133
+
134
+ // 1. explicit sourceRoot(s)
135
+ for (const sr of sourceRootList(config)) add(resolve(projectDir, sr));
136
+
137
+ // 2. workspace package dirs
138
+ for (const d of getWorkspaceDirs(projectDir)) add(d);
139
+
140
+ // 3. conventional roots (only those that exist)
141
+ const conventional = ['src', 'app', 'lib', 'server', 'api', 'backend/src', 'backend', 'cli'];
142
+ for (const cr of conventional) add(resolve(projectDir, cr));
143
+
144
+ // 4. Fall back to the project root ONLY when nothing else resolved. Adding it
145
+ // unconditionally would pull in examples/, scripts/, and fixtures, producing
146
+ // false "in code" signals for env vars and routes.
147
+ if (out.size === 0) out.add(resolve(projectDir));
148
+
149
+ return [...out];
150
+ }
151
+
152
+ /**
153
+ * Collect every relevant package.json across the monorepo:
154
+ * root, the nearest package for each declared sourceRoot, and workspace packages.
155
+ * @returns {Array<{ dir: string, pkg: object }>}
156
+ */
157
+ export function collectPackageJsons(projectDir, config = {}) {
158
+ const dirs = new Set([resolve(projectDir)]);
159
+
160
+ for (const sr of sourceRootList(config)) {
161
+ const npd = nearestPackageDir(projectDir, resolve(projectDir, sr));
162
+ if (npd) dirs.add(npd);
163
+ }
164
+ for (const d of getWorkspaceDirs(projectDir)) dirs.add(d);
165
+
166
+ const result = [];
167
+ for (const dir of dirs) {
168
+ const pkg = safeReadJson(join(dir, 'package.json'));
169
+ if (pkg) result.push({ dir, pkg });
170
+ }
171
+ return result;
172
+ }
173
+
174
+ /** Detect whether the project ships a Docker setup. */
175
+ export function detectDocker(projectDir, config = {}) {
176
+ const candidates = ['Dockerfile', 'docker-compose.yml', 'docker-compose.yaml', '.dockerignore'];
177
+ const root = resolve(projectDir);
178
+ const dirs = new Set([root]);
179
+
180
+ // Walk every ancestor from each sourceRoot up to the project root — a
181
+ // Dockerfile commonly sits at the package root (e.g. backend/Dockerfile).
182
+ for (const sr of sourceRootList(config)) {
183
+ let cur = resolve(projectDir, sr);
184
+ while (cur && cur.startsWith(root)) {
185
+ dirs.add(cur);
186
+ const parent = dirname(cur);
187
+ if (parent === cur) break;
188
+ cur = parent;
189
+ }
190
+ }
191
+ for (const d of getWorkspaceDirs(projectDir)) dirs.add(d);
192
+
193
+ for (const dir of dirs) {
194
+ for (const f of candidates) {
195
+ if (existsSync(join(dir, f))) return true;
196
+ }
197
+ }
198
+ return false;
199
+ }
200
+
201
+ /**
202
+ * Grep source files under the resolved source roots for environment variable
203
+ * usage in both the Node (process dot env) and Vite (import meta env) styles,
204
+ * including bracket access.
205
+ * @returns {Set<string>} variable names referenced in code
206
+ */
207
+ export function grepEnvUsage(projectDir, config = {}) {
208
+ const names = new Set();
209
+ const roots = resolveSourceRoots(projectDir, config);
210
+ const seen = new Set();
211
+
212
+ const patterns = [
213
+ /process\.env\.([A-Z][A-Z0-9_]+)/g,
214
+ /process\.env\[\s*['"]([A-Z][A-Z0-9_]+)['"]\s*\]/g,
215
+ /import\.meta\.env\.([A-Z][A-Z0-9_]+)/g,
216
+ ];
217
+
218
+ const visit = (filePath) => {
219
+ if (seen.has(filePath)) return;
220
+ seen.add(filePath);
221
+ if (!CODE_EXTENSIONS.has(extname(filePath))) return;
222
+ const rel = relative(projectDir, filePath);
223
+ if (shouldIgnore(rel, config)) return;
224
+ let content;
225
+ try { content = readFileSync(filePath, 'utf-8'); } catch { return; }
226
+ if (!content.includes('env')) return;
227
+ for (const re of patterns) {
228
+ let m;
229
+ const rx = new RegExp(re.source, 'g');
230
+ while ((m = rx.exec(content)) !== null) names.add(m[1]);
231
+ }
232
+ };
233
+
234
+ const walk = (dir) => {
235
+ let entries;
236
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
237
+ for (const e of entries) {
238
+ if (IGNORE_DIRS.has(e.name) || e.name.startsWith('.')) continue;
239
+ const full = join(dir, e.name);
240
+ if (e.isDirectory()) walk(full);
241
+ else if (e.isFile()) visit(full);
242
+ }
243
+ };
244
+
245
+ for (const root of roots) walk(root);
246
+ return names;
247
+ }
@@ -0,0 +1,179 @@
1
+ /**
2
+ * API-Surface Validator — Detects drift between the documented API surface
3
+ * (docs-canonical/API-REFERENCE.md) and the project's actual API surface.
4
+ *
5
+ * This is the check that catches a deleted endpoint still being documented —
6
+ * the exact class of drift DocGuard exists to prevent.
7
+ *
8
+ * Actual surface, in order of confidence:
9
+ * 1. OpenAPI spec (sourceRoot/workspace-aware) → high confidence
10
+ * 2. Monorepo-aware code route scan → lower confidence (warn only)
11
+ *
12
+ * Severity policy:
13
+ * - documented-but-absent → ERROR when confirmed by an OpenAPI spec
14
+ * (docs lie about a real endpoint → fail the build);
15
+ * downgraded to WARNING on heuristic code-scan only.
16
+ * - present-but-undocumented → WARNING (a real route missing from the docs).
17
+ *
18
+ * Returns { errors, warnings, passed, total } like the other validators.
19
+ */
20
+
21
+ import { existsSync, readFileSync } from 'node:fs';
22
+ import { resolve, dirname, join } from 'node:path';
23
+ import { detectOpenAPI } from '../scanners/doc-tools.mjs';
24
+ import { scanRoutesDeep } from '../scanners/routes.mjs';
25
+ import { parseApiReferenceDoc, compareEndpoints } from '../scanners/api-doc.mjs';
26
+ import { collectPackageJsons, getWorkspaceDirs } from '../shared-source.mjs';
27
+
28
+ const MAX_REPORTED = 15;
29
+
30
+ /** Walk up from a dir to the nearest enclosing package.json directory. */
31
+ function nearestPackageDir(projectDir, startDir) {
32
+ let cur = startDir;
33
+ const root = resolve(projectDir);
34
+ while (cur && cur.startsWith(root)) {
35
+ if (existsSync(join(cur, 'package.json'))) return cur;
36
+ const parent = dirname(cur);
37
+ if (parent === cur) break;
38
+ cur = parent;
39
+ }
40
+ return null;
41
+ }
42
+
43
+ /**
44
+ * Build an ordered list of directories to search for an OpenAPI spec.
45
+ * The spec under the configured sourceRoot's package takes precedence over a
46
+ * (possibly stale) copy at the repo root — monorepos frequently keep a
47
+ * divergent root copy.
48
+ */
49
+ function orderedSpecDirs(projectDir, config) {
50
+ const ordered = [];
51
+ const seen = new Set();
52
+ const add = (d) => { if (d && !seen.has(d)) { seen.add(d); ordered.push(d); } };
53
+
54
+ const srList = config?.sourceRoot
55
+ ? (Array.isArray(config.sourceRoot) ? config.sourceRoot : [config.sourceRoot])
56
+ : [];
57
+ for (const sr of srList) {
58
+ const abs = resolve(projectDir, sr);
59
+ add(nearestPackageDir(projectDir, abs));
60
+ add(abs);
61
+ }
62
+ for (const d of getWorkspaceDirs(projectDir)) add(d);
63
+ add(resolve(projectDir)); // root copy last — lowest priority
64
+ return ordered;
65
+ }
66
+
67
+ /**
68
+ * Locate the authoritative OpenAPI spec across the monorepo.
69
+ * Returns the FIRST spec found in priority order (sourceRoot first).
70
+ */
71
+ function findOpenApiEndpoints(projectDir, config) {
72
+ for (const dir of orderedSpecDirs(projectDir, config)) {
73
+ const oa = detectOpenAPI(dir);
74
+ if (oa.found && oa.endpoints?.length) {
75
+ const endpoints = oa.endpoints.filter(e => e && e.method && e.path);
76
+ if (endpoints.length) return { endpoints, path: oa.path };
77
+ }
78
+ }
79
+ return null;
80
+ }
81
+
82
+ function detectFramework(projectDir, config) {
83
+ const deps = {};
84
+ for (const { pkg } of collectPackageJsons(projectDir, config)) {
85
+ Object.assign(deps, pkg.dependencies || {}, pkg.devDependencies || {});
86
+ }
87
+ if (deps.next) return 'Next.js';
88
+ if (deps.express) return 'Express';
89
+ if (deps.fastify) return 'Fastify';
90
+ if (deps.hono) return 'Hono';
91
+ return '';
92
+ }
93
+
94
+ /**
95
+ * Resolve the actual API surface.
96
+ * @returns {{ endpoints: Array<{method,path}>, confidence: 'spec'|'code'|'none', source: string }}
97
+ */
98
+ export function resolveApiSurface(projectDir, config) {
99
+ const spec = findOpenApiEndpoints(projectDir, config);
100
+ if (spec) {
101
+ return {
102
+ endpoints: spec.endpoints.map(e => ({ method: e.method, path: e.path })),
103
+ confidence: 'spec',
104
+ source: spec.path,
105
+ };
106
+ }
107
+
108
+ // Fallback: monorepo-aware code route scan
109
+ const framework = detectFramework(projectDir, config);
110
+ const routes = scanRoutesDeep(projectDir, { framework }, { openapi: { found: false } }, { config });
111
+ if (routes.length) {
112
+ return {
113
+ endpoints: routes.map(r => ({ method: r.method, path: r.path })),
114
+ confidence: 'code',
115
+ source: 'code-scan',
116
+ };
117
+ }
118
+
119
+ return { endpoints: [], confidence: 'none', source: null };
120
+ }
121
+
122
+ export function validateApiSurface(projectDir, config) {
123
+ const errors = [];
124
+ const warnings = [];
125
+
126
+ const apiDocPath = resolve(projectDir, 'docs-canonical/API-REFERENCE.md');
127
+ if (!existsSync(apiDocPath)) {
128
+ // No API reference doc → nothing to validate (not applicable).
129
+ return { errors, warnings, passed: 0, total: 0 };
130
+ }
131
+
132
+ const documented = parseApiReferenceDoc(readFileSync(apiDocPath, 'utf-8'));
133
+ const surface = resolveApiSurface(projectDir, config);
134
+
135
+ // If we cannot determine the actual surface, do not fabricate drift.
136
+ if (surface.confidence === 'none' || documented.length === 0) {
137
+ return { errors, warnings, passed: documented.length, total: documented.length };
138
+ }
139
+
140
+ const { documentedButAbsent, presentButUndocumented, matched } =
141
+ compareEndpoints(documented, surface.endpoints);
142
+
143
+ const total = matched.length + documentedButAbsent.length + presentButUndocumented.length;
144
+ const passed = matched.length;
145
+
146
+ const trim = (arr) => {
147
+ const shown = arr.slice(0, MAX_REPORTED);
148
+ const extra = arr.length - shown.length;
149
+ return { shown, extra };
150
+ };
151
+
152
+ // documented-but-absent
153
+ if (documentedButAbsent.length) {
154
+ const { shown, extra } = trim(documentedButAbsent);
155
+ for (const e of shown) {
156
+ const msg = `Documented endpoint not found in code: ${e.method} ${e.path} (docs-canonical/API-REFERENCE.md)`;
157
+ if (surface.confidence === 'spec') errors.push(msg);
158
+ else warnings.push(`${msg} [code-scan — verify]`);
159
+ }
160
+ if (extra > 0) {
161
+ const tail = `…and ${extra} more documented endpoint(s) not found in code`;
162
+ if (surface.confidence === 'spec') errors.push(tail);
163
+ else warnings.push(tail);
164
+ }
165
+ }
166
+
167
+ // present-but-undocumented
168
+ if (presentButUndocumented.length) {
169
+ const { shown, extra } = trim(presentButUndocumented);
170
+ for (const e of shown) {
171
+ warnings.push(`Undocumented endpoint in code: ${e.method} ${e.path} — add it to docs-canonical/API-REFERENCE.md`);
172
+ }
173
+ if (extra > 0) {
174
+ warnings.push(`…and ${extra} more undocumented endpoint(s) in code`);
175
+ }
176
+ }
177
+
178
+ return { errors, warnings, passed, total };
179
+ }
@@ -58,10 +58,11 @@ export function validateArchitecture(projectDir, config) {
58
58
  }
59
59
  }
60
60
 
61
- // ── 5. Report import stats ──
61
+ // ── 5. No boundaries declared and no circular deps to check → not applicable.
62
+ // (Previously this returned a fake 1/1 pass, rendering a confident green ✅
63
+ // for projects that declared no layer boundaries — it validated nothing.)
62
64
  if (results.total === 0) {
63
- results.total = 1;
64
- results.passed = 1;
65
+ results.note = 'no layer boundaries declared in ARCHITECTURE.md';
65
66
  }
66
67
 
67
68
  return results;
@@ -1,9 +1,28 @@
1
1
  /**
2
- * Changelog Validator — Checks CHANGELOG.md has [Unreleased] section
2
+ * Changelog Validator — Checks CHANGELOG.md has an [Unreleased] section,
3
+ * follows Keep a Changelog format, and (per STANDARD.md) that staged code
4
+ * changes are accompanied by a CHANGELOG update.
3
5
  */
4
6
 
5
7
  import { existsSync, readFileSync } from 'node:fs';
6
- import { resolve } from 'node:path';
8
+ import { resolve, basename } from 'node:path';
9
+ import { execFileSync } from 'node:child_process';
10
+
11
+ const CODE_EXT_RE = /\.(ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|rb|php|cs|kt|swift)$/;
12
+
13
+ /** Return staged file paths (relative to repo root), or null if git is unavailable. */
14
+ function getStagedFiles(projectDir) {
15
+ try {
16
+ const out = execFileSync('git', ['diff', '--cached', '--name-only'], {
17
+ cwd: projectDir,
18
+ encoding: 'utf-8',
19
+ stdio: ['ignore', 'pipe', 'ignore'],
20
+ });
21
+ return out.split('\n').map(s => s.trim()).filter(Boolean);
22
+ } catch {
23
+ return null; // not a git repo, or git not installed
24
+ }
25
+ }
7
26
 
8
27
  export function validateChangelog(projectDir, config) {
9
28
  const results = { name: 'changelog', errors: [], warnings: [], passed: 0, total: 0 };
@@ -35,5 +54,26 @@ export function validateChangelog(projectDir, config) {
35
54
  );
36
55
  }
37
56
 
57
+ // Per STANDARD.md: if there are staged CODE changes, CHANGELOG.md should be
58
+ // updated in the same commit. Only assessed when git is available AND there
59
+ // are staged code changes (otherwise the check is not applicable).
60
+ const staged = getStagedFiles(projectDir);
61
+ if (staged && staged.length > 0) {
62
+ const changelogName = basename(config.requiredFiles.changelog);
63
+ const stagedCode = staged.filter(f => CODE_EXT_RE.test(f));
64
+ const changelogStaged = staged.some(f => basename(f) === changelogName);
65
+
66
+ if (stagedCode.length > 0) {
67
+ results.total++;
68
+ if (changelogStaged) {
69
+ results.passed++;
70
+ } else {
71
+ results.warnings.push(
72
+ `${stagedCode.length} code file(s) staged but ${changelogName} is not — add a CHANGELOG entry for this change`
73
+ );
74
+ }
75
+ }
76
+ }
77
+
38
78
  return results;
39
79
  }
@@ -23,7 +23,7 @@
23
23
 
24
24
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
25
25
  import { resolve, join, extname } from 'node:path';
26
- import { execSync } from 'node:child_process';
26
+ import { execSync, execFileSync } from 'node:child_process';
27
27
 
28
28
  // ──── Metric Thresholds ────
29
29
  // These define "good" vs "warning" boundaries for each metric.
@@ -464,9 +464,10 @@ function findUnderstandingCli() {
464
464
  */
465
465
  function runUnderstandingDeepScan(filePath) {
466
466
  try {
467
- const result = execSync(`understanding analyze "${filePath}" --enhanced --json 2>/dev/null`, {
467
+ const result = execFileSync('understanding', ['analyze', filePath, '--enhanced', '--json'], {
468
468
  encoding: 'utf-8',
469
469
  timeout: 10000,
470
+ stdio: ['pipe', 'pipe', 'ignore'],
470
471
  });
471
472
  return JSON.parse(result);
472
473
  } catch {
@@ -15,6 +15,7 @@
15
15
 
16
16
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
17
17
  import { resolve, join, relative, basename, extname } from 'node:path';
18
+ import { resolveSourceRoots } from '../shared-source.mjs';
18
19
 
19
20
  const IGNORE_DIRS = new Set([
20
21
  'node_modules', '.git', '.next', 'dist', 'build', 'coverage',
@@ -62,13 +63,13 @@ export function validateDocsCoverage(projectDir, config) {
62
63
  warnings.push(...binChecks.warnings);
63
64
 
64
65
  // ── Check 3: Source directory structure matches ARCHITECTURE.md ──
65
- const dirChecks = checkSourceDirs(projectDir, allDocContent);
66
+ const dirChecks = checkSourceDirs(projectDir, allDocContent, config);
66
67
  total += dirChecks.total;
67
68
  passed += dirChecks.passed;
68
69
  warnings.push(...dirChecks.warnings);
69
70
 
70
71
  // ── Check 4: Config filenames referenced in source code but not documented ──
71
- const codeConfigChecks = checkCodeReferencedConfigs(projectDir, allDocContent);
72
+ const codeConfigChecks = checkCodeReferencedConfigs(projectDir, allDocContent, config);
72
73
  total += codeConfigChecks.total;
73
74
  passed += codeConfigChecks.passed;
74
75
  warnings.push(...codeConfigChecks.warnings);
@@ -160,7 +161,7 @@ function checkPackageBins(projectDir, allDocContent) {
160
161
  /**
161
162
  * Check 3: Source directories are referenced in ARCHITECTURE.md.
162
163
  */
163
- function checkSourceDirs(projectDir, allDocContent) {
164
+ function checkSourceDirs(projectDir, allDocContent, config = {}) {
164
165
  const warnings = [];
165
166
  let passed = 0;
166
167
  let total = 0;
@@ -172,12 +173,10 @@ function checkSourceDirs(projectDir, allDocContent) {
172
173
  try { archContent = readFileSync(archPath, 'utf-8'); } catch { return { warnings, passed, total }; }
173
174
 
174
175
  const lowerArchContent = archContent.toLowerCase();
175
- const sourceRoots = ['src', 'lib', 'app', 'cli', 'server', 'api'];
176
-
177
- for (const root of sourceRoots) {
178
- const rootDir = resolve(projectDir, root);
179
- if (!existsSync(rootDir)) continue;
180
176
 
177
+ // Monorepo-aware: honor config.sourceRoot + workspaces instead of a hardcoded list.
178
+ for (const rootDir of resolveSourceRoots(projectDir, config)) {
179
+ const root = relative(projectDir, rootDir) || basename(rootDir);
181
180
  let entries;
182
181
  try { entries = readdirSync(rootDir); } catch { continue; }
183
182
 
@@ -212,7 +211,7 @@ function checkSourceDirs(projectDir, allDocContent) {
212
211
  * patterns — these are configs the project USES. Avoids matching config names
213
212
  * sitting in arrays (scan patterns for detecting other projects' configs).
214
213
  */
215
- function checkCodeReferencedConfigs(projectDir, allDocContent) {
214
+ function checkCodeReferencedConfigs(projectDir, allDocContent, config = {}) {
216
215
  const warnings = [];
217
216
  let passed = 0;
218
217
  let total = 0;
@@ -224,8 +223,6 @@ function checkCodeReferencedConfigs(projectDir, allDocContent) {
224
223
  // resolve(dir, '.docguardignore'), existsSync('.env.example'), readFileSync('vitest.config.ts')
225
224
  const usageRegex = /(?:resolve|join|existsSync|readFileSync|accessSync|writeFileSync)\s*\([^)]*['"`]([^'"`\n]{2,})['"`]/g;
226
225
 
227
- const sourceRoots = ['src', 'lib', 'cli', 'bin', 'server', 'api', 'app'];
228
-
229
226
  const scanFile = (filePath) => {
230
227
  const ext = extname(filePath);
231
228
  if (!['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx'].includes(ext)) return;
@@ -247,9 +244,7 @@ function checkCodeReferencedConfigs(projectDir, allDocContent) {
247
244
  }
248
245
  };
249
246
 
250
- for (const root of sourceRoots) {
251
- const rootDir = resolve(projectDir, root);
252
- if (!existsSync(rootDir)) continue;
247
+ for (const rootDir of resolveSourceRoots(projectDir, config)) {
253
248
  walkFiles(rootDir, scanFile);
254
249
  }
255
250