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
|
@@ -30,6 +30,7 @@ import { scanRoutesDeep } from '../scanners/routes.mjs';
|
|
|
30
30
|
import { parseApiReferenceDoc, compareEndpoints, endpointKey } from '../scanners/api-doc.mjs';
|
|
31
31
|
import { collectPackageJsons, getWorkspaceDirs } from '../shared-source.mjs';
|
|
32
32
|
import { relPosix } from '../shared-ignore.mjs';
|
|
33
|
+
import { mkFinding, resultFromFindings } from '../findings.mjs';
|
|
33
34
|
|
|
34
35
|
const MAX_REPORTED = 15;
|
|
35
36
|
const API_DOC = 'docs-canonical/API-REFERENCE.md';
|
|
@@ -250,9 +251,12 @@ export function computeSpecVsRouteDrift(projectDir, config) {
|
|
|
250
251
|
};
|
|
251
252
|
}
|
|
252
253
|
|
|
254
|
+
// v0.29: migrated to structured findings (API001–API005). Messages are
|
|
255
|
+
// byte-identical to the legacy strings — resultFromFindings derives the
|
|
256
|
+
// errors/warnings arrays from the same findings; `fixes` and
|
|
257
|
+
// `authoritativeSpec` are preserved.
|
|
253
258
|
export function validateApiSurface(projectDir, config) {
|
|
254
|
-
const
|
|
255
|
-
const warnings = [];
|
|
259
|
+
const findings = [];
|
|
256
260
|
const fixes = [];
|
|
257
261
|
const trim = (arr) => {
|
|
258
262
|
const shown = arr.slice(0, MAX_REPORTED);
|
|
@@ -268,7 +272,7 @@ export function validateApiSurface(projectDir, config) {
|
|
|
268
272
|
const anyRouteFile = config.changedFiles.some(f => ROUTE_RE.test(f));
|
|
269
273
|
if (!anyRouteFile) {
|
|
270
274
|
return {
|
|
271
|
-
errors, warnings, passed: 0, total: 0, fixes,
|
|
275
|
+
errors: [], warnings: [], passed: 0, total: 0, fixes,
|
|
272
276
|
applicable: false,
|
|
273
277
|
note: 'no route/spec files in changed set',
|
|
274
278
|
};
|
|
@@ -280,12 +284,17 @@ export function validateApiSurface(projectDir, config) {
|
|
|
280
284
|
// choked on it. We fall back to code scanning (below), but the parse failure
|
|
281
285
|
// is surfaced here rather than silently producing a clean "no surface" pass.
|
|
282
286
|
for (const specPath of findUnparseableSpecs(projectDir, config)) {
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
`
|
|
288
|
-
|
|
287
|
+
findings.push(mkFinding({
|
|
288
|
+
code: 'API001',
|
|
289
|
+
validator: 'apiSurface',
|
|
290
|
+
severity: 'warn',
|
|
291
|
+
message: `OpenAPI spec ${specPath} declares paths but DocGuard parsed 0 endpoints from it ` +
|
|
292
|
+
`(likely an unsupported YAML feature — $ref, anchors, or folded scalars). ` +
|
|
293
|
+
`Falling back to code scanning; the spec's own endpoint list is unavailable. ` +
|
|
294
|
+
`Validate it with a full OpenAPI linter.`,
|
|
295
|
+
location: specPath,
|
|
296
|
+
suggestion: { kind: 'review', text: 'Validate the spec with a full OpenAPI linter (e.g. spectral) and simplify unsupported YAML features' },
|
|
297
|
+
}));
|
|
289
298
|
}
|
|
290
299
|
|
|
291
300
|
const drift = computeApiSurfaceDrift(projectDir, config);
|
|
@@ -296,10 +305,15 @@ export function validateApiSurface(projectDir, config) {
|
|
|
296
305
|
const others = divergence.specs.slice(1).map(s => s.relPath).join(', ');
|
|
297
306
|
const sample = divergence.divergent.slice(0, 8).join(', ');
|
|
298
307
|
const more = divergence.divergent.length > 8 ? ` (+${divergence.divergent.length - 8} more)` : '';
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
308
|
+
findings.push(mkFinding({
|
|
309
|
+
code: 'API002',
|
|
310
|
+
validator: 'apiSurface',
|
|
311
|
+
severity: 'warn',
|
|
312
|
+
message: `Multiple OpenAPI specs disagree on ${divergence.divergent.length} endpoint(s): ` +
|
|
313
|
+
`${divergence.authoritative} (treated as authoritative) vs ${others}. Divergent: ${sample}${more}`,
|
|
314
|
+
location: divergence.authoritative,
|
|
315
|
+
suggestion: { kind: 'review', text: 'Regenerate or delete the stale spec copy so every spec agrees on the endpoint set' },
|
|
316
|
+
}));
|
|
303
317
|
}
|
|
304
318
|
|
|
305
319
|
// ── #4: spec declares an endpoint with no registered route ──
|
|
@@ -314,12 +328,30 @@ export function validateApiSurface(projectDir, config) {
|
|
|
314
328
|
if (specRoute.specDeclaredNoRoute.length) {
|
|
315
329
|
const { shown, extra } = trim(specRoute.specDeclaredNoRoute);
|
|
316
330
|
for (const e of shown) {
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
331
|
+
findings.push(mkFinding({
|
|
332
|
+
code: 'API003',
|
|
333
|
+
validator: 'apiSurface',
|
|
334
|
+
severity: 'warn',
|
|
335
|
+
// "may be wrong" — the route scanner can be blind to dynamic
|
|
336
|
+
// registration, so this is a candidate false positive by design.
|
|
337
|
+
confidence: 'low',
|
|
338
|
+
message: `OpenAPI spec (${specRoute.specPath}) declares ${e.method} ${e.path} but no route registers it in code — ` +
|
|
339
|
+
`the spec may be wrong, and the API-REFERENCE doc reconciles clean against it, hiding the gap.`,
|
|
340
|
+
location: specRoute.specPath,
|
|
341
|
+
suggestion: { kind: 'review', text: 'Verify the endpoint: remove it from the spec if it no longer exists, or check whether the route is registered dynamically' },
|
|
342
|
+
}));
|
|
343
|
+
}
|
|
344
|
+
if (extra > 0) {
|
|
345
|
+
findings.push(mkFinding({
|
|
346
|
+
code: 'API003',
|
|
347
|
+
validator: 'apiSurface',
|
|
348
|
+
severity: 'warn',
|
|
349
|
+
confidence: 'low',
|
|
350
|
+
message: `…and ${extra} more spec-declared endpoint(s) with no registered route`,
|
|
351
|
+
location: specRoute.specPath,
|
|
352
|
+
suggestion: { kind: 'review', text: 'Verify each spec-declared endpoint against the registered routes' },
|
|
353
|
+
}));
|
|
321
354
|
}
|
|
322
|
-
if (extra > 0) warnings.push(`…and ${extra} more spec-declared endpoint(s) with no registered route`);
|
|
323
355
|
}
|
|
324
356
|
}
|
|
325
357
|
|
|
@@ -327,7 +359,8 @@ export function validateApiSurface(projectDir, config) {
|
|
|
327
359
|
// Nothing to validate against the API-REFERENCE doc — but the spec-vs-route
|
|
328
360
|
// check above may still have produced findings.
|
|
329
361
|
return {
|
|
330
|
-
|
|
362
|
+
...resultFromFindings(findings, { passed: specRoutePassed, total: specRouteTotal }),
|
|
363
|
+
fixes,
|
|
331
364
|
authoritativeSpec: drift.source || specRoute.specPath,
|
|
332
365
|
};
|
|
333
366
|
}
|
|
@@ -341,13 +374,51 @@ export function validateApiSurface(projectDir, config) {
|
|
|
341
374
|
const { shown, extra } = trim(documentedButAbsent);
|
|
342
375
|
for (const e of shown) {
|
|
343
376
|
const msg = `Documented endpoint not found in code: ${e.method} ${e.path} (${API_DOC})`;
|
|
344
|
-
if (confidence === 'spec')
|
|
345
|
-
|
|
377
|
+
if (confidence === 'spec') {
|
|
378
|
+
findings.push(mkFinding({
|
|
379
|
+
code: 'API004',
|
|
380
|
+
validator: 'apiSurface',
|
|
381
|
+
severity: 'error',
|
|
382
|
+
message: msg,
|
|
383
|
+
location: API_DOC,
|
|
384
|
+
suggestion: { kind: 'fix', text: 'Remove the dead endpoint from the doc', command: 'docguard fix --write' },
|
|
385
|
+
}));
|
|
386
|
+
} else {
|
|
387
|
+
findings.push(mkFinding({
|
|
388
|
+
code: 'API004',
|
|
389
|
+
validator: 'apiSurface',
|
|
390
|
+
severity: 'warn',
|
|
391
|
+
// The "[code-scan — verify]" suffix marks this as heuristic-only:
|
|
392
|
+
// the route scanner may simply not see the endpoint's registration.
|
|
393
|
+
confidence: 'low',
|
|
394
|
+
message: `${msg} [code-scan — verify]`,
|
|
395
|
+
location: API_DOC,
|
|
396
|
+
suggestion: { kind: 'review', text: 'Verify the endpoint really is gone from the code, then remove it from the doc' },
|
|
397
|
+
}));
|
|
398
|
+
}
|
|
346
399
|
}
|
|
347
400
|
if (extra > 0) {
|
|
348
401
|
const tail = `…and ${extra} more documented endpoint(s) not found in code`;
|
|
349
|
-
if (confidence === 'spec')
|
|
350
|
-
|
|
402
|
+
if (confidence === 'spec') {
|
|
403
|
+
findings.push(mkFinding({
|
|
404
|
+
code: 'API004',
|
|
405
|
+
validator: 'apiSurface',
|
|
406
|
+
severity: 'error',
|
|
407
|
+
message: tail,
|
|
408
|
+
location: API_DOC,
|
|
409
|
+
suggestion: { kind: 'fix', text: 'Remove the dead endpoints from the doc', command: 'docguard fix --write' },
|
|
410
|
+
}));
|
|
411
|
+
} else {
|
|
412
|
+
findings.push(mkFinding({
|
|
413
|
+
code: 'API004',
|
|
414
|
+
validator: 'apiSurface',
|
|
415
|
+
severity: 'warn',
|
|
416
|
+
confidence: 'low',
|
|
417
|
+
message: tail,
|
|
418
|
+
location: API_DOC,
|
|
419
|
+
suggestion: { kind: 'review', text: 'Verify each documented endpoint against the code, then prune the doc' },
|
|
420
|
+
}));
|
|
421
|
+
}
|
|
351
422
|
}
|
|
352
423
|
// Only spec-confirmed absences are safe to auto-remove.
|
|
353
424
|
if (confidence === 'spec') {
|
|
@@ -361,10 +432,26 @@ export function validateApiSurface(projectDir, config) {
|
|
|
361
432
|
if (presentButUndocumented.length) {
|
|
362
433
|
const { shown, extra } = trim(presentButUndocumented);
|
|
363
434
|
for (const e of shown) {
|
|
364
|
-
|
|
435
|
+
findings.push(mkFinding({
|
|
436
|
+
code: 'API005',
|
|
437
|
+
validator: 'apiSurface',
|
|
438
|
+
severity: 'warn',
|
|
439
|
+
message: `Undocumented endpoint in code: ${e.method} ${e.path} — add it to ${API_DOC}`,
|
|
440
|
+
location: API_DOC,
|
|
441
|
+
suggestion: { kind: 'fix', text: `Document the endpoint in ${API_DOC}` },
|
|
442
|
+
}));
|
|
443
|
+
}
|
|
444
|
+
if (extra > 0) {
|
|
445
|
+
findings.push(mkFinding({
|
|
446
|
+
code: 'API005',
|
|
447
|
+
validator: 'apiSurface',
|
|
448
|
+
severity: 'warn',
|
|
449
|
+
message: `…and ${extra} more undocumented endpoint(s) in code`,
|
|
450
|
+
location: API_DOC,
|
|
451
|
+
suggestion: { kind: 'fix', text: `Document the remaining endpoints in ${API_DOC}` },
|
|
452
|
+
}));
|
|
365
453
|
}
|
|
366
|
-
if (extra > 0) warnings.push(`…and ${extra} more undocumented endpoint(s) in code`);
|
|
367
454
|
}
|
|
368
455
|
|
|
369
|
-
return {
|
|
456
|
+
return { ...resultFromFindings(findings, { passed, total }), fixes, authoritativeSpec: source };
|
|
370
457
|
}
|
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
|
|
18
18
|
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
19
19
|
import { resolve, join, extname, relative, dirname, basename } from 'node:path';
|
|
20
|
-
import { shouldIgnore } from '../shared-ignore.mjs';
|
|
20
|
+
import { shouldIgnore, walkFiles as sharedWalkFiles } from '../shared-ignore.mjs';
|
|
21
|
+
import { mkFinding, resultFromFindings } from '../findings.mjs';
|
|
21
22
|
|
|
22
23
|
const IGNORE_DIRS = new Set([
|
|
23
24
|
'node_modules', '.git', '.next', 'dist', 'build',
|
|
@@ -27,24 +28,42 @@ const IGNORE_DIRS = new Set([
|
|
|
27
28
|
|
|
28
29
|
const CODE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.mjs', '.cjs', '.jsx']);
|
|
29
30
|
|
|
31
|
+
// v0.29: migrated to structured findings (ARC001–ARC003). Messages are
|
|
32
|
+
// byte-identical to the legacy strings — resultFromFindings derives the
|
|
33
|
+
// errors/warnings arrays from the same findings array (acc), which the
|
|
34
|
+
// helpers below mutate in place.
|
|
30
35
|
export function validateArchitecture(projectDir, config) {
|
|
31
|
-
const
|
|
36
|
+
const acc = { findings: [], passed: 0, total: 0 };
|
|
37
|
+
const compose = () => ({
|
|
38
|
+
name: 'architecture',
|
|
39
|
+
...resultFromFindings(acc.findings, { passed: acc.passed, total: acc.total }),
|
|
40
|
+
});
|
|
32
41
|
|
|
33
42
|
// ── 1. Config-driven layer validation ──
|
|
34
43
|
const layers = config.layers;
|
|
35
44
|
if (layers && Object.keys(layers).length > 0) {
|
|
36
|
-
validateConfigLayers(projectDir, config, layers,
|
|
45
|
+
validateConfigLayers(projectDir, config, layers, acc);
|
|
37
46
|
}
|
|
38
47
|
|
|
39
48
|
// ── 2. Auto-detect import graph ──
|
|
40
49
|
const importGraph = buildImportGraph(projectDir, config);
|
|
41
|
-
if (importGraph.files.length === 0) return
|
|
50
|
+
if (importGraph.files.length === 0) return compose();
|
|
42
51
|
|
|
43
52
|
// ── 3. Detect circular dependencies ──
|
|
44
53
|
const circles = detectCircularDeps(importGraph);
|
|
45
54
|
for (const circle of circles) {
|
|
46
|
-
|
|
47
|
-
|
|
55
|
+
acc.total++;
|
|
56
|
+
acc.findings.push(mkFinding({
|
|
57
|
+
code: 'ARC002',
|
|
58
|
+
validator: 'architecture',
|
|
59
|
+
severity: 'warn',
|
|
60
|
+
message: `Circular dependency: ${circle.join(' → ')}`,
|
|
61
|
+
location: circle[0],
|
|
62
|
+
suggestion: {
|
|
63
|
+
kind: 'fix',
|
|
64
|
+
text: 'Break the cycle — convert one edge to a dynamic import() or extract the shared code into a third module',
|
|
65
|
+
},
|
|
66
|
+
}));
|
|
48
67
|
}
|
|
49
68
|
|
|
50
69
|
// ── 4. Check layer boundaries from ARCHITECTURE.md ──
|
|
@@ -54,13 +73,14 @@ export function validateArchitecture(projectDir, config) {
|
|
|
54
73
|
const declaredLayers = parseLayerBoundaries(archContent);
|
|
55
74
|
|
|
56
75
|
if (declaredLayers.length > 0) {
|
|
57
|
-
validateLayerBoundaries(projectDir, importGraph, declaredLayers,
|
|
76
|
+
validateLayerBoundaries(projectDir, importGraph, declaredLayers, acc);
|
|
58
77
|
}
|
|
59
78
|
}
|
|
60
79
|
|
|
61
80
|
// ── 5. No boundaries declared and no circular deps to check → not applicable.
|
|
62
81
|
// (Previously this returned a fake 1/1 pass, rendering a confident green ✅
|
|
63
82
|
// for projects that declared no layer boundaries — it validated nothing.)
|
|
83
|
+
const results = compose();
|
|
64
84
|
if (results.total === 0) {
|
|
65
85
|
results.note = 'no layer boundaries declared in ARCHITECTURE.md';
|
|
66
86
|
}
|
|
@@ -70,7 +90,7 @@ export function validateArchitecture(projectDir, config) {
|
|
|
70
90
|
|
|
71
91
|
// ── Config-driven validation (existing behavior) ────────────────────────────
|
|
72
92
|
|
|
73
|
-
function validateConfigLayers(projectDir, config, layers,
|
|
93
|
+
function validateConfigLayers(projectDir, config, layers, acc) {
|
|
74
94
|
const layerMap = {};
|
|
75
95
|
for (const [layerName, layerConfig] of Object.entries(layers)) {
|
|
76
96
|
if (layerConfig.dir && layerConfig.canImport) {
|
|
@@ -102,10 +122,18 @@ function validateConfigLayers(projectDir, config, layers, results) {
|
|
|
102
122
|
|
|
103
123
|
for (const forbiddenDir of layer.forbidden) {
|
|
104
124
|
if (spec.includes(forbiddenDir) || spec.includes(`/${forbiddenDir}/`)) {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
125
|
+
acc.total++;
|
|
126
|
+
acc.findings.push(mkFinding({
|
|
127
|
+
code: 'ARC001',
|
|
128
|
+
validator: 'architecture',
|
|
129
|
+
severity: 'error',
|
|
130
|
+
message: `${relPath}: ${layer.name} layer imports from forbidden layer (${forbiddenDir})`,
|
|
131
|
+
location: relPath,
|
|
132
|
+
suggestion: {
|
|
133
|
+
kind: 'fix',
|
|
134
|
+
text: 'Remove the import or route it through an allowed layer (see the layers config in .docguard.json)',
|
|
135
|
+
},
|
|
136
|
+
}));
|
|
109
137
|
}
|
|
110
138
|
}
|
|
111
139
|
}
|
|
@@ -307,7 +335,7 @@ function parseLayerBoundaries(archContent) {
|
|
|
307
335
|
return layers;
|
|
308
336
|
}
|
|
309
337
|
|
|
310
|
-
function validateLayerBoundaries(projectDir, graph, declaredLayers,
|
|
338
|
+
function validateLayerBoundaries(projectDir, graph, declaredLayers, acc) {
|
|
311
339
|
// Map directory patterns to layer names
|
|
312
340
|
const layerDirMap = new Map();
|
|
313
341
|
for (const layer of declaredLayers) {
|
|
@@ -327,13 +355,21 @@ function validateLayerBoundaries(projectDir, graph, declaredLayers, results) {
|
|
|
327
355
|
|
|
328
356
|
// Check if this import is forbidden
|
|
329
357
|
if (fromLayer.cannotImport.some(l => l.includes(toLayer.name) || toLayer.name.includes(l))) {
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
358
|
+
acc.total++;
|
|
359
|
+
acc.findings.push(mkFinding({
|
|
360
|
+
code: 'ARC003',
|
|
361
|
+
validator: 'architecture',
|
|
362
|
+
severity: 'error',
|
|
363
|
+
message: `${edge.from}: ${fromLayer.name} → ${toLayer.name} (forbidden by ARCHITECTURE.md)`,
|
|
364
|
+
location: edge.from,
|
|
365
|
+
suggestion: {
|
|
366
|
+
kind: 'review',
|
|
367
|
+
text: 'Remove or invert the import — or update the Layer Boundaries table in ARCHITECTURE.md if the rule changed',
|
|
368
|
+
},
|
|
369
|
+
}));
|
|
334
370
|
} else {
|
|
335
|
-
|
|
336
|
-
|
|
371
|
+
acc.total++;
|
|
372
|
+
acc.passed++;
|
|
337
373
|
}
|
|
338
374
|
}
|
|
339
375
|
}
|
|
@@ -376,33 +412,20 @@ function getFileLayer(filePath, layerDirMap) {
|
|
|
376
412
|
|
|
377
413
|
// ── Utilities ───────────────────────────────────────────────────────────────
|
|
378
414
|
|
|
415
|
+
// v0.29 consolidation: traversal delegates to the shared canonical walker.
|
|
416
|
+
// The old version pruned config-ignored DIRECTORIES before descending; the
|
|
417
|
+
// per-file check below yields the same result set (ignore-glob semantics match
|
|
418
|
+
// any path under the dir — see globToRegex's `^pattern/` alternation), at the
|
|
419
|
+
// cost of descending then filtering. Correctness-equivalent, verified by the
|
|
420
|
+
// ignore-validator specs.
|
|
379
421
|
function getFilesRecursive(dir, config, projectDir) {
|
|
380
422
|
const results = [];
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
let entries;
|
|
384
|
-
try {
|
|
385
|
-
entries = readdirSync(dir);
|
|
386
|
-
} catch { return results; }
|
|
387
|
-
|
|
388
|
-
for (const entry of entries) {
|
|
389
|
-
if (IGNORE_DIRS.has(entry) || entry.startsWith('.')) continue;
|
|
390
|
-
|
|
391
|
-
// Check config.ignore for this directory
|
|
423
|
+
sharedWalkFiles(dir, (fullPath) => {
|
|
392
424
|
if (config && projectDir) {
|
|
393
|
-
const relPath = relative(projectDir,
|
|
394
|
-
if (shouldIgnore(relPath, config))
|
|
425
|
+
const relPath = relative(projectDir, fullPath);
|
|
426
|
+
if (shouldIgnore(relPath, config)) return;
|
|
395
427
|
}
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
try {
|
|
399
|
-
const stat = statSync(fullPath);
|
|
400
|
-
if (stat.isDirectory()) {
|
|
401
|
-
results.push(...getFilesRecursive(fullPath, config, projectDir));
|
|
402
|
-
} else {
|
|
403
|
-
results.push(fullPath);
|
|
404
|
-
}
|
|
405
|
-
} catch { /* skip */ }
|
|
406
|
-
}
|
|
428
|
+
results.push(fullPath);
|
|
429
|
+
}, { ignoreDirs: IGNORE_DIRS });
|
|
407
430
|
return results;
|
|
408
431
|
}
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
|
|
41
41
|
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
42
42
|
import { resolve, join } from 'node:path';
|
|
43
|
+
import { mkFinding, resultFromFindings } from '../findings.mjs';
|
|
43
44
|
|
|
44
45
|
/**
|
|
45
46
|
* Validate that README count claims about DocGuard's surface match code-truth.
|
|
@@ -48,30 +49,38 @@ import { resolve, join } from 'node:path';
|
|
|
48
49
|
* @param {string} projectDir - Project root directory
|
|
49
50
|
* @param {object} config - DocGuard config (unused but required by validator interface)
|
|
50
51
|
* @param {Array} [guardResults] - Results array from runGuardInternal (optional but recommended)
|
|
52
|
+
*
|
|
53
|
+
* v0.29: migrated to structured findings (CSY001–CSY004). Messages are
|
|
54
|
+
* byte-identical to the legacy strings — resultFromFindings derives the
|
|
55
|
+
* errors/warnings arrays from the same findings array at every return point.
|
|
51
56
|
* @returns {{ errors: string[], warnings: string[], fixes: object[], passed: number, total: number, na?: boolean, naReason?: string }}
|
|
52
57
|
*/
|
|
53
58
|
export function validateCanonicalSync(projectDir, config, guardResults) {
|
|
54
|
-
const
|
|
59
|
+
const findings = [];
|
|
60
|
+
const fixes = [];
|
|
61
|
+
let passed = 0;
|
|
62
|
+
let total = 0;
|
|
63
|
+
// Compose the legacy result shape (plus findings) at every return point.
|
|
64
|
+
const compose = (extra) => ({ ...resultFromFindings(findings, { passed, total }), fixes, ...extra });
|
|
55
65
|
|
|
56
66
|
// ── Gate: only run in DocGuard's own repo ─────────────────────────────
|
|
57
67
|
const pkgPath = resolve(projectDir, 'package.json');
|
|
58
68
|
if (!existsSync(pkgPath)) {
|
|
59
|
-
return {
|
|
69
|
+
return compose({ na: true, naReason: 'no package.json' });
|
|
60
70
|
}
|
|
61
71
|
|
|
62
72
|
let pkg;
|
|
63
73
|
try {
|
|
64
74
|
pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
|
65
75
|
} catch {
|
|
66
|
-
return {
|
|
76
|
+
return compose({ na: true, naReason: 'unreadable package.json' });
|
|
67
77
|
}
|
|
68
78
|
|
|
69
79
|
if (pkg.name !== 'docguard-cli') {
|
|
70
|
-
return {
|
|
71
|
-
...result,
|
|
80
|
+
return compose({
|
|
72
81
|
na: true,
|
|
73
82
|
naReason: 'canonical-sync only runs in the docguard-cli repo (it polices DocGuard\'s own surface)',
|
|
74
|
-
};
|
|
83
|
+
});
|
|
75
84
|
}
|
|
76
85
|
|
|
77
86
|
// ── Gather code-truth ────────────────────────────────────────────────
|
|
@@ -80,7 +89,7 @@ export function validateCanonicalSync(projectDir, config, guardResults) {
|
|
|
80
89
|
const validatorsDir = resolve(cliDir, 'validators');
|
|
81
90
|
|
|
82
91
|
if (!existsSync(commandsDir) || !existsSync(validatorsDir)) {
|
|
83
|
-
return {
|
|
92
|
+
return compose({ na: true, naReason: 'cli/commands or cli/validators not found' });
|
|
84
93
|
}
|
|
85
94
|
|
|
86
95
|
const commandFiles = readdirSync(commandsDir).filter(f => f.endsWith('.mjs'));
|
|
@@ -137,60 +146,77 @@ export function validateCanonicalSync(projectDir, config, guardResults) {
|
|
|
137
146
|
try { readme += readFileSync(p, 'utf-8') + '\n'; readAny = true; } catch { /* skip unreadable */ }
|
|
138
147
|
}
|
|
139
148
|
if (!readAny) {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
149
|
+
findings.push(mkFinding({
|
|
150
|
+
code: 'CSY001',
|
|
151
|
+
validator: 'canonicalSync',
|
|
152
|
+
severity: 'warn',
|
|
153
|
+
message: 'canonical-sync: no README.md or AGENTS.md found — cannot check surface claims',
|
|
154
|
+
location: 'README.md',
|
|
155
|
+
suggestion: { kind: 'review', text: 'Add a README.md (or AGENTS.md) so DocGuard can police its own surface claims' },
|
|
156
|
+
}));
|
|
157
|
+
total = 1;
|
|
158
|
+
return compose();
|
|
143
159
|
}
|
|
144
160
|
|
|
145
161
|
// ── Check 1: "ships N commands" ─────────────────────────────────────
|
|
146
162
|
// Check ALL claims (matchAll), not just the first: with README + AGENTS.md
|
|
147
163
|
// concatenated, a correct claim in one file must not mask a stale claim in
|
|
148
164
|
// the other (the same first-match-masking trap the secret scanner had).
|
|
149
|
-
|
|
165
|
+
total++;
|
|
150
166
|
const cmdMatches = [...readme.matchAll(/ships\s+\*{0,2}(\d+)\s+commands?\*{0,2}/gi)];
|
|
151
167
|
if (cmdMatches.length > 0) {
|
|
152
168
|
const wrong = [...new Set(cmdMatches.map(m => Number(m[1])).filter(n => n !== actualCommandCount))];
|
|
153
169
|
if (wrong.length === 0) {
|
|
154
|
-
|
|
170
|
+
passed++;
|
|
155
171
|
} else {
|
|
156
172
|
const detail = actualUserFacingCount !== actualCommandFileCount
|
|
157
173
|
? `${actualCommandCount} user-facing commands in --help (${actualCommandFileCount} files including deprecation aliases)`
|
|
158
174
|
: `${actualCommandCount} command file(s)`;
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
175
|
+
findings.push(mkFinding({
|
|
176
|
+
code: 'CSY002',
|
|
177
|
+
validator: 'canonicalSync',
|
|
178
|
+
severity: 'warn',
|
|
179
|
+
message: `A surface doc (README.md/AGENTS.md) claims ${wrong.map(n => `"ships ${n} commands"`).join(' / ')} but the real count is ${detail}. Update it.`,
|
|
180
|
+
location: null,
|
|
181
|
+
suggestion: { kind: 'fix', text: 'Update the "ships N commands" claim in README.md/AGENTS.md to the real count' },
|
|
182
|
+
}));
|
|
162
183
|
}
|
|
163
184
|
} else {
|
|
164
185
|
// No claim found — that's OK, just don't check this one
|
|
165
|
-
|
|
186
|
+
passed++;
|
|
166
187
|
}
|
|
167
188
|
|
|
168
189
|
// ── Check 2: "N validators" in surface context ──────────────────────
|
|
169
190
|
// Match phrases like "22 validators", "all 22 validators", "the 22 validators"
|
|
170
191
|
// but NOT phase-log entries like "Built with 9 validators" (those are
|
|
171
192
|
// historical, and ROADMAP.md/CHANGELOG.md are skipped at the file level).
|
|
172
|
-
|
|
193
|
+
total++;
|
|
173
194
|
const validatorMatches = [...readme.matchAll(/(?:all|the|with|across|ships?)\s+\*{0,2}(\d+)\s+validators?\*{0,2}/gi)];
|
|
174
195
|
if (validatorMatches.length > 0) {
|
|
175
196
|
const wrongClaims = validatorMatches
|
|
176
197
|
.map(m => Number(m[1]))
|
|
177
198
|
.filter(n => n !== actualValidatorCount);
|
|
178
199
|
if (wrongClaims.length === 0) {
|
|
179
|
-
|
|
200
|
+
passed++;
|
|
180
201
|
} else {
|
|
181
202
|
const uniqueWrong = [...new Set(wrongClaims)];
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
203
|
+
findings.push(mkFinding({
|
|
204
|
+
code: 'CSY003',
|
|
205
|
+
validator: 'canonicalSync',
|
|
206
|
+
severity: 'warn',
|
|
207
|
+
message: `A surface doc (README.md/AGENTS.md) claims ${uniqueWrong.map(n => `"${n} validators"`).join(' / ')} but guard reports ${actualValidatorCount}. Update it.`,
|
|
208
|
+
location: null,
|
|
209
|
+
suggestion: { kind: 'fix', text: 'Update the "N validators" claim in README.md/AGENTS.md to match guard\'s count' },
|
|
210
|
+
}));
|
|
185
211
|
}
|
|
186
212
|
} else {
|
|
187
|
-
|
|
213
|
+
passed++;
|
|
188
214
|
}
|
|
189
215
|
|
|
190
216
|
// ── Check 3: architecture-diagram counts ────────────────────────────
|
|
191
217
|
// Catches the specific "Commands (N)" and "Validators (N)" patterns in
|
|
192
218
|
// the mermaid block that drifted across 5 releases.
|
|
193
|
-
|
|
219
|
+
total++;
|
|
194
220
|
const archMatches = [
|
|
195
221
|
{ re: /Commands\s*\((\d+)\)/, label: 'Commands', expected: actualCommandCount },
|
|
196
222
|
{ re: /Validators\s*\((\d+)\)/, label: 'Validators', expected: actualValidatorCount },
|
|
@@ -203,12 +229,17 @@ export function validateCanonicalSync(projectDir, config, guardResults) {
|
|
|
203
229
|
}
|
|
204
230
|
}
|
|
205
231
|
if (archWrong.length === 0) {
|
|
206
|
-
|
|
232
|
+
passed++;
|
|
207
233
|
} else {
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
234
|
+
findings.push(mkFinding({
|
|
235
|
+
code: 'CSY004',
|
|
236
|
+
validator: 'canonicalSync',
|
|
237
|
+
severity: 'warn',
|
|
238
|
+
message: `README.md architecture diagram has stale counts: ${archWrong.join('; ')}. Update the mermaid block.`,
|
|
239
|
+
location: 'README.md',
|
|
240
|
+
suggestion: { kind: 'fix', text: 'Update the Commands (N) / Validators (N) labels in the README mermaid block' },
|
|
241
|
+
}));
|
|
211
242
|
}
|
|
212
243
|
|
|
213
|
-
return
|
|
244
|
+
return compose();
|
|
214
245
|
}
|