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.
package/PHILOSOPHY.md CHANGED
@@ -115,6 +115,7 @@ CDD is a practitioner methodology whose patterns align with peer-reviewed resear
115
115
  - **Generate → validate → evaluate pipeline** — inspired by the AITPG framework (Lopez et al., IEEE TSE 2026): multi-agent generation grounded in standards produces more comprehensive documentation while staying semantically aligned with expert references.
116
116
  - **Calibrated quality evaluation** — DocGuard's HIGH/MEDIUM/LOW labels and multi-signal scoring adapt the CJE framework from TRACE (Lopez et al., IEEE TMLCN 2026).
117
117
  - **Standards-grounded generation** — each canonical document maps to a relevant standard (arc42, C4, OWASP ASVS, ISO 29119, OpenAPI, 12-Factor App).
118
+ - **Enforcement over instructions** — a 2026 ETH Zurich study (138 repos, 5,694 agent PRs) found the most common style of agent-instruction file *degrades* agent performance; practitioner reports converge on the same conclusion: written rules are routinely ignored, programmatic checks are respected. CDD's answer is to make the docs machine-verified rather than merely machine-readable.
118
119
 
119
120
  > **Lead researcher**: [Martin Manuel Lopez](https://github.com/martinmanuel9) · [ORCID 0009-0002-7652-2385](https://orcid.org/0009-0002-7652-2385), University of Arizona
120
121
 
package/README.md CHANGED
@@ -41,6 +41,7 @@
41
41
  - [Slash Commands](#-slash-commands)
42
42
  - [Examples](#-examples)
43
43
  - [Testing](#-testing)
44
+ - [Enterprise Adoption](#-enterprise-adoption)
44
45
  - [CI/CD Integration](#%EF%B8%8F-cicd-integration)
45
46
  - [What's New](#-whats-new)
46
47
  - [File Structure](#-file-structure)
@@ -68,7 +69,7 @@ DocGuard is an official [GitHub Spec Kit](https://github.com/github/spec-kit) co
68
69
 
69
70
  ```mermaid
70
71
  graph TD
71
- CLI["CLI Entry<br/>docguard.mjs"] --> Commands["Commands (18)"]
72
+ CLI["CLI Entry<br/>docguard.mjs"] --> Commands["Commands (20)"]
72
73
  Commands --> guard["guard"]
73
74
  Commands --> generate["generate"]
74
75
  Commands --> score["score"]
@@ -108,6 +109,14 @@ against what the code does, on every commit, with no LLM required. The full
108
109
  thesis (and the research behind it) lives in [PHILOSOPHY.md](PHILOSOPHY.md);
109
110
  recent feature highlights moved [below](#-whats-new).
110
111
 
112
+ The field data backs the enforcement-over-instructions bet: an ETH Zurich
113
+ study across 138 repos / 5,694 agent PRs found the most popular style of
114
+ agent-instruction file *hurts* agent performance, and practitioners keep
115
+ converging on the same lesson — written rules are routinely ignored;
116
+ programmatic checks are what agents (and humans) actually respect. That is
117
+ exactly the layer DocGuard provides: not another instructions file, but the
118
+ validator suite that makes the instructions and docs verifiably true.
119
+
111
120
  ---
112
121
 
113
122
  ## ⚡ Quick Start
@@ -247,7 +256,7 @@ This installs DocGuard's slash commands (`/docguard.init`, `/docguard.guard`, `/
247
256
 
248
257
  ## Usage
249
258
 
250
- DocGuard ships **18 commands** (the "Daily 5" + 13 situational tools, including the zero-install `demo` and the `mcp` server). Six additional one-shot scaffolders are accessed via `docguard init --with <name>`. Eight v0.19 commands continue to work as deprecation aliases through v0.20.x — see [MIGRATION-v0.20.md](docs-implementation/MIGRATION-v0.20.md).
259
+ DocGuard ships **20 commands** (the "Daily 5" + 15 situational tools, including the zero-install `demo`, the `mcp` server, and the `ci` pipeline gate). Six additional one-shot scaffolders are accessed via `docguard init --with <name>`. Seven v0.19 commands continue to work as deprecation aliases through v0.20.x — see [MIGRATION-v0.20.md](docs-implementation/MIGRATION-v0.20.md).
251
260
 
252
261
  **The Daily 5** — what you'll reach for 95% of the time:
253
262
 
@@ -274,7 +283,10 @@ DocGuard ships **18 commands** (the "Daily 5" + 13 situational tools, including
274
283
  | `verify --semantic` | Extract documented numbers/limits/enums (retention days, rate limits, GSI/role counts, status enums) as a task list for an agent to check against code — the semantic-drift class regex/AST can't see |
275
284
  | `verify --instructions` | Audit AGENTS.md/CLAUDE.md themselves for drift: duplicate rules, never-vs-always contradictions, stale file pointers, unknown commands — plus clustered rule pairs as agent judgment tasks |
276
285
  | `feedback` | Report likely false positives back to DocGuard — local-first record + a 1-click prefilled, redacted GitHub issue (zero typing) |
277
- | `mcp` | MCP server — exposes guard/score/explain/verify/diagnose as native tools for Claude, Cursor, and any MCP client. Stdio: `claude mcp add docguard -- npx docguard-cli mcp`. Team-shared HTTP: `docguard mcp --transport http --port 8585` (loopback by default; non-loopback binds require `--api-key`) |
286
+ | `mcp` | MCP server — exposes guard/score/explain/verify/report/diagnose as native tools for Claude, Cursor, and any MCP client. Stdio: `claude mcp add docguard -- npx docguard-cli mcp`. Team-shared HTTP: `docguard mcp --transport http --port 8585` (loopback by default; non-loopback binds require `--api-key`) |
287
+ | `report` | Compliance-evidence bundle for audits — guard verdict + CDD score + ALCOA+ attributes + fix history, stamped with git commit and a tamper-evident sha256 integrity hash (`--format json`, `--out <file>`). Evidence, not a gate: always exits 0 |
288
+ | `ci` | Pipeline gate: guard + score in one command — never scaffolds or touches source; its only write is its own `.docguard/history.jsonl` (opt out: `--no-history`). `--threshold <n>` fails below a score, `--fail-on-warning` for strict mode, `--format json` for parsers |
289
+ | `score --trend` | Score trajectory from recorded `ci` runs — sparkline, delta, and the last 10 runs with commit stamps |
278
290
  | `memory` | Per-domain accuracy headline (endpoints / entities / env / tech) |
279
291
  | `memory --diff` | Drill into which specific claims don't match code |
280
292
  | `memory --pack` | Write `.docguard/context-pack.md` — compact, code-truth-stamped session-start context for AI agents |
@@ -308,6 +320,8 @@ Run them solo (`docguard init --with hooks`) or stacked (`docguard init --with a
308
320
  | `--quiet` / `-q` | Suppress banner — for hooks, CI loops, scripts | All |
309
321
  | `--format json` | Machine-readable output (clean JSON, no ANSI bleed) | guard, score, diff, trace, diagnose, memory, impact, explain |
310
322
  | `--format sarif` | SARIF 2.1.0 output — findings as rules/results for GitHub Code Scanning and SARIF dashboards | guard |
323
+ | `--format junit` | JUnit XML output — one testcase per validator, for GitLab CI (`artifacts:reports:junit`), Jenkins, Azure DevOps, CircleCI | guard |
324
+ | `--update-baseline` | Adopt DocGuard on a legacy repo without a red day one: freeze today's findings into a committed `.docguard.baseline.json`; guard/ci then gate only NEW drift. Suppression is always visible ("N pre-existing finding(s) suppressed"), and `--no-baseline` shows the full picture | guard |
311
325
  | `--full` | Generate `llms-full.txt` (full doc bodies inlined) instead of the `llms.txt` link index | llms |
312
326
  | `--pack` | Write `.docguard/context-pack.md` — agent session-start context | memory |
313
327
  | `--sync` | Regenerate the agent-file family (CLAUDE.md, Copilot, Cursor, …) from AGENTS.md; hash-marked, never touches hand-written files without `--force` | agents |
@@ -563,6 +577,20 @@ DocGuard runs its own `guard`, `score`, `diff`, `diagnose`, and `badge` commands
563
577
 
564
578
  ---
565
579
 
580
+ ## 🏢 Enterprise Adoption
581
+
582
+ Everything runs local or in your CI — no SaaS, no data leaving your infra.
583
+ The pieces that matter at company scale:
584
+
585
+ | Need | DocGuard answer |
586
+ |------|-----------------|
587
+ | **Adopt on a legacy repo** without a red pipeline on day one | `guard --update-baseline` freezes existing findings into a committed `.docguard.baseline.json`; only NEW drift gates from then on (suppression always visible) |
588
+ | **Audit trail** for compliance reviews | `docguard report` — commit-stamped evidence bundle (guard verdict, findings by code, CDD score, ALCOA+ data-integrity attributes, fix history) with a tamper-evident sha256 integrity hash |
589
+ | **Every CI system**, not just GitHub | `guard --format sarif` (GitHub Code Scanning) · `--format junit` (GitLab, Jenkins, Azure DevOps, CircleCI) · `--format json` (anything else) |
590
+ | **Trajectory, not snapshots** | `docguard ci` records every run to `.docguard/history.jsonl`; `score --trend` shows the sparkline + delta |
591
+ | **AI agents on the team** | MCP server (stdio or team-shared HTTP) exposes guard/score/explain/verify/report/diagnose as read-only tools; `agents --sync` keeps the whole agent-file family drift-proof |
592
+ | **Data-integrity framing auditors know** | ALCOA+ scoring (FDA 21 CFR Part 11 / EMA Annex 11 vocabulary) built into `score` and `report` |
593
+
566
594
  ## ⚙️ CI/CD Integration
567
595
 
568
596
  > **Full recipes:** see [`docs-canonical/CI-RECIPES.md`](./docs-canonical/CI-RECIPES.md) for guard, auto-fix (commits mechanical fixes back to PRs), nightly sync, score-on-PR, and pre-commit configs.
@@ -631,31 +659,27 @@ Two ready-to-use templates ship with the Spec Kit extension and as standalone fi
631
659
 
632
660
  ## ✨ What's New
633
661
 
634
- Recent highlights across the v0.16 → v0.19 line:
635
-
636
- - **`docguard explain <validator>`** — `docguard explain freshness` prints purpose, rules, common
637
- failures, and fix recipes for any of the 27 validators. No need to dig into source.
638
- - **`docguard memory --diff`** surface what changed in your canonical docs between two refs
639
- (`HEAD~10..HEAD` by default). Great for code review and changelog drafting.
640
- - **`docguard score --diff`** see exactly which validators moved the score up or down between
641
- two commits. Pinpoints regressions without re-running the full suite by hand.
642
- - **`docguard upgrade --apply --pr`** — when the config schema bumps, DocGuard migrates
643
- `.docguard.json` for you and (optionally) opens a PR with the change.
644
- - **Language-aware traceability** — both `docguard trace` *and* the guard-time Traceability validator
645
- understand Python, Rust, Go, Java, Ruby, and PHP layouts in addition to JS/TS, via a shared pattern
646
- set (`cli/shared-trace-patterns.mjs`) so the two never drift apart.
647
- - **Per-validator severity overrides** escalate `freshness` to `high` for production repos,
648
- demote `doc-quality` to `low` for prototypes. Configurable per-project.
649
- - **JSON Schema for `.docguard.json`** IDE autocomplete, in-line docs, and validation via
650
- `$schema`. Shipped in the package at `schemas/docguard-config.schema.json`.
651
- - **Version pin (`docguardVersion` + `--pin`)** — pin the CLI version your project supports so
652
- CI fails loudly if someone bumps DocGuard without re-running the suite.
653
- - **Cross-process plan cache** repeated runs reuse the validator plan across processes when
654
- the working tree hasn't changed. ~30% faster guard runs on typical repos.
655
- - **Headless-aware banner** — `--quiet`, `--format json`, `--write`, and `--changed-only`
656
- automatically suppress the banner so JSON output stays parse-clean.
657
- - **npm-pack smoke gate** — every release now extracts the actual tarball and runs the CLI
658
- end-to-end before publish, catching missing-file regressions.
662
+ Highlights of the current line (v0.29 → v0.33):
663
+
664
+ - **Adoption baseline** — `guard --update-baseline` freezes a legacy repo's existing findings
665
+ into a committed `.docguard.baseline.json`; guard/ci then gate only NEW drift, with suppression
666
+ always visible. Adopt today, burn down at your own pace.
667
+ - **`docguard report`** commit-stamped compliance-evidence bundle (guard verdict, findings by
668
+ code, CDD score, ALCOA+ attributes, fix history) with a tamper-evident sha256 integrity hash.
669
+ Also exposed as the `docguard_report` MCP tool.
670
+ - **Score history + `score --trend`** — `docguard ci` records every run to
671
+ `.docguard/history.jsonl`; the trend view shows the sparkline and delta over time.
672
+ - **Three machine formats for guard** — `--format json`, `--format sarif` (GitHub Code
673
+ Scanning), and `--format junit` (GitLab, Jenkins, Azure DevOps, CircleCI).
674
+ - **MCP server, stdio + team HTTP** — guard/score/explain/verify/report/diagnose as read-only
675
+ agent tools: `claude mcp add docguard -- npx docguard-cli mcp`.
676
+ - **Agent-file family sync** — `agents --sync` treats AGENTS.md as canonical and regenerates
677
+ CLAUDE.md / `.cursor/rules` / Copilot / Gemini variants with drift-proof source-hash markers.
678
+ - **`verify --semantic` and `verify --instructions`** — extract documented numbers/limits/enums
679
+ as agent verification tasks; audit the agent-instruction files themselves for contradictions
680
+ and stale pointers.
681
+ - **`docguard agent`**one-shot ordered task graph with pre-filled code-truth, collapsing ~10
682
+ agent round-trips into one call.
659
683
 
660
684
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
661
685
 
@@ -6,11 +6,18 @@
6
6
  * 0 = All pass, score meets threshold
7
7
  * 1 = Guard errors or score below threshold
8
8
  * 2 = Guard warnings only
9
+ *
10
+ * v0.33: each run appends one line to `.docguard/history.jsonl` (score,
11
+ * grade, commit, guard counts) so `docguard score --trend` can show the
12
+ * trajectory. Opt out with `--no-history`. The append is silent-on-failure —
13
+ * recording history must never fail the pipeline it records.
9
14
  */
10
15
 
11
16
  import { c } from '../shared.mjs';
12
17
  import { runGuardInternal } from './guard.mjs';
13
18
  import { runScoreInternal } from './score.mjs';
19
+ import { appendHistory } from '../writers/history.mjs';
20
+ import { getHeadInfo, isGitRepo } from '../shared-git.mjs';
14
21
 
15
22
  export function runCI(projectDir, config, flags) {
16
23
  const threshold = parseInt(flags.threshold || '0', 10);
@@ -26,12 +33,41 @@ export function runCI(projectDir, config, flags) {
26
33
 
27
34
  // ── Run guard (internal — no subprocess) ──
28
35
  const guardData = runGuardInternal(projectDir, config);
29
- const hasErrors = guardData.errors > 0;
30
- const hasWarnings = guardData.warnings > 0;
36
+ // Severity-aware effective counts (M2): `guard` gates on these, so `ci`
37
+ // must too — a severity=low demotion or severity=high escalation has to
38
+ // produce the same verdict from both commands.
39
+ const hasErrors = guardData.effectiveErrors > 0;
40
+ const hasWarnings = guardData.effectiveWarnings > 0;
31
41
 
32
42
  // ── Get score ──
33
43
  const scoreData = runScoreInternal(projectDir, config);
34
44
 
45
+ // Status reflects EVERY gate, not just guard (L3): a threshold or
46
+ // --fail-on-warning failure exits 1 and must not be recorded as PASS in
47
+ // history or the JSON consumers parse.
48
+ const thresholdMet = threshold <= 0 || scoreData.score >= threshold;
49
+ const status =
50
+ hasErrors || !thresholdMet || (failOnWarning && hasWarnings) ? 'FAIL'
51
+ : hasWarnings ? 'WARN'
52
+ : 'PASS';
53
+
54
+ // ── Record history (unless opted out) ──
55
+ if (!flags.noHistory) {
56
+ const git = isGitRepo(projectDir) ? getHeadInfo(projectDir) : null;
57
+ appendHistory(projectDir, {
58
+ timestamp: new Date().toISOString(),
59
+ commit: git ? git.commit.slice(0, 12) : null,
60
+ score: scoreData.score,
61
+ grade: scoreData.grade,
62
+ errors: guardData.errors,
63
+ warnings: guardData.warnings,
64
+ baselineSuppressed: guardData.baselineSuppressed || 0,
65
+ passed: guardData.passed,
66
+ total: guardData.total,
67
+ status,
68
+ });
69
+ }
70
+
35
71
  // ── Output ──
36
72
  if (isJson) {
37
73
  const result = {
@@ -44,14 +80,18 @@ export function runCI(projectDir, config, flags) {
44
80
  passed: guardData.passed,
45
81
  total: guardData.total,
46
82
  status: guardData.status,
83
+ baselineSuppressed: guardData.baselineSuppressed || 0,
47
84
  validators: guardData.validators.filter(v => v.status !== 'skipped'),
48
85
  },
49
86
  threshold,
50
- thresholdMet: threshold <= 0 || scoreData.score >= threshold,
51
- status: hasErrors ? 'FAIL' : hasWarnings ? 'WARN' : 'PASS',
87
+ thresholdMet,
88
+ status,
52
89
  timestamp: new Date().toISOString(),
53
90
  };
54
- console.log(JSON.stringify(result, null, 2));
91
+ // Machine output must survive a pipe: stdout.write + natural exit, never
92
+ // console.log + process.exit (>8 KB payloads truncate mid-flush — same
93
+ // class as the guard --format json bug fixed in v0.28).
94
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
55
95
  } else {
56
96
  // Text output
57
97
  const guardStatus = hasErrors
@@ -61,20 +101,19 @@ export function runCI(projectDir, config, flags) {
61
101
  : `${c.green}✅ PASS${c.reset}`;
62
102
 
63
103
  console.log(` ${c.bold}Guard:${c.reset} ${guardStatus} (${guardData.passed}/${guardData.total})`);
104
+ if (guardData.baselineSuppressed > 0) {
105
+ console.log(` ${c.dim}📋 ${guardData.baselineSuppressed} pre-existing finding(s) suppressed by the committed baseline${c.reset}`);
106
+ }
64
107
  console.log(` ${c.bold}Score:${c.reset} ${scoreData.score}/100 (${scoreData.grade})`);
65
108
 
66
109
  if (threshold > 0) {
67
- const met = scoreData.score >= threshold;
68
- console.log(` ${c.bold}Threshold:${c.reset} ${met ? `${c.green}✅ ≥${threshold}` : `${c.red}❌ <${threshold}`}${c.reset}`);
110
+ console.log(` ${c.bold}Threshold:${c.reset} ${thresholdMet ? `${c.green}✅ ≥${threshold}` : `${c.red}❌ <${threshold}`}${c.reset}`);
69
111
  }
70
112
 
71
113
  console.log('');
72
114
  }
73
115
 
74
- // Exit code determination
75
- if (hasErrors) process.exit(1);
76
- if (threshold > 0 && scoreData.score < threshold) process.exit(1);
77
- if (failOnWarning && hasWarnings) process.exit(1);
78
- if (hasWarnings) process.exit(2);
79
- process.exit(0);
116
+ // Exit code follows `status` exactly — one derivation, no drift between
117
+ // what history/JSON record and what the pipeline does.
118
+ process.exitCode = status === 'FAIL' ? 1 : status === 'WARN' ? 2 : 0;
80
119
  }
@@ -16,6 +16,8 @@ import { checkUpgradeStatus } from './upgrade.mjs';
16
16
  import { changedFilesSince, isGitRepo } from '../shared-git.mjs';
17
17
  import { extractSemanticClaims } from '../scanners/semantic-claims.mjs';
18
18
  import { toSarif } from '../writers/sarif.mjs';
19
+ import { toJUnit } from '../writers/junit.mjs';
20
+ import { loadBaseline, saveBaseline, fingerprintFinding, BASELINE_FILE } from '../writers/baseline.mjs';
19
21
  import { readFileSync, writeFileSync, existsSync } from 'node:fs';
20
22
  import { resolve as resolvePath, relative as relativePath } from 'node:path';
21
23
  import { fileURLToPath as fp } from 'node:url';
@@ -393,6 +395,42 @@ export function runGuardInternal(projectDir, config) {
393
395
  }
394
396
  }
395
397
 
398
+ // ── Adoption baseline (v0.33) ──
399
+ // If the repo committed `.docguard.baseline.json`, findings frozen at
400
+ // adoption time are suppressed BEFORE any tally — so exit codes, severity
401
+ // rollups, json/sarif/junit, ci, and report all gate only NEW drift.
402
+ // Suppression is visible (baselineSuppressed in the payload + a display
403
+ // note), applies only to findings-backed results (legacy string-only
404
+ // errors/warnings can't be fingerprinted), and `--no-baseline`
405
+ // (config.baseline === false) turns it off.
406
+ let baselineSuppressed = 0;
407
+ const baselineMap = config.baseline === false ? null : loadBaseline(projectDir);
408
+ if (baselineMap) {
409
+ // Occurrence budget: each fingerprint suppresses at most its frozen
410
+ // count (H2). Validators run in a fixed order, so consumption is
411
+ // deterministic — the same tree always suppresses the same instances.
412
+ const remaining = new Map(baselineMap);
413
+ for (const r of results) {
414
+ if (!Array.isArray(r.findings) || r.findings.length === 0) continue;
415
+ if (r.errors.length + r.warnings.length !== r.findings.length) continue;
416
+ const kept = r.findings.filter(f => {
417
+ const fp = fingerprintFinding(f);
418
+ const budget = remaining.get(fp) || 0;
419
+ if (budget <= 0) return true;
420
+ remaining.set(fp, budget - 1);
421
+ return false;
422
+ });
423
+ const removed = r.findings.length - kept.length;
424
+ if (removed === 0) continue;
425
+ baselineSuppressed += removed;
426
+ r.findings = kept;
427
+ r.errors = kept.filter(f => f.severity === 'error').map(f => f.message);
428
+ r.warnings = kept.filter(f => f.severity !== 'error').map(f => f.message);
429
+ r.total = r.passed + kept.length;
430
+ Object.assign(r, classifyResult(r));
431
+ }
432
+ }
433
+
396
434
  const activeResults = results.filter(r => r.status !== 'skipped');
397
435
  const totalErrors = activeResults.reduce((sum, r) => sum + r.errors.length, 0);
398
436
  const totalWarnings = activeResults.reduce((sum, r) => sum + r.warnings.length, 0);
@@ -464,6 +502,7 @@ export function runGuardInternal(projectDir, config) {
464
502
  // things they've marked as high-severity.
465
503
  effectiveErrors,
466
504
  effectiveWarnings,
505
+ baselineSuppressed,
467
506
  coverage,
468
507
  semanticClaims,
469
508
  validators: results,
@@ -555,6 +594,30 @@ export function runGuard(projectDir, config, flags) {
555
594
  console.log(`${c.cyan}⚡ docguard guard --changed-only${c.reset} ${c.dim}(${label})${c.reset}${escalatedNote}\n`);
556
595
  }
557
596
 
597
+ // ── `--update-baseline`: freeze the CURRENT full finding set ──
598
+ // Runs with the baseline disabled so the file captures everything visible
599
+ // today (updating through an active baseline would only ever shrink it).
600
+ if (flags.updateBaseline) {
601
+ // --changed-only rewrites config.validators to the 5-validator lite set;
602
+ // freezing THAT would silently shrink the committed team baseline to a
603
+ // subset (L1). Refuse the combination rather than corrupt the file.
604
+ if (flags.changedOnly) {
605
+ console.error(`${c.red}✗ --update-baseline cannot be combined with --changed-only — the baseline must freeze the FULL validator set, not the pre-commit lite subset.${c.reset}`);
606
+ process.exitCode = 1;
607
+ return;
608
+ }
609
+ const fullData = runGuardInternal(projectDir, { ...config, baseline: false });
610
+ const n = saveBaseline(projectDir, fullData.findings || []);
611
+ if (flags.format === 'json') {
612
+ process.stdout.write(JSON.stringify({ written: true, file: BASELINE_FILE, fingerprints: n, findings: (fullData.findings || []).length }, null, 2) + '\n');
613
+ } else {
614
+ console.log(`${c.green}✅ Baseline written:${c.reset} ${BASELINE_FILE} (${n} fingerprint(s))`);
615
+ console.log(`${c.dim} Commit it. guard/ci now gate only NEW findings; --no-baseline shows everything.${c.reset}`);
616
+ }
617
+ process.exitCode = 0;
618
+ return;
619
+ }
620
+
558
621
  const data = runGuardInternal(projectDir, config);
559
622
 
560
623
  // ── SARIF output (2.1.0) ──
@@ -567,6 +630,17 @@ export function runGuard(projectDir, config, flags) {
567
630
  return;
568
631
  }
569
632
 
633
+ // ── JUnit XML output ──
634
+ // SARIF is GitHub's language; JUnit is everyone else's (GitLab
635
+ // artifacts:reports:junit, Jenkins junit step, Azure DevOps, CircleCI).
636
+ // Exit-code semantics identical to sarif/json.
637
+ if (flags.format === 'junit') {
638
+ const xml = toJUnit(data);
639
+ process.exitCode = data.effectiveErrors > 0 ? 1 : data.effectiveWarnings > 0 ? 2 : 0;
640
+ process.stdout.write(xml + '\n');
641
+ return;
642
+ }
643
+
570
644
  // ── JSON output ──
571
645
  if (flags.format === 'json') {
572
646
  // Use severity-aware effective counts for exit code; raw counts stay in the JSON
@@ -671,6 +745,12 @@ export function runGuard(projectDir, config, flags) {
671
745
  console.log(` ${c.red}${c.bold}❌ FAIL${c.reset} ${c.red}— ${data.passed}/${data.total} passed, ${data.effectiveErrors} blocking issue(s)${warnSuffix}${c.reset}`);
672
746
  }
673
747
 
748
+ // Baseline suppression is always visible — a gate that hides findings
749
+ // silently is the false-green failure mode this tool exists to prevent.
750
+ if (data.baselineSuppressed > 0) {
751
+ console.log(` ${c.dim}📋 ${data.baselineSuppressed} pre-existing finding(s) suppressed by ${BASELINE_FILE} (--no-baseline to show)${c.reset}`);
752
+ }
753
+
674
754
  // ── Next steps — every run ends with a suggested action (v0.27) ──
675
755
  // The field-report principle: whenever DocGuard calls out an issue it must
676
756
  // suggest what to do next; on a clean run it points at the next workflow step
@@ -27,6 +27,7 @@ import { resolve, dirname } from 'node:path';
27
27
  import { fileURLToPath } from 'node:url';
28
28
  import { runGuardInternal } from './guard.mjs';
29
29
  import { runScoreInternal } from './score.mjs';
30
+ import { buildReport } from './report.mjs';
30
31
  import { loadConfig } from '../config.mjs';
31
32
  import { CODES } from '../findings.mjs';
32
33
  import { extractSemanticClaims, buildSemanticVerifyTasks } from '../scanners/semantic-claims.mjs';
@@ -110,6 +111,16 @@ const TOOLS = [
110
111
  },
111
112
  annotations: READONLY_ANNOTATIONS,
112
113
  },
114
+ {
115
+ name: 'docguard_report',
116
+ title: 'Compliance-evidence bundle',
117
+ description: 'Generate the commit-stamped compliance-evidence bundle: guard verdict per validator, findings grouped by stable code, CDD score, ALCOA+ data-integrity attributes, fix history, and a tamper-evident sha256 integrity hash. Evidence, not a gate — it reports state without failing.',
118
+ inputSchema: {
119
+ type: 'object',
120
+ properties: { ...PROJECT_DIR_PROP },
121
+ },
122
+ annotations: READONLY_ANNOTATIONS,
123
+ },
113
124
  {
114
125
  name: 'docguard_diagnose',
115
126
  title: 'Diagnose what to fix',
@@ -169,6 +180,11 @@ const TOOL_HANDLERS = {
169
180
  };
170
181
  },
171
182
 
183
+ docguard_report(args, defaultDir) {
184
+ const { dir, config } = resolveTarget(args, defaultDir);
185
+ return buildReport(dir, config);
186
+ },
187
+
172
188
  docguard_diagnose(args, defaultDir) {
173
189
  const { dir, config } = resolveTarget(args, defaultDir);
174
190
  const data = runGuardInternal(dir, config);
@@ -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
+ }