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.
- package/README.es.md +102 -0
- package/README.md +80 -32
- 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/commands/trace.mjs +364 -1
- package/cli/commands/verify.mjs +93 -6
- package/cli/docguard.mjs +42 -5
- package/cli/findings.mjs +511 -0
- package/cli/scanners/agent-readability.mjs +202 -0
- package/cli/scanners/instruction-audit.mjs +320 -0
- package/cli/scanners/semantic-claims.mjs +7 -1
- package/cli/scanners/speckit.mjs +443 -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 +2 -1
- package/schemas/docguard-config.schema.json +28 -0
- package/templates/ci/gitlab-component.yml +90 -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
|
@@ -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
|
/**
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
595
|
+
passed++;
|
|
576
596
|
} else {
|
|
577
|
-
|
|
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
|
-
|
|
609
|
+
total++;
|
|
586
610
|
if (m.ambiguousPronounRatio <= THRESHOLDS.ambiguousPronounRatio.warn) {
|
|
587
|
-
|
|
611
|
+
passed++;
|
|
588
612
|
} else {
|
|
589
|
-
|
|
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
|
-
|
|
620
|
+
total++;
|
|
597
621
|
if (m.atomicityScore <= THRESHOLDS.atomicityScore.warn) {
|
|
598
|
-
|
|
622
|
+
passed++;
|
|
599
623
|
} else {
|
|
600
|
-
|
|
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
|
-
|
|
631
|
+
total++;
|
|
608
632
|
if (m.fleschReadingEase >= THRESHOLDS.fleschReadingEase.warn) {
|
|
609
|
-
|
|
633
|
+
passed++;
|
|
610
634
|
} else {
|
|
611
|
-
|
|
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
|
-
|
|
642
|
+
total++;
|
|
619
643
|
if (m.fleschKincaidGrade <= THRESHOLDS.fleschKincaidGrade.warn) {
|
|
620
|
-
|
|
644
|
+
passed++;
|
|
621
645
|
} else {
|
|
622
|
-
|
|
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
|
-
|
|
653
|
+
total++;
|
|
630
654
|
if (m.avgSentenceLength <= THRESHOLDS.avgSentenceLength.warn) {
|
|
631
|
-
|
|
655
|
+
passed++;
|
|
632
656
|
} else {
|
|
633
|
-
|
|
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
|
-
|
|
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
|
-
|
|
672
|
+
passed++;
|
|
649
673
|
} else {
|
|
650
|
-
|
|
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
|
-
|
|
686
|
+
total++;
|
|
659
687
|
if (m.conditionalLoad <= THRESHOLDS.conditionalLoad.warn) {
|
|
660
|
-
|
|
688
|
+
passed++;
|
|
661
689
|
} else {
|
|
662
|
-
|
|
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
|
-
|
|
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
|
}
|