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.
- package/PHILOSOPHY.md +59 -106
- package/README.md +26 -3
- package/cli/commands/diagnose.mjs +171 -58
- package/cli/commands/diff.mjs +110 -137
- package/cli/commands/fix.mjs +152 -4
- package/cli/commands/generate.mjs +148 -27
- package/cli/commands/guard.mjs +45 -24
- package/cli/commands/hooks.mjs +40 -2
- package/cli/commands/score.mjs +22 -0
- package/cli/commands/sync.mjs +123 -0
- package/cli/docguard.mjs +22 -0
- package/cli/scanners/api-doc.mjs +122 -0
- package/cli/scanners/doc-tools.mjs +1 -1
- package/cli/scanners/frontend.mjs +438 -0
- package/cli/scanners/integrations.mjs +116 -0
- package/cli/scanners/memory-plan.mjs +242 -0
- package/cli/scanners/project-type.mjs +310 -0
- package/cli/scanners/routes.mjs +194 -32
- package/cli/scanners/schemas.mjs +174 -1
- package/cli/shared-source.mjs +247 -0
- package/cli/validators/api-surface.mjs +254 -0
- package/cli/validators/architecture.mjs +4 -3
- package/cli/validators/changelog.mjs +45 -4
- package/cli/validators/doc-quality.mjs +3 -2
- package/cli/validators/docs-coverage.mjs +9 -14
- package/cli/validators/docs-diff.mjs +117 -66
- package/cli/validators/docs-sync.mjs +30 -24
- package/cli/validators/drift.mjs +6 -2
- package/cli/validators/environment.mjs +43 -3
- package/cli/validators/freshness.mjs +4 -3
- package/cli/validators/metadata-sync.mjs +17 -7
- package/cli/validators/metrics-consistency.mjs +9 -4
- package/cli/validators/schema-sync.mjs +19 -10
- package/cli/validators/security.mjs +20 -7
- package/cli/validators/structure.mjs +8 -1
- package/cli/validators/test-spec.mjs +26 -17
- package/cli/validators/todo-tracking.mjs +21 -8
- package/cli/validators/traceability.mjs +61 -36
- package/cli/writers/api-reference.mjs +101 -0
- package/cli/writers/mechanical.mjs +116 -0
- package/cli/writers/sections.mjs +148 -0
- package/commands/docguard.fix.md +19 -3
- package/commands/docguard.guard.md +5 -4
- package/docs/doc-sections.md +37 -0
- package/docs/quickstart.md +1 -1
- package/extensions/spec-kit-docguard/README.md +8 -5
- package/extensions/spec-kit-docguard/commands/fix.md +74 -0
- package/extensions/spec-kit-docguard/commands/generate.md +25 -2
- package/extensions/spec-kit-docguard/commands/guard.md +6 -5
- package/extensions/spec-kit-docguard/commands/sync.md +62 -0
- package/extensions/spec-kit-docguard/skills/docguard-fix/SKILL.md +11 -1
- package/extensions/spec-kit-docguard/skills/docguard-guard/SKILL.md +3 -2
- package/extensions/spec-kit-docguard/skills/docguard-sync/SKILL.md +111 -0
- package/package.json +1 -1
- 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.
|
|
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.
|
|
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 =
|
|
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
|
|
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
|
|