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
@@ -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,254 @@
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
+ * Also flags MULTIPLE OpenAPI specs in the repo that disagree on their endpoint
19
+ * set (e.g. a served spec and a generated spec that have diverged).
20
+ *
21
+ * Returns { errors, warnings, passed, total, fixes, authoritativeSpec } — the
22
+ * `fixes` array lists deterministic remove-endpoint actions that
23
+ * `docguard fix --write` can apply without an LLM.
24
+ */
25
+
26
+ import { existsSync, readFileSync } from 'node:fs';
27
+ import { resolve, dirname, join } from 'node:path';
28
+ import { detectOpenAPI } from '../scanners/doc-tools.mjs';
29
+ import { scanRoutesDeep } from '../scanners/routes.mjs';
30
+ import { parseApiReferenceDoc, compareEndpoints, endpointKey } from '../scanners/api-doc.mjs';
31
+ import { collectPackageJsons, getWorkspaceDirs } from '../shared-source.mjs';
32
+
33
+ const MAX_REPORTED = 15;
34
+ const API_DOC = 'docs-canonical/API-REFERENCE.md';
35
+
36
+ /** Walk up from a dir to the nearest enclosing package.json directory. */
37
+ function nearestPackageDir(projectDir, startDir) {
38
+ let cur = startDir;
39
+ const root = resolve(projectDir);
40
+ while (cur && cur.startsWith(root)) {
41
+ if (existsSync(join(cur, 'package.json'))) return cur;
42
+ const parent = dirname(cur);
43
+ if (parent === cur) break;
44
+ cur = parent;
45
+ }
46
+ return null;
47
+ }
48
+
49
+ /**
50
+ * Build an ordered list of directories to search for an OpenAPI spec.
51
+ * The spec under the configured sourceRoot's package takes precedence over a
52
+ * (possibly stale) copy at the repo root — monorepos frequently keep a
53
+ * divergent root copy. Only CANONICAL bases are searched (sourceRoot package,
54
+ * workspaces, repo root) — never worktrees / vendor / scan-tool dirs.
55
+ */
56
+ function orderedSpecDirs(projectDir, config) {
57
+ const ordered = [];
58
+ const seen = new Set();
59
+ const add = (d) => { if (d && !seen.has(d)) { seen.add(d); ordered.push(d); } };
60
+
61
+ const srList = config?.sourceRoot
62
+ ? (Array.isArray(config.sourceRoot) ? config.sourceRoot : [config.sourceRoot])
63
+ : [];
64
+ for (const sr of srList) {
65
+ const abs = resolve(projectDir, sr);
66
+ add(nearestPackageDir(projectDir, abs));
67
+ add(abs);
68
+ }
69
+ for (const d of getWorkspaceDirs(projectDir)) add(d);
70
+ add(resolve(projectDir)); // root copy last — lowest priority
71
+ return ordered;
72
+ }
73
+
74
+ /**
75
+ * Enumerate every OpenAPI spec found in a canonical location, in priority order.
76
+ * @returns {Array<{ absPath: string, relPath: string, endpoints: object[] }>}
77
+ */
78
+ export function findAllOpenApiSpecs(projectDir, config) {
79
+ const specs = [];
80
+ const seenAbs = new Set();
81
+ for (const dir of orderedSpecDirs(projectDir, config)) {
82
+ const oa = detectOpenAPI(dir);
83
+ if (!oa.found || !oa.endpoints?.length) continue;
84
+ const absPath = resolve(dir, oa.path);
85
+ if (seenAbs.has(absPath)) continue;
86
+ seenAbs.add(absPath);
87
+ specs.push({
88
+ absPath,
89
+ relPath: absPath.startsWith(resolve(projectDir))
90
+ ? absPath.slice(resolve(projectDir).length + 1)
91
+ : absPath,
92
+ endpoints: oa.endpoints.filter(e => e && e.method && e.path),
93
+ });
94
+ }
95
+ return specs;
96
+ }
97
+
98
+ /**
99
+ * Detect divergence between multiple canonical OpenAPI specs.
100
+ * @returns {null | { specs, divergent: string[], authoritative: string }}
101
+ */
102
+ export function detectSpecDivergence(projectDir, config) {
103
+ const specs = findAllOpenApiSpecs(projectDir, config);
104
+ if (specs.length < 2) return null;
105
+
106
+ const keySets = specs.map(s => new Set(s.endpoints.map(e => endpointKey(e.method, e.path))));
107
+ // Union and symmetric difference across all specs.
108
+ const union = new Set();
109
+ for (const ks of keySets) for (const k of ks) union.add(k);
110
+ const divergent = [...union].filter(k => !keySets.every(ks => ks.has(k)));
111
+
112
+ if (divergent.length === 0) return null;
113
+ return { specs, divergent, authoritative: specs[0].relPath };
114
+ }
115
+
116
+ function detectFramework(projectDir, config) {
117
+ const deps = {};
118
+ for (const { pkg } of collectPackageJsons(projectDir, config)) {
119
+ Object.assign(deps, pkg.dependencies || {}, pkg.devDependencies || {});
120
+ }
121
+ if (deps.next) return 'Next.js';
122
+ if (deps.express) return 'Express';
123
+ if (deps.fastify) return 'Fastify';
124
+ if (deps.hono) return 'Hono';
125
+ return '';
126
+ }
127
+
128
+ /**
129
+ * Resolve the actual API surface.
130
+ * @returns {{ endpoints: Array<{method,path}>, confidence: 'spec'|'code'|'none', source: string }}
131
+ */
132
+ export function resolveApiSurface(projectDir, config) {
133
+ const specs = findAllOpenApiSpecs(projectDir, config);
134
+ if (specs.length > 0) {
135
+ const spec = specs[0]; // highest priority (sourceRoot first, root last)
136
+ return {
137
+ endpoints: spec.endpoints.map(e => ({ method: e.method, path: e.path })),
138
+ confidence: 'spec',
139
+ source: spec.relPath,
140
+ };
141
+ }
142
+
143
+ // Fallback: monorepo-aware code route scan
144
+ const framework = detectFramework(projectDir, config);
145
+ const routes = scanRoutesDeep(projectDir, { framework }, { openapi: { found: false } }, { config });
146
+ if (routes.length) {
147
+ return {
148
+ endpoints: routes.map(r => ({ method: r.method, path: r.path })),
149
+ confidence: 'code',
150
+ source: 'code-scan',
151
+ };
152
+ }
153
+
154
+ return { endpoints: [], confidence: 'none', source: null };
155
+ }
156
+
157
+ /**
158
+ * Compute API-surface drift in a structured, reusable form.
159
+ * Used by the validator AND by `docguard fix --write`.
160
+ * @returns {{ applicable, confidence, source, documented, documentedButAbsent,
161
+ * presentButUndocumented, matched }}
162
+ */
163
+ export function computeApiSurfaceDrift(projectDir, config) {
164
+ const apiDocPath = resolve(projectDir, API_DOC);
165
+ if (!existsSync(apiDocPath)) {
166
+ return { applicable: false, confidence: 'none', source: null,
167
+ documented: [], documentedButAbsent: [], presentButUndocumented: [], matched: [] };
168
+ }
169
+
170
+ const documented = parseApiReferenceDoc(readFileSync(apiDocPath, 'utf-8'));
171
+ const surface = resolveApiSurface(projectDir, config);
172
+
173
+ if (surface.confidence === 'none' || documented.length === 0) {
174
+ return { applicable: false, confidence: surface.confidence, source: surface.source,
175
+ documented, documentedButAbsent: [], presentButUndocumented: [], matched: [] };
176
+ }
177
+
178
+ const cmp = compareEndpoints(documented, surface.endpoints);
179
+ return {
180
+ applicable: true,
181
+ confidence: surface.confidence,
182
+ source: surface.source,
183
+ documented,
184
+ documentedButAbsent: cmp.documentedButAbsent,
185
+ presentButUndocumented: cmp.presentButUndocumented,
186
+ matched: cmp.matched,
187
+ };
188
+ }
189
+
190
+ export function validateApiSurface(projectDir, config) {
191
+ const errors = [];
192
+ const warnings = [];
193
+ const fixes = [];
194
+
195
+ const drift = computeApiSurfaceDrift(projectDir, config);
196
+
197
+ // ── Multi-spec divergence (independent of the API-REFERENCE doc) ──
198
+ const divergence = detectSpecDivergence(projectDir, config);
199
+ if (divergence) {
200
+ const others = divergence.specs.slice(1).map(s => s.relPath).join(', ');
201
+ const sample = divergence.divergent.slice(0, 8).join(', ');
202
+ const more = divergence.divergent.length > 8 ? ` (+${divergence.divergent.length - 8} more)` : '';
203
+ warnings.push(
204
+ `Multiple OpenAPI specs disagree on ${divergence.divergent.length} endpoint(s): ` +
205
+ `${divergence.authoritative} (treated as authoritative) vs ${others}. Divergent: ${sample}${more}`
206
+ );
207
+ }
208
+
209
+ if (!drift.applicable) {
210
+ // Nothing to validate against the API-REFERENCE doc.
211
+ return { errors, warnings, passed: 0, total: 0, fixes, authoritativeSpec: drift.source };
212
+ }
213
+
214
+ const { documentedButAbsent, presentButUndocumented, matched, confidence, source } = drift;
215
+ const total = matched.length + documentedButAbsent.length + presentButUndocumented.length;
216
+ const passed = matched.length;
217
+
218
+ const trim = (arr) => {
219
+ const shown = arr.slice(0, MAX_REPORTED);
220
+ return { shown, extra: arr.length - shown.length };
221
+ };
222
+
223
+ // documented-but-absent → deterministic remove-endpoint fixes
224
+ if (documentedButAbsent.length) {
225
+ const { shown, extra } = trim(documentedButAbsent);
226
+ for (const e of shown) {
227
+ const msg = `Documented endpoint not found in code: ${e.method} ${e.path} (${API_DOC})`;
228
+ if (confidence === 'spec') errors.push(msg);
229
+ else warnings.push(`${msg} [code-scan — verify]`);
230
+ }
231
+ if (extra > 0) {
232
+ const tail = `…and ${extra} more documented endpoint(s) not found in code`;
233
+ if (confidence === 'spec') errors.push(tail);
234
+ else warnings.push(tail);
235
+ }
236
+ // Only spec-confirmed absences are safe to auto-remove.
237
+ if (confidence === 'spec') {
238
+ for (const e of documentedButAbsent) {
239
+ fixes.push({ type: 'remove-endpoint', method: e.method, path: e.path, doc: API_DOC });
240
+ }
241
+ }
242
+ }
243
+
244
+ // present-but-undocumented → warning (NOT auto-applied; needs a real block)
245
+ if (presentButUndocumented.length) {
246
+ const { shown, extra } = trim(presentButUndocumented);
247
+ for (const e of shown) {
248
+ warnings.push(`Undocumented endpoint in code: ${e.method} ${e.path} — add it to ${API_DOC}`);
249
+ }
250
+ if (extra > 0) warnings.push(`…and ${extra} more undocumented endpoint(s) in code`);
251
+ }
252
+
253
+ return { errors, warnings, passed, total, fixes, authoritativeSpec: source };
254
+ }
@@ -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,12 +1,31 @@
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
- const results = { name: 'changelog', errors: [], warnings: [], passed: 0, total: 0 };
28
+ const results = { name: 'changelog', errors: [], warnings: [], passed: 0, total: 0, fixes: [] };
10
29
 
11
30
  const changelogPath = resolve(projectDir, config.requiredFiles.changelog);
12
31
  if (!existsSync(changelogPath)) {
@@ -21,7 +40,8 @@ export function validateChangelog(projectDir, config) {
21
40
  if (content.includes('[Unreleased]') || content.includes('[unreleased]')) {
22
41
  results.passed++;
23
42
  } else {
24
- results.warnings.push('CHANGELOG.md: missing [Unreleased] section');
43
+ results.warnings.push('CHANGELOG.md: missing [Unreleased] section — fix with `docguard fix --write`');
44
+ results.fixes.push({ type: 'insert-changelog-unreleased', file: config.requiredFiles.changelog });
25
45
  }
26
46
 
27
47
  // Check it follows Keep a Changelog format (at least has ## headers)
@@ -35,5 +55,26 @@ export function validateChangelog(projectDir, config) {
35
55
  );
36
56
  }
37
57
 
58
+ // Per STANDARD.md: if there are staged CODE changes, CHANGELOG.md should be
59
+ // updated in the same commit. Only assessed when git is available AND there
60
+ // are staged code changes (otherwise the check is not applicable).
61
+ const staged = getStagedFiles(projectDir);
62
+ if (staged && staged.length > 0) {
63
+ const changelogName = basename(config.requiredFiles.changelog);
64
+ const stagedCode = staged.filter(f => CODE_EXT_RE.test(f));
65
+ const changelogStaged = staged.some(f => basename(f) === changelogName);
66
+
67
+ if (stagedCode.length > 0) {
68
+ results.total++;
69
+ if (changelogStaged) {
70
+ results.passed++;
71
+ } else {
72
+ results.warnings.push(
73
+ `${stagedCode.length} code file(s) staged but ${changelogName} is not — add a CHANGELOG entry for this change`
74
+ );
75
+ }
76
+ }
77
+ }
78
+
38
79
  return results;
39
80
  }
@@ -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