docguard-cli 0.27.0 → 0.29.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/README.es.md +102 -0
- package/README.md +65 -31
- package/README.pt-BR.md +101 -0
- package/STANDARD.md +20 -10
- package/cli/commands/agents.mjs +149 -0
- package/cli/commands/diff.mjs +6 -15
- package/cli/commands/explain.mjs +8 -6
- package/cli/commands/generate.mjs +14 -1001
- package/cli/commands/guard.mjs +149 -15
- package/cli/commands/init.mjs +23 -1
- package/cli/commands/llms.mjs +67 -5
- package/cli/commands/mcp.mjs +263 -0
- package/cli/commands/memory.mjs +115 -0
- package/cli/commands/score.mjs +76 -12
- package/cli/commands/sync-tests.mjs +272 -0
- package/cli/commands/sync.mjs +6 -0
- package/cli/commands/verify.mjs +67 -0
- package/cli/docguard.mjs +62 -5
- package/cli/findings.mjs +499 -0
- package/cli/scanners/agent-readability.mjs +202 -0
- package/cli/scanners/semantic-claims.mjs +160 -0
- package/cli/scanners/speckit.mjs +98 -28
- package/cli/shared-ignore.mjs +148 -16
- package/cli/shared.mjs +45 -1
- package/cli/validators/api-surface.mjs +182 -29
- package/cli/validators/architecture.mjs +91 -56
- package/cli/validators/canonical-sync.mjs +59 -28
- package/cli/validators/changelog.mjs +41 -17
- package/cli/validators/cross-reference.mjs +28 -11
- package/cli/validators/doc-quality.mjs +78 -44
- package/cli/validators/docs-coverage.mjs +90 -63
- package/cli/validators/docs-diff.mjs +63 -64
- package/cli/validators/docs-sync.mjs +48 -33
- package/cli/validators/drift.mjs +40 -34
- package/cli/validators/environment.mjs +67 -27
- package/cli/validators/freshness.mjs +12 -5
- package/cli/validators/generated-staleness.mjs +26 -10
- package/cli/validators/metadata-sync.mjs +28 -25
- package/cli/validators/metrics-consistency.mjs +89 -47
- package/cli/validators/schema-sync.mjs +37 -32
- package/cli/validators/security.mjs +7 -20
- package/cli/validators/spec-kit.mjs +3 -0
- package/cli/validators/structure.mjs +58 -23
- package/cli/validators/surface-sync.mjs +34 -15
- package/cli/validators/test-spec.mjs +87 -29
- package/cli/validators/todo-tracking.mjs +83 -74
- package/cli/validators/traceability.mjs +67 -39
- package/cli/writers/doc-generators.mjs +853 -0
- package/cli/writers/generate-io.mjs +142 -0
- package/cli/writers/sarif.mjs +129 -0
- package/commands/docguard.fix.md +56 -53
- package/commands/docguard.guard.md +53 -47
- package/commands/docguard.review.md +49 -31
- package/docs/ai-integration.md +133 -134
- package/docs/commands.md +49 -3
- package/docs/configuration.md +38 -0
- package/docs/faq.md +15 -0
- package/extensions/spec-kit-docguard/extension.yml +1 -1
- package/extensions/spec-kit-docguard/skills/docguard-fix/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-guard/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-review/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-score/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-sync/SKILL.md +2 -2
- package/package.json +1 -1
- package/schemas/docguard-config.schema.json +17 -0
- package/templates/ENVIRONMENT.md.template +5 -0
- package/templates/REQUIREMENTS.md.template +2 -0
- package/templates/SECURITY.md.template +6 -1
- package/templates/TEST-SPEC.md.template +5 -0
- package/templates/commands/docguard.fix.md +33 -10
- package/templates/commands/docguard.guard.md +40 -26
- package/templates/commands/docguard.init.md +23 -11
- package/templates/commands/docguard.review.md +25 -8
- package/templates/commands/docguard.update.md +14 -4
|
@@ -7,12 +7,18 @@
|
|
|
7
7
|
*
|
|
8
8
|
* Respects config.ignore and config.testPatterns for test file discovery.
|
|
9
9
|
* Uses shared-ignore.mjs for consistent filtering (Constitution IV, v1.1.0).
|
|
10
|
+
*
|
|
11
|
+
* v0.29: migrated to structured findings (DDF001–DDF002). Messages are
|
|
12
|
+
* byte-identical to the legacy strings — resultFromFindings derives the
|
|
13
|
+
* errors/warnings arrays from the same findings, so counts, exit codes, and
|
|
14
|
+
* existing tests are unaffected; guard just renders richer output.
|
|
10
15
|
*/
|
|
11
16
|
|
|
12
17
|
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
13
18
|
import { resolve, join, extname, basename, relative } from 'node:path';
|
|
14
|
-
import { shouldIgnore, globMatch } from '../shared-ignore.mjs';
|
|
19
|
+
import { shouldIgnore, globMatch, walkFiles as sharedWalkFiles } from '../shared-ignore.mjs';
|
|
15
20
|
import { collectPackageJsons, detectDocker, resolveSourceRoots } from '../shared-source.mjs';
|
|
21
|
+
import { mkFinding, resultFromFindings } from '../findings.mjs';
|
|
16
22
|
|
|
17
23
|
const IGNORE_DIRS = new Set([
|
|
18
24
|
'node_modules', '.git', '.next', 'dist', 'build',
|
|
@@ -25,12 +31,35 @@ const CODE_EXTENSIONS = new Set([
|
|
|
25
31
|
'.py', '.java', '.go', '.rs', '.rb', '.php',
|
|
26
32
|
]);
|
|
27
33
|
|
|
34
|
+
// v0.29: one stable finding code per drift domain (not per occurrence).
|
|
35
|
+
// Location = the canonical doc that owns the domain, so an agent knows which
|
|
36
|
+
// file to reconcile. Drift is two-sided (doc stale OR code changed), hence
|
|
37
|
+
// suggestion kind 'review' — a human decides which side is wrong.
|
|
38
|
+
const DRIFT_FINDINGS = {
|
|
39
|
+
'Tech Stack': {
|
|
40
|
+
code: 'DDF001',
|
|
41
|
+
location: 'docs-canonical/ARCHITECTURE.md',
|
|
42
|
+
suggestion: {
|
|
43
|
+
kind: 'review',
|
|
44
|
+
text: 'Reconcile the Tech Stack in ARCHITECTURE.md with the actual dependencies — document the new tech or remove stale entries',
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
'Test Files': {
|
|
48
|
+
code: 'DDF002',
|
|
49
|
+
location: 'docs-canonical/TEST-SPEC.md',
|
|
50
|
+
suggestion: {
|
|
51
|
+
kind: 'review',
|
|
52
|
+
text: 'Reconcile TEST-SPEC.md with the test files on disk — document new tests or remove stale entries',
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
|
|
28
57
|
/**
|
|
29
58
|
* Validate doc-code alignment — compares canonical docs vs source code.
|
|
30
59
|
* @returns {{ errors: string[], warnings: string[], passed: number, total: number }}
|
|
31
60
|
*/
|
|
32
61
|
export function validateDocsDiff(projectDir, config) {
|
|
33
|
-
const
|
|
62
|
+
const findings = [];
|
|
34
63
|
let passed = 0;
|
|
35
64
|
let total = 0;
|
|
36
65
|
|
|
@@ -70,11 +99,19 @@ export function validateDocsDiff(projectDir, config) {
|
|
|
70
99
|
if (stale > 0) {
|
|
71
100
|
parts.push(`${stale} documented but not found in code: ${fmtList(result.onlyInDocs)}`);
|
|
72
101
|
}
|
|
73
|
-
|
|
102
|
+
const meta = DRIFT_FINDINGS[result.title] || {};
|
|
103
|
+
findings.push(mkFinding({
|
|
104
|
+
code: meta.code,
|
|
105
|
+
validator: 'docsDiff',
|
|
106
|
+
severity: 'warn',
|
|
107
|
+
message: `${result.title} drift: ${parts.join('; ')}`,
|
|
108
|
+
location: meta.location,
|
|
109
|
+
suggestion: meta.suggestion,
|
|
110
|
+
}));
|
|
74
111
|
}
|
|
75
112
|
}
|
|
76
113
|
|
|
77
|
-
return
|
|
114
|
+
return resultFromFindings(findings, { passed, total });
|
|
78
115
|
}
|
|
79
116
|
|
|
80
117
|
// ── Diff Functions (lightweight versions for validator) ──────────────────
|
|
@@ -244,77 +281,39 @@ export function collectCodeTests(dir, config = {}) {
|
|
|
244
281
|
export function getTestFilesFromPatterns(dir, patterns, config) {
|
|
245
282
|
const results = new Set();
|
|
246
283
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
const stat = statSync(fullPath);
|
|
257
|
-
if (stat.isDirectory()) {
|
|
258
|
-
walk(fullPath);
|
|
259
|
-
} else if (stat.isFile()) {
|
|
260
|
-
const relPath = relative(dir, fullPath);
|
|
261
|
-
// Skip files in globally ignored paths
|
|
262
|
-
if (config && shouldIgnore(relPath, config)) continue;
|
|
263
|
-
// Use globMatch for positive pattern matching (rejects node_modules internally)
|
|
264
|
-
if (globMatch(relPath, patterns)) {
|
|
265
|
-
results.add(relPath);
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
} catch { /* skip */ }
|
|
284
|
+
// v0.29 consolidation: traversal delegates to the shared canonical walker;
|
|
285
|
+
// per-file filtering (config ignore + positive globMatch) stays here.
|
|
286
|
+
sharedWalkFiles(dir, (fullPath) => {
|
|
287
|
+
const relPath = relative(dir, fullPath);
|
|
288
|
+
// Skip files in globally ignored paths
|
|
289
|
+
if (config && shouldIgnore(relPath, config)) return;
|
|
290
|
+
// Use globMatch for positive pattern matching (rejects node_modules internally)
|
|
291
|
+
if (globMatch(relPath, patterns)) {
|
|
292
|
+
results.add(relPath);
|
|
269
293
|
}
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
walk(dir);
|
|
294
|
+
}, { ignoreDirs: IGNORE_DIRS });
|
|
273
295
|
return [...results];
|
|
274
296
|
}
|
|
275
297
|
|
|
276
298
|
/** Returns true if any file with the given extension exists under dir (ignoring vendor dirs). */
|
|
277
299
|
function hasFileWithExt(dir, ext, config) {
|
|
300
|
+
// v0.29 consolidation: shared walker. The old hand-rolled version early-exited
|
|
301
|
+
// on first match; the shared walker completes the traversal — acceptable here
|
|
302
|
+
// (one-shot existence probe on source trees already pruned by IGNORE_DIRS).
|
|
278
303
|
let found = false;
|
|
279
|
-
|
|
280
|
-
if (found) return;
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
if (found) return;
|
|
285
|
-
if (IGNORE_DIRS.has(entry) || entry.startsWith('.')) continue;
|
|
286
|
-
const full = join(d, entry);
|
|
287
|
-
try {
|
|
288
|
-
const stat = statSync(full);
|
|
289
|
-
if (stat.isDirectory()) walk(full);
|
|
290
|
-
else if (stat.isFile() && extname(full) === ext) {
|
|
291
|
-
const rel = relative(dir, full);
|
|
292
|
-
if (!config || !shouldIgnore(rel, config)) found = true;
|
|
293
|
-
}
|
|
294
|
-
} catch { /* skip */ }
|
|
295
|
-
}
|
|
296
|
-
};
|
|
297
|
-
walk(dir);
|
|
304
|
+
sharedWalkFiles(dir, (full) => {
|
|
305
|
+
if (found || extname(full) !== ext) return;
|
|
306
|
+
const rel = relative(dir, full);
|
|
307
|
+
if (!config || !shouldIgnore(rel, config)) found = true;
|
|
308
|
+
}, { ignoreDirs: IGNORE_DIRS });
|
|
298
309
|
return found;
|
|
299
310
|
}
|
|
300
311
|
|
|
312
|
+
// v0.29 consolidation: traversal delegates to the shared canonical walker.
|
|
301
313
|
function getFilesRecursive(dir, config) {
|
|
302
314
|
const results = [];
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
for (const entry of entries) {
|
|
308
|
-
if (IGNORE_DIRS.has(entry) || entry.startsWith('.')) continue;
|
|
309
|
-
const fullPath = join(dir, entry);
|
|
310
|
-
try {
|
|
311
|
-
const stat = statSync(fullPath);
|
|
312
|
-
if (stat.isDirectory()) {
|
|
313
|
-
results.push(...getFilesRecursive(fullPath, config));
|
|
314
|
-
} else if (stat.isFile() && CODE_EXTENSIONS.has(extname(fullPath))) {
|
|
315
|
-
results.push(fullPath);
|
|
316
|
-
}
|
|
317
|
-
} catch { /* skip */ }
|
|
318
|
-
}
|
|
315
|
+
sharedWalkFiles(dir, (fullPath) => {
|
|
316
|
+
if (CODE_EXTENSIONS.has(extname(fullPath))) results.push(fullPath);
|
|
317
|
+
}, { ignoreDirs: IGNORE_DIRS });
|
|
319
318
|
return results;
|
|
320
319
|
}
|
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Docs-Sync Validator — Checks that source files have matching canonical doc entries
|
|
3
|
+
*
|
|
4
|
+
* v0.29: migrated to structured findings (DSY001–DSY003). Messages are
|
|
5
|
+
* byte-identical to the legacy strings — resultFromFindings derives the
|
|
6
|
+
* errors/warnings arrays from the same findings, so counts, exit codes, and
|
|
7
|
+
* existing tests are unaffected; guard just renders richer output.
|
|
3
8
|
*/
|
|
4
9
|
|
|
5
10
|
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
6
11
|
import { resolve, join, extname, basename } from 'node:path';
|
|
7
12
|
import { resolveSourceRoots } from '../shared-source.mjs';
|
|
8
|
-
import { relPosix } from '../shared-ignore.mjs';
|
|
13
|
+
import { relPosix, walkFiles as sharedWalkFiles } from '../shared-ignore.mjs';
|
|
14
|
+
import { mkFinding, resultFromFindings } from '../findings.mjs';
|
|
9
15
|
|
|
10
16
|
const IGNORE_DIRS = new Set([
|
|
11
17
|
'node_modules', '.git', '.next', '.nuxt', 'dist', 'build', 'out',
|
|
@@ -61,7 +67,9 @@ function expandDirs(projectDir, config, subPaths) {
|
|
|
61
67
|
}
|
|
62
68
|
|
|
63
69
|
export function validateDocsSync(projectDir, config) {
|
|
64
|
-
const
|
|
70
|
+
const findings = [];
|
|
71
|
+
let passed = 0;
|
|
72
|
+
let total = 0;
|
|
65
73
|
|
|
66
74
|
// Load all canonical doc content for checking
|
|
67
75
|
const canonicalDir = resolve(projectDir, 'docs-canonical');
|
|
@@ -78,7 +86,8 @@ export function validateDocsSync(projectDir, config) {
|
|
|
78
86
|
}
|
|
79
87
|
|
|
80
88
|
if (!canonicalContent) {
|
|
81
|
-
|
|
89
|
+
// No canonical docs to check against
|
|
90
|
+
return { name: 'docs-sync', ...resultFromFindings([], { passed: 0, total: 0 }) };
|
|
82
91
|
}
|
|
83
92
|
|
|
84
93
|
// N-1: When the guard runs in --changed-only mode, config.changedFiles is
|
|
@@ -110,14 +119,21 @@ export function validateDocsSync(projectDir, config) {
|
|
|
110
119
|
// N-1: skip files outside the --changed-only scope.
|
|
111
120
|
if (!inScope(relPath)) continue;
|
|
112
121
|
|
|
113
|
-
|
|
122
|
+
total++;
|
|
114
123
|
const name = basename(file, ext);
|
|
115
124
|
|
|
116
125
|
// Check if the file path or name is mentioned in any canonical doc
|
|
117
126
|
if (canonicalContent.includes(relPath) || canonicalContent.includes(name)) {
|
|
118
|
-
|
|
127
|
+
passed++;
|
|
119
128
|
} else {
|
|
120
|
-
|
|
129
|
+
findings.push(mkFinding({
|
|
130
|
+
code: 'DSY001',
|
|
131
|
+
validator: 'docsSync',
|
|
132
|
+
severity: 'warn',
|
|
133
|
+
message: `route ${relPath} not referenced in any canonical doc`,
|
|
134
|
+
location: relPath,
|
|
135
|
+
suggestion: { kind: 'fix', text: 'Reference this route (by path or name) in a canonical doc, e.g. ARCHITECTURE.md' },
|
|
136
|
+
}));
|
|
121
137
|
}
|
|
122
138
|
}
|
|
123
139
|
}
|
|
@@ -135,13 +151,20 @@ export function validateDocsSync(projectDir, config) {
|
|
|
135
151
|
// N-1: skip files outside the --changed-only scope.
|
|
136
152
|
if (!inScope(relPath)) continue;
|
|
137
153
|
|
|
138
|
-
|
|
154
|
+
total++;
|
|
139
155
|
const name = basename(file, ext);
|
|
140
156
|
|
|
141
157
|
if (canonicalContent.includes(relPath) || canonicalContent.includes(name)) {
|
|
142
|
-
|
|
158
|
+
passed++;
|
|
143
159
|
} else {
|
|
144
|
-
|
|
160
|
+
findings.push(mkFinding({
|
|
161
|
+
code: 'DSY002',
|
|
162
|
+
validator: 'docsSync',
|
|
163
|
+
severity: 'warn',
|
|
164
|
+
message: `Service ${relPath} not referenced in any canonical doc`,
|
|
165
|
+
location: relPath,
|
|
166
|
+
suggestion: { kind: 'fix', text: 'Reference this service (by path or name) in a canonical doc, e.g. ARCHITECTURE.md' },
|
|
167
|
+
}));
|
|
145
168
|
}
|
|
146
169
|
}
|
|
147
170
|
}
|
|
@@ -184,7 +207,7 @@ export function validateDocsSync(projectDir, config) {
|
|
|
184
207
|
const rawName = basename(file, ext).toLowerCase();
|
|
185
208
|
if (rawName === 'index' || rawName === 'middleware' || rawName.startsWith('_')) continue;
|
|
186
209
|
|
|
187
|
-
|
|
210
|
+
total++;
|
|
188
211
|
|
|
189
212
|
// Strategy 1: Parse the route file for actual route paths
|
|
190
213
|
// Look for router.get('/path'), app.post('/path'), etc.
|
|
@@ -224,38 +247,30 @@ export function validateDocsSync(projectDir, config) {
|
|
|
224
247
|
}
|
|
225
248
|
|
|
226
249
|
if (matched) {
|
|
227
|
-
|
|
250
|
+
passed++;
|
|
228
251
|
} else {
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
252
|
+
findings.push(mkFinding({
|
|
253
|
+
code: 'DSY003',
|
|
254
|
+
validator: 'docsSync',
|
|
255
|
+
severity: 'warn',
|
|
256
|
+
message: `Route file ${basename(file)} exists but no matching paths found in ${openapiFile}. ` +
|
|
257
|
+
`Run your spec generator (e.g., zod-to-openapi) to update the API spec`,
|
|
258
|
+
location: relPathForFilter,
|
|
259
|
+
suggestion: { kind: 'fix', text: `Regenerate ${openapiFile} (e.g. via zod-to-openapi) so it covers this route file's paths` },
|
|
260
|
+
}));
|
|
233
261
|
}
|
|
234
262
|
}
|
|
235
263
|
}
|
|
236
264
|
}
|
|
237
265
|
|
|
238
|
-
return
|
|
266
|
+
return { name: 'docs-sync', ...resultFromFindings(findings, { passed, total }) };
|
|
239
267
|
}
|
|
240
268
|
|
|
269
|
+
// v0.29 consolidation: traversal delegates to the shared canonical walker;
|
|
270
|
+
// IGNORE_DIRS stays local (its __tests__/__test__ entries are intentional —
|
|
271
|
+
// co-located test dirs are not the source under documentation).
|
|
241
272
|
function getFilesRecursive(dir) {
|
|
242
273
|
const results = [];
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
const entries = readdirSync(dir);
|
|
246
|
-
for (const entry of entries) {
|
|
247
|
-
if (IGNORE_DIRS.has(entry) || entry.startsWith('.')) continue;
|
|
248
|
-
const fullPath = join(dir, entry);
|
|
249
|
-
try {
|
|
250
|
-
const stat = statSync(fullPath);
|
|
251
|
-
if (stat.isDirectory()) {
|
|
252
|
-
results.push(...getFilesRecursive(fullPath));
|
|
253
|
-
} else {
|
|
254
|
-
results.push(fullPath);
|
|
255
|
-
}
|
|
256
|
-
} catch {
|
|
257
|
-
// Skip
|
|
258
|
-
}
|
|
259
|
-
}
|
|
274
|
+
sharedWalkFiles(dir, (fullPath) => results.push(fullPath), { ignoreDirs: IGNORE_DIRS });
|
|
260
275
|
return results;
|
|
261
276
|
}
|
package/cli/validators/drift.mjs
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Drift Validator — Every // DRIFT: comment must have a DRIFT-LOG.md entry
|
|
3
|
+
*
|
|
4
|
+
* v0.29: migrated to structured findings (DRF001–DRF002). Messages are
|
|
5
|
+
* byte-identical to the legacy strings — resultFromFindings derives the
|
|
6
|
+
* errors/warnings arrays from the same findings, so counts, exit codes, and
|
|
7
|
+
* existing tests are unaffected; guard just renders richer output.
|
|
3
8
|
*/
|
|
4
9
|
|
|
5
10
|
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
6
11
|
import { resolve, join, extname } from 'node:path';
|
|
7
|
-
import { relPosix } from '../shared-ignore.mjs';
|
|
12
|
+
import { relPosix, walkFiles as sharedWalkFiles } from '../shared-ignore.mjs';
|
|
13
|
+
import { mkFinding, resultFromFindings } from '../findings.mjs';
|
|
8
14
|
|
|
9
15
|
const CODE_EXTENSIONS = new Set([
|
|
10
16
|
'.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx',
|
|
@@ -19,7 +25,9 @@ const IGNORE_DIRS = new Set([
|
|
|
19
25
|
]);
|
|
20
26
|
|
|
21
27
|
export function validateDrift(projectDir, config) {
|
|
22
|
-
const
|
|
28
|
+
const findings = [];
|
|
29
|
+
let passed = 0;
|
|
30
|
+
let total = 0;
|
|
23
31
|
|
|
24
32
|
// v0.15-P3: when config.changedFiles is set (--changed-only mode), only
|
|
25
33
|
// visit the listed paths. Drift comments in unchanged files are still in
|
|
@@ -64,58 +72,56 @@ export function validateDrift(projectDir, config) {
|
|
|
64
72
|
|
|
65
73
|
if (driftComments.length === 0) {
|
|
66
74
|
// No // DRIFT: comments to reconcile — not applicable (NOT a pass).
|
|
67
|
-
|
|
68
|
-
|
|
75
|
+
return {
|
|
76
|
+
name: 'drift',
|
|
77
|
+
...resultFromFindings([], { passed: 0, total: 0 }),
|
|
78
|
+
note: 'no // DRIFT: comments in code',
|
|
79
|
+
};
|
|
69
80
|
}
|
|
70
81
|
|
|
71
82
|
// Read DRIFT-LOG.md
|
|
72
83
|
const driftLogPath = resolve(projectDir, config.requiredFiles.driftLog);
|
|
73
84
|
if (!existsSync(driftLogPath)) {
|
|
74
|
-
results.total = driftComments.length;
|
|
75
85
|
for (const dc of driftComments) {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
86
|
+
findings.push(mkFinding({
|
|
87
|
+
code: 'DRF001',
|
|
88
|
+
validator: 'drift',
|
|
89
|
+
severity: 'error',
|
|
90
|
+
message: `${dc.file}:${dc.line} has DRIFT comment but DRIFT-LOG.md doesn't exist`,
|
|
91
|
+
location: `${dc.file}:${dc.line}`,
|
|
92
|
+
suggestion: { kind: 'fix', text: 'Create the drift log, then record this deviation in it', command: 'docguard init' },
|
|
93
|
+
}));
|
|
79
94
|
}
|
|
80
|
-
return
|
|
95
|
+
return { name: 'drift', ...resultFromFindings(findings, { passed: 0, total: driftComments.length }) };
|
|
81
96
|
}
|
|
82
97
|
|
|
83
98
|
const driftLogContent = readFileSync(driftLogPath, 'utf-8');
|
|
84
99
|
|
|
85
100
|
// Check each drift comment has a matching entry in DRIFT-LOG.md
|
|
86
101
|
for (const dc of driftComments) {
|
|
87
|
-
|
|
102
|
+
total++;
|
|
88
103
|
// Check if the file is mentioned in DRIFT-LOG.md
|
|
89
104
|
if (driftLogContent.includes(dc.file)) {
|
|
90
|
-
|
|
105
|
+
passed++;
|
|
91
106
|
} else {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
107
|
+
findings.push(mkFinding({
|
|
108
|
+
code: 'DRF002',
|
|
109
|
+
validator: 'drift',
|
|
110
|
+
severity: 'error',
|
|
111
|
+
message: `${dc.file}:${dc.line} — DRIFT comment not logged in DRIFT-LOG.md`,
|
|
112
|
+
location: `${dc.file}:${dc.line}`,
|
|
113
|
+
suggestion: { kind: 'fix', text: 'Add an entry for this file to DRIFT-LOG.md explaining the deviation' },
|
|
114
|
+
}));
|
|
95
115
|
}
|
|
96
116
|
}
|
|
97
117
|
|
|
98
|
-
return
|
|
118
|
+
return { name: 'drift', ...resultFromFindings(findings, { passed, total }) };
|
|
99
119
|
}
|
|
100
120
|
|
|
121
|
+
// v0.29 consolidation: traversal delegates to the shared canonical walker;
|
|
122
|
+
// the IGNORE_DIRS set above stays local because its entries are intentional
|
|
123
|
+
// per-validator variance (e.g. 'cli' — DocGuard's own source has DRIFT: in
|
|
124
|
+
// regex patterns).
|
|
101
125
|
function walkDir(dir, callback) {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
const entries = readdirSync(dir);
|
|
105
|
-
for (const entry of entries) {
|
|
106
|
-
if (IGNORE_DIRS.has(entry)) continue;
|
|
107
|
-
if (entry.startsWith('.')) continue;
|
|
108
|
-
|
|
109
|
-
const fullPath = join(dir, entry);
|
|
110
|
-
try {
|
|
111
|
-
const stat = statSync(fullPath);
|
|
112
|
-
if (stat.isDirectory()) {
|
|
113
|
-
walkDir(fullPath, callback);
|
|
114
|
-
} else if (stat.isFile()) {
|
|
115
|
-
callback(fullPath);
|
|
116
|
-
}
|
|
117
|
-
} catch {
|
|
118
|
-
// Skip files we can't read
|
|
119
|
-
}
|
|
120
|
-
}
|
|
126
|
+
sharedWalkFiles(dir, callback, { ignoreDirs: IGNORE_DIRS });
|
|
121
127
|
}
|
|
@@ -1,19 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Environment Validator — Checks ENVIRONMENT.md docs and .env.example
|
|
3
3
|
* Now respects projectTypeConfig (e.g., skip env checks for CLI tools)
|
|
4
|
+
*
|
|
5
|
+
* v0.29: migrated to structured findings (ENV001–ENV005). Messages are
|
|
6
|
+
* byte-identical to the legacy strings — resultFromFindings derives the
|
|
7
|
+
* errors/warnings arrays from the same findings, so counts, exit codes, and
|
|
8
|
+
* existing tests are unaffected; guard just renders richer output.
|
|
4
9
|
*/
|
|
5
10
|
|
|
6
11
|
import { existsSync, readFileSync } from 'node:fs';
|
|
7
12
|
import { resolve } from 'node:path';
|
|
8
13
|
import { grepEnvUsage } from '../shared-source.mjs';
|
|
14
|
+
import { mkFinding, resultFromFindings } from '../findings.mjs';
|
|
9
15
|
|
|
10
16
|
export function validateEnvironment(projectDir, config) {
|
|
11
|
-
const
|
|
17
|
+
const findings = [];
|
|
18
|
+
let passed = 0;
|
|
19
|
+
let total = 0;
|
|
12
20
|
const ptc = config.projectTypeConfig || {};
|
|
13
21
|
|
|
14
|
-
const
|
|
22
|
+
const envDoc = 'docs-canonical/ENVIRONMENT.md';
|
|
23
|
+
const envDocPath = resolve(projectDir, envDoc);
|
|
15
24
|
if (!existsSync(envDocPath)) {
|
|
16
|
-
|
|
25
|
+
// Structure validator catches missing files. Keep the exact legacy shape
|
|
26
|
+
// here (no `findings` key) — tests deep-equal this early return.
|
|
27
|
+
return { name: 'environment', errors: [], warnings: [], passed: 0, total: 0 };
|
|
17
28
|
}
|
|
18
29
|
|
|
19
30
|
const content = readFileSync(envDocPath, 'utf-8');
|
|
@@ -21,18 +32,32 @@ export function validateEnvironment(projectDir, config) {
|
|
|
21
32
|
// Check for required sections (anchored headings — not substring matches that
|
|
22
33
|
// could hit a TOC entry or code block).
|
|
23
34
|
const hasHeading = (re) => re.test(content);
|
|
24
|
-
|
|
35
|
+
total++;
|
|
25
36
|
if (hasHeading(/^#{2,3}\s+(Prerequisites|Setup Steps)\b/m)) {
|
|
26
|
-
|
|
37
|
+
passed++;
|
|
27
38
|
} else {
|
|
28
|
-
|
|
39
|
+
findings.push(mkFinding({
|
|
40
|
+
code: 'ENV001',
|
|
41
|
+
validator: 'environment',
|
|
42
|
+
severity: 'warn',
|
|
43
|
+
message: 'ENVIRONMENT.md: missing "## Prerequisites" or "## Setup Steps" section',
|
|
44
|
+
location: envDoc,
|
|
45
|
+
suggestion: { kind: 'fix', text: 'Add a "## Setup Steps" (or "## Prerequisites") section describing how to get the project running' },
|
|
46
|
+
}));
|
|
29
47
|
}
|
|
30
48
|
|
|
31
|
-
|
|
49
|
+
total++;
|
|
32
50
|
if (hasHeading(/^#{2,3}\s+Environment Variables\b/m)) {
|
|
33
|
-
|
|
51
|
+
passed++;
|
|
34
52
|
} else {
|
|
35
|
-
|
|
53
|
+
findings.push(mkFinding({
|
|
54
|
+
code: 'ENV002',
|
|
55
|
+
validator: 'environment',
|
|
56
|
+
severity: 'warn',
|
|
57
|
+
message: 'ENVIRONMENT.md: missing "## Environment Variables" section',
|
|
58
|
+
location: envDoc,
|
|
59
|
+
suggestion: { kind: 'fix', text: 'Add a "## Environment Variables" section documenting each variable the app reads' },
|
|
60
|
+
}));
|
|
36
61
|
}
|
|
37
62
|
|
|
38
63
|
// ── Real code-truth check: env vars USED in code but documented nowhere ──
|
|
@@ -92,15 +117,20 @@ export function validateEnvironment(projectDir, config) {
|
|
|
92
117
|
// vacuous (always passes) and would just inflate the count.
|
|
93
118
|
if (codeUsed.size > 0) {
|
|
94
119
|
const usedButUndocumented = [...codeUsed].filter(v => !documented.has(v));
|
|
95
|
-
|
|
120
|
+
total++;
|
|
96
121
|
if (usedButUndocumented.length === 0) {
|
|
97
|
-
|
|
122
|
+
passed++;
|
|
98
123
|
} else {
|
|
99
124
|
const shown = usedButUndocumented.slice(0, 10).join(', ');
|
|
100
125
|
const more = usedButUndocumented.length > 10 ? ` (+${usedButUndocumented.length - 10} more)` : '';
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
126
|
+
findings.push(mkFinding({
|
|
127
|
+
code: 'ENV003',
|
|
128
|
+
validator: 'environment',
|
|
129
|
+
severity: 'warn',
|
|
130
|
+
message: `${usedButUndocumented.length} env var(s) used in code but not documented in ENVIRONMENT.md / .env.example: ${shown}${more}`,
|
|
131
|
+
location: envDoc,
|
|
132
|
+
suggestion: { kind: 'fix', text: 'Document each listed variable in ENVIRONMENT.md, or add it to .env.example' },
|
|
133
|
+
}));
|
|
104
134
|
}
|
|
105
135
|
}
|
|
106
136
|
}
|
|
@@ -109,35 +139,45 @@ export function validateEnvironment(projectDir, config) {
|
|
|
109
139
|
if (ptc.needsEnvExample !== false && ptc.needsEnvVars !== false) {
|
|
110
140
|
// Check if .env.example is referenced and exists
|
|
111
141
|
if (content.includes('.env.example')) {
|
|
112
|
-
|
|
142
|
+
total++;
|
|
113
143
|
if (existsSync(resolve(projectDir, '.env.example'))) {
|
|
114
|
-
|
|
144
|
+
passed++;
|
|
115
145
|
} else {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
146
|
+
findings.push(mkFinding({
|
|
147
|
+
code: 'ENV004',
|
|
148
|
+
validator: 'environment',
|
|
149
|
+
severity: 'warn',
|
|
150
|
+
message: 'ENVIRONMENT.md references .env.example but the file does not exist',
|
|
151
|
+
location: envDoc,
|
|
152
|
+
suggestion: { kind: 'fix', text: 'Create .env.example with placeholder values, or remove the stale reference from ENVIRONMENT.md' },
|
|
153
|
+
}));
|
|
119
154
|
}
|
|
120
155
|
}
|
|
121
156
|
|
|
122
157
|
// Check if any .env file exists but no .env.example is provided
|
|
123
|
-
|
|
158
|
+
total++;
|
|
124
159
|
const hasEnvFile = ['.env', '.env.local', '.env.development'].some(f =>
|
|
125
160
|
existsSync(resolve(projectDir, f))
|
|
126
161
|
);
|
|
127
162
|
const hasEnvExample = existsSync(resolve(projectDir, '.env.example'));
|
|
128
163
|
|
|
129
164
|
if (hasEnvFile && !hasEnvExample) {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
165
|
+
findings.push(mkFinding({
|
|
166
|
+
code: 'ENV005',
|
|
167
|
+
validator: 'environment',
|
|
168
|
+
severity: 'warn',
|
|
169
|
+
message: '.env file exists but no .env.example template — new contributors won\'t know what vars to set',
|
|
170
|
+
location: '.env.example',
|
|
171
|
+
suggestion: { kind: 'fix', text: 'Create a .env.example template listing every variable with a placeholder value' },
|
|
172
|
+
}));
|
|
133
173
|
} else {
|
|
134
|
-
|
|
174
|
+
passed++;
|
|
135
175
|
}
|
|
136
176
|
} else {
|
|
137
177
|
// CLI/library project — just verify doc exists and has basic content
|
|
138
|
-
|
|
139
|
-
|
|
178
|
+
total++;
|
|
179
|
+
passed++;
|
|
140
180
|
}
|
|
141
181
|
|
|
142
|
-
return
|
|
182
|
+
return { name: 'environment', ...resultFromFindings(findings, { passed, total }) };
|
|
143
183
|
}
|
|
@@ -28,11 +28,8 @@ try {
|
|
|
28
28
|
_sharedGetLastCommitDate = null;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
'coverage', '.cache', '__pycache__', '.venv', 'vendor',
|
|
34
|
-
'templates', 'configs', 'Research',
|
|
35
|
-
]);
|
|
31
|
+
// (v0.29 cleanup: a dead IGNORE_DIRS set lived here — defined but never
|
|
32
|
+
// referenced. Freshness reads specific configured docs; it never walks.)
|
|
36
33
|
|
|
37
34
|
/**
|
|
38
35
|
* Read the `<!-- docguard:last-reviewed YYYY-MM-DD -->` header from a doc file.
|
|
@@ -245,6 +242,8 @@ export function validateFreshness(dir, config) {
|
|
|
245
242
|
// an agent that can stamp a marker but not commit was left guessing.
|
|
246
243
|
results.push({
|
|
247
244
|
status: 'warn',
|
|
245
|
+
code: 'FRS001',
|
|
246
|
+
doc: docFile,
|
|
248
247
|
message: `${docFile} exists but is not yet committed to git — commit it, or add a <!-- docguard:last-reviewed YYYY-MM-DD --> marker (or <!-- docguard:status approved -->).`,
|
|
249
248
|
});
|
|
250
249
|
continue;
|
|
@@ -266,6 +265,8 @@ export function validateFreshness(dir, config) {
|
|
|
266
265
|
if (codeCommitsSince >= WARNING_THRESHOLD_COMMITS) {
|
|
267
266
|
results.push({
|
|
268
267
|
status: 'warn',
|
|
268
|
+
code: 'FRS002',
|
|
269
|
+
doc: docFile,
|
|
269
270
|
message: `${docFile} — ${codeCommitsSince} code commits since last doc update (${docDate.toISOString().split('T')[0]})`,
|
|
270
271
|
});
|
|
271
272
|
continue;
|
|
@@ -277,6 +278,8 @@ export function validateFreshness(dir, config) {
|
|
|
277
278
|
if (daysDiff > STALE_THRESHOLD_DAYS) {
|
|
278
279
|
results.push({
|
|
279
280
|
status: 'warn',
|
|
281
|
+
code: 'FRS003',
|
|
282
|
+
doc: docFile,
|
|
280
283
|
message: `${docFile} — last updated ${daysDiff} days before latest code change`,
|
|
281
284
|
});
|
|
282
285
|
continue;
|
|
@@ -300,6 +303,8 @@ export function validateFreshness(dir, config) {
|
|
|
300
303
|
if (daysDiff > 7) {
|
|
301
304
|
results.push({
|
|
302
305
|
status: 'warn',
|
|
306
|
+
code: 'FRS004',
|
|
307
|
+
doc: config.requiredFiles?.changelog || 'CHANGELOG.md',
|
|
303
308
|
message: `CHANGELOG.md not updated in ${daysDiff} days despite code changes`,
|
|
304
309
|
});
|
|
305
310
|
} else {
|
|
@@ -336,6 +341,8 @@ export function validateFreshness(dir, config) {
|
|
|
336
341
|
if (codeCommitsSince > 3) {
|
|
337
342
|
results.push({
|
|
338
343
|
status: 'warn',
|
|
344
|
+
code: 'FRS005',
|
|
345
|
+
doc: config.requiredFiles?.driftLog || 'DRIFT-LOG.md',
|
|
339
346
|
message: `DRIFT-LOG.md may be stale — ${driftCount} DRIFT comments found in recent commits`,
|
|
340
347
|
});
|
|
341
348
|
}
|