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.
Files changed (33) hide show
  1. package/PHILOSOPHY.md +1 -0
  2. package/README.md +70 -30
  3. package/cli/commands/ci.mjs +52 -13
  4. package/cli/commands/guard.mjs +80 -0
  5. package/cli/commands/hooks.mjs +167 -2
  6. package/cli/commands/impact.mjs +213 -5
  7. package/cli/commands/mcp.mjs +195 -53
  8. package/cli/commands/report.mjs +200 -0
  9. package/cli/commands/score.mjs +55 -1
  10. package/cli/docguard.mjs +101 -13
  11. package/cli/findings.mjs +6 -0
  12. package/cli/scanners/agent-readability.mjs +6 -1
  13. package/cli/scanners/semantic-claims.mjs +10 -2
  14. package/cli/shared-git.mjs +23 -0
  15. package/cli/validators/architecture.mjs +8 -1
  16. package/cli/validators/cross-reference.mjs +124 -3
  17. package/cli/validators/docs-coverage.mjs +5 -0
  18. package/cli/validators/reference-existence.mjs +172 -18
  19. package/cli/validators/traceability.mjs +63 -0
  20. package/cli/writers/baseline.mjs +84 -0
  21. package/cli/writers/history.mjs +82 -0
  22. package/cli/writers/junit.mjs +103 -0
  23. package/docs/commands.md +30 -2
  24. package/docs/configuration.md +14 -0
  25. package/docs/faq.md +12 -0
  26. package/extensions/spec-kit-docguard/extension.yml +1 -1
  27. package/extensions/spec-kit-docguard/skills/docguard-fix/SKILL.md +2 -2
  28. package/extensions/spec-kit-docguard/skills/docguard-guard/SKILL.md +2 -2
  29. package/extensions/spec-kit-docguard/skills/docguard-review/SKILL.md +2 -2
  30. package/extensions/spec-kit-docguard/skills/docguard-score/SKILL.md +2 -2
  31. package/extensions/spec-kit-docguard/skills/docguard-sync/SKILL.md +2 -2
  32. package/package.json +1 -1
  33. 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
+ }
@@ -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: ![CDD Score](${badgeUrl})${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} GitHub Actions / pipeline config
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 · ci · badge · llms · publish${c.reset} → ${c.cyan}init --with <name>${c.reset}
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
- const jsonMode = flags.format === 'json' || flags.format === 'sarif';
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
- const headless = jsonMode || flags.write || flags.checkOnly || flags.changedOnly || flags.quiet || flags.plan || command === 'agent' || command === 'mcp';
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: { since: '0.20', replacement: 'docguard init --with 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
- if (DEPRECATED_COMMANDS[command] && !flags.quiet) {
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
- await runInit(projectDir, config, { ...flags, with: ['ci'], skipPrompts: true });
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')) docs.push(`docs-canonical/${f}`);
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
  }
@@ -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
- function buildImportGraph(projectDir, config) {
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);