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
|
@@ -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) {
|
|
@@ -97,15 +117,23 @@ function validateConfigLayers(projectDir, config, layers, results) {
|
|
|
97
117
|
const relPath = relative(projectDir, file);
|
|
98
118
|
const imports = extractImports(content);
|
|
99
119
|
|
|
100
|
-
for (const
|
|
101
|
-
if (!
|
|
120
|
+
for (const { spec } of imports) {
|
|
121
|
+
if (!spec.startsWith('.') && !spec.startsWith('/')) continue;
|
|
102
122
|
|
|
103
123
|
for (const forbiddenDir of layer.forbidden) {
|
|
104
|
-
if (
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
124
|
+
if (spec.includes(forbiddenDir) || spec.includes(`/${forbiddenDir}/`)) {
|
|
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
|
}
|
|
@@ -135,14 +163,19 @@ function buildImportGraph(projectDir, config) {
|
|
|
135
163
|
|
|
136
164
|
const resolvedImports = [];
|
|
137
165
|
for (const imp of imports) {
|
|
138
|
-
if (!imp.startsWith('.') && !imp.startsWith('/')) continue;
|
|
166
|
+
if (!imp.spec.startsWith('.') && !imp.spec.startsWith('/')) continue;
|
|
139
167
|
|
|
140
168
|
// Resolve relative imports
|
|
141
169
|
const fromDir = dirname(file);
|
|
142
|
-
const resolved = resolveImport(fromDir, imp, projectDir);
|
|
170
|
+
const resolved = resolveImport(fromDir, imp.spec, projectDir);
|
|
143
171
|
if (resolved) {
|
|
144
|
-
|
|
145
|
-
|
|
172
|
+
graph.edges.push({ from: relPath, to: resolved, dynamic: imp.dynamic });
|
|
173
|
+
// v0.28 (field report #2): a dynamic `await import()` does NOT create a
|
|
174
|
+
// load-time edge — it's the canonical way to BREAK an import cycle. So
|
|
175
|
+
// it's excluded from the cycle-detection adjacency (fileMap) while still
|
|
176
|
+
// recorded in graph.edges for layer-boundary checks (an import is still
|
|
177
|
+
// an import for layering).
|
|
178
|
+
if (!imp.dynamic) resolvedImports.push(resolved);
|
|
146
179
|
}
|
|
147
180
|
}
|
|
148
181
|
|
|
@@ -153,26 +186,33 @@ function buildImportGraph(projectDir, config) {
|
|
|
153
186
|
return graph;
|
|
154
187
|
}
|
|
155
188
|
|
|
189
|
+
/**
|
|
190
|
+
* Extract a file's imports as `{ spec, dynamic }`. `dynamic:true` marks a
|
|
191
|
+
* runtime `import('…')` — which does NOT create a load-time dependency edge and
|
|
192
|
+
* is the canonical way to break an import cycle (field report #2). ES `import …
|
|
193
|
+
* from` and CommonJS `require()` are load-time (static).
|
|
194
|
+
*/
|
|
156
195
|
function extractImports(content) {
|
|
157
196
|
const imports = [];
|
|
158
197
|
|
|
159
|
-
// ES module imports
|
|
198
|
+
// ES module imports (static, load-time). `import\s+` requires whitespace after
|
|
199
|
+
// `import`, so it never matches a dynamic `import(` call.
|
|
160
200
|
const esImportRegex = /import\s+(?:.*?\s+from\s+)?['"]([^'"]+)['"]/g;
|
|
161
201
|
let match;
|
|
162
202
|
while ((match = esImportRegex.exec(content)) !== null) {
|
|
163
|
-
imports.push(match[1]);
|
|
203
|
+
imports.push({ spec: match[1], dynamic: false });
|
|
164
204
|
}
|
|
165
205
|
|
|
166
|
-
// Dynamic imports
|
|
206
|
+
// Dynamic imports (runtime — NOT a load-time cycle edge)
|
|
167
207
|
const dynamicRegex = /import\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
|
|
168
208
|
while ((match = dynamicRegex.exec(content)) !== null) {
|
|
169
|
-
imports.push(match[1]);
|
|
209
|
+
imports.push({ spec: match[1], dynamic: true });
|
|
170
210
|
}
|
|
171
211
|
|
|
172
|
-
// CommonJS require
|
|
212
|
+
// CommonJS require (static, load-time)
|
|
173
213
|
const requireRegex = /require\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
|
|
174
214
|
while ((match = requireRegex.exec(content)) !== null) {
|
|
175
|
-
imports.push(match[1]);
|
|
215
|
+
imports.push({ spec: match[1], dynamic: false });
|
|
176
216
|
}
|
|
177
217
|
|
|
178
218
|
return imports;
|
|
@@ -295,7 +335,7 @@ function parseLayerBoundaries(archContent) {
|
|
|
295
335
|
return layers;
|
|
296
336
|
}
|
|
297
337
|
|
|
298
|
-
function validateLayerBoundaries(projectDir, graph, declaredLayers,
|
|
338
|
+
function validateLayerBoundaries(projectDir, graph, declaredLayers, acc) {
|
|
299
339
|
// Map directory patterns to layer names
|
|
300
340
|
const layerDirMap = new Map();
|
|
301
341
|
for (const layer of declaredLayers) {
|
|
@@ -315,13 +355,21 @@ function validateLayerBoundaries(projectDir, graph, declaredLayers, results) {
|
|
|
315
355
|
|
|
316
356
|
// Check if this import is forbidden
|
|
317
357
|
if (fromLayer.cannotImport.some(l => l.includes(toLayer.name) || toLayer.name.includes(l))) {
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
+
}));
|
|
322
370
|
} else {
|
|
323
|
-
|
|
324
|
-
|
|
371
|
+
acc.total++;
|
|
372
|
+
acc.passed++;
|
|
325
373
|
}
|
|
326
374
|
}
|
|
327
375
|
}
|
|
@@ -364,33 +412,20 @@ function getFileLayer(filePath, layerDirMap) {
|
|
|
364
412
|
|
|
365
413
|
// ── Utilities ───────────────────────────────────────────────────────────────
|
|
366
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.
|
|
367
421
|
function getFilesRecursive(dir, config, projectDir) {
|
|
368
422
|
const results = [];
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
let entries;
|
|
372
|
-
try {
|
|
373
|
-
entries = readdirSync(dir);
|
|
374
|
-
} catch { return results; }
|
|
375
|
-
|
|
376
|
-
for (const entry of entries) {
|
|
377
|
-
if (IGNORE_DIRS.has(entry) || entry.startsWith('.')) continue;
|
|
378
|
-
|
|
379
|
-
// Check config.ignore for this directory
|
|
423
|
+
sharedWalkFiles(dir, (fullPath) => {
|
|
380
424
|
if (config && projectDir) {
|
|
381
|
-
const relPath = relative(projectDir,
|
|
382
|
-
if (shouldIgnore(relPath, config))
|
|
425
|
+
const relPath = relative(projectDir, fullPath);
|
|
426
|
+
if (shouldIgnore(relPath, config)) return;
|
|
383
427
|
}
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
try {
|
|
387
|
-
const stat = statSync(fullPath);
|
|
388
|
-
if (stat.isDirectory()) {
|
|
389
|
-
results.push(...getFilesRecursive(fullPath, config, projectDir));
|
|
390
|
-
} else {
|
|
391
|
-
results.push(fullPath);
|
|
392
|
-
}
|
|
393
|
-
} catch { /* skip */ }
|
|
394
|
-
}
|
|
428
|
+
results.push(fullPath);
|
|
429
|
+
}, { ignoreDirs: IGNORE_DIRS });
|
|
395
430
|
return results;
|
|
396
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
|
}
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { existsSync, readFileSync } from 'node:fs';
|
|
8
8
|
import { resolve, basename } from 'node:path';
|
|
9
9
|
import { execFileSync } from 'node:child_process';
|
|
10
|
+
import { mkFinding, resultFromFindings } from '../findings.mjs';
|
|
10
11
|
|
|
11
12
|
const CODE_EXT_RE = /\.(ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|rb|php|cs|kt|swift)$/;
|
|
12
13
|
|
|
@@ -24,35 +25,53 @@ function getStagedFiles(projectDir) {
|
|
|
24
25
|
}
|
|
25
26
|
}
|
|
26
27
|
|
|
28
|
+
// v0.29: migrated to structured findings (CHG001–CHG003). Messages are
|
|
29
|
+
// byte-identical to the legacy strings; the `fixes` array is preserved for
|
|
30
|
+
// the fix applier.
|
|
27
31
|
export function validateChangelog(projectDir, config) {
|
|
28
|
-
const
|
|
32
|
+
const findings = [];
|
|
33
|
+
const fixes = [];
|
|
34
|
+
let passed = 0;
|
|
35
|
+
let total = 0;
|
|
29
36
|
|
|
30
37
|
const changelogPath = resolve(projectDir, config.requiredFiles.changelog);
|
|
31
38
|
if (!existsSync(changelogPath)) {
|
|
32
39
|
// Structure validator catches missing files
|
|
33
|
-
return
|
|
40
|
+
return { name: 'changelog', ...resultFromFindings([], { passed: 0, total: 0 }), fixes };
|
|
34
41
|
}
|
|
35
42
|
|
|
36
43
|
const content = readFileSync(changelogPath, 'utf-8');
|
|
37
44
|
|
|
38
45
|
// Check for [Unreleased] section
|
|
39
|
-
|
|
46
|
+
total++;
|
|
40
47
|
if (content.includes('[Unreleased]') || content.includes('[unreleased]')) {
|
|
41
|
-
|
|
48
|
+
passed++;
|
|
42
49
|
} else {
|
|
43
|
-
|
|
44
|
-
|
|
50
|
+
findings.push(mkFinding({
|
|
51
|
+
code: 'CHG001',
|
|
52
|
+
validator: 'changelog',
|
|
53
|
+
severity: 'warn',
|
|
54
|
+
message: 'CHANGELOG.md: missing [Unreleased] section — fix with `docguard fix --write`',
|
|
55
|
+
location: config.requiredFiles.changelog,
|
|
56
|
+
suggestion: { kind: 'fix', text: 'Insert an [Unreleased] section', command: 'docguard fix --write' },
|
|
57
|
+
}));
|
|
58
|
+
fixes.push({ type: 'insert-changelog-unreleased', file: config.requiredFiles.changelog });
|
|
45
59
|
}
|
|
46
60
|
|
|
47
61
|
// Check it follows Keep a Changelog format (at least has ## headers)
|
|
48
|
-
|
|
62
|
+
total++;
|
|
49
63
|
const hasVersionHeaders = /^## \[/m.test(content);
|
|
50
64
|
if (hasVersionHeaders) {
|
|
51
|
-
|
|
65
|
+
passed++;
|
|
52
66
|
} else {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
67
|
+
findings.push(mkFinding({
|
|
68
|
+
code: 'CHG002',
|
|
69
|
+
validator: 'changelog',
|
|
70
|
+
severity: 'warn',
|
|
71
|
+
message: 'CHANGELOG.md: no version sections found (expected ## [version] format)',
|
|
72
|
+
location: config.requiredFiles.changelog,
|
|
73
|
+
suggestion: { kind: 'review', text: 'Adopt Keep a Changelog format: ## [version] - YYYY-MM-DD headers' },
|
|
74
|
+
}));
|
|
56
75
|
}
|
|
57
76
|
|
|
58
77
|
// Per STANDARD.md: if there are staged CODE changes, CHANGELOG.md should be
|
|
@@ -65,16 +84,21 @@ export function validateChangelog(projectDir, config) {
|
|
|
65
84
|
const changelogStaged = staged.some(f => basename(f) === changelogName);
|
|
66
85
|
|
|
67
86
|
if (stagedCode.length > 0) {
|
|
68
|
-
|
|
87
|
+
total++;
|
|
69
88
|
if (changelogStaged) {
|
|
70
|
-
|
|
89
|
+
passed++;
|
|
71
90
|
} else {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
91
|
+
findings.push(mkFinding({
|
|
92
|
+
code: 'CHG003',
|
|
93
|
+
validator: 'changelog',
|
|
94
|
+
severity: 'warn',
|
|
95
|
+
message: `${stagedCode.length} code file(s) staged but ${changelogName} is not — add a CHANGELOG entry for this change`,
|
|
96
|
+
location: config.requiredFiles.changelog,
|
|
97
|
+
suggestion: { kind: 'fix', text: `Describe the staged change under [Unreleased] in ${changelogName}, then stage it` },
|
|
98
|
+
}));
|
|
75
99
|
}
|
|
76
100
|
}
|
|
77
101
|
}
|
|
78
102
|
|
|
79
|
-
return
|
|
103
|
+
return { name: 'changelog', ...resultFromFindings(findings, { passed, total }), fixes };
|
|
80
104
|
}
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
|
|
29
29
|
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
30
30
|
import { resolve, join, dirname, basename, relative } from 'node:path';
|
|
31
|
+
import { mkFinding, resultFromFindings } from '../findings.mjs';
|
|
31
32
|
|
|
32
33
|
/**
|
|
33
34
|
* Slugify a heading the way GitHub's markdown anchors work.
|
|
@@ -290,17 +291,21 @@ function collectCanonicalDocs(projectDir) {
|
|
|
290
291
|
/**
|
|
291
292
|
* Validator entrypoint — matches the standard signature returning
|
|
292
293
|
* { errors, warnings, passed, total }.
|
|
294
|
+
*
|
|
295
|
+
* v0.29: migrated to structured findings (XRF001–XRF002). Messages are
|
|
296
|
+
* byte-identical to the legacy strings — resultFromFindings derives the
|
|
297
|
+
* errors/warnings arrays from the same findings, so counts, exit codes, and
|
|
298
|
+
* existing tests are unaffected; guard just renders richer output.
|
|
293
299
|
*/
|
|
294
300
|
export function validateCrossReferences(projectDir, _config = {}) {
|
|
295
|
-
const
|
|
296
|
-
const warnings = [];
|
|
301
|
+
const findings = [];
|
|
297
302
|
const fixes = [];
|
|
298
303
|
let passed = 0;
|
|
299
304
|
let total = 0;
|
|
300
305
|
|
|
301
306
|
const docs = collectCanonicalDocs(projectDir);
|
|
302
307
|
if (docs.length === 0) {
|
|
303
|
-
return
|
|
308
|
+
return resultFromFindings([], { passed, total, applicable: false });
|
|
304
309
|
}
|
|
305
310
|
|
|
306
311
|
// Build a map of doc path → anchor set for fast lookups during ref resolution.
|
|
@@ -336,9 +341,14 @@ export function validateCrossReferences(projectDir, _config = {}) {
|
|
|
336
341
|
}
|
|
337
342
|
targetPath = resolveTarget(docPath, ref.file, projectDir);
|
|
338
343
|
if (!targetPath) {
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
344
|
+
findings.push(mkFinding({
|
|
345
|
+
code: 'XRF001',
|
|
346
|
+
validator: 'crossReference',
|
|
347
|
+
severity: 'warn',
|
|
348
|
+
message: `${docName}:${ref.line} — broken link: target file "${ref.file}" not found`,
|
|
349
|
+
location: `${relative(projectDir, docPath)}:${ref.line}`,
|
|
350
|
+
suggestion: { kind: 'fix', text: 'Fix the link target path (or remove the dead link)' },
|
|
351
|
+
}));
|
|
342
352
|
continue;
|
|
343
353
|
}
|
|
344
354
|
} else {
|
|
@@ -372,10 +382,17 @@ export function validateCrossReferences(projectDir, _config = {}) {
|
|
|
372
382
|
// `docguard fix --write` resolves it without AI. Other near-misses
|
|
373
383
|
// still get the hint but no fix (the user needs to verify intent).
|
|
374
384
|
const isHighConfidence = suggestion && isUnambiguousSuggestion(normalizedAnchor, suggestion, anchors);
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
385
|
+
findings.push(mkFinding({
|
|
386
|
+
code: 'XRF002',
|
|
387
|
+
validator: 'crossReference',
|
|
388
|
+
severity: 'warn',
|
|
389
|
+
message: `${docName}:${ref.line} — broken anchor: "#${ref.anchor}" in ${where} doesn't match any heading${hint}` +
|
|
390
|
+
(isHighConfidence ? ' [auto-fixable]' : ''),
|
|
391
|
+
location: `${relative(projectDir, docPath)}:${ref.line}`,
|
|
392
|
+
suggestion: isHighConfidence
|
|
393
|
+
? { kind: 'fix', text: `Replace #${ref.anchor} with #${suggestion}`, command: 'docguard fix --write' }
|
|
394
|
+
: { kind: 'review', text: 'Update the anchor to match a real heading in the target doc' },
|
|
395
|
+
}));
|
|
379
396
|
if (isHighConfidence) {
|
|
380
397
|
fixes.push({
|
|
381
398
|
type: 'replace-anchor',
|
|
@@ -394,7 +411,7 @@ export function validateCrossReferences(projectDir, _config = {}) {
|
|
|
394
411
|
}
|
|
395
412
|
}
|
|
396
413
|
|
|
397
|
-
return {
|
|
414
|
+
return { ...resultFromFindings(findings, { passed, total }), fixes };
|
|
398
415
|
}
|
|
399
416
|
|
|
400
417
|
/**
|