docguard-cli 0.32.0 → 0.33.1

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.
@@ -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
@@ -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)
@@ -567,18 +583,26 @@ async function main() {
567
583
  // `generate --plan` (and were already suppressed for `--plan --write`).
568
584
  // v0.29: 'sarif' joins 'json' — any machine format where stdout IS the
569
585
  // artifact belongs here, or the banner corrupts the payload.
570
- 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';
571
588
  // `agent` emits a machine task graph (JSON by default) — it must be banner-
572
589
  // free and side-effect-free like the other read-only commands.
573
590
  // `mcp`: stdout IS the JSON-RPC transport — any banner byte corrupts the stream.
574
591
  // `nudge-hook`: stdout is the Claude Code hook feedback channel — any banner
575
592
  // byte corrupts the JSON the hook runner parses.
576
- const headless = jsonMode || flags.write || flags.checkOnly || flags.changedOnly || flags.quiet || flags.plan || command === 'agent' || command === 'mcp' || command === 'nudge-hook';
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';
577
596
 
578
597
  if (!headless) printBanner();
579
598
 
580
599
  const config = loadConfig(projectDir);
581
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
+
582
606
  // Commands that only READ and REPORT — they must never mutate the working
583
607
  // tree. Scaffolding (ensureSkills → .agent/.specify, spawning `specify`)
584
608
  // belongs to setup/init/generate and the `init --with` family, where the
@@ -598,6 +622,14 @@ async function main() {
598
622
  'feedback',
599
623
  // verify only reads docs and emits a task list — pure report.
600
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',
601
633
  // mcp serves read-only tools over stdio — scaffolding writes are off-limits.
602
634
  'mcp',
603
635
  // nudge-hook runs inside an agent's PostToolUse hook — it may write only
@@ -625,7 +657,8 @@ async function main() {
625
657
  setup: { since: '0.20', replacement: 'docguard init --wizard' },
626
658
  agents: { since: '0.20', replacement: 'docguard init --with agents' },
627
659
  hooks: { since: '0.20', replacement: 'docguard init --with hooks' },
628
- 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.
629
662
  badge: { since: '0.20', replacement: 'docguard init --with badge' },
630
663
  llms: { since: '0.20', replacement: 'docguard init --with llms' },
631
664
  publish: { since: '0.20', replacement: 'docguard init --with publish' },
@@ -720,7 +753,13 @@ async function main() {
720
753
  await runInit(projectDir, config, { ...flags, with: ['badge'], skipPrompts: true });
721
754
  break;
722
755
  case 'ci':
723
- 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);
724
763
  break;
725
764
  case 'fix':
726
765
  runFix(projectDir, config, flags);
@@ -766,6 +805,12 @@ async function main() {
766
805
  // drift — the class regex/AST can't see). Read-only.
767
806
  runVerify(projectDir, config, flags);
768
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;
769
814
  case 'mcp':
770
815
  // MCP stdio server — guard/score/explain/verify-claims/diagnose as tools
771
816
  // for MCP clients. Long-lived; resolves when stdin closes.
@@ -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
  *
@@ -43,6 +43,11 @@ const COMMON_DOTFILES = new Set([
43
43
  '.babelrc', '.browserslistrc', '.stylelintrc',
44
44
  '.dockerignore', '.python-version', '.tool-versions', '.ruby-version',
45
45
  '.gitkeep', '.keep',
46
+ // DocGuard's own files — self-explanatory (embedded _comment / schema),
47
+ // and flagging them creates a warning the moment a team adopts the tool
48
+ // (e.g. `guard --update-baseline` writing the baseline instantly produced
49
+ // a DCV001 about the baseline file itself).
50
+ '.docguard.json', '.docguardignore', '.docguard.baseline.json',
46
51
  ]);
47
52
 
48
53
  // Generated tool artifacts (caches, coverage data, lock-data) that land at the
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Adoption Baseline — `.docguard.baseline.json` (repo root, COMMITTED).
3
+ *
4
+ * The brownfield-adoption pattern (ESLint/semgrep-style): a legacy repo
5
+ * freezes its existing findings once (`guard --update-baseline`), commits the
6
+ * file, and from then on guard/ci gate only NEW drift. Suppressed findings
7
+ * are counted and displayed — never silently hidden — and the baseline is a
8
+ * reviewable diff in every PR that updates it.
9
+ *
10
+ * Root, not `.docguard/`: the state dir is gitignored, and a baseline only
11
+ * works if the whole team and CI share it.
12
+ *
13
+ * Fingerprints are content-addressed, not line-addressed: `code | location
14
+ * path (line numbers stripped) | message with digit-runs normalized to #`.
15
+ * Line numbers churn on every edit and messages embed volatile counts
16
+ * ("21 commits since…") — both would rot the baseline in a week.
17
+ */
18
+
19
+ import { createHash } from 'node:crypto';
20
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
21
+ import { resolve } from 'node:path';
22
+
23
+ export const BASELINE_FILE = '.docguard.baseline.json';
24
+
25
+ /** Stable fingerprint for one finding. */
26
+ export function fingerprintFinding(f) {
27
+ const code = f.code || 'UNCODED';
28
+ const path = typeof f.location === 'string' ? f.location.replace(/:\d+$/, '') : '';
29
+ const msg = String(f.message || '').replace(/\d+/g, '#').replace(/\s+/g, ' ').trim();
30
+ return createHash('sha256').update(`${code}|${path}|${msg}`).digest('hex').slice(0, 16);
31
+ }
32
+
33
+ /**
34
+ * Load the committed baseline as a Map of fingerprint → allowed occurrence
35
+ * count, or null when the project has none (the common case — zero overhead).
36
+ *
37
+ * Occurrence counts matter (review finding H2): two findings with the same
38
+ * code + file + message shape — e.g. two hardcoded passwords in one file —
39
+ * share a fingerprint. A count-less set would let one frozen instance
40
+ * suppress every FUTURE instance of that class in that file, a
41
+ * security-relevant false negative. With counts, freezing 1 suppresses 1;
42
+ * a second appearance surfaces and gates.
43
+ */
44
+ export function loadBaseline(projectDir) {
45
+ const p = resolve(projectDir, BASELINE_FILE);
46
+ if (!existsSync(p)) return null;
47
+ try {
48
+ const data = JSON.parse(readFileSync(p, 'utf-8'));
49
+ if (!data || typeof data.fingerprints !== 'object' || data.fingerprints === null) return null;
50
+ const map = new Map();
51
+ for (const [fp, n] of Object.entries(data.fingerprints)) {
52
+ const count = Number.isInteger(n) && n > 0 ? n : 0;
53
+ if (count > 0) map.set(fp, count);
54
+ }
55
+ return map.size > 0 ? map : null;
56
+ } catch {
57
+ // A malformed baseline must not silently un-gate CI: treat as absent so
58
+ // every finding surfaces (fail-open on visibility, fail-closed on hiding).
59
+ return null;
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Write the baseline from the current findings: fingerprint → occurrence
65
+ * count, keys sorted so the committed file diffs cleanly. Returns the number
66
+ * of distinct fingerprints.
67
+ */
68
+ export function saveBaseline(projectDir, findings) {
69
+ const counts = {};
70
+ for (const f of findings) {
71
+ const fp = fingerprintFinding(f);
72
+ counts[fp] = (counts[fp] || 0) + 1;
73
+ }
74
+ const fingerprints = Object.fromEntries(Object.keys(counts).sort().map(k => [k, counts[k]]));
75
+ const doc = {
76
+ _comment: 'DocGuard adoption baseline — existing findings frozen at adoption time (fingerprint → occurrence count). Guard suppresses up to that many instances of each and gates everything new. Regenerate with: docguard guard --update-baseline',
77
+ version: 2,
78
+ generatedAt: new Date().toISOString(),
79
+ count: Object.keys(fingerprints).length,
80
+ fingerprints,
81
+ };
82
+ writeFileSync(resolve(projectDir, BASELINE_FILE), JSON.stringify(doc, null, 2) + '\n');
83
+ return Object.keys(fingerprints).length;
84
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Score History — local-first trend memory at `.docguard/history.jsonl`.
3
+ *
4
+ * `docguard ci` appends one line per run ({timestamp, commit, score, grade,
5
+ * errors, warnings, passed, total, status}); `docguard score --trend` reads
6
+ * it back and renders the trajectory. JSONL because append is the hot path:
7
+ * one O(1) write per CI run, and a truncated last line (crash mid-write)
8
+ * corrupts one entry, not the file. The rare trim rewrite goes through a
9
+ * temp-file + rename so a crash mid-trim can't truncate history; concurrent
10
+ * appends during a trim window can still lose an entry — acceptable for a
11
+ * trend log, not a ledger.
12
+ *
13
+ * Local-first by design: `.docguard/` is gitignored, so history accumulates
14
+ * per checkout. In ephemeral CI, persist it across runs with a cache/artifact
15
+ * step (see CI-RECIPES) — the file format is stable and merge-friendly.
16
+ */
17
+
18
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
19
+ import { resolve, dirname } from 'node:path';
20
+
21
+ const HISTORY_PATH = '.docguard/history.jsonl';
22
+
23
+ // Trim trigger: beyond this many entries the file is rewritten keeping the
24
+ // most recent MAX_ENTRIES. Generous — 1000 CI runs of ~150 bytes ≈ 150 KB.
25
+ const MAX_ENTRIES = 1000;
26
+
27
+ /**
28
+ * Append one run entry. Silent no-op on failure (read-only checkouts, odd
29
+ * CI filesystems) — recording history must never fail the pipeline it's
30
+ * recording.
31
+ */
32
+ export function appendHistory(projectDir, entry) {
33
+ try {
34
+ const p = resolve(projectDir, HISTORY_PATH);
35
+ mkdirSync(dirname(p), { recursive: true });
36
+ appendFileSync(p, JSON.stringify(entry) + '\n');
37
+ // Occasional trim, checked cheaply by size (~200 KB ≫ MAX_ENTRIES rows).
38
+ // Temp-file + rename: a crash mid-trim leaves the old file intact
39
+ // instead of a truncated one (L2).
40
+ if (statSync(p).size > 256 * 1024) {
41
+ const rows = loadHistory(projectDir, MAX_ENTRIES);
42
+ const tmp = p + '.tmp';
43
+ writeFileSync(tmp, rows.map(r => JSON.stringify(r)).join('\n') + '\n');
44
+ renameSync(tmp, p);
45
+ }
46
+ return true;
47
+ } catch {
48
+ return false;
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Read the last `limit` valid entries, oldest → newest. Malformed lines
54
+ * (partial writes, hand edits) are skipped, never thrown.
55
+ */
56
+ export function loadHistory(projectDir, limit = 50) {
57
+ try {
58
+ const p = resolve(projectDir, HISTORY_PATH);
59
+ if (!existsSync(p)) return [];
60
+ const out = [];
61
+ for (const line of readFileSync(p, 'utf-8').split('\n')) {
62
+ if (!line.trim()) continue;
63
+ try {
64
+ const e = JSON.parse(line);
65
+ if (e && typeof e.score === 'number') out.push(e);
66
+ } catch { /* skip malformed line */ }
67
+ }
68
+ return out.slice(-limit);
69
+ } catch {
70
+ return [];
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Unicode sparkline over the score series (0–100 → ▁–█). Pure display.
76
+ */
77
+ export function sparkline(scores) {
78
+ const BARS = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
79
+ return scores
80
+ .map(s => BARS[Math.min(BARS.length - 1, Math.max(0, Math.floor((s / 100) * BARS.length)))])
81
+ .join('');
82
+ }