docguard-cli 0.28.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 +64 -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/generate.mjs +14 -1001
- package/cli/commands/guard.mjs +136 -8
- 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/docguard.mjs +31 -2
- package/cli/findings.mjs +499 -0
- package/cli/scanners/agent-readability.mjs +202 -0
- package/cli/scanners/semantic-claims.mjs +7 -1
- 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 +113 -26
- package/cli/validators/architecture.mjs +66 -43
- 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/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
|
@@ -11,13 +11,19 @@
|
|
|
11
11
|
* - package.json bin entries not documented
|
|
12
12
|
* - Source directories not referenced in ARCHITECTURE.md
|
|
13
13
|
* - README.md missing standard sections (inspired by Standard README spec)
|
|
14
|
+
*
|
|
15
|
+
* v0.29: migrated to structured findings (DCV001–DCV006). Messages are
|
|
16
|
+
* byte-identical to the legacy strings — resultFromFindings derives the
|
|
17
|
+
* errors/warnings arrays from the same findings, so counts, exit codes, and
|
|
18
|
+
* existing tests are unaffected; guard just renders richer output.
|
|
14
19
|
*/
|
|
15
20
|
|
|
16
21
|
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
17
22
|
import { resolve, join, relative, basename, extname } from 'node:path';
|
|
18
23
|
import { resolveSourceRoots } from '../shared-source.mjs';
|
|
19
|
-
import { shouldIgnore } from '../shared-ignore.mjs';
|
|
24
|
+
import { shouldIgnore, walkFiles as sharedWalkFiles } from '../shared-ignore.mjs';
|
|
20
25
|
import { detectIaC, hasInfrastructureHeading, buildIaCWarning } from '../scanners/iac.mjs';
|
|
26
|
+
import { mkFinding, resultFromFindings } from '../findings.mjs';
|
|
21
27
|
|
|
22
28
|
const IGNORE_DIRS = new Set([
|
|
23
29
|
'node_modules', '.git', '.next', '.nuxt', 'dist', 'build', 'out',
|
|
@@ -56,14 +62,15 @@ function isGeneratedArtifact(name) {
|
|
|
56
62
|
* @returns {{ errors: string[], warnings: string[], passed: number, total: number }}
|
|
57
63
|
*/
|
|
58
64
|
export function validateDocsCoverage(projectDir, config) {
|
|
59
|
-
const
|
|
65
|
+
const findings = [];
|
|
60
66
|
let passed = 0;
|
|
61
67
|
let total = 0;
|
|
62
68
|
|
|
63
69
|
// Collect all doc content for searching
|
|
64
70
|
const allDocContent = collectDocContent(projectDir);
|
|
65
71
|
if (!allDocContent) {
|
|
66
|
-
|
|
72
|
+
// Literal legacy shape (no findings key) — tests deepEqual this exact object.
|
|
73
|
+
return { errors: [], warnings: [], passed: 0, total: 0 };
|
|
67
74
|
}
|
|
68
75
|
|
|
69
76
|
// IaC detection runs once and informs both Check 3 (suppression) and
|
|
@@ -74,39 +81,39 @@ export function validateDocsCoverage(projectDir, config) {
|
|
|
74
81
|
const configChecks = checkConfigFiles(projectDir, allDocContent, config);
|
|
75
82
|
total += configChecks.total;
|
|
76
83
|
passed += configChecks.passed;
|
|
77
|
-
|
|
84
|
+
findings.push(...configChecks.findings);
|
|
78
85
|
|
|
79
86
|
// ── Check 2: package.json bin entries documented ──
|
|
80
87
|
const binChecks = checkPackageBins(projectDir, allDocContent);
|
|
81
88
|
total += binChecks.total;
|
|
82
89
|
passed += binChecks.passed;
|
|
83
|
-
|
|
90
|
+
findings.push(...binChecks.findings);
|
|
84
91
|
|
|
85
92
|
// ── Check 3: Source directory structure matches ARCHITECTURE.md ──
|
|
86
93
|
const dirChecks = checkSourceDirs(projectDir, allDocContent, config, iac);
|
|
87
94
|
total += dirChecks.total;
|
|
88
95
|
passed += dirChecks.passed;
|
|
89
|
-
|
|
96
|
+
findings.push(...dirChecks.findings);
|
|
90
97
|
|
|
91
98
|
// ── Check 4: Config filenames referenced in source code but not documented ──
|
|
92
99
|
const codeConfigChecks = checkCodeReferencedConfigs(projectDir, allDocContent, config);
|
|
93
100
|
total += codeConfigChecks.total;
|
|
94
101
|
passed += codeConfigChecks.passed;
|
|
95
|
-
|
|
102
|
+
findings.push(...codeConfigChecks.findings);
|
|
96
103
|
|
|
97
104
|
// ── Check 5: README section completeness (Standard README spec) ──
|
|
98
105
|
const readmeChecks = checkReadmeSections(projectDir);
|
|
99
106
|
total += readmeChecks.total;
|
|
100
107
|
passed += readmeChecks.passed;
|
|
101
|
-
|
|
108
|
+
findings.push(...readmeChecks.findings);
|
|
102
109
|
|
|
103
110
|
// ── Check 6: IaC-aware Infrastructure documentation ──
|
|
104
111
|
const iacChecks = checkIaCDocumentation(projectDir, iac);
|
|
105
112
|
total += iacChecks.total;
|
|
106
113
|
passed += iacChecks.passed;
|
|
107
|
-
|
|
114
|
+
findings.push(...iacChecks.findings);
|
|
108
115
|
|
|
109
|
-
return
|
|
116
|
+
return resultFromFindings(findings, { passed, total });
|
|
110
117
|
}
|
|
111
118
|
|
|
112
119
|
// ── Check Functions ─────────────────────────────────────────────────────────
|
|
@@ -118,12 +125,12 @@ export function validateDocsCoverage(projectDir, config) {
|
|
|
118
125
|
* consistently across all docs-coverage checks).
|
|
119
126
|
*/
|
|
120
127
|
function checkConfigFiles(projectDir, allDocContent, config = {}) {
|
|
121
|
-
const
|
|
128
|
+
const findings = [];
|
|
122
129
|
let passed = 0;
|
|
123
130
|
let total = 0;
|
|
124
131
|
|
|
125
132
|
let entries;
|
|
126
|
-
try { entries = readdirSync(projectDir); } catch { return {
|
|
133
|
+
try { entries = readdirSync(projectDir); } catch { return { findings, passed, total }; }
|
|
127
134
|
|
|
128
135
|
const lowerDocContent = allDocContent.toLowerCase();
|
|
129
136
|
|
|
@@ -155,28 +162,33 @@ function checkConfigFiles(projectDir, allDocContent, config = {}) {
|
|
|
155
162
|
if (lowerDocContent.includes(entry.toLowerCase())) {
|
|
156
163
|
passed++;
|
|
157
164
|
} else {
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
165
|
+
findings.push(mkFinding({
|
|
166
|
+
code: 'DCV001',
|
|
167
|
+
validator: 'docsCoverage',
|
|
168
|
+
severity: 'warn',
|
|
169
|
+
message: `Config file "${entry}" exists but is not mentioned in any documentation. Document its purpose in ARCHITECTURE.md or README.md`,
|
|
170
|
+
location: entry,
|
|
171
|
+
suggestion: { kind: 'fix', text: 'Explain what this config file does in ARCHITECTURE.md or README.md' },
|
|
172
|
+
}));
|
|
161
173
|
}
|
|
162
174
|
}
|
|
163
175
|
|
|
164
|
-
return {
|
|
176
|
+
return { findings, passed, total };
|
|
165
177
|
}
|
|
166
178
|
|
|
167
179
|
/**
|
|
168
180
|
* Check 2: package.json bin entries (CLI commands users run) are documented.
|
|
169
181
|
*/
|
|
170
182
|
function checkPackageBins(projectDir, allDocContent) {
|
|
171
|
-
const
|
|
183
|
+
const findings = [];
|
|
172
184
|
let passed = 0;
|
|
173
185
|
let total = 0;
|
|
174
186
|
|
|
175
187
|
const pkgPath = resolve(projectDir, 'package.json');
|
|
176
|
-
if (!existsSync(pkgPath)) return {
|
|
188
|
+
if (!existsSync(pkgPath)) return { findings, passed, total };
|
|
177
189
|
|
|
178
190
|
let pkg;
|
|
179
|
-
try { pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')); } catch { return {
|
|
191
|
+
try { pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')); } catch { return { findings, passed, total }; }
|
|
180
192
|
|
|
181
193
|
const bins = typeof pkg.bin === 'string'
|
|
182
194
|
? { [pkg.name]: pkg.bin }
|
|
@@ -189,13 +201,18 @@ function checkPackageBins(projectDir, allDocContent) {
|
|
|
189
201
|
if (lowerDocContent.includes(binName.toLowerCase())) {
|
|
190
202
|
passed++;
|
|
191
203
|
} else {
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
204
|
+
findings.push(mkFinding({
|
|
205
|
+
code: 'DCV002',
|
|
206
|
+
validator: 'docsCoverage',
|
|
207
|
+
severity: 'warn',
|
|
208
|
+
message: `package.json defines CLI command "${binName}" but it's not mentioned in any documentation`,
|
|
209
|
+
location: 'package.json',
|
|
210
|
+
suggestion: { kind: 'fix', text: `Document the "${binName}" command in README.md (e.g. under Usage)` },
|
|
211
|
+
}));
|
|
195
212
|
}
|
|
196
213
|
}
|
|
197
214
|
|
|
198
|
-
return {
|
|
215
|
+
return { findings, passed, total };
|
|
199
216
|
}
|
|
200
217
|
|
|
201
218
|
/**
|
|
@@ -207,15 +224,15 @@ function checkPackageBins(projectDir, allDocContent) {
|
|
|
207
224
|
* instead (FR-011).
|
|
208
225
|
*/
|
|
209
226
|
function checkSourceDirs(projectDir, allDocContent, config = {}, iac = { isIaC: false, tools: [] }) {
|
|
210
|
-
const
|
|
227
|
+
const findings = [];
|
|
211
228
|
let passed = 0;
|
|
212
229
|
let total = 0;
|
|
213
230
|
|
|
214
231
|
const archPath = resolve(projectDir, 'docs-canonical/ARCHITECTURE.md');
|
|
215
|
-
if (!existsSync(archPath)) return {
|
|
232
|
+
if (!existsSync(archPath)) return { findings, passed, total };
|
|
216
233
|
|
|
217
234
|
let archContent;
|
|
218
|
-
try { archContent = readFileSync(archPath, 'utf-8'); } catch { return {
|
|
235
|
+
try { archContent = readFileSync(archPath, 'utf-8'); } catch { return { findings, passed, total }; }
|
|
219
236
|
|
|
220
237
|
const lowerArchContent = archContent.toLowerCase();
|
|
221
238
|
const infraDocumented = hasInfrastructureHeading(archContent);
|
|
@@ -264,14 +281,19 @@ function checkSourceDirs(projectDir, allDocContent, config = {}, iac = { isIaC:
|
|
|
264
281
|
if (lowerArchContent.includes(searchName) || lowerArchContent.includes(root + '/' + entry)) {
|
|
265
282
|
passed++;
|
|
266
283
|
} else {
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
284
|
+
findings.push(mkFinding({
|
|
285
|
+
code: 'DCV003',
|
|
286
|
+
validator: 'docsCoverage',
|
|
287
|
+
severity: 'warn',
|
|
288
|
+
message: `Source directory "${root}/${entry}/" is not referenced in ARCHITECTURE.md`,
|
|
289
|
+
location: relPath,
|
|
290
|
+
suggestion: { kind: 'fix', text: 'Add this directory to the Component Map in docs-canonical/ARCHITECTURE.md' },
|
|
291
|
+
}));
|
|
270
292
|
}
|
|
271
293
|
}
|
|
272
294
|
}
|
|
273
295
|
|
|
274
|
-
return {
|
|
296
|
+
return { findings, passed, total };
|
|
275
297
|
}
|
|
276
298
|
|
|
277
299
|
/**
|
|
@@ -310,30 +332,37 @@ function isInsideIaCPackage(relPath, packageDirs) {
|
|
|
310
332
|
* warnings that would otherwise fire for bin/, lib/, modules/, handlers/, etc.
|
|
311
333
|
*/
|
|
312
334
|
function checkIaCDocumentation(projectDir, iac) {
|
|
313
|
-
const
|
|
314
|
-
if (!iac || !iac.isIaC) return {
|
|
335
|
+
const findings = [];
|
|
336
|
+
if (!iac || !iac.isIaC) return { findings, passed: 0, total: 0 };
|
|
315
337
|
|
|
316
338
|
const archPath = resolve(projectDir, 'docs-canonical/ARCHITECTURE.md');
|
|
317
339
|
if (!existsSync(archPath)) {
|
|
318
340
|
// No ARCHITECTURE.md at all — structure validator will catch that.
|
|
319
341
|
// Don't double-warn here.
|
|
320
|
-
return {
|
|
342
|
+
return { findings, passed: 0, total: 0 };
|
|
321
343
|
}
|
|
322
344
|
|
|
323
345
|
let archContent;
|
|
324
|
-
try { archContent = readFileSync(archPath, 'utf-8'); } catch { return {
|
|
346
|
+
try { archContent = readFileSync(archPath, 'utf-8'); } catch { return { findings, passed: 0, total: 0 }; }
|
|
325
347
|
|
|
326
348
|
if (hasInfrastructureHeading(archContent)) {
|
|
327
349
|
// One pass per tool — counted as total per IaC tool present.
|
|
328
|
-
return {
|
|
350
|
+
return { findings, passed: iac.tools.length, total: iac.tools.length };
|
|
329
351
|
}
|
|
330
352
|
|
|
331
353
|
// One actionable warning per detected IaC tool. Most projects use one tool,
|
|
332
354
|
// but a multi-tool monorepo gets one targeted message each.
|
|
333
355
|
for (const tool of iac.tools) {
|
|
334
|
-
|
|
356
|
+
findings.push(mkFinding({
|
|
357
|
+
code: 'DCV006',
|
|
358
|
+
validator: 'docsCoverage',
|
|
359
|
+
severity: 'warn',
|
|
360
|
+
message: buildIaCWarning(tool),
|
|
361
|
+
location: 'docs-canonical/ARCHITECTURE.md',
|
|
362
|
+
suggestion: { kind: 'fix', text: `Add an "Infrastructure" section to ARCHITECTURE.md covering the ${tool.label} layout` },
|
|
363
|
+
}));
|
|
335
364
|
}
|
|
336
|
-
return {
|
|
365
|
+
return { findings, passed: 0, total: iac.tools.length };
|
|
337
366
|
}
|
|
338
367
|
|
|
339
368
|
/**
|
|
@@ -344,7 +373,7 @@ function checkIaCDocumentation(projectDir, iac) {
|
|
|
344
373
|
* sitting in arrays (scan patterns for detecting other projects' configs).
|
|
345
374
|
*/
|
|
346
375
|
function checkCodeReferencedConfigs(projectDir, allDocContent, config = {}) {
|
|
347
|
-
const
|
|
376
|
+
const findings = [];
|
|
348
377
|
let passed = 0;
|
|
349
378
|
let total = 0;
|
|
350
379
|
|
|
@@ -386,13 +415,18 @@ function checkCodeReferencedConfigs(projectDir, allDocContent, config = {}) {
|
|
|
386
415
|
if (lowerDocContent.includes(configName.toLowerCase())) {
|
|
387
416
|
passed++;
|
|
388
417
|
} else {
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
418
|
+
findings.push(mkFinding({
|
|
419
|
+
code: 'DCV004',
|
|
420
|
+
validator: 'docsCoverage',
|
|
421
|
+
severity: 'warn',
|
|
422
|
+
message: `Code references config file "${configName}" but no documentation mentions it. Add it to README.md or ARCHITECTURE.md`,
|
|
423
|
+
location: configName,
|
|
424
|
+
suggestion: { kind: 'fix', text: 'Describe this config file (purpose and format) in README.md or ARCHITECTURE.md' },
|
|
425
|
+
}));
|
|
392
426
|
}
|
|
393
427
|
}
|
|
394
428
|
|
|
395
|
-
return {
|
|
429
|
+
return { findings, passed, total };
|
|
396
430
|
}
|
|
397
431
|
|
|
398
432
|
/**
|
|
@@ -401,15 +435,15 @@ function checkCodeReferencedConfigs(projectDir, allDocContent, config = {}) {
|
|
|
401
435
|
* and Make a README (https://www.makeareadme.com/).
|
|
402
436
|
*/
|
|
403
437
|
function checkReadmeSections(projectDir) {
|
|
404
|
-
const
|
|
438
|
+
const findings = [];
|
|
405
439
|
let passed = 0;
|
|
406
440
|
let total = 0;
|
|
407
441
|
|
|
408
442
|
const readmePath = resolve(projectDir, 'README.md');
|
|
409
|
-
if (!existsSync(readmePath)) return {
|
|
443
|
+
if (!existsSync(readmePath)) return { findings, passed, total };
|
|
410
444
|
|
|
411
445
|
let content;
|
|
412
|
-
try { content = readFileSync(readmePath, 'utf-8'); } catch { return {
|
|
446
|
+
try { content = readFileSync(readmePath, 'utf-8'); } catch { return { findings, passed, total }; }
|
|
413
447
|
|
|
414
448
|
const lowerContent = content.toLowerCase();
|
|
415
449
|
|
|
@@ -431,7 +465,14 @@ function checkReadmeSections(projectDir) {
|
|
|
431
465
|
if (section.patterns.some(p => lowerContent.includes(p))) {
|
|
432
466
|
passed++;
|
|
433
467
|
} else {
|
|
434
|
-
|
|
468
|
+
findings.push(mkFinding({
|
|
469
|
+
code: 'DCV005',
|
|
470
|
+
validator: 'docsCoverage',
|
|
471
|
+
severity: 'warn',
|
|
472
|
+
message: `README.md is missing a "${section.name}" section (Standard README spec)`,
|
|
473
|
+
location: 'README.md',
|
|
474
|
+
suggestion: { kind: 'fix', text: `Add a "${section.name}" section to README.md` },
|
|
475
|
+
}));
|
|
435
476
|
}
|
|
436
477
|
}
|
|
437
478
|
|
|
@@ -445,7 +486,7 @@ function checkReadmeSections(projectDir) {
|
|
|
445
486
|
}
|
|
446
487
|
}
|
|
447
488
|
|
|
448
|
-
return {
|
|
489
|
+
return { findings, passed, total };
|
|
449
490
|
}
|
|
450
491
|
|
|
451
492
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
@@ -497,21 +538,7 @@ function collectDocContent(projectDir) {
|
|
|
497
538
|
return parts.join('\n');
|
|
498
539
|
}
|
|
499
540
|
|
|
541
|
+
// v0.29 consolidation: traversal delegates to the shared canonical walker.
|
|
500
542
|
function walkFiles(dir, callback) {
|
|
501
|
-
|
|
502
|
-
let entries;
|
|
503
|
-
try { entries = readdirSync(dir); } catch { return; }
|
|
504
|
-
|
|
505
|
-
for (const entry of entries) {
|
|
506
|
-
if (IGNORE_DIRS.has(entry) || entry.startsWith('.')) continue;
|
|
507
|
-
const fullPath = join(dir, entry);
|
|
508
|
-
try {
|
|
509
|
-
const stat = statSync(fullPath);
|
|
510
|
-
if (stat.isDirectory()) {
|
|
511
|
-
walkFiles(fullPath, callback);
|
|
512
|
-
} else if (stat.isFile()) {
|
|
513
|
-
callback(fullPath);
|
|
514
|
-
}
|
|
515
|
-
} catch { /* skip */ }
|
|
516
|
-
}
|
|
543
|
+
sharedWalkFiles(dir, callback, { ignoreDirs: IGNORE_DIRS });
|
|
517
544
|
}
|
|
@@ -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
|
}
|