docguard-cli 0.32.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.
@@ -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
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * JUnit XML writer — `docguard guard --format junit`.
3
+ *
4
+ * SARIF covers GitHub Code Scanning; JUnit covers everything else an
5
+ * enterprise runs: GitLab CI (`artifacts:reports:junit`), Jenkins
6
+ * (`junit` step), Azure DevOps, CircleCI, Bamboo. One testcase per
7
+ * validator keeps the report readable in those UIs — a failed validator
8
+ * shows its findings (code + message + location) as the failure body.
9
+ *
10
+ * Mapping (deterministic):
11
+ * validator error findings → <failure> (red in every CI)
12
+ * validator crashed (fail, no
13
+ * structured findings) → <error> from its string errors (red)
14
+ * warn-only validator → passing testcase + findings in
15
+ * <system-out> (visible, non-gating)
16
+ * skipped / n/a → <skipped/>
17
+ *
18
+ * Zero npm dependencies — pure string assembly with strict XML escaping.
19
+ */
20
+
21
+ function esc(s) {
22
+ return String(s ?? '')
23
+ .replace(/&/g, '&amp;')
24
+ .replace(/</g, '&lt;')
25
+ .replace(/>/g, '&gt;')
26
+ .replace(/"/g, '&quot;')
27
+ .replace(/'/g, '&apos;');
28
+ }
29
+
30
+ function findingLine(f) {
31
+ const code = f.code ? `[${f.code}] ` : '';
32
+ const loc = f.location ? ` (${f.location})` : '';
33
+ return `${code}${f.message}${loc}`;
34
+ }
35
+
36
+ /**
37
+ * Build the JUnit XML document from runGuardInternal's data.
38
+ * `data.validators` entries: { name, status, findings? }; `data.findings`
39
+ * is the flat list with `validator` back-references — we group by the
40
+ * validator display name via each result's own findings when present,
41
+ * falling back to the flat list.
42
+ */
43
+ export function toJUnit(data) {
44
+ const cases = [];
45
+ let failures = 0, errorCount = 0, skipped = 0;
46
+
47
+ for (const v of data.validators || []) {
48
+ const vFindings = Array.isArray(v.findings)
49
+ ? v.findings
50
+ : (data.findings || []).filter(f => f.validator === v.key || f.validator === v.name);
51
+ const errors = vFindings.filter(f => f.severity === 'error');
52
+ const warns = vFindings.filter(f => f.severity !== 'error');
53
+ const attrs = `name="${esc(v.name)}" classname="docguard.guard"`;
54
+
55
+ if (v.status === 'skipped' || v.status === 'na') {
56
+ skipped++;
57
+ cases.push(` <testcase ${attrs}><skipped/></testcase>`);
58
+ } else if (errors.length > 0) {
59
+ failures++;
60
+ const body = errors.map(findingLine).join('\n');
61
+ cases.push(
62
+ ` <testcase ${attrs}>\n` +
63
+ ` <failure message="${esc(errors[0].message)}" type="${esc(errors[0].code || 'docguard')}">${esc(body)}</failure>\n` +
64
+ ` </testcase>`
65
+ );
66
+ } else if (v.status === 'fail') {
67
+ // A validator that failed WITHOUT structured error findings — the
68
+ // crash path (guard catches the throw and records string errors only).
69
+ // This must go red in CI, not render as a passing testcase (M1).
70
+ errorCount++;
71
+ const body = (v.errors || []).join('\n') || 'validator failed without structured findings';
72
+ cases.push(
73
+ ` <testcase ${attrs}>\n` +
74
+ ` <error message="${esc((v.errors || [])[0] || 'validator failed')}" type="docguard.crash">${esc(body)}</error>\n` +
75
+ ` </testcase>`
76
+ );
77
+ } else if (warns.length > 0) {
78
+ const body = warns.map(findingLine).join('\n');
79
+ cases.push(
80
+ ` <testcase ${attrs}>\n` +
81
+ ` <system-out>${esc(body)}</system-out>\n` +
82
+ ` </testcase>`
83
+ );
84
+ } else {
85
+ cases.push(` <testcase ${attrs}/>`);
86
+ }
87
+ }
88
+
89
+ const total = (data.validators || []).length;
90
+ const suiteAttrs =
91
+ `name="docguard guard — ${esc(data.project || 'project')}" ` +
92
+ `tests="${total}" failures="${failures}" errors="${errorCount}" skipped="${skipped}" ` +
93
+ `timestamp="${esc(data.timestamp || '')}"`;
94
+
95
+ return (
96
+ `<?xml version="1.0" encoding="UTF-8"?>\n` +
97
+ `<testsuites tests="${total}" failures="${failures}" errors="${errorCount}">\n` +
98
+ ` <testsuite ${suiteAttrs}>\n` +
99
+ cases.join('\n') + (cases.length ? '\n' : '') +
100
+ ` </testsuite>\n` +
101
+ `</testsuites>`
102
+ );
103
+ }
package/docs/commands.md CHANGED
@@ -38,6 +38,17 @@ npx docguard-cli diagnose --format prompt # Raw AI prompt (all issues combined)
38
38
  npx docguard-cli guard # Text output
39
39
  npx docguard-cli guard --format json # Structured JSON (the stable agent contract)
40
40
  npx docguard-cli guard --format sarif # SARIF 2.1.0 for GitHub Code Scanning
41
+ npx docguard-cli guard --format junit # JUnit XML for GitLab/Jenkins/Azure DevOps
42
+ npx docguard-cli guard --update-baseline # Freeze current findings (brownfield adoption)
43
+ npx docguard-cli guard --no-baseline # Ignore the committed baseline this run
44
+ ```
45
+
46
+ **Adoption baseline:** on a legacy repo, `--update-baseline` writes
47
+ `.docguard.baseline.json` (commit it). From then on guard/ci suppress those
48
+ frozen findings — visibly — and gate only new drift. Fingerprints are stable
49
+ across line-number churn and volatile counts, so the baseline doesn't rot.
50
+
51
+ ```bash
41
52
  npx docguard-cli guard --verbose # Show all check details
42
53
  npx docguard-cli guard --changed-only # Pre-commit lite mode (fast subset)
43
54
  ```
@@ -181,7 +192,7 @@ never touched without `--force`.
181
192
 
182
193
  **MCP server over stdio** — DocGuard's read-only core as native agent tools
183
194
  (`docguard_guard`, `docguard_score`, `docguard_explain`,
184
- `docguard_verify_claims`, `docguard_diagnose`).
195
+ `docguard_verify_claims`, `docguard_report`, `docguard_diagnose`).
185
196
 
186
197
  ```bash
187
198
  claude mcp add docguard -- npx docguard-cli mcp
@@ -211,15 +222,32 @@ npx docguard-cli memory --pack # .docguard/context-pack.md (session-start co
211
222
 
212
223
  ## DevOps Commands
213
224
 
225
+ ### `docguard report`
226
+
227
+ **Compliance-evidence bundle for audits** — guard verdict, CDD score, ALCOA+
228
+ data-integrity attributes, findings grouped by code, and fix history, stamped
229
+ with the git commit and a tamper-evident sha256 integrity hash. Evidence, not
230
+ a gate: always exits 0 (`guard`/`ci` fail builds).
231
+
232
+ ```bash
233
+ npx docguard-cli report # markdown to stdout
234
+ npx docguard-cli report --format json # machine bundle
235
+ npx docguard-cli report --out evidence.md # write to a file
236
+ ```
237
+
214
238
  ### `docguard ci`
215
239
 
216
- **Single command for CI/CD pipelines.** Runs guard + score internally (no subprocess).
240
+ **Single command for CI/CD pipelines.** Runs guard + score internally (no
241
+ subprocess). Read-only and machine-clean: it never scaffolds or mutates the
242
+ workspace it validates. Each run appends one line to `.docguard/history.jsonl`
243
+ so `docguard score --trend` can show the trajectory (opt out: `--no-history`).
217
244
 
218
245
  ```bash
219
246
  npx docguard-cli ci # Basic check
220
247
  npx docguard-cli ci --threshold 70 # Fail below score 70
221
248
  npx docguard-cli ci --threshold 80 --fail-on-warning # Strict mode
222
249
  npx docguard-cli ci --format json # JSON for GitHub Actions
250
+ npx docguard-cli score --trend # Score history from past ci runs
223
251
  ```
224
252
 
225
253
  ### `docguard hooks`
@@ -111,6 +111,20 @@ Conventional doc folders (`docs/`, `doc/`, `documentation/`, `guides/`,
111
111
  that set with non-standard homes — it never replaces auto-detection. To exclude
112
112
  a conventional dir, list it in `.docguardignore`.
113
113
 
114
+ ## Adoption baseline — `baseline`
115
+
116
+ When a committed `.docguard.baseline.json` exists (written by
117
+ `docguard guard --update-baseline`), guard/ci suppress the frozen findings
118
+ and gate only new drift. Set `"baseline": false` in `.docguard.json` to
119
+ ignore the file entirely (same as always passing `--no-baseline`):
120
+
121
+ ```json
122
+ { "baseline": false }
123
+ ```
124
+
125
+ Suppression is always visible in output and in the `baselineSuppressed`
126
+ JSON field — nothing is silently hidden.
127
+
114
128
  ## Muting a validator
115
129
 
116
130
  Two ways to turn a validator off, for two different intents:
package/docs/faq.md CHANGED
@@ -123,6 +123,18 @@ Yes — DocGuard ships a template at `templates/ci/github-actions.yml`. Copy it
123
123
 
124
124
  Only if you install hooks (`docguard hooks`). Without hooks, it's advisory only.
125
125
 
126
+ ### I ran guard on our legacy repo and got dozens of findings. Now what?
127
+
128
+ Freeze them and move forward:
129
+
130
+ ```bash
131
+ npx docguard-cli guard --update-baseline # writes .docguard.baseline.json — commit it
132
+ ```
133
+
134
+ From then on guard/ci pass, suppress the frozen findings **visibly**
135
+ ("N pre-existing finding(s) suppressed"), and gate only NEW drift. Burn the
136
+ baseline down at your own pace; `--no-baseline` shows the full picture anytime.
137
+
126
138
  ---
127
139
 
128
140
  ## Technical