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