docguard-cli 0.31.0 → 0.33.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/PHILOSOPHY.md +1 -0
- package/README.md +70 -30
- package/cli/commands/ci.mjs +52 -13
- package/cli/commands/guard.mjs +80 -0
- package/cli/commands/hooks.mjs +167 -2
- package/cli/commands/impact.mjs +213 -5
- package/cli/commands/mcp.mjs +195 -53
- package/cli/commands/report.mjs +200 -0
- package/cli/commands/score.mjs +55 -1
- package/cli/docguard.mjs +101 -13
- package/cli/findings.mjs +6 -0
- package/cli/scanners/agent-readability.mjs +6 -1
- package/cli/scanners/semantic-claims.mjs +10 -2
- package/cli/shared-git.mjs +23 -0
- package/cli/validators/architecture.mjs +8 -1
- package/cli/validators/cross-reference.mjs +124 -3
- package/cli/validators/docs-coverage.mjs +5 -0
- package/cli/validators/reference-existence.mjs +172 -18
- package/cli/validators/traceability.mjs +63 -0
- package/cli/writers/baseline.mjs +84 -0
- package/cli/writers/history.mjs +82 -0
- package/cli/writers/junit.mjs +103 -0
- package/docs/commands.md +30 -2
- package/docs/configuration.md +14 -0
- package/docs/faq.md +12 -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 +5 -0
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Report Command — Compliance-evidence bundle for audits.
|
|
3
|
+
*
|
|
4
|
+
* `docguard report` runs guard + score internally and emits a deterministic
|
|
5
|
+
* evidence report: who/what/when (git commit, branch, tool version), the
|
|
6
|
+
* guard verdict per validator, the findings summary, the CDD score with its
|
|
7
|
+
* ALCOA+ data-integrity attributes, and the mechanical-fix history. An
|
|
8
|
+
* `integrity` sha256 over the canonical JSON payload makes the bundle
|
|
9
|
+
* tamper-evident — re-running `docguard report --format json` at the same
|
|
10
|
+
* commit reproduces the same evidence. The generation timestamp and the
|
|
11
|
+
* ALCOA+ section are excluded from the hash for that reason (both are
|
|
12
|
+
* wall-clock-relative; ALCOA's Contemporaneous attribute also depends on
|
|
13
|
+
* file mtimes, which reset on a fresh clone).
|
|
14
|
+
*
|
|
15
|
+
* Report is EVIDENCE, not a gate: it always exits 0. `guard` and `ci` remain
|
|
16
|
+
* the commands that fail builds. This split matters for auditors — evidence
|
|
17
|
+
* collection must not change behavior depending on what it observes.
|
|
18
|
+
*
|
|
19
|
+
* Output: markdown to stdout by default, `--format json` for the machine
|
|
20
|
+
* bundle, `--out <file>` to write either format to a file instead.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { createHash } from 'node:crypto';
|
|
24
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
25
|
+
import { resolve as resolvePath, dirname } from 'node:path';
|
|
26
|
+
import { fileURLToPath } from 'node:url';
|
|
27
|
+
import { c } from '../shared.mjs';
|
|
28
|
+
import { runGuardInternal } from './guard.mjs';
|
|
29
|
+
import { runScoreInternal, computeAlcoaCompliance } from './score.mjs';
|
|
30
|
+
import { getHeadInfo, isGitRepo } from '../shared-git.mjs';
|
|
31
|
+
import { loadFixMemory } from '../writers/fix-memory.mjs';
|
|
32
|
+
|
|
33
|
+
const _PKG = JSON.parse(readFileSync(resolvePath(dirname(fileURLToPath(import.meta.url)), '..', '..', 'package.json'), 'utf-8'));
|
|
34
|
+
const CLI_VERSION = _PKG.version;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Build the evidence payload. Pure gather — no printing, no exit. The
|
|
38
|
+
* `integrity` hash covers everything EXCEPT `generatedAt` and the hash
|
|
39
|
+
* itself, so the same tree state always yields the same hash.
|
|
40
|
+
*/
|
|
41
|
+
export function buildReport(projectDir, config) {
|
|
42
|
+
const guardData = runGuardInternal(projectDir, config);
|
|
43
|
+
const scoreData = runScoreInternal(projectDir, config);
|
|
44
|
+
const alcoa = computeAlcoaCompliance(projectDir, config, scoreData.categories);
|
|
45
|
+
const git = isGitRepo(projectDir) ? getHeadInfo(projectDir) : null;
|
|
46
|
+
const fixMemory = loadFixMemory(projectDir);
|
|
47
|
+
|
|
48
|
+
// Findings grouped by stable code — auditors care about "how many of
|
|
49
|
+
// which class", not the per-file noise. Codeless findings group as OTHER.
|
|
50
|
+
const byCode = new Map();
|
|
51
|
+
for (const f of guardData.findings || []) {
|
|
52
|
+
const code = f.code || 'OTHER';
|
|
53
|
+
const entry = byCode.get(code) || { code, severity: f.severity, count: 0, sample: null };
|
|
54
|
+
entry.count++;
|
|
55
|
+
if (!entry.sample && f.message) entry.sample = f.message;
|
|
56
|
+
byCode.set(code, entry);
|
|
57
|
+
}
|
|
58
|
+
const findingsSummary = [...byCode.values()].sort((a, b) => b.count - a.count || a.code.localeCompare(b.code));
|
|
59
|
+
|
|
60
|
+
const payload = {
|
|
61
|
+
tool: { name: 'docguard', version: CLI_VERSION },
|
|
62
|
+
project: {
|
|
63
|
+
name: config.projectName,
|
|
64
|
+
profile: config.profile || 'standard',
|
|
65
|
+
type: config.projectType || 'unknown',
|
|
66
|
+
},
|
|
67
|
+
git: git ? { commit: git.commit, branch: git.branch, dirty: git.dirty } : null,
|
|
68
|
+
guard: {
|
|
69
|
+
status: guardData.status,
|
|
70
|
+
passed: guardData.passed,
|
|
71
|
+
total: guardData.total,
|
|
72
|
+
errors: guardData.errors,
|
|
73
|
+
warnings: guardData.warnings,
|
|
74
|
+
// Audit-critical (H3): evidence must disclose what a committed baseline
|
|
75
|
+
// is suppressing — "no findings" with a hidden baseline is false green.
|
|
76
|
+
baselineSuppressed: guardData.baselineSuppressed || 0,
|
|
77
|
+
validators: (guardData.validators || [])
|
|
78
|
+
.filter(v => v.status !== 'skipped')
|
|
79
|
+
.map(v => ({ name: v.name, status: v.status })),
|
|
80
|
+
},
|
|
81
|
+
findings: findingsSummary,
|
|
82
|
+
score: {
|
|
83
|
+
score: scoreData.score,
|
|
84
|
+
grade: scoreData.grade,
|
|
85
|
+
categories: scoreData.categories,
|
|
86
|
+
},
|
|
87
|
+
alcoa: {
|
|
88
|
+
score: alcoa.score,
|
|
89
|
+
met: alcoa.met,
|
|
90
|
+
total: alcoa.total,
|
|
91
|
+
attributes: alcoa.attributes.map(a => ({
|
|
92
|
+
name: a.name, met: a.met, evidence: a.evidence, gap: a.gap,
|
|
93
|
+
})),
|
|
94
|
+
},
|
|
95
|
+
fixHistory: {
|
|
96
|
+
entries: fixMemory.entries.length,
|
|
97
|
+
lastApplied: fixMemory.entries.length
|
|
98
|
+
? fixMemory.entries.reduce((max, e) => (e.appliedAt > max ? e.appliedAt : max), '')
|
|
99
|
+
: null,
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
// Integrity scope (M3): the hash covers the git-stable sections only. The
|
|
104
|
+
// ALCOA+ block is excluded because its Contemporaneous attribute derives
|
|
105
|
+
// from file mtimes vs now — it drifts with wall-clock time and resets on a
|
|
106
|
+
// fresh clone, which would break "same commit ⇒ same hash".
|
|
107
|
+
const { alcoa: _unhashed, ...hashable } = payload;
|
|
108
|
+
const integrity = 'sha256:' + createHash('sha256').update(JSON.stringify(hashable)).digest('hex');
|
|
109
|
+
return { ...payload, generatedAt: new Date().toISOString(), integrity };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function toMarkdown(r) {
|
|
113
|
+
const lines = [];
|
|
114
|
+
const gitLine = r.git
|
|
115
|
+
? `commit \`${r.git.commit.slice(0, 12)}\`${r.git.branch ? ` (${r.git.branch})` : ' (detached HEAD)'}${r.git.dirty ? ' — **uncommitted changes present**' : ''}`
|
|
116
|
+
: 'not a git repository';
|
|
117
|
+
|
|
118
|
+
lines.push(`# Documentation Compliance Report — ${r.project.name}`);
|
|
119
|
+
lines.push('');
|
|
120
|
+
lines.push(`Generated ${r.generatedAt} by DocGuard v${r.tool.version} · ${gitLine}`);
|
|
121
|
+
lines.push('');
|
|
122
|
+
lines.push('## Summary');
|
|
123
|
+
lines.push('');
|
|
124
|
+
lines.push('| Metric | Value |');
|
|
125
|
+
lines.push('|--------|-------|');
|
|
126
|
+
lines.push(`| CDD Score | ${r.score.score}/100 (${r.score.grade}) |`);
|
|
127
|
+
lines.push(`| Guard | ${r.guard.status.toUpperCase()} — ${r.guard.passed}/${r.guard.total} checks, ${r.guard.errors} error(s), ${r.guard.warnings} warning(s) |`);
|
|
128
|
+
if (r.guard.baselineSuppressed > 0) {
|
|
129
|
+
lines.push(`| Baseline | ⚠️ ${r.guard.baselineSuppressed} pre-existing finding(s) suppressed by \`.docguard.baseline.json\` — not reflected in the counts above |`);
|
|
130
|
+
}
|
|
131
|
+
lines.push(`| ALCOA+ data integrity | ${r.alcoa.score}% (${r.alcoa.met}/${r.alcoa.total} attributes) |`);
|
|
132
|
+
lines.push(`| Profile | ${r.project.profile} (${r.project.type}) |`);
|
|
133
|
+
lines.push('');
|
|
134
|
+
|
|
135
|
+
lines.push('## Validators');
|
|
136
|
+
lines.push('');
|
|
137
|
+
lines.push('| Validator | Status |');
|
|
138
|
+
lines.push('|-----------|--------|');
|
|
139
|
+
for (const v of r.guard.validators) {
|
|
140
|
+
const icon = v.status === 'pass' ? '✅' : v.status === 'warn' ? '⚠️' : v.status === 'na' ? '➖' : '❌';
|
|
141
|
+
lines.push(`| ${v.name} | ${icon} ${v.status} |`);
|
|
142
|
+
}
|
|
143
|
+
lines.push('');
|
|
144
|
+
|
|
145
|
+
lines.push('## Findings');
|
|
146
|
+
lines.push('');
|
|
147
|
+
if (r.findings.length === 0) {
|
|
148
|
+
lines.push(r.guard.baselineSuppressed > 0
|
|
149
|
+
? `No new findings beyond the ${r.guard.baselineSuppressed} suppressed by the committed baseline (run \`docguard guard --no-baseline\` for the full picture).`
|
|
150
|
+
: 'No findings — documentation matches the implementation at this commit.');
|
|
151
|
+
} else {
|
|
152
|
+
lines.push('| Code | Severity | Count | Example |');
|
|
153
|
+
lines.push('|------|----------|------:|---------|');
|
|
154
|
+
for (const f of r.findings) {
|
|
155
|
+
lines.push(`| ${f.code} | ${f.severity} | ${f.count} | ${(f.sample || '').replace(/\|/g, '\\|')} |`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
lines.push('');
|
|
159
|
+
|
|
160
|
+
lines.push('## ALCOA+ Attributes');
|
|
161
|
+
lines.push('');
|
|
162
|
+
lines.push('| Attribute | Met | Evidence / Gap |');
|
|
163
|
+
lines.push('|-----------|-----|----------------|');
|
|
164
|
+
for (const a of r.alcoa.attributes) {
|
|
165
|
+
lines.push(`| ${a.name} | ${a.met ? '✅' : '❌'} | ${(a.met ? a.evidence : a.gap) || '—'} |`);
|
|
166
|
+
}
|
|
167
|
+
lines.push('');
|
|
168
|
+
|
|
169
|
+
lines.push('## Fix History');
|
|
170
|
+
lines.push('');
|
|
171
|
+
lines.push(r.fixHistory.entries
|
|
172
|
+
? `${r.fixHistory.entries} mechanical fix(es) on record (\`.docguard/fixed.json\`), last applied ${r.fixHistory.lastApplied}.`
|
|
173
|
+
: 'No mechanical fixes on record.');
|
|
174
|
+
lines.push('');
|
|
175
|
+
|
|
176
|
+
lines.push('## Integrity');
|
|
177
|
+
lines.push('');
|
|
178
|
+
lines.push(`\`${r.integrity}\` — sha256 over the canonical JSON payload, excluding \`generatedAt\`, this hash, and the \`alcoa\` section (its Contemporaneous attribute is wall-clock/mtime-relative). Re-run \`docguard report --format json\` at the same commit to reproduce.`);
|
|
179
|
+
lines.push('');
|
|
180
|
+
return lines.join('\n');
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function runReport(projectDir, config, flags) {
|
|
184
|
+
const report = buildReport(projectDir, config);
|
|
185
|
+
const isJson = flags.format === 'json';
|
|
186
|
+
const output = isJson ? JSON.stringify(report, null, 2) : toMarkdown(report);
|
|
187
|
+
|
|
188
|
+
if (flags.out) {
|
|
189
|
+
writeFileSync(resolvePath(projectDir, flags.out), output + '\n');
|
|
190
|
+
// Chrome goes to stderr-style short confirm only in non-JSON mode; in
|
|
191
|
+
// JSON mode stay silent so scripted callers see nothing unexpected.
|
|
192
|
+
if (!isJson) console.log(`${c.green}✅ Report written to ${flags.out}${c.reset}`);
|
|
193
|
+
return report;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Machine/markdown output IS the artifact: write + natural exit (never
|
|
197
|
+
// console.log + process.exit — >8 KB payloads truncate through a pipe).
|
|
198
|
+
process.stdout.write(output + '\n');
|
|
199
|
+
return report;
|
|
200
|
+
}
|
package/cli/commands/score.mjs
CHANGED
|
@@ -11,6 +11,7 @@ import { validateSecurity } from '../validators/security.mjs';
|
|
|
11
11
|
import { runGuardInternal } from './guard.mjs';
|
|
12
12
|
import { extractSemanticClaims } from '../scanners/semantic-claims.mjs';
|
|
13
13
|
import { assessAgentReadability } from '../scanners/agent-readability.mjs';
|
|
14
|
+
import { loadHistory, sparkline } from '../writers/history.mjs';
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
17
|
* Detect whether the project configures a test runner (the "Check 3" of the
|
|
@@ -144,6 +145,10 @@ const WEIGHTS = {
|
|
|
144
145
|
};
|
|
145
146
|
|
|
146
147
|
export function runScore(projectDir, config, flags) {
|
|
148
|
+
// v0.33: `--trend` renders the local score history recorded by `docguard
|
|
149
|
+
// ci` (.docguard/history.jsonl) instead of recomputing a score.
|
|
150
|
+
if (flags.trend) return runTrend(projectDir, config, flags);
|
|
151
|
+
|
|
147
152
|
// v0.16-P1: suppress banner in JSON mode so stdout stays parseable.
|
|
148
153
|
// Was already fixed for guard/diagnose in v0.12; score/trace/diff missed
|
|
149
154
|
// the pattern. Reported on a Python project where `score --format json`
|
|
@@ -344,6 +349,52 @@ export function runScore(projectDir, config, flags) {
|
|
|
344
349
|
console.log(` ${c.dim}📎 Badge: ${c.reset}\n`);
|
|
345
350
|
}
|
|
346
351
|
|
|
352
|
+
/**
|
|
353
|
+
* `score --trend` — render the score trajectory from `.docguard/history.jsonl`
|
|
354
|
+
* (written by `docguard ci`). Read-only display; exits 0 whether or not
|
|
355
|
+
* history exists — trend is information, not a gate.
|
|
356
|
+
*/
|
|
357
|
+
function runTrend(projectDir, config, flags) {
|
|
358
|
+
const isJson = flags.format === 'json';
|
|
359
|
+
const entries = loadHistory(projectDir, 50);
|
|
360
|
+
|
|
361
|
+
if (isJson) {
|
|
362
|
+
const latest = entries[entries.length - 1] || null;
|
|
363
|
+
const first = entries[0] || null;
|
|
364
|
+
process.stdout.write(JSON.stringify({
|
|
365
|
+
project: config.projectName,
|
|
366
|
+
entries,
|
|
367
|
+
latest,
|
|
368
|
+
delta: latest && first ? latest.score - first.score : null,
|
|
369
|
+
}, null, 2) + '\n');
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
console.log(`${c.bold}📈 DocGuard Score Trend — ${config.projectName}${c.reset}\n`);
|
|
374
|
+
if (entries.length === 0) {
|
|
375
|
+
console.log(` ${c.dim}No history yet. Run ${c.cyan}docguard ci${c.dim} to start recording`);
|
|
376
|
+
console.log(` score history to .docguard/history.jsonl (one line per run).${c.reset}\n`);
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const scores = entries.map(e => e.score);
|
|
381
|
+
const latest = entries[entries.length - 1];
|
|
382
|
+
const first = entries[0];
|
|
383
|
+
const delta = latest.score - first.score;
|
|
384
|
+
const deltaStr = delta > 0 ? `${c.green}+${delta}${c.reset}` : delta < 0 ? `${c.red}${delta}${c.reset}` : '±0';
|
|
385
|
+
|
|
386
|
+
console.log(` ${sparkline(scores)} ${first.score} → ${c.bold}${latest.score}${c.reset} (${deltaStr}) over ${entries.length} run(s)\n`);
|
|
387
|
+
|
|
388
|
+
const recent = entries.slice(-10);
|
|
389
|
+
for (const e of recent) {
|
|
390
|
+
const icon = e.status === 'PASS' ? '✅' : e.status === 'WARN' ? '⚠️ ' : '❌';
|
|
391
|
+
const when = (e.timestamp || '').slice(0, 10);
|
|
392
|
+
const commit = e.commit ? ` ${c.dim}@${e.commit.slice(0, 7)}${c.reset}` : '';
|
|
393
|
+
console.log(` ${icon} ${when} ${String(e.score).padStart(3)}/100 (${e.grade})${commit}`);
|
|
394
|
+
}
|
|
395
|
+
console.log('');
|
|
396
|
+
}
|
|
397
|
+
|
|
347
398
|
/**
|
|
348
399
|
* Internal scoring — returns data without printing.
|
|
349
400
|
* Used by badge, ci, and other commands that need the score.
|
|
@@ -363,8 +414,11 @@ export function runScoreInternal(projectDir, config) {
|
|
|
363
414
|
* + Complete, Consistent, Enduring, Available
|
|
364
415
|
*
|
|
365
416
|
* Reference: WHO Technical Report Series, No. 996, 2016, Annex 5
|
|
417
|
+
*
|
|
418
|
+
* Exported for `docguard report` — the evidence bundle embeds the same
|
|
419
|
+
* ALCOA+ table the score display renders, from one computation.
|
|
366
420
|
*/
|
|
367
|
-
function computeAlcoaCompliance(projectDir, config, scores) {
|
|
421
|
+
export function computeAlcoaCompliance(projectDir, config, scores) {
|
|
368
422
|
const attributes = [];
|
|
369
423
|
|
|
370
424
|
// 1. Attributable — Can we trace who wrote/reviewed docs?
|
package/cli/docguard.mjs
CHANGED
|
@@ -29,7 +29,7 @@ import { runScore } from './commands/score.mjs';
|
|
|
29
29
|
import { runDiff } from './commands/diff.mjs';
|
|
30
30
|
import { runAgents } from './commands/agents.mjs';
|
|
31
31
|
import { runGenerate } from './commands/generate.mjs';
|
|
32
|
-
import { runHooks } from './commands/hooks.mjs';
|
|
32
|
+
import { runHooks, runNudgeHook } from './commands/hooks.mjs';
|
|
33
33
|
import { runBadge } from './commands/badge.mjs';
|
|
34
34
|
import { runCI } from './commands/ci.mjs';
|
|
35
35
|
import { runFix } from './commands/fix.mjs';
|
|
@@ -45,6 +45,7 @@ import { runImpact } from './commands/impact.mjs';
|
|
|
45
45
|
import { runExplain } from './commands/explain.mjs';
|
|
46
46
|
import { runFeedback } from './commands/feedback.mjs';
|
|
47
47
|
import { runVerify } from './commands/verify.mjs';
|
|
48
|
+
import { runReport } from './commands/report.mjs';
|
|
48
49
|
import { runMemory } from './commands/memory.mjs';
|
|
49
50
|
import { runDemo } from './commands/demo.mjs';
|
|
50
51
|
import { runAgent } from './commands/agent.mjs';
|
|
@@ -80,7 +81,7 @@ ${c.bold}The Daily 5${c.reset} ${c.dim}— what you'll reach for 95% of the time
|
|
|
80
81
|
${c.green}guard${c.reset} Validate against canonical docs (all validators)
|
|
81
82
|
${c.green}diff${c.reset} Show gaps between docs and code (add ${c.cyan}--since <ref>${c.reset} for changed-file impact)
|
|
82
83
|
${c.green}sync${c.reset} Refresh code-truth doc sections — keeps memory always up to date
|
|
83
|
-
${c.green}score${c.reset} CDD maturity score (0-100; ${c.cyan}--diff${c.reset} for delta between refs)
|
|
84
|
+
${c.green}score${c.reset} CDD maturity score (0-100; ${c.cyan}--diff${c.reset} for delta between refs, ${c.cyan}--trend${c.reset} for history from \`ci\` runs)
|
|
84
85
|
|
|
85
86
|
${c.bold}Tools (situational, but day-to-day useful)${c.reset}
|
|
86
87
|
${c.green}demo${c.reset} Zero-install tour: see what DocGuard catches against a sample project in 30s
|
|
@@ -91,7 +92,9 @@ ${c.bold}Tools (situational, but day-to-day useful)${c.reset}
|
|
|
91
92
|
${c.green}explain${c.reset} Explain a validator key, warning text, or finding code (${c.cyan}docguard explain SEC001${c.reset})
|
|
92
93
|
${c.green}verify${c.reset} Extract documented numbers/limits/enums for an agent to check vs code (${c.cyan}--semantic${c.reset})
|
|
93
94
|
${c.green}feedback${c.reset} Report likely false positives back to DocGuard (local-first + 1-click prefilled issue)
|
|
94
|
-
${c.green}mcp${c.reset} MCP server over stdio — guard/score/explain/verify/diagnose as agent tools
|
|
95
|
+
${c.green}mcp${c.reset} MCP server over stdio — guard/score/explain/verify/report/diagnose as agent tools
|
|
96
|
+
${c.green}report${c.reset} Compliance-evidence bundle — guard + score + ALCOA+ + integrity hash (${c.cyan}--format json${c.reset}, ${c.cyan}--out <file>${c.reset})
|
|
97
|
+
${c.green}ci${c.reset} Pipeline gate: guard + score in one command (${c.cyan}--threshold <n>${c.reset}, ${c.cyan}--fail-on-warning${c.reset}, ${c.cyan}--format json${c.reset}; records score history)
|
|
95
98
|
${c.green}memory${c.reset} Show what DocGuard remembers (${c.cyan}--diff${c.reset} drills into drift)
|
|
96
99
|
${c.green}trace${c.reset} Requirements traceability matrix (${c.cyan}--reverse${c.reset} for code→doc map, ${c.cyan}--features${c.reset} for per-feature adherence)
|
|
97
100
|
${c.green}upgrade${c.reset} Migrate ${c.cyan}.docguard.json${c.reset} schema + CLI (${c.cyan}--apply --pr${c.reset} for team-wide PR)
|
|
@@ -100,14 +103,14 @@ ${c.bold}Tools (situational, but day-to-day useful)${c.reset}
|
|
|
100
103
|
${c.bold}init --with <name>${c.reset} ${c.dim}— optional scaffolders, picked at init time${c.reset}
|
|
101
104
|
${c.dim}agents${c.reset} AGENTS.md / CLAUDE.md / .cursor/rules / Copilot instructions
|
|
102
105
|
${c.dim}hooks${c.reset} Git pre-commit / pre-push hooks
|
|
103
|
-
${c.dim}ci${c.reset}
|
|
106
|
+
${c.dim}ci${c.reset} Run the CI gate (guard + score) once, right after init
|
|
104
107
|
${c.dim}badge${c.reset} Shields.io score badges for README
|
|
105
108
|
${c.dim}llms${c.reset} llms.txt generation
|
|
106
109
|
${c.dim}publish${c.reset} External doc-site scaffold (Mintlify) ${c.dim}— experimental${c.reset}
|
|
107
110
|
|
|
108
111
|
${c.bold}Deprecation aliases${c.reset} ${c.dim}— still work in v0.20.x with a yellow warning${c.reset}
|
|
109
112
|
${c.dim}setup${c.reset} → ${c.cyan}init --wizard${c.reset}
|
|
110
|
-
${c.dim}agents · hooks ·
|
|
113
|
+
${c.dim}agents · hooks · badge · llms · publish${c.reset} → ${c.cyan}init --with <name>${c.reset}
|
|
111
114
|
${c.dim}impact${c.reset} → ${c.cyan}diff --since <ref>${c.reset}
|
|
112
115
|
${c.dim}audit${c.reset} → ${c.green}guard${c.reset} ${c.dim}(permanent — no warning, no removal planned)${c.reset}
|
|
113
116
|
${c.dim}See docs-implementation/MIGRATION-v0.20.md for the full timeline.${c.reset}
|
|
@@ -211,13 +214,15 @@ const COMMAND_HELP = {
|
|
|
211
214
|
},
|
|
212
215
|
guard: {
|
|
213
216
|
summary: 'Validate code against canonical docs (all validators).',
|
|
214
|
-
usage: 'docguard guard [--format json] [--changed-only] [--fail-on-warning]',
|
|
217
|
+
usage: 'docguard guard [--format json|sarif|junit] [--changed-only] [--fail-on-warning]',
|
|
215
218
|
flags: [
|
|
216
|
-
['--format json', 'Machine-readable results for CI'],
|
|
219
|
+
['--format json', 'Machine-readable results for CI (also: sarif, junit)'],
|
|
217
220
|
['--changed-only', 'Only validate docs/code touched in the working tree'],
|
|
218
221
|
['--fail-on-warning', 'Exit non-zero on warnings (strict CI)'],
|
|
222
|
+
['--update-baseline', 'Freeze current findings to .docguard.baseline.json — adopt on a legacy repo without a red day one'],
|
|
223
|
+
['--no-baseline', 'Ignore the committed baseline for this run (show everything)'],
|
|
219
224
|
],
|
|
220
|
-
examples: ['docguard guard', 'docguard guard --format json'],
|
|
225
|
+
examples: ['docguard guard', 'docguard guard --format json', 'docguard guard --update-baseline'],
|
|
221
226
|
},
|
|
222
227
|
score: {
|
|
223
228
|
summary: 'CDD maturity score (0–100).',
|
|
@@ -428,6 +433,17 @@ async function main() {
|
|
|
428
433
|
// avoid collision with `docguard init --profile <name>`. `--show-timings`
|
|
429
434
|
// is the long form for users who prefer explicit verbs.
|
|
430
435
|
flags.timings = true;
|
|
436
|
+
} else if (args[i] === '--trend') {
|
|
437
|
+
flags.trend = true;
|
|
438
|
+
} else if (args[i] === '--no-history') {
|
|
439
|
+
flags.noHistory = true;
|
|
440
|
+
} else if (args[i] === '--update-baseline') {
|
|
441
|
+
flags.updateBaseline = true;
|
|
442
|
+
} else if (args[i] === '--no-baseline') {
|
|
443
|
+
flags.noBaseline = true;
|
|
444
|
+
} else if (args[i] === '--out' && args[i + 1]) {
|
|
445
|
+
flags.out = args[i + 1];
|
|
446
|
+
i++;
|
|
431
447
|
} else if (args[i] === '--quiet' || args[i] === '-q') {
|
|
432
448
|
// v0.16-P5: suppress the banner + ensureSkills decorative line.
|
|
433
449
|
// Useful inside git hooks (every commit prints the banner otherwise)
|
|
@@ -495,6 +511,32 @@ async function main() {
|
|
|
495
511
|
i++;
|
|
496
512
|
} else if (args[i] === '--no-fix') {
|
|
497
513
|
flags.noFix = true;
|
|
514
|
+
} else if (args[i] === '--no-indirect') {
|
|
515
|
+
// impact: skip the reverse-import-graph (indirect code→doc) analysis.
|
|
516
|
+
flags.indirect = false;
|
|
517
|
+
} else if (args[i] === '--prs') {
|
|
518
|
+
// impact: open-PR doc-conflict analysis (needs the gh CLI).
|
|
519
|
+
flags.prs = true;
|
|
520
|
+
} else if (args[i] === '--claude') {
|
|
521
|
+
// hooks: install/remove the Claude Code agent nudge hook.
|
|
522
|
+
flags.claude = true;
|
|
523
|
+
} else if (args[i] === '--transport' && args[i + 1]) {
|
|
524
|
+
// mcp: stdio (default) or http (Streamable HTTP, team-shared server).
|
|
525
|
+
flags.transport = args[i + 1];
|
|
526
|
+
i++;
|
|
527
|
+
} else if (args[i] === '--port' && args[i + 1]) {
|
|
528
|
+
flags.port = args[i + 1];
|
|
529
|
+
i++;
|
|
530
|
+
} else if (args[i] === '--host' && args[i + 1]) {
|
|
531
|
+
flags.host = args[i + 1];
|
|
532
|
+
i++;
|
|
533
|
+
} else if (args[i] === '--api-key' && args[i + 1]) {
|
|
534
|
+
flags.apiKey = args[i + 1];
|
|
535
|
+
i++;
|
|
536
|
+
} else if (args[i] === '--path' && args[i + 1]) {
|
|
537
|
+
// mcp --transport http: HTTP mount path (default /mcp).
|
|
538
|
+
flags.path = args[i + 1];
|
|
539
|
+
i++;
|
|
498
540
|
} else if (args[i] === '--signals') {
|
|
499
541
|
flags.signals = true;
|
|
500
542
|
} else if (args[i] === '--debate') {
|
|
@@ -541,16 +583,26 @@ async function main() {
|
|
|
541
583
|
// `generate --plan` (and were already suppressed for `--plan --write`).
|
|
542
584
|
// v0.29: 'sarif' joins 'json' — any machine format where stdout IS the
|
|
543
585
|
// artifact belongs here, or the banner corrupts the payload.
|
|
544
|
-
|
|
586
|
+
// v0.33: 'junit' joins for the same reason (GitLab/Jenkins parse stdout XML).
|
|
587
|
+
const jsonMode = flags.format === 'json' || flags.format === 'sarif' || flags.format === 'junit';
|
|
545
588
|
// `agent` emits a machine task graph (JSON by default) — it must be banner-
|
|
546
589
|
// free and side-effect-free like the other read-only commands.
|
|
547
590
|
// `mcp`: stdout IS the JSON-RPC transport — any banner byte corrupts the stream.
|
|
548
|
-
|
|
591
|
+
// `nudge-hook`: stdout is the Claude Code hook feedback channel — any banner
|
|
592
|
+
// byte corrupts the JSON the hook runner parses.
|
|
593
|
+
// `report`: stdout IS the evidence artifact (markdown or JSON) — banner
|
|
594
|
+
// bytes would corrupt it for redirection/piping in both formats.
|
|
595
|
+
const headless = jsonMode || flags.write || flags.checkOnly || flags.changedOnly || flags.quiet || flags.plan || command === 'agent' || command === 'mcp' || command === 'nudge-hook' || command === 'report';
|
|
549
596
|
|
|
550
597
|
if (!headless) printBanner();
|
|
551
598
|
|
|
552
599
|
const config = loadConfig(projectDir);
|
|
553
600
|
|
|
601
|
+
// `--no-baseline` disables the committed adoption baseline for this run —
|
|
602
|
+
// threaded through config so guard, ci, report, and mcp all honor it the
|
|
603
|
+
// same way runGuardInternal sees everything else.
|
|
604
|
+
if (flags.noBaseline) config.baseline = false;
|
|
605
|
+
|
|
554
606
|
// Commands that only READ and REPORT — they must never mutate the working
|
|
555
607
|
// tree. Scaffolding (ensureSkills → .agent/.specify, spawning `specify`)
|
|
556
608
|
// belongs to setup/init/generate and the `init --with` family, where the
|
|
@@ -570,8 +622,19 @@ async function main() {
|
|
|
570
622
|
'feedback',
|
|
571
623
|
// verify only reads docs and emits a task list — pure report.
|
|
572
624
|
'verify',
|
|
625
|
+
// report gathers evidence (guard+score, read-only); --out writes only the
|
|
626
|
+
// user-named file — it must never scaffold or mutate the tree otherwise.
|
|
627
|
+
'report',
|
|
628
|
+
// ci is the pipeline gate — it must never scaffold into the workspace it
|
|
629
|
+
// gates (review finding H1: bare `docguard ci` in text mode ran
|
|
630
|
+
// ensureSkills and wrote ~9 files before gating). Its only write is its
|
|
631
|
+
// own .docguard/history.jsonl, same carve-out as feedback.
|
|
632
|
+
'ci',
|
|
573
633
|
// mcp serves read-only tools over stdio — scaffolding writes are off-limits.
|
|
574
634
|
'mcp',
|
|
635
|
+
// nudge-hook runs inside an agent's PostToolUse hook — it may write only
|
|
636
|
+
// its own .docguard/nudge-state.json throttle file, never scaffold skills.
|
|
637
|
+
'nudge-hook',
|
|
575
638
|
]);
|
|
576
639
|
|
|
577
640
|
// Silent auto-check: install skills/commands if missing. Skip entirely in
|
|
@@ -594,7 +657,8 @@ async function main() {
|
|
|
594
657
|
setup: { since: '0.20', replacement: 'docguard init --wizard' },
|
|
595
658
|
agents: { since: '0.20', replacement: 'docguard init --with agents' },
|
|
596
659
|
hooks: { since: '0.20', replacement: 'docguard init --with hooks' },
|
|
597
|
-
ci
|
|
660
|
+
// `ci` was deprecated here in v0.20 → un-deprecated in v0.33: it is the
|
|
661
|
+
// documented pipeline gate (guard + score + threshold), not a scaffolder.
|
|
598
662
|
badge: { since: '0.20', replacement: 'docguard init --with badge' },
|
|
599
663
|
llms: { since: '0.20', replacement: 'docguard init --with llms' },
|
|
600
664
|
publish: { since: '0.20', replacement: 'docguard init --with publish' },
|
|
@@ -624,7 +688,9 @@ async function main() {
|
|
|
624
688
|
process.exit(1);
|
|
625
689
|
}
|
|
626
690
|
|
|
627
|
-
|
|
691
|
+
// `hooks --claude` is a first-class new surface (agent nudge hook), not the
|
|
692
|
+
// deprecated git-hooks alias — no deprecation warning for it.
|
|
693
|
+
if (DEPRECATED_COMMANDS[command] && !flags.quiet && !(command === 'hooks' && flags.claude)) {
|
|
628
694
|
const { since, replacement } = DEPRECATED_COMMANDS[command];
|
|
629
695
|
console.error(`${c.yellow}⚠ Deprecated since v${since}:${c.reset} ${c.cyan}docguard ${command}${c.reset} → use ${c.cyan}${replacement}${c.reset}`);
|
|
630
696
|
console.error(`${c.dim} The old form still works in v0.20.x but will be removed in v1.0. See MIGRATION-v0.20.md.${c.reset}`);
|
|
@@ -671,13 +737,29 @@ async function main() {
|
|
|
671
737
|
runAgent(projectDir, config, flags);
|
|
672
738
|
break;
|
|
673
739
|
case 'hooks':
|
|
740
|
+
if (flags.claude) {
|
|
741
|
+
// Agent nudge hook (.claude/settings.json) — direct path, no wizard.
|
|
742
|
+
runHooks(projectDir, config, flags);
|
|
743
|
+
break;
|
|
744
|
+
}
|
|
674
745
|
await runInit(projectDir, config, { ...flags, with: ['hooks'], skipPrompts: true });
|
|
675
746
|
break;
|
|
747
|
+
case 'nudge-hook':
|
|
748
|
+
// Runtime for the Claude Code PostToolUse hook. stdout is the machine
|
|
749
|
+
// channel (headless — see the jsonMode/banner gate above).
|
|
750
|
+
runNudgeHook(projectDir);
|
|
751
|
+
break;
|
|
676
752
|
case 'badge':
|
|
677
753
|
await runInit(projectDir, config, { ...flags, with: ['badge'], skipPrompts: true });
|
|
678
754
|
break;
|
|
679
755
|
case 'ci':
|
|
680
|
-
|
|
756
|
+
// v0.33: restored as a first-class command. The v0.20 deprecation
|
|
757
|
+
// routed `ci` through runInit --with ci, which (a) scaffolded missing
|
|
758
|
+
// docs INTO the CI workspace — a validate command mutating the tree —
|
|
759
|
+
// and (b) printed init chrome into `--format json` stdout, corrupting
|
|
760
|
+
// it for parsers. A pipeline gate must be read-only and machine-clean,
|
|
761
|
+
// so it dispatches straight to runCI like guard/score.
|
|
762
|
+
runCI(projectDir, config, flags);
|
|
681
763
|
break;
|
|
682
764
|
case 'fix':
|
|
683
765
|
runFix(projectDir, config, flags);
|
|
@@ -723,6 +805,12 @@ async function main() {
|
|
|
723
805
|
// drift — the class regex/AST can't see). Read-only.
|
|
724
806
|
runVerify(projectDir, config, flags);
|
|
725
807
|
break;
|
|
808
|
+
case 'report':
|
|
809
|
+
// Compliance-evidence bundle (guard + score + ALCOA+ + fix history +
|
|
810
|
+
// integrity hash). Evidence, not a gate — always exits 0; guard/ci fail
|
|
811
|
+
// builds. Auditors need evidence collection that never self-censors.
|
|
812
|
+
runReport(projectDir, config, flags);
|
|
813
|
+
break;
|
|
726
814
|
case 'mcp':
|
|
727
815
|
// MCP stdio server — guard/score/explain/verify-claims/diagnose as tools
|
|
728
816
|
// for MCP clients. Long-lived; resolves when stdin closes.
|
package/cli/findings.mjs
CHANGED
|
@@ -614,6 +614,12 @@ export const CODES = {
|
|
|
614
614
|
help: 'A code-element reference in the doc matched source when the doc was last updated, but matches ZERO source instances at HEAD (two-revision check, arXiv 2212.01479). Excludes the two documented false-positive modes (removed-but-config-relevant flags, and symbols whose literal string was deleted while logic remains). Verify and update the reference.',
|
|
615
615
|
suppress: '<!-- docguard:ignore REF001 — still relevant, e.g. user-facing flag -->',
|
|
616
616
|
},
|
|
617
|
+
REF002: {
|
|
618
|
+
validator: 'reference-existence',
|
|
619
|
+
title: 'Code cites an ADR that has no document',
|
|
620
|
+
help: 'A code comment cites an Architecture Decision Record (e.g. ADR-012) that no ADR document defines — the citation is stale (renumbered, removed) or the ADR was never written. Numbers compare as integers, so ADR-0011 matches ADR-11. IETF RFC citations are deliberately not checked (external registry). Write the ADR, fix the number, or suppress on the citation line.',
|
|
621
|
+
suppress: '// docguard:ignore REF002 — your reason',
|
|
622
|
+
},
|
|
617
623
|
APS001: {
|
|
618
624
|
validator: 'api-doc-smells',
|
|
619
625
|
title: 'Bloated API documentation',
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
16
16
|
import { resolve, dirname } from 'node:path';
|
|
17
|
+
import { loadIgnorePatterns } from '../shared.mjs';
|
|
17
18
|
|
|
18
19
|
/** chars/4 — the standard rough token estimate; consistency matters more than precision. */
|
|
19
20
|
const estTokens = (s) => Math.ceil(s.length / 4);
|
|
@@ -31,9 +32,13 @@ function readIfExists(path) {
|
|
|
31
32
|
function canonicalDocs(projectDir) {
|
|
32
33
|
const dir = resolve(projectDir, 'docs-canonical');
|
|
33
34
|
if (!existsSync(dir)) return [];
|
|
35
|
+
// Honor .docguardignore — an excluded doc (e.g. a historical audit) must
|
|
36
|
+
// not drag down the readability metrics either (same rule as the
|
|
37
|
+
// semantic-claim extractor, bug-212).
|
|
38
|
+
const isIgnored = loadIgnorePatterns(projectDir);
|
|
34
39
|
try {
|
|
35
40
|
return readdirSync(dir)
|
|
36
|
-
.filter(f => f.toLowerCase().endsWith('.md'))
|
|
41
|
+
.filter(f => f.toLowerCase().endsWith('.md') && !isIgnored(`docs-canonical/${f}`))
|
|
37
42
|
.sort()
|
|
38
43
|
.map(f => ({ name: `docs-canonical/${f}`, content: readIfExists(resolve(dir, f)) }))
|
|
39
44
|
.filter(d => d.content !== null);
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
|
|
24
24
|
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
25
25
|
import { resolve, join } from 'node:path';
|
|
26
|
+
import { loadIgnorePatterns } from '../shared.mjs';
|
|
26
27
|
|
|
27
28
|
// Numbers are only claims when adjacent to a recognized unit.
|
|
28
29
|
const NUMBER_PATTERNS = [
|
|
@@ -49,17 +50,24 @@ const MAX_CLAIMS = 80;
|
|
|
49
50
|
|
|
50
51
|
/** Canonical docs + the root docs where limits/counts commonly live. */
|
|
51
52
|
function claimSourceDocs(projectDir) {
|
|
53
|
+
// Honor .docguardignore: a doc the user explicitly excluded from validation
|
|
54
|
+
// (e.g. a historical audit full of point-in-time counts) must not feed the
|
|
55
|
+
// "unverified claims" pool either — it inflated the count and buried the
|
|
56
|
+
// claims that ARE actionable (bug-212).
|
|
57
|
+
const isIgnored = loadIgnorePatterns(projectDir);
|
|
52
58
|
const docs = [];
|
|
53
59
|
const canonical = resolve(projectDir, 'docs-canonical');
|
|
54
60
|
if (existsSync(canonical)) {
|
|
55
61
|
try {
|
|
56
62
|
for (const f of readdirSync(canonical)) {
|
|
57
|
-
if (f.toLowerCase().endsWith('.md')
|
|
63
|
+
if (f.toLowerCase().endsWith('.md') && !isIgnored(`docs-canonical/${f}`)) {
|
|
64
|
+
docs.push(`docs-canonical/${f}`);
|
|
65
|
+
}
|
|
58
66
|
}
|
|
59
67
|
} catch { /* ignore */ }
|
|
60
68
|
}
|
|
61
69
|
for (const root of ['README.md', 'AGENTS.md']) {
|
|
62
|
-
if (existsSync(resolve(projectDir, root))) docs.push(root);
|
|
70
|
+
if (existsSync(resolve(projectDir, root)) && !isIgnored(root)) docs.push(root);
|
|
63
71
|
}
|
|
64
72
|
return docs;
|
|
65
73
|
}
|
package/cli/shared-git.mjs
CHANGED
|
@@ -241,6 +241,29 @@ export function lastCommitHash(dir, filePath) {
|
|
|
241
241
|
}
|
|
242
242
|
}
|
|
243
243
|
|
|
244
|
+
/**
|
|
245
|
+
* Resolve HEAD identity for evidence reports: { commit, branch, dirty }.
|
|
246
|
+
* `branch` is null on a detached HEAD (common in CI checkouts); `dirty` is
|
|
247
|
+
* true when tracked files have uncommitted changes — evidence consumers need
|
|
248
|
+
* to know the report may not describe a reproducible tree. Returns null when
|
|
249
|
+
* the dir isn't a git repo or git is unavailable.
|
|
250
|
+
*/
|
|
251
|
+
export function getHeadInfo(dir) {
|
|
252
|
+
try {
|
|
253
|
+
const run = (args) => execFileSync('git', args, {
|
|
254
|
+
cwd: dir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'],
|
|
255
|
+
}).trim();
|
|
256
|
+
const commit = run(['rev-parse', 'HEAD']);
|
|
257
|
+
if (!commit) return null;
|
|
258
|
+
const branchRaw = run(['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
259
|
+
const branch = branchRaw === 'HEAD' ? null : branchRaw;
|
|
260
|
+
const dirty = run(['status', '--porcelain', '--untracked-files=no']) !== '';
|
|
261
|
+
return { commit, branch, dirty };
|
|
262
|
+
} catch {
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
244
267
|
/**
|
|
245
268
|
* Resolve the absolute path to this repo's git hooks directory.
|
|
246
269
|
*
|
|
@@ -143,7 +143,14 @@ function validateConfigLayers(projectDir, config, layers, acc) {
|
|
|
143
143
|
|
|
144
144
|
// ── Import Graph Builder ────────────────────────────────────────────────────
|
|
145
145
|
|
|
146
|
-
|
|
146
|
+
/**
|
|
147
|
+
* Build the project's JS/TS import graph. Exported for reuse by `impact`
|
|
148
|
+
* (indirect code→doc analysis walks this graph's reverse edges) — one graph
|
|
149
|
+
* builder, not two.
|
|
150
|
+
*
|
|
151
|
+
* @returns {{files: string[], edges: {from,to,dynamic}[], fileMap: Map<string,string[]>}}
|
|
152
|
+
*/
|
|
153
|
+
export function buildImportGraph(projectDir, config) {
|
|
147
154
|
const graph = { files: [], edges: [], fileMap: new Map() };
|
|
148
155
|
|
|
149
156
|
const allFiles = getFilesRecursive(projectDir, config, projectDir);
|