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.
Files changed (74) hide show
  1. package/README.es.md +102 -0
  2. package/README.md +65 -31
  3. package/README.pt-BR.md +101 -0
  4. package/STANDARD.md +20 -10
  5. package/cli/commands/agents.mjs +149 -0
  6. package/cli/commands/diff.mjs +6 -15
  7. package/cli/commands/explain.mjs +8 -6
  8. package/cli/commands/generate.mjs +14 -1001
  9. package/cli/commands/guard.mjs +149 -15
  10. package/cli/commands/init.mjs +23 -1
  11. package/cli/commands/llms.mjs +67 -5
  12. package/cli/commands/mcp.mjs +263 -0
  13. package/cli/commands/memory.mjs +115 -0
  14. package/cli/commands/score.mjs +76 -12
  15. package/cli/commands/sync-tests.mjs +272 -0
  16. package/cli/commands/sync.mjs +6 -0
  17. package/cli/commands/verify.mjs +67 -0
  18. package/cli/docguard.mjs +62 -5
  19. package/cli/findings.mjs +499 -0
  20. package/cli/scanners/agent-readability.mjs +202 -0
  21. package/cli/scanners/semantic-claims.mjs +160 -0
  22. package/cli/scanners/speckit.mjs +98 -28
  23. package/cli/shared-ignore.mjs +148 -16
  24. package/cli/shared.mjs +45 -1
  25. package/cli/validators/api-surface.mjs +182 -29
  26. package/cli/validators/architecture.mjs +91 -56
  27. package/cli/validators/canonical-sync.mjs +59 -28
  28. package/cli/validators/changelog.mjs +41 -17
  29. package/cli/validators/cross-reference.mjs +28 -11
  30. package/cli/validators/doc-quality.mjs +78 -44
  31. package/cli/validators/docs-coverage.mjs +90 -63
  32. package/cli/validators/docs-diff.mjs +63 -64
  33. package/cli/validators/docs-sync.mjs +48 -33
  34. package/cli/validators/drift.mjs +40 -34
  35. package/cli/validators/environment.mjs +67 -27
  36. package/cli/validators/freshness.mjs +12 -5
  37. package/cli/validators/generated-staleness.mjs +26 -10
  38. package/cli/validators/metadata-sync.mjs +28 -25
  39. package/cli/validators/metrics-consistency.mjs +89 -47
  40. package/cli/validators/schema-sync.mjs +37 -32
  41. package/cli/validators/security.mjs +7 -20
  42. package/cli/validators/spec-kit.mjs +3 -0
  43. package/cli/validators/structure.mjs +58 -23
  44. package/cli/validators/surface-sync.mjs +34 -15
  45. package/cli/validators/test-spec.mjs +87 -29
  46. package/cli/validators/todo-tracking.mjs +83 -74
  47. package/cli/validators/traceability.mjs +67 -39
  48. package/cli/writers/doc-generators.mjs +853 -0
  49. package/cli/writers/generate-io.mjs +142 -0
  50. package/cli/writers/sarif.mjs +129 -0
  51. package/commands/docguard.fix.md +56 -53
  52. package/commands/docguard.guard.md +53 -47
  53. package/commands/docguard.review.md +49 -31
  54. package/docs/ai-integration.md +133 -134
  55. package/docs/commands.md +49 -3
  56. package/docs/configuration.md +38 -0
  57. package/docs/faq.md +15 -0
  58. package/extensions/spec-kit-docguard/extension.yml +1 -1
  59. package/extensions/spec-kit-docguard/skills/docguard-fix/SKILL.md +2 -2
  60. package/extensions/spec-kit-docguard/skills/docguard-guard/SKILL.md +2 -2
  61. package/extensions/spec-kit-docguard/skills/docguard-review/SKILL.md +2 -2
  62. package/extensions/spec-kit-docguard/skills/docguard-score/SKILL.md +2 -2
  63. package/extensions/spec-kit-docguard/skills/docguard-sync/SKILL.md +2 -2
  64. package/package.json +1 -1
  65. package/schemas/docguard-config.schema.json +17 -0
  66. package/templates/ENVIRONMENT.md.template +5 -0
  67. package/templates/REQUIREMENTS.md.template +2 -0
  68. package/templates/SECURITY.md.template +6 -1
  69. package/templates/TEST-SPEC.md.template +5 -0
  70. package/templates/commands/docguard.fix.md +33 -10
  71. package/templates/commands/docguard.guard.md +40 -26
  72. package/templates/commands/docguard.init.md +23 -11
  73. package/templates/commands/docguard.review.md +25 -8
  74. 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 results = { name: 'architecture', errors: [], warnings: [], passed: 0, total: 0 };
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, results);
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 results;
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
- results.total++;
47
- results.warnings.push(`Circular dependency: ${circle.join(' → ')}`);
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, results);
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, results) {
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 imp of imports) {
101
- if (!imp.startsWith('.') && !imp.startsWith('/')) continue;
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 (imp.includes(forbiddenDir) || imp.includes(`/${forbiddenDir}/`)) {
105
- results.total++;
106
- results.errors.push(
107
- `${relPath}: ${layer.name} layer imports from forbidden layer (${forbiddenDir})`
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
- resolvedImports.push(resolved);
145
- graph.edges.push({ from: relPath, to: resolved });
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, results) {
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
- results.total++;
319
- results.errors.push(
320
- `${edge.from}: ${fromLayer.name} → ${toLayer.name} (forbidden by ARCHITECTURE.md)`
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
- results.total++;
324
- results.passed++;
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
- if (!existsSync(dir)) return results;
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, join(dir, entry));
382
- if (shouldIgnore(relPath, config)) continue;
425
+ const relPath = relative(projectDir, fullPath);
426
+ if (shouldIgnore(relPath, config)) return;
383
427
  }
384
-
385
- const fullPath = join(dir, entry);
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 result = { errors: [], warnings: [], fixes: [], passed: 0, total: 0 };
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 { ...result, na: true, naReason: 'no package.json' };
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 { ...result, na: true, naReason: 'unreadable package.json' };
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 { ...result, na: true, naReason: 'cli/commands or cli/validators not found' };
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
- result.warnings.push('canonical-sync: no README.md or AGENTS.md found — cannot check surface claims');
141
- result.total = 1;
142
- return result;
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
- result.total++;
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
- result.passed++;
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
- result.warnings.push(
160
- `A surface doc (README.md/AGENTS.md) claims ${wrong.map(n => `"ships ${n} commands"`).join(' / ')} but the real count is ${detail}. Update it.`
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
- result.passed++;
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
- result.total++;
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
- result.passed++;
200
+ passed++;
180
201
  } else {
181
202
  const uniqueWrong = [...new Set(wrongClaims)];
182
- result.warnings.push(
183
- `A surface doc (README.md/AGENTS.md) claims ${uniqueWrong.map(n => `"${n} validators"`).join(' / ')} but guard reports ${actualValidatorCount}. Update it.`
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
- result.passed++;
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
- result.total++;
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
- result.passed++;
232
+ passed++;
207
233
  } else {
208
- result.warnings.push(
209
- `README.md architecture diagram has stale counts: ${archWrong.join('; ')}. Update the mermaid block.`
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 result;
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 results = { name: 'changelog', errors: [], warnings: [], passed: 0, total: 0, fixes: [] };
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 results;
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
- results.total++;
46
+ total++;
40
47
  if (content.includes('[Unreleased]') || content.includes('[unreleased]')) {
41
- results.passed++;
48
+ passed++;
42
49
  } else {
43
- results.warnings.push('CHANGELOG.md: missing [Unreleased] section — fix with `docguard fix --write`');
44
- results.fixes.push({ type: 'insert-changelog-unreleased', file: config.requiredFiles.changelog });
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
- results.total++;
62
+ total++;
49
63
  const hasVersionHeaders = /^## \[/m.test(content);
50
64
  if (hasVersionHeaders) {
51
- results.passed++;
65
+ passed++;
52
66
  } else {
53
- results.warnings.push(
54
- 'CHANGELOG.md: no version sections found (expected ## [version] format)'
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
- results.total++;
87
+ total++;
69
88
  if (changelogStaged) {
70
- results.passed++;
89
+ passed++;
71
90
  } else {
72
- results.warnings.push(
73
- `${stagedCode.length} code file(s) staged but ${changelogName} is not — add a CHANGELOG entry for this change`
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 results;
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 errors = [];
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 { errors, warnings, passed, total, applicable: false };
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
- warnings.push(
340
- `${docName}:${ref.line} — broken link: target file "${ref.file}" not found`
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
- warnings.push(
376
- `${docName}:${ref.line} — broken anchor: "#${ref.anchor}" in ${where} doesn't match any heading${hint}` +
377
- (isHighConfidence ? ' [auto-fixable]' : '')
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 { errors, warnings, passed, total, fixes };
414
+ return { ...resultFromFindings(findings, { passed, total }), fixes };
398
415
  }
399
416
 
400
417
  /**