docguard-cli 0.28.0 → 0.30.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 (69) hide show
  1. package/README.es.md +102 -0
  2. package/README.md +80 -32
  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/generate.mjs +14 -1001
  8. package/cli/commands/guard.mjs +136 -8
  9. package/cli/commands/llms.mjs +67 -5
  10. package/cli/commands/mcp.mjs +263 -0
  11. package/cli/commands/memory.mjs +115 -0
  12. package/cli/commands/score.mjs +76 -12
  13. package/cli/commands/trace.mjs +364 -1
  14. package/cli/commands/verify.mjs +93 -6
  15. package/cli/docguard.mjs +42 -5
  16. package/cli/findings.mjs +511 -0
  17. package/cli/scanners/agent-readability.mjs +202 -0
  18. package/cli/scanners/instruction-audit.mjs +320 -0
  19. package/cli/scanners/semantic-claims.mjs +7 -1
  20. package/cli/scanners/speckit.mjs +443 -28
  21. package/cli/shared-ignore.mjs +148 -16
  22. package/cli/shared.mjs +45 -1
  23. package/cli/validators/api-surface.mjs +113 -26
  24. package/cli/validators/architecture.mjs +66 -43
  25. package/cli/validators/canonical-sync.mjs +59 -28
  26. package/cli/validators/changelog.mjs +41 -17
  27. package/cli/validators/cross-reference.mjs +28 -11
  28. package/cli/validators/doc-quality.mjs +78 -44
  29. package/cli/validators/docs-coverage.mjs +90 -63
  30. package/cli/validators/docs-diff.mjs +63 -64
  31. package/cli/validators/docs-sync.mjs +48 -33
  32. package/cli/validators/drift.mjs +40 -34
  33. package/cli/validators/environment.mjs +67 -27
  34. package/cli/validators/freshness.mjs +12 -5
  35. package/cli/validators/generated-staleness.mjs +26 -10
  36. package/cli/validators/metadata-sync.mjs +28 -25
  37. package/cli/validators/metrics-consistency.mjs +89 -47
  38. package/cli/validators/schema-sync.mjs +37 -32
  39. package/cli/validators/security.mjs +7 -20
  40. package/cli/validators/spec-kit.mjs +3 -0
  41. package/cli/validators/structure.mjs +58 -23
  42. package/cli/validators/surface-sync.mjs +34 -15
  43. package/cli/validators/test-spec.mjs +87 -29
  44. package/cli/validators/todo-tracking.mjs +83 -74
  45. package/cli/validators/traceability.mjs +67 -39
  46. package/cli/writers/doc-generators.mjs +853 -0
  47. package/cli/writers/generate-io.mjs +142 -0
  48. package/cli/writers/sarif.mjs +129 -0
  49. package/commands/docguard.fix.md +56 -53
  50. package/commands/docguard.guard.md +53 -47
  51. package/commands/docguard.review.md +49 -31
  52. package/docs/ai-integration.md +133 -134
  53. package/docs/commands.md +49 -3
  54. package/docs/configuration.md +38 -0
  55. package/docs/faq.md +15 -0
  56. package/extensions/spec-kit-docguard/extension.yml +1 -1
  57. package/extensions/spec-kit-docguard/skills/docguard-fix/SKILL.md +2 -2
  58. package/extensions/spec-kit-docguard/skills/docguard-guard/SKILL.md +2 -2
  59. package/extensions/spec-kit-docguard/skills/docguard-review/SKILL.md +2 -2
  60. package/extensions/spec-kit-docguard/skills/docguard-score/SKILL.md +2 -2
  61. package/extensions/spec-kit-docguard/skills/docguard-sync/SKILL.md +2 -2
  62. package/package.json +2 -1
  63. package/schemas/docguard-config.schema.json +28 -0
  64. package/templates/ci/gitlab-component.yml +90 -0
  65. package/templates/commands/docguard.fix.md +33 -10
  66. package/templates/commands/docguard.guard.md +40 -26
  67. package/templates/commands/docguard.init.md +23 -11
  68. package/templates/commands/docguard.review.md +25 -8
  69. package/templates/commands/docguard.update.md +14 -4
@@ -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
  /**
@@ -21,7 +21,8 @@
21
21
  */
22
22
 
23
23
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
24
- import { resolve, join, extname } from 'node:path';
24
+ import { resolve, join, extname, relative } from 'node:path';
25
+ import { mkFinding, resultFromFindings } from '../findings.mjs';
25
26
 
26
27
  // ──── Metric Thresholds ────
27
28
  // These define "good" vs "warning" boundaries for each metric.
@@ -548,13 +549,21 @@ function analyzeDocument(doc) {
548
549
  *
549
550
  * Scans all canonical docs, runs 8 metrics on each, and reports
550
551
  * per-doc findings as warnings when thresholds are exceeded.
552
+ *
553
+ * v0.29: migrated to structured findings (DQ001–DQ008, one code per metric).
554
+ * Messages are byte-identical to the legacy strings — resultFromFindings
555
+ * derives the errors/warnings arrays from the same findings, so counts, exit
556
+ * codes, and existing tests are unaffected; guard just renders richer output.
551
557
  */
552
558
  export function validateDocQuality(projectDir, config) {
553
- const results = { errors: [], warnings: [], passed: 0, total: 0 };
559
+ const findings = [];
560
+ let passed = 0;
561
+ let total = 0;
554
562
 
555
563
  const docs = getCanonicalDocs(projectDir);
556
564
  if (docs.length === 0) {
557
- return results;
565
+ // Literal legacy shape (no findings key) — tests deepEqual this object.
566
+ return { errors: [], warnings: [], passed: 0, total: 0 };
558
567
  }
559
568
 
560
569
  for (const doc of docs) {
@@ -564,107 +573,132 @@ export function validateDocQuality(projectDir, config) {
564
573
  if (analysis.skipped) continue;
565
574
 
566
575
  const m = analysis.metrics;
576
+ const loc = relative(projectDir, doc.path);
577
+ const warn = (code, message, suggestion) => {
578
+ findings.push(mkFinding({
579
+ code,
580
+ validator: 'docQuality',
581
+ severity: 'warn',
582
+ message,
583
+ location: loc,
584
+ suggestion,
585
+ }));
586
+ };
567
587
 
568
588
  // ── Check 1: Passive Voice ──
569
- results.total++;
589
+ total++;
570
590
  const passiveOv = analysis.overrides?.passiveVoice;
571
591
  const passiveThreshold = passiveOv?.threshold
572
592
  ?? config.docQuality?.passiveVoiceThreshold
573
593
  ?? THRESHOLDS.passiveVoiceRatio.warn;
574
594
  if (passiveOv?.off || m.passiveVoiceRatio <= passiveThreshold) {
575
- results.passed++;
595
+ passed++;
576
596
  } else {
577
- results.warnings.push(
597
+ warn('DQ001',
578
598
  `${doc.name}: High passive voice ratio (${(m.passiveVoiceRatio * 100).toFixed(0)}% of sentences). ` +
579
599
  `Use active voice for clarity. Found ${analysis.details.passive.count}/${analysis.details.passive.total} passive sentences. ` +
580
- `If the passive voice is intentional (sequence/flow doc), add: <!-- docguard:quality passive-voice off — your reason -->`
581
- );
600
+ `If the passive voice is intentional (sequence/flow doc), add: <!-- docguard:quality passive-voice off — your reason -->`,
601
+ {
602
+ kind: 'suppress',
603
+ text: 'Rewrite in active voice, or opt this doc out if passive is intentional',
604
+ pragma: '<!-- docguard:quality passive-voice off — your reason -->',
605
+ });
582
606
  }
583
607
 
584
608
  // ── Check 2: Ambiguous Pronouns ──
585
- results.total++;
609
+ total++;
586
610
  if (m.ambiguousPronounRatio <= THRESHOLDS.ambiguousPronounRatio.warn) {
587
- results.passed++;
611
+ passed++;
588
612
  } else {
589
- results.warnings.push(
613
+ warn('DQ002',
590
614
  `${doc.name}: High ambiguous pronoun ratio (${(m.ambiguousPronounRatio * 100).toFixed(1)}%). ` +
591
- `Replace "it/this/that/they" with specific nouns for clarity`
592
- );
615
+ `Replace "it/this/that/they" with specific nouns for clarity`,
616
+ { kind: 'fix', text: 'Replace vague pronouns with the specific noun they refer to' });
593
617
  }
594
618
 
595
619
  // ── Check 3: Atomicity ──
596
- results.total++;
620
+ total++;
597
621
  if (m.atomicityScore <= THRESHOLDS.atomicityScore.warn) {
598
- results.passed++;
622
+ passed++;
599
623
  } else {
600
- results.warnings.push(
624
+ warn('DQ003',
601
625
  `${doc.name}: Low atomicity (${(m.atomicityScore * 100).toFixed(0)}% compound sentences). ` +
602
- `Split compound sentences for easier verification (IEEE 830 §4.1)`
603
- );
626
+ `Split compound sentences for easier verification (IEEE 830 §4.1)`,
627
+ { kind: 'fix', text: 'Split compound sentences into one statement per sentence' });
604
628
  }
605
629
 
606
630
  // ── Check 4: Flesch Reading Ease ──
607
- results.total++;
631
+ total++;
608
632
  if (m.fleschReadingEase >= THRESHOLDS.fleschReadingEase.warn) {
609
- results.passed++;
633
+ passed++;
610
634
  } else {
611
- results.warnings.push(
635
+ warn('DQ004',
612
636
  `${doc.name}: Very low readability (Flesch score: ${m.fleschReadingEase}/100 — ${getReadabilityLabel(m.fleschReadingEase)}). ` +
613
- `Shorten sentences and use simpler words`
614
- );
637
+ `Shorten sentences and use simpler words`,
638
+ { kind: 'fix', text: 'Shorten sentences and prefer simpler words' });
615
639
  }
616
640
 
617
641
  // ── Check 5: Flesch-Kincaid Grade ──
618
- results.total++;
642
+ total++;
619
643
  if (m.fleschKincaidGrade <= THRESHOLDS.fleschKincaidGrade.warn) {
620
- results.passed++;
644
+ passed++;
621
645
  } else {
622
- results.warnings.push(
646
+ warn('DQ005',
623
647
  `${doc.name}: Reading level too high (grade ${m.fleschKincaidGrade} — ${getGradeLabel(m.fleschKincaidGrade)}). ` +
624
- `Aim for grade 12-16 for technical docs`
625
- );
648
+ `Aim for grade 12-16 for technical docs`,
649
+ { kind: 'fix', text: 'Simplify the prose toward a grade 12-16 reading level' });
626
650
  }
627
651
 
628
652
  // ── Check 6: Sentence Length ──
629
- results.total++;
653
+ total++;
630
654
  if (m.avgSentenceLength <= THRESHOLDS.avgSentenceLength.warn) {
631
- results.passed++;
655
+ passed++;
632
656
  } else {
633
- results.warnings.push(
657
+ warn('DQ006',
634
658
  `${doc.name}: Average sentence too long (${m.avgSentenceLength} words). ` +
635
- `Target ≤30 words per sentence for readability`
636
- );
659
+ `Target ≤30 words per sentence for readability`,
660
+ { kind: 'fix', text: 'Break long sentences up — target 30 words or fewer' });
637
661
  }
638
662
 
639
663
  // ── Check 7: Negation Load ──
640
664
  // Per-doc override (security/operational docs legitimately use "never",
641
665
  // "must not", "cannot") and a project-wide config threshold both honored.
642
- results.total++;
666
+ total++;
643
667
  const negOv = analysis.overrides?.negationLoad;
644
668
  const negThreshold = negOv?.threshold
645
669
  ?? config.docQuality?.negationLoadThreshold
646
670
  ?? THRESHOLDS.negationLoad.warn;
647
671
  if (negOv?.off || m.negationLoad <= negThreshold) {
648
- results.passed++;
672
+ passed++;
649
673
  } else {
650
- results.warnings.push(
674
+ warn('DQ007',
651
675
  `${doc.name}: High negation load (${(m.negationLoad * 100).toFixed(0)}% of sentences use negation). ` +
652
676
  `Rephrase in positive terms: "must not fail" → "must succeed" (IEEE 830 §4.3). ` +
653
- `If the negation is intentional, add: <!-- docguard:quality negation-load off — your reason -->`
654
- );
677
+ `If the negation is intentional, add: <!-- docguard:quality negation-load off — your reason -->`,
678
+ {
679
+ kind: 'suppress',
680
+ text: 'Rephrase in positive terms, or opt this doc out if the negation is intentional',
681
+ pragma: '<!-- docguard:quality negation-load off — your reason -->',
682
+ });
655
683
  }
656
684
 
657
685
  // ── Check 8: Conditional Load ──
658
- results.total++;
686
+ total++;
659
687
  if (m.conditionalLoad <= THRESHOLDS.conditionalLoad.warn) {
660
- results.passed++;
688
+ passed++;
661
689
  } else {
662
- results.warnings.push(
690
+ warn('DQ008',
663
691
  `${doc.name}: High conditional load (${(m.conditionalLoad * 100).toFixed(0)}% of sentences are conditional). ` +
664
- `Simplify by splitting conditionals into separate requirements`
665
- );
692
+ `Simplify by splitting conditionals into separate requirements`,
693
+ { kind: 'fix', text: 'Split conditional sentences into separate, unconditional requirements' });
666
694
  }
667
695
  }
668
696
 
669
- return results;
697
+ if (total === 0) {
698
+ // Every doc was skipped (insufficient prose) — same literal legacy shape,
699
+ // because tests deepEqual this exact object for the all-skipped case too.
700
+ return { errors: [], warnings: [], passed: 0, total: 0 };
701
+ }
702
+
703
+ return resultFromFindings(findings, { passed, total });
670
704
  }