diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 66e2191..e43ab82 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,6 +72,33 @@ jobs: - name: Diagnose run: node cli/docguard.mjs diagnose + # ── v0.33 surfaces — dogfood every new command shape ── + - name: CI gate (self, records history) + run: node cli/docguard.mjs ci || [ $? -eq 2 ] + + - name: Score trend (reads history from the ci step) + run: node cli/docguard.mjs score --trend + + - name: Compliance report (markdown + json + integrity determinism) + run: | + node cli/docguard.mjs report > /tmp/report.md + head -5 /tmp/report.md + node cli/docguard.mjs report --format json | python3 -c "import json,sys; d=json.load(sys.stdin); assert d['integrity'].startswith('sha256:'), d; print('report json OK', d['integrity'][:20])" + + - name: Guard JUnit output is valid XML + run: | + node cli/docguard.mjs guard --format junit > /tmp/guard-junit.xml || [ $? -eq 2 ] + python3 -c "import xml.etree.ElementTree as ET; r=ET.parse('/tmp/guard-junit.xml').getroot(); print('junit OK:', r.attrib)" + + - name: Baseline roundtrip (freeze → pass → restore) + run: | + mkdir -p /tmp/docguard-baseline && cd /tmp/docguard-baseline + printf '{"projectName":"bl-ci","profile":"standard"}' > .docguard.json + mkdir -p docs-canonical && echo "# Architecture" > docs-canonical/ARCHITECTURE.md + node $GITHUB_WORKSPACE/cli/docguard.mjs guard --update-baseline + node $GITHUB_WORKSPACE/cli/docguard.mjs guard # must exit 0 now + node $GITHUB_WORKSPACE/cli/docguard.mjs guard --no-baseline || [ $? -ne 0 ] + # ── Cross-project tests ── - name: Init in temp dir run: | diff --git a/.wolf/anatomy.md b/.wolf/anatomy.md index f9e1357..21dbf77 100644 --- a/.wolf/anatomy.md +++ b/.wolf/anatomy.md @@ -1,7 +1,7 @@ # anatomy.md -> Auto-maintained by OpenWolf. Last scanned: 2026-07-11T17:47:57.144Z -> Files: 681 tracked | Anatomy hits: 0 | Misses: 0 +> Auto-maintained by OpenWolf. Last scanned: 2026-07-16T01:51:08.316Z +> Files: 692 tracked | Anatomy hits: 0 | Misses: 0 ## ../../../../../../../tmp/ @@ -23,10 +23,10 @@ - `.pre-commit-hooks.yaml` — DocGuard hooks for the pre-commit framework (https://pre-commit.com). (~490 tok) - `action.yml` — Declares fs (~7591 tok) - `AGENTS.md` — AI Agent Instructions — DocGuard (~1827 tok) -- `CHANGELOG.md` — Changelog (~47879 tok) +- `CHANGELOG.md` — Changelog (~49280 tok) - `CLAUDE.md` — OpenWolf (~57 tok) - `CODE_OF_CONDUCT.md` — Contributor Covenant Code of Conduct (~532 tok) -- `COMPARISONS.md` — Comparisons & Honest Limitations (~3053 tok) +- `COMPARISONS.md` — Comparisons & Honest Limitations (~3382 tok) - `CONTRIBUTING.md` — Contributing to DocGuard (~1780 tok) - `docguard-cli-0.22.0.tgz` (~80828 tok) - `docguard-cli-0.23.0.tgz` (~81238 tok) @@ -36,13 +36,13 @@ - `LICENSE` — Project license (~286 tok) - `package-lock.json` — npm lock file (~617 tok) - `package.json` — Node.js package manifest (~459 tok) -- `PHILOSOPHY.md` — The Philosophy of Canonical-Driven Development (~1757 tok) +- `PHILOSOPHY.md` — The Philosophy of Canonical-Driven Development (~1854 tok) - `pr_description.md` (~260 tok) - `pyproject.toml` — The enforcement tool for Canonical-Driven Development (CDD). Audit, generate, and guard your project (~358 tok) - `README.es.md` — 🛡️ DocGuard (~1098 tok) -- `README.md` — Project documentation (~8909 tok) +- `README.md` — Project documentation (~9554 tok) - `README.pt-BR.md` — 🛡️ DocGuard (~1075 tok) -- `ROADMAP.md` — DocGuard Roadmap (~2432 tok) +- `ROADMAP.md` — DocGuard Roadmap (~2529 tok) - `SECURITY.md` — Security Policy (~357 tok) - `server.json` (~272 tok) - `smithery.yaml` — Smithery configuration for the DocGuard MCP server. (~283 tok) @@ -952,7 +952,7 @@ ## .github/workflows/ -- `ci.yml` — CI: DocGuard CI (~1041 tok) +- `ci.yml` — CI: DocGuard CI (~1468 tok) - `release.yml` — CI: Auto Release — Tag, GitHub Release, npm, PyPI (~3434 tok) - `sync-speckit-catalog.yml` — CI: Prepare Spec Kit Catalog Submission (Manual) (~1385 tok) @@ -965,9 +965,9 @@ ## cli/ - `config.mjs` — DocGuard — configuration loading. (~3265 tok) -- `docguard.mjs` — DocGuard CLI — The enforcement tool for Canonical-Driven Development (CDD) (~10132 tok) +- `docguard.mjs` — DocGuard CLI — The enforcement tool for Canonical-Driven Development (CDD) (~10928 tok) - `findings.mjs` — Findings — the structured, LLM-addressable result unit (v0.27). (~10332 tok) -- `shared-git.mjs` — Shared Git Utilities — single source of truth for `git log` plumbing (~1851 tok) +- `shared-git.mjs` — Shared Git Utilities — single source of truth for `git log` plumbing (~3012 tok) - `shared-ignore.mjs` — Shared Ignore Utility — Unified file filtering for all validators. (~4132 tok) - `shared-source.mjs` — Shared Source Resolution — Monorepo-aware source discovery. (~4655 tok) - `shared.mjs` — Shared constants for DocGuard CLI — colors, profiles, version. (~3496 tok) @@ -976,18 +976,20 @@ - `agent.mjs` — `docguard agent` — the one-shot agent task graph. (~1644 tok) - `agents.mjs` — Agents Command — Generate agent-specific config files from AGENTS.md (~3307 tok) +- `ci.mjs` — CI Command — Single command for CI/CD pipelines (~1226 tok) - `diff.mjs` — Diff Command — Show differences between canonical docs and implementation (~4128 tok) - `explain.mjs` — Explain Command — v0.16-P6. (~9474 tok) - `feedback.mjs` — Feedback Command — close the loop with the tool's maintainers (v0.27). (~1920 tok) - `generate.mjs` — Generate Command — Reverse-engineer canonical docs from an existing codebase (~6250 tok) -- `guard.mjs` — Guard Command — Validate project against its canonical documentation (~11608 tok) +- `guard.mjs` — Guard Command — Validate project against its canonical documentation (~12874 tok) - `hooks.mjs` — Hooks Command — Generate pre-commit/pre-push hooks for DocGuard (~5102 tok) - `impact.mjs` — Impact Command — S-11 (~5302 tok) - `init.mjs` — Init Command — Initialize CDD documentation from templates (~7278 tok) - `llms.mjs` — llms Command — Generate llms.txt from canonical documentation (~2137 tok) -- `mcp.mjs` — MCP Command — DocGuard as a Model Context Protocol server (stdio). (~4705 tok) +- `mcp.mjs` — MCP Command — DocGuard as a Model Context Protocol server (stdio). (~4890 tok) - `memory.mjs` — Memory Command — v0.17-P2. (~2957 tok) -- `score.mjs` — Score Command — Calculate CDD maturity score (0-100) (~12221 tok) +- `report.mjs` — Report Command — Compliance-evidence bundle for audits. (~2383 tok) +- `score.mjs` — Score Command — Calculate CDD maturity score (0-100) (~12818 tok) - `setup.mjs` — Setup Command — Interactive onboarding wizard for DocGuard (~5280 tok) - `sync-tests.mjs` — `docguard sync --tests` — reconcile the TEST-SPEC Source-to-Test Map from disk. (~3191 tok) - `sync.mjs` — Sync Command — keep the documentation memory ALWAYS UP TO DATE. (~2371 tok) @@ -1013,7 +1015,7 @@ - `changelog.mjs` — Changelog Validator — Checks CHANGELOG.md has an [Unreleased] section, (~1050 tok) - `cross-reference.mjs` — Cross-Reference Validator — S-8 / K-7 (~6034 tok) - `doc-quality.mjs` — Doc Quality Validator — Measures documentation writing quality (~6826 tok) -- `docs-coverage.mjs` — Docs-Coverage Validator — Detects code features not referenced in docs. (~5516 tok) +- `docs-coverage.mjs` — Docs-Coverage Validator — Detects code features not referenced in docs. (~5607 tok) - `docs-diff.mjs` — Docs-Diff Validator — Checks alignment between canonical docs and code. (~3326 tok) - `docs-sync.mjs` — Docs-Sync Validator — Checks that source files have matching canonical doc entries (~2949 tok) - `drift.mjs` — Drift Validator — Every // DRIFT: comment must have a DRIFT-LOG.md entry (~1290 tok) @@ -1034,12 +1036,16 @@ ## cli/writers/ +- `baseline.mjs` — Adoption Baseline — `.docguard.baseline.json` (repo root, COMMITTED). (~994 tok) +- `history.mjs` — Score History — local-first trend memory at `.docguard/history.jsonl`. (~815 tok) +- `junit.mjs` — JUnit XML writer — `docguard guard --format junit`. (~1073 tok) - `mechanical.mjs` — Mechanical Fix Registry — applies deterministic, no-LLM fixes in place. (~3290 tok) - `sarif.mjs` — SARIF 2.1.0 writer — `docguard guard --format sarif`. (~1315 tok) ## docs-canonical/ - `ARCHITECTURE.md` — Architecture (~3029 tok) +- `CI-RECIPES.md` — CI Recipes (~2441 tok) - `DATA-MODEL.md` — Data Model (~1741 tok) - `REQUIREMENTS.md` — Requirements (~673 tok) - `SURFACE-AUDIT.md` — DocGuard Surface Audit (~5703 tok) @@ -1048,9 +1054,9 @@ ## docs/ - `ai-integration.md` — AI Integration Guide (~2024 tok) -- `commands.md` — Commands Reference (~2157 tok) -- `configuration.md` — Configuration (~1375 tok) -- `faq.md` — Frequently Asked Questions (~1519 tok) +- `commands.md` — Commands Reference (~2511 tok) +- `configuration.md` — Configuration (~1491 tok) +- `faq.md` — Frequently Asked Questions (~1627 tok) ## extensions/spec-kit-docguard/ @@ -1066,7 +1072,7 @@ ## schemas/ -- `docguard-config.schema.json` — behavior: docs (~3015 tok) +- `docguard-config.schema.json` — behavior: docs (~3090 tok) ## templates/ @@ -1092,6 +1098,7 @@ - `agent-readability.test.mjs` — Declares metric (~1049 tok) - `agent.test.mjs` — Exports x (~1138 tok) - `agents-sync.test.mjs` — CLI: run (~1367 tok) +- `baseline.test.mjs` — Adoption baseline — `.docguard.baseline.json` (v0.33). (~1919 tok) - `changelog.test.mjs` — Exports x, x (~1315 tok) - `commands.test.mjs` — DocGuard CLI Tests — Tests all commands and flags (~10442 tok) - `cross-reference.test.mjs` — Cross-Reference Validator — K-7 / S-8 (~4060 tok) @@ -1099,13 +1106,15 @@ - `field-report-3-deferred.test.mjs` — v0.28 — the detection-gap items deferred from LLM field report #3. (~3325 tok) - `field-report-3.test.mjs` — v0.27 — LLM field report #3. (~3315 tok) - `freshness.test.mjs` — runGit: commitOldCode (~3536 tok) +- `history.test.mjs` — Score history + `score --trend` (v0.33). (~1702 tok) - `hooks.test.mjs` — Declares afterFirst (~2420 tok) - `impact.test.mjs` — S-11 — `docguard impact` command. (~4128 tok) - `instruction-audit.test.mjs` — Instruction Audit scanner tests (field report #11 — MemoryLint-inspired). (~2387 tok) - `inventory.test.mjs` — Declares make (~1048 tok) +- `junit.test.mjs` — `docguard guard --format junit` — JUnit XML for GitLab/Jenkins/Azure (v0.33). (~1307 tok) - `llms-full-pack.test.mjs` — CLI: run (~1304 tok) -- `mcp-http.test.mjs` — MCP Streamable HTTP transport — `docguard mcp --transport http`. (~1412 tok) -- `mcp.test.mjs` — MCP Server Tests — `docguard mcp` over stdio. (~2138 tok) +- `mcp-http.test.mjs` — MCP Streamable HTTP transport — `docguard mcp --transport http`. (~1416 tok) +- `mcp.test.mjs` — MCP Server Tests — `docguard mcp` over stdio. (~2282 tok) - `mechanical.test.mjs` — Declares write (~1161 tok) - `memory-plan.test.mjs` — Exports u, Button (~1834 tok) - `metrics-consistency.test.mjs` — Declares guardResults (~3942 tok) @@ -1113,8 +1122,10 @@ - `nudge-hook.test.mjs` — Agent nudge hook — `docguard hooks --claude` + `docguard nudge-hook`. (~1681 tok) - `project-type.test.mjs` — Declares make (~2140 tok) - `reference-existence.test.mjs` — Exports validateToken, getUserById, verifyToken, getUserById + 4 more (~1853 tok) +- `report.test.mjs` — `docguard report` — compliance-evidence bundle (v0.33). (~1636 tok) - `sarif.test.mjs` — Declares CLI (~1455 tok) - `shared-source.test.mjs` — API routes: GET (4 endpoints) (~1714 tok) - `speckit-phantom.test.mjs` — Exports users, fetchUserRecords, retry, adapter + 2 more (~2500 tok) - `trace-features.test.mjs` — `trace --features` — per-feature spec-kit adherence scoring. (~2423 tok) - `traceability.test.mjs` — Exports foo, GET, x (~3729 tok) +- `v020-consolidation.test.mjs` — v0.20 — Surface consolidation tests. (~2540 tok) diff --git a/.wolf/buglog.json b/.wolf/buglog.json index 669c13d..d930573 100644 --- a/.wolf/buglog.json +++ b/.wolf/buglog.json @@ -23,7 +23,7 @@ "error_message": "Significant refactor of ", "file": ".github/workflows/sync-speckit-catalog.yml", "root_cause": "8 lines replaced/restructured", - "fix": "Rewrote 25→28 lines (8 removed)", + "fix": "Rewrote 25\u219228 lines (8 removed)", "tags": [ "auto-detected", "refactor", @@ -103,7 +103,7 @@ "error_message": "Significant refactor of ", "file": "../../../docs-canonical/REQUIREMENTS.md", "root_cause": "5 lines replaced/restructured", - "fix": "Rewrote 5→5 lines (5 removed)", + "fix": "Rewrote 5\u21925 lines (5 removed)", "tags": [ "auto-detected", "refactor", @@ -135,7 +135,7 @@ "error_message": "Wrong reference: execSync should be execFileSync", "file": "../../../vscode-extension/extension.js", "root_cause": "Used \"execSync\" instead of \"execFileSync\"", - "fix": "Changed execSync → execFileSync", + "fix": "Changed execSync \u2192 execFileSync", "tags": [ "auto-detected", "wrong-reference", @@ -151,7 +151,7 @@ "error_message": "Significant refactor of ", "file": "../../../vscode-extension/extension.js", "root_cause": "4 lines replaced/restructured", - "fix": "Rewrote 21→37 lines (4 removed)", + "fix": "Rewrote 21\u219237 lines (4 removed)", "tags": [ "auto-detected", "refactor", @@ -246,7 +246,7 @@ "timestamp": "2026-05-29T17:47:26.119Z", "error_message": "Missing error handling in unknown", "file": "../../../docs-canonical/SURFACE-AUDIT.md", - "root_cause": "Code path had no error handling — exceptions would propagate uncaught", + "root_cause": "Code path had no error handling \u2014 exceptions would propagate uncaught", "fix": "Added try/catch block", "tags": [ "auto-detected", @@ -279,7 +279,7 @@ "error_message": "Significant refactor of ", "file": "../../../docs-canonical/ARCHITECTURE.md", "root_cause": "4 lines replaced/restructured", - "fix": "Rewrote 5→6 lines (4 removed)", + "fix": "Rewrote 5\u21926 lines (4 removed)", "tags": [ "auto-detected", "refactor", @@ -356,9 +356,9 @@ { "id": "bug-023", "timestamp": "2026-06-02T15:41:53Z", - "error_message": ".docguardignore trailing-slash dir patterns (e.g. 'tests/', 'base-research/') silently match nothing — ignored dirs were still scanned/flagged by every shouldIgnore + buildIgnoreFilter consumer", + "error_message": ".docguardignore trailing-slash dir patterns (e.g. 'tests/', 'base-research/') silently match nothing \u2014 ignored dirs were still scanned/flagged by every shouldIgnore + buildIgnoreFilter consumer", "file": "cli/shared-ignore.mjs", - "root_cause": "globToRegex did not strip a gitignore-style trailing slash, so 'dir/' built an alternation that could only match a literal 'dir//' (double slash) — never a real path. routes.mjs and todo-tracking.mjs WERE correctly wired to shouldIgnore (fcc7264); the glob translator itself was broken. The test suite used 'dir/' forms only in loader/merge assertions and never asserted they MATCH a file, so it stayed green — a false-green coverage gap.", + "root_cause": "globToRegex did not strip a gitignore-style trailing slash, so 'dir/' built an alternation that could only match a literal 'dir//' (double slash) \u2014 never a real path. routes.mjs and todo-tracking.mjs WERE correctly wired to shouldIgnore (fcc7264); the glob translator itself was broken. The test suite used 'dir/' forms only in loader/merge assertions and never asserted they MATCH a file, so it stayed green \u2014 a false-green coverage gap.", "fix": "Strip a trailing slash in globToRegex (const normalized = pattern.replace trailing-slash || pattern) so 'dir', 'dir/' and 'dir/**' behave identically. Added 4 end-to-end regression tests in tests/docguardignore.test.mjs that assert trailing-slash patterns actually match and do not over-match a sibling sharing the prefix.", "tags": [ "ignore", @@ -378,7 +378,7 @@ "timestamp": "2026-06-02T15:41:53Z", "error_message": "generate --plan --write crashes with ENOENT creating docs-implementation/KNOWN-GOTCHAS.md", "file": "cli/commands/generate.mjs", - "root_cause": "The --plan --write loop mkdir'd only docs-canonical/, then wrote each doc with a raw writeFileSync. buildMemoryPlan unconditionally plans docs-implementation/ docs (KNOWN-GOTCHAS.md, CURRENT-STATE.md), so the first write into that not-yet-created dir threw ENOENT — docs-canonical docs written, first docs-implementation write crashed.", + "root_cause": "The --plan --write loop mkdir'd only docs-canonical/, then wrote each doc with a raw writeFileSync. buildMemoryPlan unconditionally plans docs-implementation/ docs (KNOWN-GOTCHAS.md, CURRENT-STATE.md), so the first write into that not-yet-created dir threw ENOENT \u2014 docs-canonical docs written, first docs-implementation write crashed.", "fix": "Folded mkdirSync(dirname(filePath),{recursive:true}) into the safeWrite helper and routed the --plan --write loop through safeWrite instead of raw writeFileSync (also gains the .bak backup that path previously lacked). Added a regression test in tests/commands.test.mjs running generate --plan --write in a temp project with no docs-implementation/.", "tags": [ "generate", @@ -395,7 +395,7 @@ { "id": "bug-025", "timestamp": "2026-06-02T19:16:48Z", - "error_message": "docguard init --fix drops into the interactive doc-picker (\"Which canonical docs does your project need?\") and hangs/fails with no TTY — contradicts its documented \"Auto-create missing files from templates\"", + "error_message": "docguard init --fix drops into the interactive doc-picker (\"Which canonical docs does your project need?\") and hangs/fails with no TTY \u2014 contradicts its documented \"Auto-create missing files from templates\"", "file": "cli/commands/init.mjs", "root_cause": "--fix was a dead flag: documented (docguard.mjs:111) and set (docguard.mjs:186) but consumed nowhere. init.mjs never read flags.fix, so init ran its normal path; with no skeleton/skipPrompts/wizard/profile set it fell into the interactive askQuestion loop, which blocks/fails without a TTY (CI/agent use).", "fix": "Wired flags.fix into init.mjs: shouldRunGenerate() returns false on --fix (skip the interactive scan-and-propose preview) and the non-interactive branch fires on flags.fix (use profile defaults). The create-loop already skips existing files, so --fix only fills gaps. Added a regression test asserting init --fix creates docs without entering the picker.", @@ -415,9 +415,9 @@ { "id": "bug-026", "timestamp": "2026-06-02T19:16:48Z", - "error_message": "docguard generate --plan (a read-only preview) has side effects — scaffolds .agent/skills, .agent/commands, .specify/ and runs an npm/pip step", + "error_message": "docguard generate --plan (a read-only preview) has side effects \u2014 scaffolds .agent/skills, .agent/commands, .specify/ and runs an npm/pip step", "file": "cli/docguard.mjs", - "root_cause": "The dispatcher runs ensureSkills() on any command that is not setup/init and not headless. headless = jsonMode||write||checkOnly||changedOnly||quiet, which excluded a bare --plan — so generate --plan triggered ensureSkills' writes. Inconsistent: generate --plan --write was already headless (flags.write) and did NOT scaffold.", + "root_cause": "The dispatcher runs ensureSkills() on any command that is not setup/init and not headless. headless = jsonMode||write||checkOnly||changedOnly||quiet, which excluded a bare --plan \u2014 so generate --plan triggered ensureSkills' writes. Inconsistent: generate --plan --write was already headless (flags.write) and did NOT scaffold.", "fix": "Added flags.plan to the headless set in docguard.mjs so generate --plan suppresses the banner AND ensureSkills, matching --plan --write. Verified by a before/after probe: a non-headless generate creates .agent (YES), generate --plan does not (no). Added a regression test asserting --plan leaves .agent/ and .specify/ untouched.", "tags": [ "generate", @@ -436,7 +436,7 @@ { "id": "bug-027", "timestamp": "2026-06-02T19:30:13Z", - "error_message": "project-type detection (generate --plan) misclassifies a Click CLI as 'Express, Flask' — it ingests tests/fixtures/*/package.json + requirements.txt because ignored dirs are not honored", + "error_message": "project-type detection (generate --plan) misclassifies a Click CLI as 'Express, Flask' \u2014 it ingests tests/fixtures/*/package.json + requirements.txt because ignored dirs are not honored", "file": "cli/scanners/project-type.mjs", "root_cause": "findManifests pruned only a hardcoded IGNORE_DIRS set + dot-dirs and never honored config.ignore/.docguardignore. detectEcosystems received config but marked it _config and never passed it to findManifests, so fixture/vendored manifests in user-ignored dirs polluted the detected frameworks/profile. This is the buildMemoryPlan/--plan path (generate's full path uses its own detectStack).", "fix": "Threaded the already-arriving config into findManifests and pruned dirs + skipped manifest files via shouldIgnore(relPosix(root, child), config); detectEcosystems now passes config through (was _config). Depends on bug-023's trailing-slash glob fix so a 'tests/' pattern matches. Regression test in tests/project-type.test.mjs uses a control (fixtures pollute without ignore) + the fix (tests/ ignore drops Express/Flask, keeps the real Click).", @@ -457,10 +457,10 @@ { "id": "bug-028", "timestamp": "2026-06-03T01:32:47Z", - "error_message": "A hand-corrected source=code section (scanner mislabeled it) is flagged stale forever, and the suggested remedy `sync --write` reverts the correction — no override existed", + "error_message": "A hand-corrected source=code section (scanner mislabeled it) is flagged stale forever, and the suggested remedy `sync --write` reverts the correction \u2014 no override existed", "file": "cli/validators/generated-staleness.mjs", "root_cause": "Generated-Staleness compared every source=code section against scanner output with no per-section opt-out (quality checks had one; code sections didn't), and sync --write unconditionally replaced source=code sections. So a legitimately hand-maintained section had no escape from the stale-forever / auto-revert trap.", - "fix": "Honor a `pinned` attribute on the section marker (), already parsed by parseSections. generated-staleness exempts pinned sections (counted as a pass, mirroring the docguard:quality opt-out) and appends a discoverability hint to the stale warning; sync.mjs skips pinned sections. Tests in generated-staleness.test.mjs (control+fix) and sync.test.mjs (don't-revert).", + "fix": "Honor a `pinned` attribute on the section marker (), already parsed by parseSections. generated-staleness exempts pinned sections (counted as a pass, mirroring the docguard:quality opt-out) and appends a discoverability hint to the stale warning; sync.mjs skips pinned sections. Tests in generated-staleness.test.mjs (control+fix) and sync.test.mjs (don't-revert).", "tags": [ "generated-staleness", "sync", @@ -477,7 +477,7 @@ { "id": "bug-029", "timestamp": "2026-06-03T01:32:47Z", - "error_message": "docguard --help printed the global help, not the command's own flags — flag discovery (generate --plan --write, init --skeleton/--wizard/--with) was impossible from --help", + "error_message": "docguard --help printed the global help, not the command's own flags \u2014 flag discovery (generate --plan --write, init --skeleton/--wizard/--with) was impossible from --help", "file": "cli/docguard.mjs", "root_cause": "v0.24 made --help non-destructive but routed it to printHelp() (global) because there was no per-command help; the global help lists commands but not each command's full flag set + examples.", "fix": "Added a COMMAND_HELP map (init/generate/guard/score/diff/sync/fix/trace/upgrade/ci/memory) + printCommandHelp(command); the --help dispatch now calls it. Unmapped commands fall back to global help. Flags derived from the vetted global help to avoid documenting non-existent flags. Tests assert generate/init --help show their flags and watch --help falls back without starting the watcher.", @@ -496,7 +496,7 @@ { "id": "bug-030", "timestamp": "2026-06-03T01:32:47Z", - "error_message": "generate emits docs-canonical/API-REFERENCE.md, then guard warns it 'exists but is not in your requiredFiles config' — the generator's own output is flagged orphaned", + "error_message": "generate emits docs-canonical/API-REFERENCE.md, then guard warns it 'exists but is not in your requiredFiles config' \u2014 the generator's own output is flagged orphaned", "file": "cli/commands/generate.mjs", "root_cause": "generate's standard doc set is a superset of the profile's requiredFiles.canonical, and generate never updated requiredFiles, so the traceability orphan-check (traceability.mjs:133) flagged docs the generator just created.", "fix": "Added registerGeneratedCanonicalDocs(): after writing, generate merges the canonical docs it emitted into .docguard.json requiredFiles.canonical (additive only, only docs-canonical/*.md that exist on disk, only when a config exists, idempotent). Wired into both the full generate path and the --plan --write path. Test asserts requiredFiles.canonical gains ARCHITECTURE after generate --plan --write.", @@ -519,7 +519,7 @@ "error_message": "generate --plan mislabels a security/scanner tool's own pattern-strings as real surface (websec-validator flagged as using express/boto3/jwt because its recon code mentions them)", "file": "cli/commands/generate.mjs", "root_cause": "The route/SDK/auth source scanners match pattern-strings; for a cli/library/tool whose source CONTAINS those strings as detection patterns, the auto-extracted surface is the tool's own code, not real usage. No confidence signal existed to warn the user.", - "fix": "F1 (chosen scope: flag, don't suppress — suppression would risk a false-green). Added surfaceConfidence(kind): web kinds (webapp/api/service) = 'normal', everything else = 'low'. generate --plan --format json now emits surface.confidence; the text output prints a 'Low-confidence surface — verify, pin with pinned=...' advisory when a non-web kind has web surface. Guard detection unchanged. Test asserts cli→low, api→normal.", + "fix": "F1 (chosen scope: flag, don't suppress \u2014 suppression would risk a false-green). Added surfaceConfidence(kind): web kinds (webapp/api/service) = 'normal', everything else = 'low'. generate --plan --format json now emits surface.confidence; the text output prints a 'Low-confidence surface \u2014 verify, pin with pinned=...' advisory when a non-web kind has web surface. Guard detection unchanged. Test asserts cli\u2192low, api\u2192normal.", "tags": [ "generate", "scanner-tool", @@ -541,7 +541,7 @@ "timestamp": "2026-06-05T03:02:00Z", "error_message": "Auto Release workflow failed: 20 AST tests (parseJsTs, extractJsRouteCalls, Fastify/Express/JSX route extraction) failed in the release 'test' job, blocking the v0.25.0 publish", "file": ".github/workflows/release.yml", - "root_cause": "The release.yml 'test' job ran `npm test` with NO `npm ci` step, so node_modules was empty and @babel/parser (a regular dependency, not optional) couldn't load — every AST-dependent test failed. Latent since the job was written; only surfaced at v0.25.0 because prior releases' detect-version step skipped this job (batched-push / non-tip version bump), so the test job never actually executed.", + "root_cause": "The release.yml 'test' job ran `npm test` with NO `npm ci` step, so node_modules was empty and @babel/parser (a regular dependency, not optional) couldn't load \u2014 every AST-dependent test failed. Latent since the job was written; only surfaced at v0.25.0 because prior releases' detect-version step skipped this job (batched-push / non-tip version bump), so the test job never actually executed.", "fix": "Added an `npm ci` 'Install dependencies' step before 'Run Tests', matching ci.yml. Re-triggered via workflow_dispatch (release.yml is paths-filtered to package.json, so the workflow-only fix commit didn't auto-trigger it). All jobs then green; v0.25.0 published to npm + PyPI + GitHub Release.", "tags": [ "ci", @@ -560,7 +560,7 @@ "error_message": "Significant refactor of ", "file": ".github/workflows/release.yml", "root_cause": "84 lines replaced/restructured", - "fix": "Rewrote 135→68 lines (84 removed)", + "fix": "Rewrote 135\u219268 lines (84 removed)", "tags": [ "auto-detected", "refactor", @@ -616,7 +616,7 @@ "error_message": "Significant refactor of ", "file": ".github/workflows/release.yml", "root_cause": "35 lines replaced/restructured", - "fix": "Rewrote 68→95 lines (35 removed)", + "fix": "Rewrote 68\u219295 lines (35 removed)", "tags": [ "auto-detected", "refactor", @@ -680,7 +680,7 @@ "error_message": "Significant refactor of ", "file": "cli/docguard.mjs", "root_cause": "3 lines replaced/restructured", - "fix": "Rewrote 8→29 lines (3 removed)", + "fix": "Rewrote 8\u219229 lines (3 removed)", "tags": [ "auto-detected", "refactor", @@ -695,7 +695,7 @@ "timestamp": "2026-06-10T20:13:36.017Z", "error_message": "Missing error handling in unknown", "file": "tests/commands.test.mjs", - "root_cause": "Code path had no error handling — exceptions would propagate uncaught", + "root_cause": "Code path had no error handling \u2014 exceptions would propagate uncaught", "fix": "Added try/catch block", "tags": [ "auto-detected", @@ -728,7 +728,7 @@ "error_message": "Significant refactor of ", "file": "cli/scanners/project-type.mjs", "root_cause": "3 lines replaced/restructured", - "fix": "Rewrote 7→10 lines (3 removed)", + "fix": "Rewrote 7\u219210 lines (3 removed)", "tags": [ "auto-detected", "refactor", @@ -760,7 +760,7 @@ "error_message": "Significant refactor of ", "file": "cli/scanners/routes.mjs", "root_cause": "4 lines replaced/restructured", - "fix": "Rewrote 12→18 lines (4 removed)", + "fix": "Rewrote 12\u219218 lines (4 removed)", "tags": [ "auto-detected", "refactor", @@ -792,7 +792,7 @@ "error_message": "Significant refactor of ", "file": "tests/ensure-skills-idempotent.test.mjs", "root_cause": "3 lines replaced/restructured", - "fix": "Rewrote 13→17 lines (3 removed)", + "fix": "Rewrote 13\u219217 lines (3 removed)", "tags": [ "auto-detected", "refactor", @@ -840,7 +840,7 @@ "error_message": "Significant refactor of ", "file": "tests/project-type.test.mjs", "root_cause": "9 lines replaced/restructured", - "fix": "Rewrote 21→28 lines (9 removed)", + "fix": "Rewrote 21\u219228 lines (9 removed)", "tags": [ "auto-detected", "refactor", @@ -920,7 +920,7 @@ "error_message": "Significant refactor of ", "file": "cli/validators/metrics-consistency.mjs", "root_cause": "2 lines replaced/restructured", - "fix": "Rewrote 5→8 lines (2 removed)", + "fix": "Rewrote 5\u21928 lines (2 removed)", "tags": [ "auto-detected", "refactor", @@ -936,7 +936,7 @@ "error_message": "Significant refactor of ", "file": "cli/writers/mechanical.mjs", "root_cause": "3 lines replaced/restructured", - "fix": "Rewrote 16→34 lines (3 removed)", + "fix": "Rewrote 16\u219234 lines (3 removed)", "tags": [ "auto-detected", "refactor", @@ -967,7 +967,7 @@ "timestamp": "2026-06-10T20:39:20.505Z", "error_message": "Missing error handling in readDocStatus", "file": "cli/validators/freshness.mjs", - "root_cause": "Code path had no error handling — exceptions would propagate uncaught", + "root_cause": "Code path had no error handling \u2014 exceptions would propagate uncaught", "fix": "Added try/catch block", "tags": [ "auto-detected", @@ -984,7 +984,7 @@ "error_message": "Significant refactor of ", "file": "cli/validators/freshness.mjs", "root_cause": "2 lines replaced/restructured", - "fix": "Rewrote 10→23 lines (2 removed)", + "fix": "Rewrote 10\u219223 lines (2 removed)", "tags": [ "auto-detected", "refactor", @@ -1016,7 +1016,7 @@ "error_message": "Significant refactor of ", "file": "cli/commands/agent.mjs", "root_cause": "2 lines replaced/restructured", - "fix": "Rewrote 3→5 lines (2 removed) | Also: console.log(`\\n ${c.dim}Run without --format text; return; | Also: const plan = buildMemoryPlan(projectDir, config);; const graph = buildAgentTaskGraph(projectDir, conf", + "fix": "Rewrote 3\u21925 lines (2 removed) | Also: console.log(`\\n ${c.dim}Run without --format text; return; | Also: const plan = buildMemoryPlan(projectDir, config);; const graph = buildAgentTaskGraph(projectDir, conf", "tags": [ "auto-detected", "refactor", @@ -1135,7 +1135,7 @@ "error_message": "Field report #2 Bug #4: project named after the git-worktree dir slug (compassionate-chaplygin-c91f47) instead of the manifest name.", "file": "cli/scanners/project-type.mjs, cli/config.mjs", "root_cause": "config.projectName was hardcoded to basename(projectDir); the manifest name was never read.", - "fix": "Added detectProjectName() (package.json/pyproject [project|tool.poetry]/Cargo/composer/go.mod → basename fallback), wired as the config default. + tests.", + "fix": "Added detectProjectName() (package.json/pyproject [project|tool.poetry]/Cargo/composer/go.mod \u2192 basename fallback), wired as the config default. + tests.", "tags": [ "project-name", "manifest", @@ -1220,7 +1220,7 @@ "error_message": "Significant refactor of ", "file": "cli/validators/security.mjs", "root_cause": "24 lines replaced/restructured", - "fix": "Rewrote 92→133 lines (24 removed)", + "fix": "Rewrote 92\u2192133 lines (24 removed)", "tags": [ "auto-detected", "refactor", @@ -1268,7 +1268,7 @@ "error_message": "Significant refactor of ", "file": "cli/commands/guard.mjs", "root_cause": "4 lines replaced/restructured", - "fix": "Rewrote 9→26 lines (4 removed)", + "fix": "Rewrote 9\u219226 lines (4 removed)", "tags": [ "auto-detected", "refactor", @@ -1300,7 +1300,7 @@ "error_message": "Wrong operator: || should be &&", "file": "cli/validators/security.mjs", "root_cause": "Used \"||\" instead of \"&&\"", - "fix": "Changed operator || → &&", + "fix": "Changed operator || \u2192 &&", "tags": [ "auto-detected", "operator-fix", @@ -1316,7 +1316,7 @@ "error_message": "Significant refactor of ", "file": "cli/commands/score.mjs", "root_cause": "59 lines replaced/restructured", - "fix": "Rewrote 65→3 lines (59 removed)", + "fix": "Rewrote 65\u21923 lines (59 removed)", "tags": [ "auto-detected", "refactor", @@ -1331,7 +1331,7 @@ "timestamp": "2026-06-19T19:13:23.491Z", "error_message": "Missing error handling in detectTestRunner", "file": "cli/commands/score.mjs", - "root_cause": "Code path had no error handling — exceptions would propagate uncaught", + "root_cause": "Code path had no error handling \u2014 exceptions would propagate uncaught", "fix": "Added try/catch block", "tags": [ "auto-detected", @@ -1348,7 +1348,7 @@ "error_message": "Wrong reference: runScoreInternal should be detectTestRunner", "file": "tests/field-report-3.test.mjs", "root_cause": "Used \"runScoreInternal\" instead of \"detectTestRunner\"", - "fix": "Changed runScoreInternal → detectTestRunner", + "fix": "Changed runScoreInternal \u2192 detectTestRunner", "tags": [ "auto-detected", "wrong-reference", @@ -1425,10 +1425,10 @@ { "id": "bug-086", "timestamp": "2026-06-19T20:05:00.000Z", - "error_message": "Security validator flags a validation-message string (e.g. password: \"New password must differ from recent passwords\") as a hardcoded password — a blocking false positive (LLM field report #3, item #1).", + "error_message": "Security validator flags a validation-message string (e.g. password: \"New password must differ from recent passwords\") as a hardcoded password \u2014 a blocking false positive (LLM field report #3, item #1).", "file": "cli/validators/security.mjs", "root_cause": "The SEC password regex matches any quoted value 8+ chars after a password/passwd/pwd key, regardless of whether the value is natural-language copy vs an actual credential.", - "fix": "Added looksLikeProse(value): a multi-word natural-language value downgrades the finding to a LOW-confidence, suppressible, reportable warning (not a blocking error). Still surfaced — no false-green. Implemented on the new findings architecture (cli/findings.mjs) with stable code SEC001.", + "fix": "Added looksLikeProse(value): a multi-word natural-language value downgrades the finding to a LOW-confidence, suppressible, reportable warning (not a blocking error). Still surfaced \u2014 no false-green. Implemented on the new findings architecture (cli/findings.mjs) with stable code SEC001.", "tags": [ "security", "false-positive", @@ -1444,9 +1444,9 @@ { "id": "bug-087", "timestamp": "2026-06-19T20:06:00.000Z", - "error_message": "tests/security.test.mjs: 'should flag common hardcoded passwords and API keys' expected 2 errors, got 1 — a real single-token password \"SuperSecretPassword!\" was wrongly downgraded to a low-confidence warning.", + "error_message": "tests/security.test.mjs: 'should flag common hardcoded passwords and API keys' expected 2 errors, got 1 \u2014 a real single-token password \"SuperSecretPassword!\" was wrongly downgraded to a low-confidence warning.", "file": "cli/validators/security.mjs", - "root_cause": "First cut of looksLikeProse treated ANY value ending in .!? as prose. Strong passwords commonly end in '!' — so a one-word secret was misclassified as natural language.", + "root_cause": "First cut of looksLikeProse treated ANY value ending in .!? as prose. Strong passwords commonly end in '!' \u2014 so a one-word secret was misclassified as natural language.", "fix": "Terminal punctuation alone no longer reclassifies a value: prose now requires >=3 words, OR a 2-word fragment ending in .!?. A single token like 'SuperSecretPassword!' stays a blocking error. The existing security test caught it.", "tags": [ "security", @@ -1465,7 +1465,7 @@ "timestamp": "2026-06-22T21:27:36.030Z", "error_message": "Missing await", "file": "cli/validators/architecture.mjs", - "root_cause": "Async call without await — returned Promise instead of value", + "root_cause": "Async call without await \u2014 returned Promise instead of value", "fix": "Added await to async call", "tags": [ "auto-detected", @@ -1482,7 +1482,7 @@ "error_message": "Significant refactor of ", "file": "cli/validators/architecture.mjs", "root_cause": "6 lines replaced/restructured", - "fix": "Rewrote 24→31 lines (6 removed)", + "fix": "Rewrote 24\u219231 lines (6 removed)", "tags": [ "auto-detected", "refactor", @@ -1530,7 +1530,7 @@ "error_message": "Significant refactor of ", "file": "cli/commands/explain.mjs", "root_cause": "3 lines replaced/restructured", - "fix": "Rewrote 11→12 lines (3 removed)", + "fix": "Rewrote 11\u219212 lines (3 removed)", "tags": [ "auto-detected", "refactor", @@ -1545,7 +1545,7 @@ "timestamp": "2026-06-22T21:36:02.265Z", "error_message": "Missing error handling in computeSpecVsRouteDrift", "file": "cli/validators/api-surface.mjs", - "root_cause": "Code path had no error handling — exceptions would propagate uncaught", + "root_cause": "Code path had no error handling \u2014 exceptions would propagate uncaught", "fix": "Added try/catch block", "tags": [ "auto-detected", @@ -1562,7 +1562,7 @@ "error_message": "Significant refactor of ", "file": "cli/validators/api-surface.mjs", "root_cause": "7 lines replaced/restructured", - "fix": "Rewrote 13→33 lines (7 removed)", + "fix": "Rewrote 13\u219233 lines (7 removed)", "tags": [ "auto-detected", "refactor", @@ -1577,7 +1577,7 @@ "timestamp": "2026-06-22T21:38:26.085Z", "error_message": "Missing error handling in unknown", "file": "cli/commands/explain.mjs", - "root_cause": "Code path had no error handling — exceptions would propagate uncaught", + "root_cause": "Code path had no error handling \u2014 exceptions would propagate uncaught", "fix": "Added try/catch block", "tags": [ "auto-detected", @@ -1610,7 +1610,7 @@ "error_message": "Significant refactor of ", "file": "cli/docguard.mjs", "root_cause": "3 lines replaced/restructured", - "fix": "Rewrote 8→9 lines (3 removed)", + "fix": "Rewrote 8\u21929 lines (3 removed)", "tags": [ "auto-detected", "refactor", @@ -1626,7 +1626,7 @@ "error_message": "Significant refactor of ", "file": "cli/scanners/semantic-claims.mjs", "root_cause": "2 lines replaced/restructured", - "fix": "Rewrote 3→5 lines (2 removed)", + "fix": "Rewrote 3\u21925 lines (2 removed)", "tags": [ "auto-detected", "refactor", @@ -1642,7 +1642,7 @@ "error_message": "Significant refactor of ", "file": "cli/commands/guard.mjs", "root_cause": "4 lines replaced/restructured", - "fix": "Rewrote 9→14 lines (4 removed) | Also: if (data.effectiveErrors > 0) process.exit(1);; if (data.effectiveWarnings > 0) process.exit(2);", + "fix": "Rewrote 9\u219214 lines (4 removed) | Also: if (data.effectiveErrors > 0) process.exit(1);; if (data.effectiveWarnings > 0) process.exit(2);", "tags": [ "auto-detected", "refactor", @@ -1721,7 +1721,7 @@ "timestamp": "2026-06-22T23:13:08.031Z", "error_message": "Missing await", "file": "CHANGELOG.md", - "root_cause": "Async call without await — returned Promise instead of value", + "root_cause": "Async call without await \u2014 returned Promise instead of value", "fix": "Added await to async call", "tags": [ "auto-detected", @@ -1756,7 +1756,7 @@ "timestamp": "2026-06-22T23:41:00.000Z", "error_message": "Architecture validator flagged a circular dependency that is broken at runtime by `await import()` (field report #2).", "file": "cli/validators/architecture.mjs", - "root_cause": "extractImports lumped ES import, dynamic import(), and require() into one list feeding both graph.edges (layer checks) and graph.fileMap (cycle DFS). A dynamic import is NOT a load-time edge — it's the canonical cycle break — but was counted as one.", + "root_cause": "extractImports lumped ES import, dynamic import(), and require() into one list feeding both graph.edges (layer checks) and graph.fileMap (cycle DFS). A dynamic import is NOT a load-time edge \u2014 it's the canonical cycle break \u2014 but was counted as one.", "fix": "extractImports now returns { spec, dynamic }. buildImportGraph records the dynamic flag on graph.edges but only pushes STATIC edges into fileMap (cycle adjacency). Cycle detection no longer sees dynamic edges; layer-boundary checks still do.", "tags": [ "architecture", @@ -1774,8 +1774,8 @@ "timestamp": "2026-06-23T03:40:34.868Z", "error_message": "Missing error handling in unknown", "file": "cli/validators/metrics-consistency.mjs", - "root_cause": "Code path had no error handling — exceptions would propagate uncaught", - "fix": "Added try/catch block | Also: // ── Collection glob counting (v0.29) ─────────────────────; // Minimal, dependency-free glob→count for `config.collectio", + "root_cause": "Code path had no error handling \u2014 exceptions would propagate uncaught", + "fix": "Added try/catch block | Also: // \u2500\u2500 Collection glob counting (v0.29) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500; // Minimal, dependency-free glob\u2192count for `config.collectio", "tags": [ "auto-detected", "error-handling", @@ -1791,7 +1791,7 @@ "error_message": "Significant refactor of ", "file": "cli/validators/metrics-consistency.mjs", "root_cause": "5 lines replaced/restructured", - "fix": "Rewrote 6→8 lines (5 removed)", + "fix": "Rewrote 6\u21928 lines (5 removed)", "tags": [ "auto-detected", "refactor", @@ -1806,7 +1806,7 @@ "timestamp": "2026-06-23T03:42:06.279Z", "error_message": "Missing error handling in unknown", "file": "cli/commands/score.mjs", - "root_cause": "Code path had no error handling — exceptions would propagate uncaught", + "root_cause": "Code path had no error handling \u2014 exceptions would propagate uncaught", "fix": "Added try/catch block", "tags": [ "auto-detected", @@ -1823,7 +1823,7 @@ "error_message": "Significant refactor of ", "file": "cli/commands/score.mjs", "root_cause": "4 lines replaced/restructured", - "fix": "Rewrote 8→14 lines (4 removed)", + "fix": "Rewrote 8\u219214 lines (4 removed)", "tags": [ "auto-detected", "refactor", @@ -1839,7 +1839,7 @@ "error_message": "Significant refactor of ", "file": "cli/commands/guard.mjs", "root_cause": "3 lines replaced/restructured", - "fix": "Rewrote 7→8 lines (3 removed) | Also: if (isIgnored(rel)) { ignored++; continue; }; if (inHome || atRoot) { tracked++; continue; } | Also: + (unclassN ? ` · ${c.yellow}${unclassN} unclassif; console.log(` ${c.yellow}⚠ ${unclassN} Markdown f", + "fix": "Rewrote 7\u21928 lines (3 removed) | Also: if (isIgnored(rel)) { ignored++; continue; }; if (inHome || atRoot) { tracked++; continue; } | Also: + (unclassN ? ` \u00b7 ${c.yellow}${unclassN} unclassif; console.log(` ${c.yellow}\u26a0 ${unclassN} Markdown f", "tags": [ "auto-detected", "refactor", @@ -1854,7 +1854,7 @@ "timestamp": "2026-06-23T03:43:06.477Z", "error_message": "Missing error handling in collectMarkdown", "file": "cli/commands/guard.mjs", - "root_cause": "Code path had no error handling — exceptions would propagate uncaught", + "root_cause": "Code path had no error handling \u2014 exceptions would propagate uncaught", "fix": "Added try/catch block | Also: // v0.29: coverage map + semantic-claim surfacing. Both are ; // they never change errors/warnings/exit code. Skipped on t", "tags": [ "auto-detected", @@ -1870,7 +1870,7 @@ "timestamp": "2026-06-23T03:50:21.581Z", "error_message": "Missing error handling in unknown", "file": "tests/metrics-consistency.test.mjs", - "root_cause": "Code path had no error handling — exceptions would propagate uncaught", + "root_cause": "Code path had no error handling \u2014 exceptions would propagate uncaught", "fix": "Added try/catch block", "tags": [ "auto-detected", @@ -1886,7 +1886,7 @@ "timestamp": "2026-06-23T03:51:49.283Z", "error_message": "Missing error handling in unknown", "file": "CHANGELOG.md", - "root_cause": "Code path had no error handling — exceptions would propagate uncaught", + "root_cause": "Code path had no error handling \u2014 exceptions would propagate uncaught", "fix": "Added try/catch block", "tags": [ "auto-detected", @@ -1900,10 +1900,10 @@ { "id": "bug-115", "timestamp": "2026-06-23T04:35:00.000Z", - "error_message": "guard PASS + ALCOA 'Accurate: 100%' / A+ while a watched canonical doc stated a wrong count ('16 extractors' vs 19 in code) — a confidence-inverting false negative (LLM field report #6, websec-validator)", + "error_message": "guard PASS + ALCOA 'Accurate: 100%' / A+ while a watched canonical doc stated a wrong count ('16 extractors' vs 19 in code) \u2014 a confidence-inverting false negative (LLM field report #6, websec-validator)", "file": "cli/commands/score.mjs", - "root_cause": "Two gaps. (1) The ALCOA 'Accurate' pillar was computed purely from structural signals (drift markers + prose quality: `scores.drift >= 80 && scores.docQuality >= 50`), never comparing a documented fact to code — so it reported 100% over a wrong number. (2) The deterministic claim checkers had the right architecture but the literal noun 'extractors' was missing from the hardcoded vocabulary in semantic-claims.mjs and metrics-consistency only knew DocGuard's own meta-counts (checks/validators/tests), so a project's domain count was never bound to a code collection.", - "fix": "Made 'Accurate' a 3-state attribute (met / unverified / unmet): 'unverified' (neutral cyan) when structure passes but documented claims are unchecked vs code — counts as not-met for the ALCOA % (stops overclaiming) but display-only, gating CDD grade untouched. Added config.collections (noun→glob) so Metrics-Consistency deterministically flags a documented count vs the real file count, in guard, no LLM. Surfaced the semantic-claim extractor + a doc-coverage map as non-gating notices in guard. Extended the claim noun vocabulary (added extractors/plugins/detectors/…). 5 new tests; 805/805 prior tests still pass.", + "root_cause": "Two gaps. (1) The ALCOA 'Accurate' pillar was computed purely from structural signals (drift markers + prose quality: `scores.drift >= 80 && scores.docQuality >= 50`), never comparing a documented fact to code \u2014 so it reported 100% over a wrong number. (2) The deterministic claim checkers had the right architecture but the literal noun 'extractors' was missing from the hardcoded vocabulary in semantic-claims.mjs and metrics-consistency only knew DocGuard's own meta-counts (checks/validators/tests), so a project's domain count was never bound to a code collection.", + "fix": "Made 'Accurate' a 3-state attribute (met / unverified / unmet): 'unverified' (neutral cyan) when structure passes but documented claims are unchecked vs code \u2014 counts as not-met for the ALCOA % (stops overclaiming) but display-only, gating CDD grade untouched. Added config.collections (noun\u2192glob) so Metrics-Consistency deterministically flags a documented count vs the real file count, in guard, no LLM. Surfaced the semantic-claim extractor + a doc-coverage map as non-gating notices in guard. Extended the claim noun vocabulary (added extractors/plugins/detectors/\u2026). 5 new tests; 805/805 prior tests still pass.", "tags": [ "field-report-6", "false-negative", @@ -1923,7 +1923,7 @@ "timestamp": "2026-06-23T19:48:11.990Z", "error_message": "Missing error handling in resolveDocDirs", "file": "cli/shared.mjs", - "root_cause": "Code path had no error handling — exceptions would propagate uncaught", + "root_cause": "Code path had no error handling \u2014 exceptions would propagate uncaught", "fix": "Added try/catch block", "tags": [ "auto-detected", @@ -1940,7 +1940,7 @@ "error_message": "Significant refactor of ", "file": "cli/validators/metrics-consistency.mjs", "root_cause": "5 lines replaced/restructured", - "fix": "Rewrote 14→17 lines (5 removed)", + "fix": "Rewrote 14\u219217 lines (5 removed)", "tags": [ "auto-detected", "refactor", @@ -1956,7 +1956,7 @@ "error_message": "Significant refactor of ", "file": "cli/commands/guard.mjs", "root_cause": "4 lines replaced/restructured", - "fix": "Rewrote 7→3 lines (4 removed)", + "fix": "Rewrote 7\u21923 lines (4 removed)", "tags": [ "auto-detected", "refactor", @@ -1971,7 +1971,7 @@ "timestamp": "2026-06-23T19:50:00.709Z", "error_message": "Missing error handling in unknown", "file": "tests/metrics-consistency.test.mjs", - "root_cause": "Code path had no error handling — exceptions would propagate uncaught", + "root_cause": "Code path had no error handling \u2014 exceptions would propagate uncaught", "fix": "Added try/catch block", "tags": [ "auto-detected", @@ -2004,7 +2004,7 @@ "error_message": "Significant refactor of ", "file": "STANDARD.md", "root_cause": "10 lines replaced/restructured", - "fix": "Rewrote 14→24 lines (10 removed)", + "fix": "Rewrote 14\u219224 lines (10 removed)", "tags": [ "auto-detected", "refactor", @@ -2020,7 +2020,7 @@ "error_message": "Significant refactor of ", "file": "README.md", "root_cause": "26 lines replaced/restructured", - "fix": "Rewrote 33→12 lines (26 removed)", + "fix": "Rewrote 33\u219212 lines (26 removed)", "tags": [ "auto-detected", "refactor", @@ -2035,7 +2035,7 @@ "timestamp": "2026-07-03T02:15:04.666Z", "error_message": "Missing error handling in unknown", "file": "README.md", - "root_cause": "Code path had no error handling — exceptions would propagate uncaught", + "root_cause": "Code path had no error handling \u2014 exceptions would propagate uncaught", "fix": "Added try/catch block", "tags": [ "auto-detected", @@ -2052,7 +2052,7 @@ "error_message": "Significant refactor of ", "file": "ROADMAP.md", "root_cause": "3 lines replaced/restructured", - "fix": "Rewrote 6→10 lines (3 removed)", + "fix": "Rewrote 6\u219210 lines (3 removed)", "tags": [ "auto-detected", "refactor", @@ -2084,7 +2084,7 @@ "error_message": "Significant refactor of ", "file": "CONTRIBUTING.md", "root_cause": "2 lines replaced/restructured", - "fix": "Rewrote 9→13 lines (2 removed) | Also: 4. Add tests in `tests/commands.test.mjs`; 5. Update `CHANGELOG.md`", + "fix": "Rewrote 9\u219213 lines (2 removed) | Also: 4. Add tests in `tests/commands.test.mjs`; 5. Update `CHANGELOG.md`", "tags": [ "auto-detected", "refactor", @@ -2100,7 +2100,7 @@ "error_message": "Significant refactor of ", "file": "cli/shared-ignore.mjs", "root_cause": "14 lines replaced/restructured", - "fix": "Rewrote 20→56 lines (14 removed)", + "fix": "Rewrote 20\u219256 lines (14 removed)", "tags": [ "auto-detected", "refactor", @@ -2115,7 +2115,7 @@ "timestamp": "2026-07-03T02:18:54.020Z", "error_message": "Missing error handling in globMatch", "file": "cli/shared-ignore.mjs", - "root_cause": "Code path had no error handling — exceptions would propagate uncaught", + "root_cause": "Code path had no error handling \u2014 exceptions would propagate uncaught", "fix": "Added try/catch block", "tags": [ "auto-detected", @@ -2132,7 +2132,7 @@ "error_message": "Significant refactor of ", "file": "cli/validators/metrics-consistency.mjs", "root_cause": "83 lines replaced/restructured", - "fix": "Rewrote 88→3 lines (83 removed)", + "fix": "Rewrote 88\u21923 lines (83 removed)", "tags": [ "auto-detected", "refactor", @@ -2164,7 +2164,7 @@ "error_message": "Significant refactor of ", "file": "cli/shared-ignore.mjs", "root_cause": "2 lines replaced/restructured", - "fix": "Rewrote 20→24 lines (2 removed)", + "fix": "Rewrote 20\u219224 lines (2 removed)", "tags": [ "auto-detected", "refactor", @@ -2180,7 +2180,7 @@ "error_message": "Significant refactor of ", "file": "cli/validators/drift.mjs", "root_cause": "14 lines replaced/restructured", - "fix": "Rewrote 21→7 lines (14 removed)", + "fix": "Rewrote 21\u21927 lines (14 removed)", "tags": [ "auto-detected", "refactor", @@ -2196,7 +2196,7 @@ "error_message": "Wrong operator: !== should be ===", "file": "cli/validators/security.mjs", "root_cause": "Used \"!==\" instead of \"===\"", - "fix": "Changed operator !== → ===", + "fix": "Changed operator !== \u2192 ===", "tags": [ "auto-detected", "operator-fix", @@ -2212,7 +2212,7 @@ "error_message": "Significant refactor of ", "file": "cli/validators/schema-sync.mjs", "root_cause": "12 lines replaced/restructured", - "fix": "Rewrote 19→6 lines (12 removed)", + "fix": "Rewrote 19\u21926 lines (12 removed)", "tags": [ "auto-detected", "refactor", @@ -2228,7 +2228,7 @@ "error_message": "Significant refactor of ", "file": "cli/validators/metadata-sync.mjs", "root_cause": "13 lines replaced/restructured", - "fix": "Rewrote 18→4 lines (13 removed)", + "fix": "Rewrote 18\u21924 lines (13 removed)", "tags": [ "auto-detected", "refactor", @@ -2244,7 +2244,7 @@ "error_message": "Significant refactor of ", "file": "cli/validators/docs-coverage.mjs", "root_cause": "13 lines replaced/restructured", - "fix": "Rewrote 18→4 lines (13 removed)", + "fix": "Rewrote 18\u21924 lines (13 removed)", "tags": [ "auto-detected", "refactor", @@ -2260,7 +2260,7 @@ "error_message": "Wrong operator: !== should be ===", "file": "cli/validators/traceability.mjs", "root_cause": "Used \"!==\" instead of \"===\"", - "fix": "Changed operator !== → ===", + "fix": "Changed operator !== \u2192 ===", "tags": [ "auto-detected", "operator-fix", @@ -2276,7 +2276,7 @@ "error_message": "Significant refactor of ", "file": "cli/validators/docs-diff.mjs", "root_cause": "15 lines replaced/restructured", - "fix": "Rewrote 27→12 lines (15 removed) | Also: if (!existsSync(dir)) return results;; let entries;", + "fix": "Rewrote 27\u219212 lines (15 removed) | Also: if (!existsSync(dir)) return results;; let entries;", "tags": [ "auto-detected", "refactor", @@ -2292,7 +2292,7 @@ "error_message": "Wrong operator: === should be !==", "file": "cli/validators/docs-diff.mjs", "root_cause": "Used \"===\" instead of \"!==\"", - "fix": "Changed operator === → !==", + "fix": "Changed operator === \u2192 !==", "tags": [ "auto-detected", "operator-fix", @@ -2308,7 +2308,7 @@ "error_message": "Significant refactor of ", "file": "cli/validators/todo-tracking.mjs", "root_cause": "13 lines replaced/restructured", - "fix": "Rewrote 20→5 lines (13 removed)", + "fix": "Rewrote 20\u21925 lines (13 removed)", "tags": [ "auto-detected", "refactor", @@ -2324,7 +2324,7 @@ "error_message": "Significant refactor of ", "file": "cli/commands/generate.mjs", "root_cause": "24 lines replaced/restructured", - "fix": "Rewrote 35→12 lines (24 removed)", + "fix": "Rewrote 35\u219212 lines (24 removed)", "tags": [ "auto-detected", "refactor", @@ -2340,7 +2340,7 @@ "error_message": "Significant refactor of ", "file": "cli/commands/diff.mjs", "root_cause": "12 lines replaced/restructured", - "fix": "Rewrote 19→9 lines (12 removed)", + "fix": "Rewrote 19\u21929 lines (12 removed)", "tags": [ "auto-detected", "refactor", @@ -2356,7 +2356,7 @@ "error_message": "Significant refactor of ", "file": "cli/validators/changelog.mjs", "root_cause": "17 lines replaced/restructured", - "fix": "Rewrote 54→77 lines (17 removed)", + "fix": "Rewrote 54\u219277 lines (17 removed)", "tags": [ "auto-detected", "refactor", @@ -2388,7 +2388,7 @@ "error_message": "Significant refactor of ", "file": "cli/validators/metrics-consistency.mjs", "root_cause": "3 lines replaced/restructured", - "fix": "Rewrote 11→22 lines (3 removed)", + "fix": "Rewrote 11\u219222 lines (3 removed)", "tags": [ "auto-detected", "refactor", @@ -2401,10 +2401,10 @@ { "id": "bug-146", "timestamp": "2026-07-03T02:35:00.000Z", - "error_message": "Entire test suite failed to load (~60 test files 'not ok') — SyntaxError: Invalid or unexpected token in shared-ignore.mjs", + "error_message": "Entire test suite failed to load (~60 test files 'not ok') \u2014 SyntaxError: Invalid or unexpected token in shared-ignore.mjs", "file": "cli/shared-ignore.mjs", - "root_cause": "A JSDoc block documenting glob syntax contained a literal `**/` — the `*/` inside it TERMINATES the block comment, leaving the rest of the doc text as bare source. Mass 'not ok' across every test file is the tell: a module-level parse error in a shared import, not test logic. node --check pinpointed the line instantly.", - "fix": "Escaped the sequence as `**\\/` inside the comment — the file's own pre-existing convention (the old globToMatchRegex JSDoc used it). Verified: node --check, import probe, full suite 835/835.", + "root_cause": "A JSDoc block documenting glob syntax contained a literal `**/` \u2014 the `*/` inside it TERMINATES the block comment, leaving the rest of the doc text as bare source. Mass 'not ok' across every test file is the tell: a module-level parse error in a shared import, not test logic. node --check pinpointed the line instantly.", + "fix": "Escaped the sequence as `**\\/` inside the comment \u2014 the file's own pre-existing convention (the old globToMatchRegex JSDoc used it). Verified: node --check, import probe, full suite 835/835.", "tags": [ "syntax-error", "jsdoc", @@ -2421,7 +2421,7 @@ "timestamp": "2026-07-03T02:36:00.000Z", "error_message": "countGlob could silently under-count a collection when a subtree was unreadable (permission denied), producing a confidently wrong 'code has N' and a data-corrupting fix suggestion", "file": "cli/shared-ignore.mjs", - "root_cause": "The walker swallowed per-entry errors and kept walking; the caller had no completeness signal, so a partial walk read as a smaller-but-authoritative count — a fail-open in the collections feature (still unreleased; caught by the project review, not in the field).", + "root_cause": "The walker swallowed per-entry errors and kept walking; the caller had no completeness signal, so a partial walk read as a smaller-but-authoritative count \u2014 a fail-open in the collections feature (still unreleased; caught by the project review, not in the field).", "fix": "Shared walkFiles returns a completeness boolean; countGlobFiles returns -1 when the walk was incomplete; callers treat <=0 as 'don't assert'. Covered by the fail-safe collection test.", "tags": [ "fail-safe", @@ -2474,7 +2474,7 @@ "error_message": "Significant refactor of ", "file": "cli/validators/test-spec.mjs", "root_cause": "3 lines replaced/restructured", - "fix": "Rewrote 16→27 lines (3 removed) | Also: results.total++;; results.warnings.push(`TEST-SPEC declares ${source | Also: results.note = 'TEST-SPEC.md declares no service-t; results.warnings.push(", + "fix": "Rewrote 16\u219227 lines (3 removed) | Also: results.total++;; results.warnings.push(`TEST-SPEC declares ${source | Also: results.note = 'TEST-SPEC.md declares no service-t; results.warnings.push(", "tags": [ "auto-detected", "refactor", @@ -2522,7 +2522,7 @@ "error_message": "Significant refactor of ", "file": "cli/validators/docs-sync.mjs", "root_cause": "3 lines replaced/restructured", - "fix": "Rewrote 9→16 lines (3 removed) | Also: results.total++;; results.passed++; | Also: results.passed++;; results.warnings.push(", + "fix": "Rewrote 9\u219216 lines (3 removed) | Also: results.total++;; results.passed++; | Also: results.passed++;; results.warnings.push(", "tags": [ "auto-detected", "refactor", @@ -2570,7 +2570,7 @@ "error_message": "Significant refactor of ", "file": "cli/validators/docs-diff.mjs", "root_cause": "2 lines replaced/restructured", - "fix": "Rewrote 9→17 lines (2 removed)", + "fix": "Rewrote 9\u219217 lines (2 removed)", "tags": [ "auto-detected", "refactor", @@ -2586,7 +2586,7 @@ "error_message": "Significant refactor of ", "file": "cli/validators/traceability.mjs", "root_cause": "3 lines replaced/restructured", - "fix": "Rewrote 14→17 lines (3 removed) | Also: warnings.push(; `Requirement ${reqId} (${location.file}:${location | Also: warnings.push(; `Test references ${reqId} (${refs[0].file}:${refs[", + "fix": "Rewrote 14\u219217 lines (3 removed) | Also: warnings.push(; `Requirement ${reqId} (${location.file}:${location | Also: warnings.push(; `Test references ${reqId} (${refs[0].file}:${refs[", "tags": [ "auto-detected", "refactor", @@ -2602,7 +2602,7 @@ "error_message": "Significant refactor of ", "file": "cli/validators/docs-coverage.mjs", "root_cause": "4 lines replaced/restructured", - "fix": "Rewrote 12→17 lines (4 removed) | Also: warnings.push(; `package.json defines CLI command \"${binName}\" but | Also: warnings.push(; `Source directory \"${root}/${entry}/\" is not refer | Also: warnings.push(; `Code references config file \"${configName}\" but n", + "fix": "Rewrote 12\u219217 lines (4 removed) | Also: warnings.push(; `package.json defines CLI command \"${binName}\" but | Also: warnings.push(; `Source directory \"${root}/${entry}/\" is not refer | Also: warnings.push(; `Code references config file \"${configName}\" but n", "tags": [ "auto-detected", "refactor", @@ -2634,7 +2634,7 @@ "error_message": "Significant refactor of ", "file": "cli/validators/todo-tracking.mjs", "root_cause": "10 lines replaced/restructured", - "fix": "Rewrote 22→31 lines (10 removed) | Also: warnings.push(; `Skipped test without explanation at ${relPath}:${ | Also: warnings.push(; `Untracked ${todo.keyword} at ${todo.file}:${todo.", + "fix": "Rewrote 22\u219231 lines (10 removed) | Also: warnings.push(; `Skipped test without explanation at ${relPath}:${ | Also: warnings.push(; `Untracked ${todo.keyword} at ${todo.file}:${todo.", "tags": [ "auto-detected", "refactor", @@ -2666,7 +2666,7 @@ "error_message": "Significant refactor of ", "file": "cli/validators/metadata-sync.mjs", "root_cause": "3 lines replaced/restructured", - "fix": "Rewrote 6→11 lines (3 removed) | Also: warnings.push(; `${relPath} references \"v${foundVersion}\" in an ac", + "fix": "Rewrote 6\u219211 lines (3 removed) | Also: warnings.push(; `${relPath} references \"v${foundVersion}\" in an ac", "tags": [ "auto-detected", "refactor", @@ -2682,7 +2682,7 @@ "error_message": "Significant refactor of ", "file": "cli/validators/schema-sync.mjs", "root_cause": "15 lines replaced/restructured", - "fix": "Rewrote 56→72 lines (15 removed)", + "fix": "Rewrote 56\u219272 lines (15 removed)", "tags": [ "auto-detected", "refactor", @@ -2698,7 +2698,7 @@ "error_message": "Significant refactor of ", "file": "cli/validators/architecture.mjs", "root_cause": "6 lines replaced/restructured", - "fix": "Rewrote 40→59 lines (6 removed) | Also: results.total++;; results.errors.push(", + "fix": "Rewrote 40\u219259 lines (6 removed) | Also: results.total++;; results.errors.push(", "tags": [ "auto-detected", "refactor", @@ -2714,7 +2714,7 @@ "error_message": "Wrong reference: results should be acc", "file": "cli/validators/architecture.mjs", "root_cause": "Used \"results\" instead of \"acc\"", - "fix": "Changed results → acc", + "fix": "Changed results \u2192 acc", "tags": [ "auto-detected", "wrong-reference", @@ -2730,7 +2730,7 @@ "error_message": "Significant refactor of ", "file": "cli/validators/canonical-sync.mjs", "root_cause": "6 lines replaced/restructured", - "fix": "Rewrote 26→34 lines (6 removed) | Also: result.total++;; result.passed++;", + "fix": "Rewrote 26\u219234 lines (6 removed) | Also: result.total++;; result.passed++;", "tags": [ "auto-detected", "refactor", @@ -2746,7 +2746,7 @@ "error_message": "Wrong reference: result should be compose", "file": "cli/validators/canonical-sync.mjs", "root_cause": "Used \"result\" instead of \"compose\"", - "fix": "Changed result → compose", + "fix": "Changed result \u2192 compose", "tags": [ "auto-detected", "wrong-reference", @@ -2778,7 +2778,7 @@ "error_message": "Significant refactor of ", "file": "cli/scanners/speckit.mjs", "root_cause": "27 lines replaced/restructured", - "fix": "Rewrote 93→162 lines (27 removed)", + "fix": "Rewrote 93\u2192162 lines (27 removed)", "tags": [ "auto-detected", "refactor", @@ -2794,7 +2794,7 @@ "error_message": "Significant refactor of ", "file": "cli/validators/cross-reference.mjs", "root_cause": "3 lines replaced/restructured", - "fix": "Rewrote 15→19 lines (3 removed) | Also: warnings.push(; `${docName}:${ref.line} — broken link: target file | Also: warnings.push(; `${docName}:${ref.line} — broken anchor: \"#${ref.a", + "fix": "Rewrote 15\u219219 lines (3 removed) | Also: warnings.push(; `${docName}:${ref.line} \u2014 broken link: target file | Also: warnings.push(; `${docName}:${ref.line} \u2014 broken anchor: \"#${ref.a", "tags": [ "auto-detected", "refactor", @@ -2810,7 +2810,7 @@ "error_message": "Significant refactor of ", "file": "cli/validators/generated-staleness.mjs", "root_cause": "4 lines replaced/restructured", - "fix": "Rewrote 6→11 lines (4 removed) | Also: result.warnings.push(; `${basename(doc.path)} → section \"${sec.id}\" is st", + "fix": "Rewrote 6\u219211 lines (4 removed) | Also: result.warnings.push(; `${basename(doc.path)} \u2192 section \"${sec.id}\" is st", "tags": [ "auto-detected", "refactor", @@ -2826,7 +2826,7 @@ "error_message": "Significant refactor of ", "file": "cli/validators/surface-sync.mjs", "root_cause": "5 lines replaced/restructured", - "fix": "Rewrote 20→28 lines (5 removed)", + "fix": "Rewrote 20\u219228 lines (5 removed)", "tags": [ "auto-detected", "refactor", @@ -2842,7 +2842,7 @@ "error_message": "Significant refactor of ", "file": "cli/validators/api-surface.mjs", "root_cause": "2 lines replaced/restructured", - "fix": "Rewrote 8→11 lines (2 removed) | Also: warnings.push(; `OpenAPI spec ${specPath} declares paths but DocGu | Also: warnings.push(; `Multiple OpenAPI specs disagree on ${divergence.d | Also: warnings.push(; `OpenAPI spec (${specRoute.specPath}) declares ${e | Also: if (confidence === 'spec') errors.push(msg);; else warnings.push(`${msg} [code-scan — verify]`);", + "fix": "Rewrote 8\u219211 lines (2 removed) | Also: warnings.push(; `OpenAPI spec ${specPath} declares paths but DocGu | Also: warnings.push(; `Multiple OpenAPI specs disagree on ${divergence.d | Also: warnings.push(; `OpenAPI spec (${specRoute.specPath}) declares ${e | Also: if (confidence === 'spec') errors.push(msg);; else warnings.push(`${msg} [code-scan \u2014 verify]`);", "tags": [ "auto-detected", "refactor", @@ -2858,7 +2858,7 @@ "error_message": "Significant refactor of ", "file": "cli/validators/doc-quality.mjs", "root_cause": "43 lines replaced/restructured", - "fix": "Rewrote 125→158 lines (43 removed)", + "fix": "Rewrote 125\u2192158 lines (43 removed)", "tags": [ "auto-detected", "refactor", @@ -2874,7 +2874,7 @@ "error_message": "Significant refactor of ", "file": "cli/commands/generate.mjs", "root_cause": "3 lines replaced/restructured", - "fix": "Rewrote 9→14 lines (3 removed)", + "fix": "Rewrote 9\u219214 lines (3 removed)", "tags": [ "auto-detected", "refactor", @@ -2890,7 +2890,7 @@ "error_message": "Significant refactor of ", "file": "CHANGELOG.md", "root_cause": "6 lines replaced/restructured", - "fix": "Rewrote 6→15 lines (6 removed)", + "fix": "Rewrote 6\u219215 lines (6 removed)", "tags": [ "auto-detected", "refactor", @@ -2906,7 +2906,7 @@ "error_message": "Significant refactor of ", "file": "templates/commands/docguard.review.md", "root_cause": "7 lines replaced/restructured", - "fix": "Rewrote 10→14 lines (7 removed)", + "fix": "Rewrote 10\u219214 lines (7 removed)", "tags": [ "auto-detected", "refactor", @@ -2922,7 +2922,7 @@ "error_message": "Significant refactor of ", "file": "templates/commands/docguard.update.md", "root_cause": "2 lines replaced/restructured", - "fix": "Rewrote 8→9 lines (2 removed) | Also: For each affected document:; 3. Update the specific section that changed", + "fix": "Rewrote 8\u21929 lines (2 removed) | Also: For each affected document:; 3. Update the specific section that changed", "tags": [ "auto-detected", "refactor", @@ -2938,7 +2938,7 @@ "error_message": "Significant refactor of ", "file": "templates/commands/docguard.init.md", "root_cause": "10 lines replaced/restructured", - "fix": "Rewrote 17→29 lines (10 removed)", + "fix": "Rewrote 17\u219229 lines (10 removed)", "tags": [ "auto-detected", "refactor", @@ -2985,7 +2985,7 @@ "timestamp": "2026-07-03T16:02:44.054Z", "error_message": "Missing error handling in generateLlmsFullTxt", "file": "cli/commands/llms.mjs", - "root_cause": "Code path had no error handling — exceptions would propagate uncaught", + "root_cause": "Code path had no error handling \u2014 exceptions would propagate uncaught", "fix": "Added try/catch block", "tags": [ "auto-detected", @@ -3017,7 +3017,7 @@ "timestamp": "2026-07-03T16:03:49.461Z", "error_message": "Missing error handling in extractConventions", "file": "cli/commands/memory.mjs", - "root_cause": "Code path had no error handling — exceptions would propagate uncaught", + "root_cause": "Code path had no error handling \u2014 exceptions would propagate uncaught", "fix": "Added try/catch block", "tags": [ "auto-detected", @@ -3033,8 +3033,8 @@ "timestamp": "2026-07-03T16:06:27.493Z", "error_message": "Missing error handling in stampMarker", "file": "cli/commands/agents.mjs", - "root_cause": "Code path had no error handling — exceptions would propagate uncaught", - "fix": "Added try/catch block | Also: // ── Sync + Check modes (v0.29) ───────────────────────────; /**", + "root_cause": "Code path had no error handling \u2014 exceptions would propagate uncaught", + "fix": "Added try/catch block | Also: // \u2500\u2500 Sync + Check modes (v0.29) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500; /**", "tags": [ "auto-detected", "error-handling", @@ -3065,7 +3065,7 @@ "timestamp": "2026-07-03T16:17:04.491Z", "error_message": "Missing await", "file": "cli/docguard.mjs", - "root_cause": "Async call without await — returned Promise instead of value", + "root_cause": "Async call without await \u2014 returned Promise instead of value", "fix": "Added await to async call", "tags": [ "auto-detected", @@ -3081,7 +3081,7 @@ "timestamp": "2026-07-03T16:18:05.057Z", "error_message": "Missing error handling in unknown", "file": "action.yml", - "root_cause": "Code path had no error handling — exceptions would propagate uncaught", + "root_cause": "Code path had no error handling \u2014 exceptions would propagate uncaught", "fix": "Added try/catch block", "tags": [ "auto-detected", @@ -3114,7 +3114,7 @@ "error_message": "Significant refactor of ", "file": "docs-canonical/ARCHITECTURE.md", "root_cause": "4 lines replaced/restructured", - "fix": "Rewrote 4→4 lines (4 removed)", + "fix": "Rewrote 4\u21924 lines (4 removed)", "tags": [ "auto-detected", "refactor", @@ -3130,7 +3130,7 @@ "error_message": "Significant refactor of ", "file": "docs/commands.md", "root_cause": "2 lines replaced/restructured", - "fix": "Rewrote 12→47 lines (2 removed)", + "fix": "Rewrote 12\u219247 lines (2 removed)", "tags": [ "auto-detected", "refactor", @@ -3145,7 +3145,7 @@ "timestamp": "2026-07-03T21:51:26.996Z", "error_message": "Missing error handling in featureGrade", "file": "cli/commands/trace.mjs", - "root_cause": "Code path had no error handling — exceptions would propagate uncaught", + "root_cause": "Code path had no error handling \u2014 exceptions would propagate uncaught", "fix": "Added try/catch block", "tags": [ "auto-detected", @@ -3161,7 +3161,7 @@ "timestamp": "2026-07-03T21:52:39.304Z", "error_message": "Missing error handling in parseCheckedTasks", "file": "cli/scanners/speckit.mjs", - "root_cause": "Code path had no error handling — exceptions would propagate uncaught", + "root_cause": "Code path had no error handling \u2014 exceptions would propagate uncaught", "fix": "Added try/catch block", "tags": [ "auto-detected", @@ -3210,7 +3210,7 @@ "error_message": "awesome-mcp-servers PR #9185 added a line containing the literal word 'undefined' to README instead of the DocGuard entry; bot flagged missing-glama, gh pr diff showed '+undefined', grep found no 'docguard'", "file": "(scratchpad node -e insertion script; result pushed to raccioly/awesome-mcp-servers add-docguard)", "root_cause": "Shell env-var vs argv error: `node -e \"...process.env.LINE...\" LINE=\"$LINE\"` passes LINE as a positional ARGV to node, NOT as an environment variable (env assignments must precede the command: `LINE=\"$LINE\" node -e`). So process.env.LINE was undefined and the script inserted the literal string 'undefined'. The invocation exited 0, so the `|| fallback` never ran, and it was committed+pushed unverified.", - "fix": "Rebuilt the branch: gh repo sync the fork to upstream, fresh clone, wrote the entry to a FILE (/tmp/dg-entry.txt) and inserted via a python heredoc (no shell-var interpolation), VERIFIED with grep for both the entry and the badge before committing, then force-pushed to the same PR branch (repairs the PR in place — keeps #9185, its thread, history). Lesson: for env→node, put the assignment before the command; and ALWAYS grep-verify a generated file insertion before commit+push.", + "fix": "Rebuilt the branch: gh repo sync the fork to upstream, fresh clone, wrote the entry to a FILE (/tmp/dg-entry.txt) and inserted via a python heredoc (no shell-var interpolation), VERIFIED with grep for both the entry and the badge before committing, then force-pushed to the same PR branch (repairs the PR in place \u2014 keeps #9185, its thread, history). Lesson: for env\u2192node, put the assignment before the command; and ALWAYS grep-verify a generated file insertion before commit+push.", "tags": [ "shell", "env-var", @@ -3264,7 +3264,7 @@ "timestamp": "2026-07-06T01:44:39.966Z", "error_message": "Missing error handling in unknown", "file": ".github/workflows/ci.yml", - "root_cause": "Code path had no error handling — exceptions would propagate uncaught", + "root_cause": "Code path had no error handling \u2014 exceptions would propagate uncaught", "fix": "Added try/catch block", "tags": [ "auto-detected", @@ -3278,10 +3278,10 @@ { "id": "bug-200", "timestamp": "2026-07-06T14:40:55.317Z", - "error_message": "ENOTDIR: not a directory, open '/dev/null/pre-commit' — docguard hooks / init --with hooks crashes when git core.hooksPath is /dev/null (Google Jules sandbox VM sets this; also any user who disables hooks that way)", + "error_message": "ENOTDIR: not a directory, open '/dev/null/pre-commit' \u2014 docguard hooks / init --with hooks crashes when git core.hooksPath is /dev/null (Google Jules sandbox VM sets this; also any user who disables hooks that way)", "file": "cli/shared-git.mjs", - "root_cause": "getHooksDir() ran `git rev-parse --git-path hooks`, which returns the literal '/dev/null' when core.hooksPath=/dev/null. The code did `if (out) return resolve(dir, out)` — resolving '/dev/null' verbatim as the hooks dir. Callers then wrote `/pre-commit` = '/dev/null/pre-commit', which is ENOTDIR because /dev/null is a device file, not a directory.", - "fix": "Guard the /dev/null pseudo-path: `if (out && out !== '/dev/null') return resolve(dir, out)`. When core.hooksPath is /dev/null we fall through to the existing `.git/hooks` directory check, so hook install/list works normally. Added a regression test in tests/hooks.test.mjs (real git repo + `git config core.hooksPath /dev/null`) asserting getHooksDir falls back to .git/hooks and runHooks writes the hook without ENOTDIR. Independently found by Google Jules (PR #307) — adopted the fix cleanly without the PR's auto-generated .agent/skills noise.", + "root_cause": "getHooksDir() ran `git rev-parse --git-path hooks`, which returns the literal '/dev/null' when core.hooksPath=/dev/null. The code did `if (out) return resolve(dir, out)` \u2014 resolving '/dev/null' verbatim as the hooks dir. Callers then wrote `/pre-commit` = '/dev/null/pre-commit', which is ENOTDIR because /dev/null is a device file, not a directory.", + "fix": "Guard the /dev/null pseudo-path: `if (out && out !== '/dev/null') return resolve(dir, out)`. When core.hooksPath is /dev/null we fall through to the existing `.git/hooks` directory check, so hook install/list works normally. Added a regression test in tests/hooks.test.mjs (real git repo + `git config core.hooksPath /dev/null`) asserting getHooksDir falls back to .git/hooks and runHooks writes the hook without ENOTDIR. Independently found by Google Jules (PR #307) \u2014 adopted the fix cleanly without the PR's auto-generated .agent/skills noise.", "tags": [ "hooks", "git", @@ -3298,10 +3298,10 @@ { "id": "bug-201", "timestamp": "2026-07-07T00:00:00Z", - "error_message": "reference-existence (REF001) fabricated 3 false positives in DocGuard AGENTS.md (after_implement, before_tasks, after_tasks flagged as removed) — symbols actually still present in .github/scripts/", + "error_message": "reference-existence (REF001) fabricated 3 false positives in DocGuard AGENTS.md (after_implement, before_tasks, after_tasks flagged as removed) \u2014 symbols actually still present in .github/scripts/", "file": "cli/validators/reference-existence.mjs", - "root_cause": "Asymmetry between two presence checks: the fast in-memory HEAD set was built with walkFiles() which SKIPS dot-directories (.github, .specify) by default (skipDotEntries), while the historical presence check used `git grep` which does NOT skip dot-dirs. So a symbol living only in a dot-dir read as absent-at-HEAD (walk missed it) but present-at-doc-rev (git grep found it) → false removed finding.", - "fix": "Confirm every walk-miss with an authoritative `git grep HEAD` before flagging (symbolExistsAtRev at HEAD). Only symbols absent from BOTH the walk AND git grep proceed to the historical check. Reduced docguard-self FPs 3→0.", + "root_cause": "Asymmetry between two presence checks: the fast in-memory HEAD set was built with walkFiles() which SKIPS dot-directories (.github, .specify) by default (skipDotEntries), while the historical presence check used `git grep` which does NOT skip dot-dirs. So a symbol living only in a dot-dir read as absent-at-HEAD (walk missed it) but present-at-doc-rev (git grep found it) \u2192 false removed finding.", + "fix": "Confirm every walk-miss with an authoritative `git grep HEAD` before flagging (symbolExistsAtRev at HEAD). Only symbols absent from BOTH the walk AND git grep proceed to the historical check. Reduced docguard-self FPs 3\u21920.", "tags": [ "reference-existence", "walkFiles", @@ -3321,7 +3321,7 @@ "error_message": "Significant refactor of ", "file": "cli/validators/reference-existence.mjs", "root_cause": "3 lines replaced/restructured", - "fix": "Rewrote 9→19 lines (3 removed)", + "fix": "Rewrote 9\u219219 lines (3 removed)", "tags": [ "auto-detected", "refactor", @@ -3336,7 +3336,7 @@ "timestamp": "2026-07-11T16:23:40.321Z", "error_message": "Missing error handling in commentIndex", "file": "cli/validators/reference-existence.mjs", - "root_cause": "Code path had no error handling — exceptions would propagate uncaught", + "root_cause": "Code path had no error handling \u2014 exceptions would propagate uncaught", "fix": "Added try/catch block", "tags": [ "auto-detected", @@ -3368,7 +3368,7 @@ "timestamp": "2026-07-11T16:32:18.404Z", "error_message": "Missing error handling in unknown", "file": "cli/validators/cross-reference.mjs", - "root_cause": "Code path had no error handling — exceptions would propagate uncaught", + "root_cause": "Code path had no error handling \u2014 exceptions would propagate uncaught", "fix": "Added try/catch block", "tags": [ "auto-detected", @@ -3385,7 +3385,7 @@ "error_message": "Significant refactor of ", "file": "cli/commands/impact.mjs", "root_cause": "4 lines replaced/restructured", - "fix": "Rewrote 14→18 lines (4 removed)", + "fix": "Rewrote 14\u219218 lines (4 removed)", "tags": [ "auto-detected", "refactor", @@ -3416,8 +3416,8 @@ "timestamp": "2026-07-11T16:37:27.018Z", "error_message": "Incorrect value in code", "file": "cli/commands/impact.mjs", - "root_cause": "Had ` ${c.dim}↳ describes${c.reset} ${ch.via} ${c.", - "fix": "Changed to ` ${c.dim}↳ describes${c.reset} ${ch.via}${c.d", + "root_cause": "Had ` ${c.dim}\u21b3 describes${c.reset} ${ch.via} ${c.", + "fix": "Changed to ` ${c.dim}\u21b3 describes${c.reset} ${ch.via}${c.d", "tags": [ "auto-detected", "wrong-value", @@ -3432,7 +3432,7 @@ "timestamp": "2026-07-11T16:40:58.825Z", "error_message": "Missing error handling in loadGraphifyDocLinks", "file": "cli/validators/traceability.mjs", - "root_cause": "Code path had no error handling — exceptions would propagate uncaught", + "root_cause": "Code path had no error handling \u2014 exceptions would propagate uncaught", "fix": "Added try/catch block", "tags": [ "auto-detected", @@ -3462,7 +3462,7 @@ { "id": "bug-211", "timestamp": "2026-07-11T00:00:00Z", - "error_message": "REF002 (new ADR-citation check) flagged 5 findings on DocGuard itself — all false positives: the validator's own doc comments using ADR-012 as EXAMPLES, plus test-fixture citations", + "error_message": "REF002 (new ADR-citation check) flagged 5 findings on DocGuard itself \u2014 all false positives: the validator's own doc comments using ADR-012 as EXAMPLES, plus test-fixture citations", "file": "cli/validators/reference-existence.mjs", "root_cause": "Citation scan had no non-product scoping (tests/ counted) and the implementation's explanatory comments contained real-looking ADR- examples that match the detector's own regex", "fix": "Applied isNonProductPath() to the citation walk (tests/fixtures/examples excluded, same scoping as surface scanners) and rewrote all in-source examples to digit-free placeholders (ADR-NNN). Self-guard back to baseline.", @@ -3484,7 +3484,7 @@ "error_message": "Significant refactor of ", "file": "cli/scanners/semantic-claims.mjs", "root_cause": "2 lines replaced/restructured", - "fix": "Rewrote 15→22 lines (2 removed)", + "fix": "Rewrote 15\u219222 lines (2 removed)", "tags": [ "auto-detected", "refactor", @@ -3499,8 +3499,8 @@ "timestamp": "2026-07-11T00:00:00Z", "error_message": "guard reported 39 unverified semantic claims; 28 came from docs-canonical/SURFACE-AUDIT.md which is explicitly listed in .docguardignore", "file": "cli/scanners/semantic-claims.mjs", - "root_cause": "claimSourceDocs() enumerated docs-canonical/ + README/AGENTS without consulting loadIgnorePatterns — the extractor predates the ignore-honoring convention and was never wired to it", - "fix": "claimSourceDocs now filters through loadIgnorePatterns(projectDir); regression test added (ignored doc contributes no claims). Self-repo unverified claims 39 → 12.", + "root_cause": "claimSourceDocs() enumerated docs-canonical/ + README/AGENTS without consulting loadIgnorePatterns \u2014 the extractor predates the ignore-honoring convention and was never wired to it", + "fix": "claimSourceDocs now filters through loadIgnorePatterns(projectDir); regression test added (ignored doc contributes no claims). Self-repo unverified claims 39 \u2192 12.", "tags": [ "semantic-claims", "docguardignore", @@ -3519,7 +3519,7 @@ "timestamp": "2026-07-11T17:29:21.685Z", "error_message": "Missing error handling in isOurNudgeGroup", "file": "cli/commands/hooks.mjs", - "root_cause": "Code path had no error handling — exceptions would propagate uncaught", + "root_cause": "Code path had no error handling \u2014 exceptions would propagate uncaught", "fix": "Added try/catch block", "tags": [ "auto-detected", @@ -3567,7 +3567,7 @@ "timestamp": "2026-07-11T17:37:01.314Z", "error_message": "Missing await", "file": "cli/commands/mcp.mjs", - "root_cause": "Async call without await — returned Promise instead of value", + "root_cause": "Async call without await \u2014 returned Promise instead of value", "fix": "Added await to async call", "tags": [ "auto-detected", @@ -3583,7 +3583,7 @@ "timestamp": "2026-07-11T17:39:48.041Z", "error_message": "Missing error handling in computeDocConflicts", "file": "cli/commands/impact.mjs", - "root_cause": "Code path had no error handling — exceptions would propagate uncaught", + "root_cause": "Code path had no error handling \u2014 exceptions would propagate uncaught", "fix": "Added try/catch block", "tags": [ "auto-detected", @@ -3599,7 +3599,7 @@ "timestamp": "2026-07-11T17:40:24.277Z", "error_message": "Missing await", "file": "tests/impact.test.mjs", - "root_cause": "Async call without await — returned Promise instead of value", + "root_cause": "Async call without await \u2014 returned Promise instead of value", "fix": "Added await to async call", "tags": [ "auto-detected", @@ -3609,6 +3609,513 @@ "related_bugs": [], "occurrences": 1, "last_seen": "2026-07-11T17:40:24.277Z" + }, + { + "id": "bug-220", + "timestamp": "2026-07-15T22:22:09.994Z", + "error_message": "Missing error handling in getHeadInfo", + "file": "cli/shared-git.mjs", + "root_cause": "Code path had no error handling \u2014 exceptions would propagate uncaught", + "fix": "Added try/catch block", + "tags": [ + "auto-detected", + "error-handling", + "mjs" + ], + "related_bugs": [], + "occurrences": 1, + "last_seen": "2026-07-15T22:22:09.994Z" + }, + { + "id": "bug-221", + "timestamp": "2026-07-15T22:23:35.321Z", + "error_message": "Wrong condition in logic", + "file": "cli/docguard.mjs", + "root_cause": "Condition was: if (args[i] === '--quiet' || args[i] === '-q')", + "fix": "Changed to: if (args[i] === '--out' && args[i + 1])", + "tags": [ + "auto-detected", + "logic-fix", + "mjs" + ], + "related_bugs": [], + "occurrences": 1, + "last_seen": "2026-07-15T22:23:35.321Z" + }, + { + "id": "bug-222", + "timestamp": "2026-07-15T22:23:43.804Z", + "error_message": "Incorrect value in code", + "file": "cli/docguard.mjs", + "root_cause": "Had 'agent'", + "fix": "Changed to `report`", + "tags": [ + "auto-detected", + "wrong-value", + "mjs" + ], + "related_bugs": [], + "occurrences": 1, + "last_seen": "2026-07-15T22:23:43.804Z" + }, + { + "id": "bug-223", + "timestamp": "2026-07-15T22:26:32.102Z", + "error_message": "Incorrect value in code", + "file": "README.md", + "root_cause": "Had \"Commands (18)\"", + "fix": "Changed to \"Commands (19)\"", + "tags": [ + "auto-detected", + "wrong-value", + "md" + ], + "related_bugs": [], + "occurrences": 2, + "last_seen": "2026-07-15T22:26:35.234Z" + }, + { + "id": "bug-224", + "timestamp": "2026-07-15T22:30:15.987Z", + "error_message": "Missing guard clause", + "file": "cli/commands/score.mjs", + "root_cause": "No early return/throw for edge case: flags.trend", + "fix": "Added guard clause: if (flags.trend)", + "tags": [ + "auto-detected", + "guard-clause", + "mjs" + ], + "related_bugs": [], + "occurrences": 1, + "last_seen": "2026-07-15T22:30:15.987Z" + }, + { + "id": "bug-225", + "timestamp": "2026-07-15T22:30:47.337Z", + "error_message": "Wrong condition in logic", + "file": "cli/docguard.mjs", + "root_cause": "Condition was: if (args[i] === '--out' && args[i + 1])", + "fix": "Changed to: if (args[i] === '--trend')", + "tags": [ + "auto-detected", + "logic-fix", + "mjs" + ], + "related_bugs": [], + "occurrences": 1, + "last_seen": "2026-07-15T22:30:47.337Z" + }, + { + "id": "bug-226", + "timestamp": "2026-07-15T22:33:51.742Z", + "error_message": "Incorrect value in code", + "file": "README.md", + "root_cause": "Had `docguard init --with `", + "fix": "Changed to `ci`", + "tags": [ + "auto-detected", + "wrong-value", + "md" + ], + "related_bugs": [], + "occurrences": 2, + "last_seen": "2026-07-15T22:33:51.817Z" + }, + { + "id": "bug-227", + "timestamp": "2026-07-15T22:34:35.108Z", + "error_message": "Significant refactor of ", + "file": "tests/history.test.mjs", + "root_cause": "2 lines replaced/restructured", + "fix": "Rewrote 4\u21928 lines (2 removed)", + "tags": [ + "auto-detected", + "refactor", + "mjs" + ], + "related_bugs": [], + "occurrences": 1, + "last_seen": "2026-07-15T22:34:35.108Z" + }, + { + "id": "bug-228", + "timestamp": "2026-07-15T22:40:00Z", + "error_message": "docguard ci --format json emits unparseable stdout (init banner + deprecation chrome) and scaffolds missing canonical docs into the CI workspace before validating", + "file": "cli/docguard.mjs", + "root_cause": "v0.20 consolidation deprecated `ci` and routed it through runInit --with ci; runInit prints chrome to stdout and creates missing docs (skipPrompts defaults), so the pipeline gate mutated the tree it was validating and corrupted machine output. docs/commands.md still documented `ci` as the pipeline command \u2014 docs and dispatch disagreed.", + "fix": "v0.33: un-deprecated `ci` \u2014 case 'ci' dispatches directly to runCI (read-only, machine-clean); removed from DEPRECATED_COMMANDS; runCI also switched from console.log+process.exit to stdout.write+process.exitCode (8KB pipe-truncation class); tests updated (v020-consolidation asserts no warning/no init; history.test asserts pure JSON + exit 1 on bare fixture)", + "tags": [ + "ci", + "deprecation-routing", + "json-corruption", + "read-only-violation", + "pipe-truncation" + ], + "related_bugs": [], + "occurrences": 1, + "last_seen": "2026-07-15T22:40:00Z" + }, + { + "id": "bug-229", + "timestamp": "2026-07-15T22:40:30.799Z", + "error_message": "Significant refactor of ", + "file": "ROADMAP.md", + "root_cause": "3 lines replaced/restructured", + "fix": "Rewrote 9\u219212 lines (3 removed)", + "tags": [ + "auto-detected", + "refactor", + "md" + ], + "related_bugs": [], + "occurrences": 1, + "last_seen": "2026-07-15T22:40:30.799Z" + }, + { + "id": "bug-230", + "timestamp": "2026-07-15T22:56:32.764Z", + "error_message": "Missing await", + "file": "tests/mcp.test.mjs", + "root_cause": "Async call without await \u2014 returned Promise instead of value", + "fix": "Added await to async call", + "tags": [ + "auto-detected", + "async-fix", + "mjs" + ], + "related_bugs": [], + "occurrences": 1, + "last_seen": "2026-07-15T22:56:32.764Z" + }, + { + "id": "bug-231", + "timestamp": "2026-07-15T22:57:30.386Z", + "error_message": "Incorrect value in code", + "file": "docs/commands.md", + "root_cause": "Had `docguard_diagnose`", + "fix": "Changed to `docguard_report`", + "tags": [ + "auto-detected", + "wrong-value", + "md" + ], + "related_bugs": [], + "occurrences": 1, + "last_seen": "2026-07-15T22:57:30.386Z" + }, + { + "id": "bug-232", + "timestamp": "2026-07-15T23:04:49.661Z", + "error_message": "Missing guard clause", + "file": "cli/commands/guard.mjs", + "root_cause": "No early return/throw for edge case: baselineSet", + "fix": "Added guard clause: if (baselineSet)", + "tags": [ + "auto-detected", + "guard-clause", + "mjs" + ], + "related_bugs": [], + "occurrences": 1, + "last_seen": "2026-07-15T23:04:49.661Z" + }, + { + "id": "bug-233", + "timestamp": "2026-07-15T23:09:01.494Z", + "error_message": "Significant refactor of ", + "file": "cli/docguard.mjs", + "root_cause": "3 lines replaced/restructured", + "fix": "Rewrote 10\u219212 lines (3 removed)", + "tags": [ + "auto-detected", + "refactor", + "mjs" + ], + "related_bugs": [], + "occurrences": 1, + "last_seen": "2026-07-15T23:09:01.494Z" + }, + { + "id": "bug-234", + "timestamp": "2026-07-16T01:46:35.147Z", + "error_message": "Missing guard clause", + "file": "cli/writers/baseline.mjs", + "root_cause": "No early return/throw for edge case: !existsSync(p", + "fix": "Added guard clause: if (!existsSync(p)", + "tags": [ + "auto-detected", + "guard-clause", + "mjs" + ], + "related_bugs": [], + "occurrences": 1, + "last_seen": "2026-07-16T01:46:35.147Z" + }, + { + "id": "bug-235", + "timestamp": "2026-07-16T01:46:53.425Z", + "error_message": "Significant refactor of ", + "file": "cli/commands/guard.mjs", + "root_cause": "3 lines replaced/restructured", + "fix": "Rewrote 17\u219227 lines (3 removed)", + "tags": [ + "auto-detected", + "refactor", + "mjs" + ], + "related_bugs": [], + "occurrences": 1, + "last_seen": "2026-07-16T01:46:53.425Z" + }, + { + "id": "bug-236", + "timestamp": "2026-07-16T01:47:25.945Z", + "error_message": "Incorrect value in code", + "file": "cli/commands/report.mjs", + "root_cause": "Had `docguard report --format json\\`", + "fix": "Changed to `alcoa\\`", + "tags": [ + "auto-detected", + "wrong-value", + "mjs" + ], + "related_bugs": [], + "occurrences": 1, + "last_seen": "2026-07-16T01:47:25.945Z" + }, + { + "id": "bug-237", + "timestamp": "2026-07-16T01:47:36.170Z", + "error_message": "Significant refactor of ", + "file": "cli/commands/report.mjs", + "root_cause": "2 lines replaced/restructured", + "fix": "Rewrote 4\u21926 lines (2 removed)", + "tags": [ + "auto-detected", + "refactor", + "mjs" + ], + "related_bugs": [], + "occurrences": 1, + "last_seen": "2026-07-16T01:47:36.170Z" + }, + { + "id": "bug-238", + "timestamp": "2026-07-16T01:47:42.005Z", + "error_message": "Significant refactor of ", + "file": "cli/commands/ci.mjs", + "root_cause": "3 lines replaced/restructured", + "fix": "Rewrote 9\u219219 lines (3 removed)", + "tags": [ + "auto-detected", + "refactor", + "mjs" + ], + "related_bugs": [], + "occurrences": 1, + "last_seen": "2026-07-16T01:47:42.005Z" + }, + { + "id": "bug-239", + "timestamp": "2026-07-16T01:48:02.963Z", + "error_message": "Wrong operator: >= should be ===", + "file": "cli/commands/ci.mjs", + "root_cause": "Used \">=\" instead of \"===\"", + "fix": "Changed operator >= \u2192 ===", + "tags": [ + "auto-detected", + "operator-fix", + "mjs" + ], + "related_bugs": [], + "occurrences": 1, + "last_seen": "2026-07-16T01:48:02.963Z" + }, + { + "id": "bug-240", + "timestamp": "2026-07-16T01:48:24.756Z", + "error_message": "Missing error handling in toJUnit", + "file": "cli/writers/junit.mjs", + "root_cause": "Code path had no error handling \u2014 exceptions would propagate uncaught", + "fix": "Added try/catch block", + "tags": [ + "auto-detected", + "error-handling", + "mjs" + ], + "related_bugs": [], + "occurrences": 1, + "last_seen": "2026-07-16T01:48:24.756Z" + }, + { + "id": "bug-241", + "timestamp": "2026-07-16T01:48:38.095Z", + "error_message": "Significant refactor of ", + "file": "cli/writers/junit.mjs", + "root_cause": "4 lines replaced/restructured", + "fix": "Rewrote 5\u21927 lines (4 removed)", + "tags": [ + "auto-detected", + "refactor", + "mjs" + ], + "related_bugs": [], + "occurrences": 1, + "last_seen": "2026-07-16T01:48:38.095Z" + }, + { + "id": "bug-242", + "timestamp": "2026-07-16T01:48:57.537Z", + "error_message": "Significant refactor of ", + "file": "cli/writers/history.mjs", + "root_cause": "3 lines replaced/restructured", + "fix": "Rewrote 6\u21928 lines (3 removed)", + "tags": [ + "auto-detected", + "refactor", + "mjs" + ], + "related_bugs": [], + "occurrences": 1, + "last_seen": "2026-07-16T01:48:57.537Z" + }, + { + "id": "bug-243", + "timestamp": "2026-07-16T01:49:15.436Z", + "error_message": "Wrong operator: || should be &&", + "file": "docs-canonical/CI-RECIPES.md", + "root_cause": "Used \"||\" instead of \"&&\"", + "fix": "Changed operator || \u2192 &&", + "tags": [ + "auto-detected", + "operator-fix", + "md" + ], + "related_bugs": [], + "occurrences": 1, + "last_seen": "2026-07-16T01:49:15.436Z" + }, + { + "id": "bug-244", + "timestamp": "2026-07-16T01:49:18.279Z", + "error_message": "Incorrect value in code", + "file": "README.md", + "root_cause": "Had `--threshold `", + "fix": "Changed to `.docguard/history.jsonl`", + "tags": [ + "auto-detected", + "wrong-value", + "md" + ], + "related_bugs": [], + "occurrences": 1, + "last_seen": "2026-07-16T01:49:18.279Z" + }, + { + "id": "bug-245", + "timestamp": "2026-07-16T01:49:41.321Z", + "error_message": "Significant refactor of ", + "file": "tests/baseline.test.mjs", + "root_cause": "3 lines replaced/restructured", + "fix": "Rewrote 8\u219243 lines (3 removed)", + "tags": [ + "auto-detected", + "refactor", + "mjs" + ], + "related_bugs": [], + "occurrences": 1, + "last_seen": "2026-07-16T01:49:41.321Z" + }, + { + "id": "bug-246", + "timestamp": "2026-07-16T01:50:53.765Z", + "error_message": "Incorrect value in code", + "file": "CHANGELOG.md", + "root_cause": "Had ``", + "fix": "Changed to ``", + "tags": [ + "auto-detected", + "wrong-value", + "md" + ], + "related_bugs": [], + "occurrences": 1, + "last_seen": "2026-07-16T01:50:53.765Z" + }, + { + "id": "bug-247", + "timestamp": "2026-07-15T23:30:00Z", + "error_message": "bare `docguard ci` (text mode) ran ensureSkills and scaffolded ~9 files into the CI workspace before gating", + "file": "cli/docguard.mjs", + "root_cause": "when ci was restored as a first-class command it was not added to READ_ONLY_COMMANDS, so the ensureSkills gate (headless-only suppression) still fired in text mode \u2014 same class as v0.26 Bug #3", + "fix": "added 'ci' to READ_ONLY_COMMANDS (its only write remains .docguard/history.jsonl); regression test asserts .agent/.specify absent after a bare ci run", + "tags": [ + "ci", + "read-only-violation", + "ensureSkills", + "review-H1" + ], + "related_bugs": [ + "bug-228" + ], + "occurrences": 1, + "last_seen": "2026-07-15T23:30:00Z" + }, + { + "id": "bug-248", + "timestamp": "2026-07-15T23:30:00Z", + "error_message": "baseline suppressed NEW findings of the same class in the same file \u2014 a second hardcoded password in a baselined file never gated", + "file": "cli/writers/baseline.mjs", + "root_cause": "fingerprint = code|path|normalized-message collides for same-class findings in one file, and the baseline was a Set \u2014 one frozen instance suppressed unlimited future instances (security-relevant false negative)", + "fix": "baseline format v2: fingerprint \u2192 occurrence count; guard consumes a per-run budget (deterministic validator order), so freezing 1 suppresses exactly 1 and the N+1th instance surfaces", + "tags": [ + "baseline", + "false-negative", + "security", + "fingerprint-collision", + "review-H2" + ], + "related_bugs": [], + "occurrences": 1, + "last_seen": "2026-07-15T23:30:00Z" + }, + { + "id": "bug-249", + "timestamp": "2026-07-15T23:30:00Z", + "error_message": "report (the audit evidence bundle) and ci hid baseline suppression entirely \u2014 'No findings' while N were frozen", + "file": "cli/commands/report.mjs", + "root_cause": "runGuardInternal applies the baseline before report/ci read the data; baselineSuppressed existed on guard data but was never propagated to the report payload, markdown, ci JSON/text, or history entries", + "fix": "baselineSuppressed now in report payload (hashed) + markdown summary row + hedged no-findings line, ci JSON/text, and history entries", + "tags": [ + "report", + "baseline", + "disclosure", + "false-green", + "review-H3" + ], + "related_bugs": [], + "occurrences": 1, + "last_seen": "2026-07-15T23:30:00Z" + }, + { + "id": "bug-250", + "timestamp": "2026-07-15T23:30:00Z", + "error_message": "JUnit showed all-green (failures=0) for a crashed validator; ci exit codes diverged from guard under severity overrides; threshold failures logged as PASS; report integrity hash unstable across clones (mtime-based ALCOA Contemporaneous)", + "file": "cli/writers/junit.mjs", + "root_cause": "four medium review findings (M1-M3, L3): junit keyed failures off error findings only (crash path has string errors, no findings); ci gated on raw counts while guard gates on effective; ci status ignored threshold/fail-on-warning; ALCOA mtime attribute was inside the hashed payload", + "fix": "junit crash path renders + errors=N; ci uses effectiveErrors/effectiveWarnings and derives exit code from the same status as history/JSON; integrity hash scope excludes the alcoa section (documented)", + "tags": [ + "junit", + "ci", + "exit-codes", + "integrity-hash", + "review-M1-M3-L3" + ], + "related_bugs": [], + "occurrences": 1, + "last_seen": "2026-07-15T23:30:00Z" } ] } \ No newline at end of file diff --git a/.wolf/cerebrum.md b/.wolf/cerebrum.md index e1ee21d..9836fc4 100644 --- a/.wolf/cerebrum.md +++ b/.wolf/cerebrum.md @@ -12,6 +12,7 @@ ## Key Learnings +- **2026-07-15 (market research — AI-age repo documentation, pre-v0.33 release)** — (1) ETH Zurich study (138 repos/5,694 agent PRs, HN 47034087/47295454): the POPULAR AGENTS.md style HURTS agent performance; best case ≈ +4%. Practitioner consensus (HN 47481682, 47938417): "explicit instructions are routinely ignored — programmatic enforcement (AST checks, pre-commit hooks) is the only reliable path." This is DocGuard's thesis verbatim → now cited in README Why-DocGuard + PHILOSOPHY Academic Foundations. (2) llms.txt reality (ppc.land, aeoengine 2026): web AI crawlers essentially never fetch it (97% of files get zero requests; Google explicitly won't support it) — but IDE agents (Cursor/Claude Code/Copilot) and MCP DO use it. Don't oversell llms.txt for SEO/citations; sell it for in-repo agent context. (3) Docs-tooling market direction (Mintlify/GitBook/Swimm 2026 roundups): "weight the auto-sync story more heavily than generation quality" — buyers care about staying-true, not first-draft. DocGuard's sync+guard is the right bet. (4) Stack Overflow blog 2026: AI coding → 4x code cloning, less refactoring — supports architecture/traceability governance pitch. Dogfooding gaps FIXED this session: ci.yml now smokes report/ci/trend/junit/baseline; FAQ gained the brownfield-adoption Q; configuration.md gained the baseline key; README What's-New was rotting at "v0.16→v0.19" (!) — refreshed to v0.29→v0.33. - **Ignore wiring map (as of fcc7264):** all `shared-ignore` consumers — incl. `routes.mjs:90` and `todo-tracking.mjs:307,383` — call `shouldIgnore`. `project-type.mjs` did NOT until the B1b fix (2026-06-02): its `walk()` in `findManifests` now honors config.ignore (prunes dirs + skips manifest files via shouldIgnore). It detects frameworks from **dependency manifests** (`has(deps,'express'/'flask')`), so before the fix it ingested fixture `package.json`/`requirements.txt`. Two different mechanisms: glob filtering on results vs. `DEFAULT_IGNORE_DIRS` (bare dir-name Set) during walks. - **`globToRegex` (shared-ignore.mjs:115) silently mishandles trailing-slash dir patterns.** Verified by execution: `buildIgnoreFilter(['tests/'])('tests/fixtures/x.js')` → `false`; `['tests']` and `['tests/**']` → `true`. Gitignore-style `dir/` (the natural form) matches nothing. This makes ignore look wired-but-broken across every consumer. (Fixed 2026-06-02 by stripping the trailing slash.) - **The dispatcher (docguard.mjs) installs DocGuard's own agent tooling as a side effect.** `ensureSkills()` runs on every command except `setup`/`init` and except when `headless` (= jsonMode||write||checkOnly||changedOnly||quiet||plan after the B4 fix). It writes `.agent/skills` + `.agent/commands` and can spawn the `specify` CLI (→ `.specify/` + pip/npm). So read-shaped commands must be added to the `headless` set to stay side-effect-free. `headless` is consumed in exactly two places: the banner and this ensureSkills gate. @@ -41,6 +42,7 @@ ## Do-Not-Repeat +- **2026-07-15** — `docguard ci` was silently routed through `runInit --with ci` since v0.20: the pipeline gate scaffolded missing docs into the workspace and corrupted `--format json` stdout with init chrome (bug-228). Un-deprecated in v0.33 (direct runCI dispatch). Lesson: when a command is deprecated-with-routing, verify the ROUTE preserves the command's contract (read-only? machine stdout?) — a passing test suite didn't catch it because the consolidation test only asserted the warning text, not the side effects. - **2026-07-03** — Adding a machine output format to a command is TWO changes, not one: the format branch in the command AND the dispatcher's `jsonMode` gate (docguard.mjs ~line 515). `guard --format sarif` initially emitted the banner + ensureSkills chrome INTO the SARIF stream because `jsonMode` only recognized `'json'` — caught by the spawn test's no-ANSI assertion before ship. Any future format (`junit`, `csv`, …) must join that gate or stdout is corrupted for parsers. Same class as the READ_ONLY_COMMANDS gotcha (learning #17). - **2026-07-03** — `agents --sync` marker contract (v0.29): generated agent-family files carry `docguard:agents-sync source=AGENTS.md hash=` (JSON targets: `_docguardSync` field instead — comments break JSON; .mdc: marker goes AFTER frontmatter, Cursor requires frontmatter first-bytes). Semantics: marked = ours to regenerate; unmarked-existing = user content, NEVER overwrite without --force; --check gates ONLY marked files (exit 2 stale, unmarked/absent = "unmanaged", not failure). If a future validator wants to enforce family freshness in guard, reuse extractMarkerHash/sourceHash from agents.mjs — don't invent a second marker. @@ -67,6 +69,10 @@ ## Decision Log +- **2026-07-15 (pre-release adversarial review — 12 findings, all fixed + regression-tested)** — Independent agent review of the v0.33 batch caught 3 HIGH: (H1) restoring `ci` as first-class REQUIRES adding it to READ_ONLY_COMMANDS too — dispatch and the ensureSkills gate are separate lists (bug-247); (H2) a count-less baseline Set suppresses UNLIMITED future same-fingerprint findings — same code+file+message-shape collides (two secrets in one file) → format v2 fingerprint→count with per-run budget (bug-248); (H3) surfaces that CONSUME runGuardInternal (report/ci) must explicitly propagate baselineSuppressed — the data flows through but disclosure doesn't (bug-249). MEDIUMs (bug-250): junit failure detection must key off v.status too (crash path has string errors, no findings); ci must gate on effective* like guard; ci status must fold in threshold/fail-on-warning (was logging PASS for failed gates); integrity hash must exclude anything mtime/wall-clock-derived (ALCOA Contemporaneous) or 'same commit ⇒ same hash' is false on fresh clones. META-LESSON: every finding was in code all 1012 tests passed on — the tests asserted the happy paths I designed; the reviewer attacked the paths I didn't. Adversarial review before release is not optional for gate/evidence features. +- **2026-07-15 (iteration 4 — adoption baseline)** — `.docguard.baseline.json` at REPO ROOT (committed; .docguard/ is gitignored so a baseline there would be per-checkout and useless). Fingerprint = sha256(code|location-path-sans-line|message-with-digits→#) first 16 hex — survives line churn + volatile counts. Applied INSIDE runGuardInternal before all tallies (guard/ci/report/sarif/junit/mcp uniform); only findings-backed results (errors+warnings === findings.length guard) — string-only legacy validators can't be fingerprinted. Malformed baseline = absent (never un-gates CI). GOTCHA FOUND: writing the baseline immediately tripped DCV001 (undocumented config file) about the baseline itself — DocGuard's own files (.docguard.json/.docguardignore/.docguard.baseline.json) now exempt in docs-coverage COMMON_DOTFILES. TEST GOTCHA: a bare fixture's security validator flags NOTHING for a hardcoded secret in src/ (no scan without more setup) — use delete-a-doc (new STR001 path fingerprint) as the deterministic new-finding vector. Config: "baseline": false + --no-baseline; schema prop added WITHOUT bumping CURRENT_SCHEMA_VERSION (additive optional, historical precedent). Empirical: taxes_helper 1→0 exit, 49 fingerprints/64 findings suppressed, --no-baseline restores. +- **2026-07-15 (iteration 3 — integration formats)** — `guard --format junit` (writers/junit.mjs: testcase-per-validator, error→, warn→, na→; exit codes = json/sarif branches; joined the docguard.mjs jsonMode gate per the Do-Not-Repeat rule — that gate now reads json|sarif|junit) + CI-RECIPES Recipe 1b (GitLab artifacts:reports:junit, Jenkinsfile). `docguard_report` MCP tool (buildReport reused; tool count 5→6 — BOTH mcp.test.mjs and mcp-http.test.mjs assert the tool list, update both). Empirical: valid XML on globalshares (27 tests/0 fail/10 skipped). Tests 1001→1005. NEXT-ITERATION candidate chosen: `guard --baseline` (ESLint/semgrep-style adoption baseline — legacy repos adopt without day-one failures, only NEW findings gate); REJECTED: report --html (marginal vs markdown), ci --webhook (breaks the nothing-leaves-your-infra claim). +- **2026-07-15 (v0.33 enterprise-evidence batch — /loop session)** — GitBook gap analysis → enterprise features, all into [Unreleased]: (1) **`docguard report`** — compliance-evidence bundle (guard+score+ALCOA+findings-by-code+fix-history, git-stamped, sha256 integrity hash excluding generatedAt so same tree = same hash; ALWAYS exit 0 — evidence never self-censors; headless in both formats). computeAlcoaCompliance exported from score.mjs; getHeadInfo added to shared-git. (2) **Score history** — writers/history.mjs (JSONL, append O(1), silent-fail, 256KB trim), `ci` appends per run (`--no-history` opt-out), `score --trend` sparkline+delta+last-10; CI-RECIPES Recipe 4b (actions/cache persistence). (3) **`ci` un-deprecated + pipe-truncation fix** (bug-228). Command count 18→20 (report, ci) — README prose+table+mermaid all updated (the three self-guard flags SSY002/CSY002/CSY004 fired exactly as cerebrum predicted, then cleared). (4) Phantom-completion was NOT built — already covered by `trace --features` task-evidence (checked tasks naming nonexistent files); verified before building, avoided a redundant validator + self-count ripple. (5) COMPARISONS.md gained "CDD vs Docs Platforms" (positioning: platforms publish, DocGuard proves; audit-trail differentiator — GitBook/ReadMe ship no audit logs at any tier). REJECTED for scope: hosted dashboard (Phase 5), multi-repo org sweep (defer until demand), PDF export. Tests 984→1001 (7 report + 10 history; one v020-consolidation test rewritten to assert the new ci contract). EMPIRICAL PASS (iteration 2, read-only, per the mandated method): `report --format json` valid + sensible on hugocross_revamp (98 A+, 0E/4W), taxes_helper (35 D, 6E/58W — FAIL state still exits 0), stablecoin-strategy-lab (92 A), globalshares_revamp (98 A+, 167W); markdown clean; `score --trend` hint correct with no history; `ci --no-history` exits 2 on WARN and writes nothing. ORG SWEEP: CUT — a 3-line shell loop over `ci --format json` covers it (documented as CI-RECIPES Recipe 4c); first-class cross-repo stays Phase 5 dashboard. - **2026-07-11 (v0.32.0 assembled — full "do it all" session)** — After the detection batch, the user approved the whole follow-on list; everything landed on PR #314 (branch claude/graphify-docguard-review-8a64ca, 10 commits): housekeeping (doc tiers 30→11 outside; TEST-SPEC 10s→30s claim), `hooks --claude` agent nudge (opt-in PostToolUse in project .claude/settings.json; runtime `nudge-hook` is headless + in READ_ONLY_COMMANDS; silent-on-error; 30-min/file throttle in .docguard/nudge-state.json), MCP Streamable HTTP (`--transport http`; non-loopback bind REFUSES without --api-key; shared dispatchMessage for both transports), `impact --prs` (pure computeDocConflicts + gh at the edge), VALIDATION.md (corpus repos described by stack, never client names — public repo). NEW BUG CLASS FOUND (bug-212 family): scanners that enumerate docs WITHOUT loadIgnorePatterns — fixed semantic-claims + agent-readability; grep for readdirSync('docs-canonical') when adding any doc scanner. GOTCHA: `hooks` is a DEPRECATED alias routed through init --with hooks; `hooks --claude` bypasses via a flags.claude branch + deprecation-warning exemption. GOTCHA: the auto-mode classifier BLOCKED `gh pr merge` (self-approval) exactly as predicted — release is parked on the user clicking merge on #314; after merge: watch release.yml, bump homebrew-tap sha256, ff-only pull the main tree, submit spec-kit catalog form, remind mcp-publisher (server.json already at 0.32.0). Addressability metric left at 43% DELIBERATELY — the remaining >120-tok sections are schema tables/mermaid; fragmenting them = metric-gaming (ALCOA honesty lesson). - **2026-07-11 (graphify batch APPLIED — [Unreleased])** — Items from the graphify review shipped same-day: (1) REF002 ADR-citation check inside reference-existence (validator count stays 27 — deliberate, avoids the self-count split); comment-gate + non-product scoping + integer number compare; RFC out of scope. (2) Wikilinks in cross-reference (XRF001/002) + impact blast radius, gated on evidence (`.obsidian/` or ≥1 resolving target) because `[[name]]` is also a non-file convention (our own memory links!); plus query-string strip + angle-bracket targets. (3) `impact` indirect mode: reverse-import BFS (2 hops) over the EXPORTED `buildImportGraph` from architecture.mjs (one builder); hub suppression at out-degree >15 was REQUIRED — without it cli/docguard.mjs flagged 7 docs on a 3-commit range, with it 2 genuine chains; `--no-indirect` opt-out. (4) Traceability consumes committed `graphify-out/graph.json` as TRC002 evidence — EXTRACTED edges only, linked file must exist, evidence-only (never a finding). Item 5 (cache portability) audited = already clean (.docguard/ gitignored, mtime-keyed cache is inherently local). Item 6 (PreToolUse nudge hooks) DEFERRED — writes to user agent configs, trust-sensitive class, needs Ricardo's call. Empirical read-only validation on 5 real repos: 0 FPs everywhere; indirect impact gave 6 explainable chains on globalshares (74 changed files) and 1 on hugocross. Tests 937→959. GOTCHAS: (a) writing `ADR-` in DocGuard's own comments trips REF002 — use ADR-NNN placeholders (bug-211); (b) referencing a dotfile like `.obsidian` in validator code trips DCV004 until a user-facing doc (README table) mentions it — CHANGELOG doesn't count. - **2026-07-11 (Graphify-Labs/graphify review — idea harvest, no code)** — Reviewed graphify (YC S26, MIT, Python ~50k LOC): tree-sitter knowledge graph of code+docs, LLM only for docs/media. NOT a competitor — it answers "how is code connected", DocGuard answers "do docs match code". Adoptable (all zero-dep-compatible): (1) **ADR/RFC citation check** — graphify's `extract.py:1087` regex `\b(ADR[- ]?\d{1,5}|RFC[- ]?\d{1,5})\b` in code comments, normalized to `ADR-0011`; DocGuard ships an ADR template but never verifies code citations point to existing ADR docs → natural REF-family extension (code→doc direction of reference-existence). (2) **`[[wikilink]]` support** — graphify's markdown extractor resolves wikilinks (extension-less → sibling .md) + strips `#anchor`/`?query` before resolution + `(?` (red in every CI UI), a crashed validator renders as `` (also red — never a silent pass), warn-only validators pass with findings in ``, skipped/N/A validators map to ``. Strict XML escaping, exit codes identical to `guard`'s json/sarif branches, banner-free stdout. GitLab and Jenkins recipes added to CI-RECIPES (Recipe 1b). + +- **`docguard_report` MCP tool.** The compliance-evidence bundle is now callable by agents: same payload as `docguard report --format json` (guard verdict, findings by code, score, ALCOA+, fix history, integrity hash), read-only annotations, over both stdio and HTTP transports. MCP tool count 5 → 6. + +- **Score history + `score --trend`.** Every `docguard ci` run appends one line to `.docguard/history.jsonl` ({timestamp, commit, score, grade, guard counts, status}); `docguard score --trend` renders the trajectory — sparkline, first→latest delta, and the last 10 runs with commit stamps (`--format json` for the raw series). Opt out per run with `ci --no-history`. Local-first: `.docguard/` stays gitignored; in ephemeral CI, persist the file across runs with a cache/artifact step (see CI-RECIPES). The append is silent-on-failure — recording history never fails the pipeline it records. + +- **`docguard report` — compliance-evidence bundle for audits.** Runs guard + score internally and emits a deterministic evidence report: tool version, git commit/branch/dirty state, per-validator guard verdict, findings grouped by stable code, CDD score with categories, the ALCOA+ data-integrity attribute table, and the mechanical-fix history from `.docguard/fixed.json`. The bundle carries a tamper-evident `sha256` integrity hash over the git-stable sections of the payload (the generation timestamp and the ALCOA+ block are excluded — the latter's Contemporaneous attribute is mtime/wall-clock-relative — so the same commit always reproduces the same hash, including on a fresh clone). Markdown to stdout by default, `--format json` for the machine bundle, `--out ` to write either to a file. Report is **evidence, not a gate**: it always exits 0 — `guard` and `ci` remain the commands that fail builds, so evidence collection never self-censors. Headless in both formats (stdout is the artifact; no banner bytes). Enterprise rationale: docs platforms ship no audit logs at any tier — DocGuard now generates a documentation audit trail from the repo itself. + +### Fixed + +- **Pre-release adversarial review — 12 findings fixed before ship.** An independent review pass over this release's batch caught and fixed, among others: `ci` missing from the read-only command set (a bare text-mode `ci` still scaffolded skills into the workspace it gates); baseline fingerprints suppressing NEW same-class findings in the same file (now occurrence-counted); `report`/`ci` not disclosing baseline suppression (now in the payload, the markdown summary, and history entries); JUnit rendering a crashed validator as a passing testcase; `ci` gating on raw instead of severity-aware counts (it now matches `guard` exactly); threshold failures recorded as `PASS` in history; `--update-baseline --changed-only` shrinking the committed baseline to the lite validator subset (now refused); and a non-atomic history trim. Every fix ships with a regression test. + +- **`docguard ci` un-deprecated and made safe for pipelines.** The v0.20 consolidation routed `ci` through `init --with ci`, which (a) scaffolded missing canonical docs INTO the CI workspace — a validate command mutating the tree it validates — and (b) printed the deprecation warning + init chrome into `--format json` stdout, corrupting it for parsers. `ci` now dispatches straight to the gate (guard + score, read-only, machine-clean) and is a first-class command again; `init --with ci` still runs the gate once after init. `runCI` also moved off `console.log + process.exit` to `stdout.write + process.exitCode` — the >8 KB pipe-truncation class fixed for `guard --format json` in v0.28. + ## [0.32.0] - 2026-07-11 Graph-informed release — detection improvements and integration surfaces diff --git a/COMPARISONS.md b/COMPARISONS.md index 16f7842..490d24a 100644 --- a/COMPARISONS.md +++ b/COMPARISONS.md @@ -68,6 +68,25 @@ --- +### CDD vs Docs Platforms (GitBook, ReadMe, Docsie) + +| Dimension | Docs platforms | CDD (DocGuard) | +|-----------|---------------|-----------------| +| **What it is** | Hosted publishing: editor, docs site, search | Governance engine: validators, drift detection, CI gates | +| **Unit of value** | A published page | A verified claim that docs match code | +| **Staleness detection** | Probabilistic AI scan (e.g. GitBook Agent, beta) | 27 deterministic validators, stable finding codes, SARIF | +| **Where docs live** | Their cloud (bi-directional git sync at best) | Your repo — git IS the editor and the audit trail | +| **Audit trail** | None at any tier (GitBook/ReadMe, 2026) | `docguard report` — commit-stamped evidence bundle with integrity hash | +| **Trend/analytics** | Reader analytics (page views, search misses) | Score history + `score --trend` (engineering trajectory) | +| **AI surface** | Q&A assistant over whatever is written | MCP server over docs *proven* current at guard time | +| **Enterprise trust** | SOC 2 / SSO / RBAC on their platform | Runs entirely local/CI — nothing leaves your infra | + +**Verdict**: Complementary, not competing. Platforms publish; DocGuard proves. +The strongest setup gates the platform publish on `docguard ci` — readers only +ever see docs that passed validation. + +--- + ## Part 2: How LLMs ACTUALLY Work With Project Context > This section exists because the user rightly asked: "Will this actually work with how LLMs process code?" diff --git a/PHILOSOPHY.md b/PHILOSOPHY.md index 58c419c..6bbf384 100644 --- a/PHILOSOPHY.md +++ b/PHILOSOPHY.md @@ -115,6 +115,7 @@ CDD is a practitioner methodology whose patterns align with peer-reviewed resear - **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. - **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). - **Standards-grounded generation** — each canonical document maps to a relevant standard (arc42, C4, OWASP ASVS, ISO 29119, OpenAPI, 12-Factor App). +- **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. > **Lead researcher**: [Martin Manuel Lopez](https://github.com/martinmanuel9) · [ORCID 0009-0002-7652-2385](https://orcid.org/0009-0002-7652-2385), University of Arizona diff --git a/README.md b/README.md index 55303c4..8d1277e 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ - [Slash Commands](#-slash-commands) - [Examples](#-examples) - [Testing](#-testing) +- [Enterprise Adoption](#-enterprise-adoption) - [CI/CD Integration](#%EF%B8%8F-cicd-integration) - [What's New](#-whats-new) - [File Structure](#-file-structure) @@ -68,7 +69,7 @@ DocGuard is an official [GitHub Spec Kit](https://github.com/github/spec-kit) co ```mermaid graph TD - CLI["CLI Entry
docguard.mjs"] --> Commands["Commands (18)"] + CLI["CLI Entry
docguard.mjs"] --> Commands["Commands (20)"] Commands --> guard["guard"] Commands --> generate["generate"] Commands --> score["score"] @@ -108,6 +109,14 @@ against what the code does, on every commit, with no LLM required. The full thesis (and the research behind it) lives in [PHILOSOPHY.md](PHILOSOPHY.md); recent feature highlights moved [below](#-whats-new). +The field data backs the enforcement-over-instructions bet: an ETH Zurich +study across 138 repos / 5,694 agent PRs found the most popular style of +agent-instruction file *hurts* agent performance, and practitioners keep +converging on the same lesson — written rules are routinely ignored; +programmatic checks are what agents (and humans) actually respect. That is +exactly the layer DocGuard provides: not another instructions file, but the +validator suite that makes the instructions and docs verifiably true. + --- ## ⚡ Quick Start @@ -247,7 +256,7 @@ This installs DocGuard's slash commands (`/docguard.init`, `/docguard.guard`, `/ ## Usage -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 `. 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). +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 `. 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). **The Daily 5** — what you'll reach for 95% of the time: @@ -274,7 +283,10 @@ DocGuard ships **18 commands** (the "Daily 5" + 13 situational tools, including | `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 | | `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 | | `feedback` | Report likely false positives back to DocGuard — local-first record + a 1-click prefilled, redacted GitHub issue (zero typing) | -| `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`) | +| `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`) | +| `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 `). Evidence, not a gate: always exits 0 | +| `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 ` fails below a score, `--fail-on-warning` for strict mode, `--format json` for parsers | +| `score --trend` | Score trajectory from recorded `ci` runs — sparkline, delta, and the last 10 runs with commit stamps | | `memory` | Per-domain accuracy headline (endpoints / entities / env / tech) | | `memory --diff` | Drill into which specific claims don't match code | | `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 | `--quiet` / `-q` | Suppress banner — for hooks, CI loops, scripts | All | | `--format json` | Machine-readable output (clean JSON, no ANSI bleed) | guard, score, diff, trace, diagnose, memory, impact, explain | | `--format sarif` | SARIF 2.1.0 output — findings as rules/results for GitHub Code Scanning and SARIF dashboards | guard | +| `--format junit` | JUnit XML output — one testcase per validator, for GitLab CI (`artifacts:reports:junit`), Jenkins, Azure DevOps, CircleCI | guard | +| `--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 | | `--full` | Generate `llms-full.txt` (full doc bodies inlined) instead of the `llms.txt` link index | llms | | `--pack` | Write `.docguard/context-pack.md` — agent session-start context | memory | | `--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 --- +## 🏢 Enterprise Adoption + +Everything runs local or in your CI — no SaaS, no data leaving your infra. +The pieces that matter at company scale: + +| Need | DocGuard answer | +|------|-----------------| +| **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) | +| **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 | +| **Every CI system**, not just GitHub | `guard --format sarif` (GitHub Code Scanning) · `--format junit` (GitLab, Jenkins, Azure DevOps, CircleCI) · `--format json` (anything else) | +| **Trajectory, not snapshots** | `docguard ci` records every run to `.docguard/history.jsonl`; `score --trend` shows the sparkline + delta | +| **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 | +| **Data-integrity framing auditors know** | ALCOA+ scoring (FDA 21 CFR Part 11 / EMA Annex 11 vocabulary) built into `score` and `report` | + ## ⚙️ CI/CD Integration > **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 ## ✨ What's New -Recent highlights across the v0.16 → v0.19 line: - -- **`docguard explain `** — `docguard explain freshness` prints purpose, rules, common - failures, and fix recipes for any of the 27 validators. No need to dig into source. -- **`docguard memory --diff`** — surface what changed in your canonical docs between two refs - (`HEAD~10..HEAD` by default). Great for code review and changelog drafting. -- **`docguard score --diff`** — see exactly which validators moved the score up or down between - two commits. Pinpoints regressions without re-running the full suite by hand. -- **`docguard upgrade --apply --pr`** — when the config schema bumps, DocGuard migrates - `.docguard.json` for you and (optionally) opens a PR with the change. -- **Language-aware traceability** — both `docguard trace` *and* the guard-time Traceability validator - understand Python, Rust, Go, Java, Ruby, and PHP layouts in addition to JS/TS, via a shared pattern - set (`cli/shared-trace-patterns.mjs`) so the two never drift apart. -- **Per-validator severity overrides** — escalate `freshness` to `high` for production repos, - demote `doc-quality` to `low` for prototypes. Configurable per-project. -- **JSON Schema for `.docguard.json`** — IDE autocomplete, in-line docs, and validation via - `$schema`. Shipped in the package at `schemas/docguard-config.schema.json`. -- **Version pin (`docguardVersion` + `--pin`)** — pin the CLI version your project supports so - CI fails loudly if someone bumps DocGuard without re-running the suite. -- **Cross-process plan cache** — repeated runs reuse the validator plan across processes when - the working tree hasn't changed. ~30% faster guard runs on typical repos. -- **Headless-aware banner** — `--quiet`, `--format json`, `--write`, and `--changed-only` - automatically suppress the banner so JSON output stays parse-clean. -- **npm-pack smoke gate** — every release now extracts the actual tarball and runs the CLI - end-to-end before publish, catching missing-file regressions. +Highlights of the current line (v0.29 → v0.33): + +- **Adoption baseline** — `guard --update-baseline` freezes a legacy repo's existing findings + into a committed `.docguard.baseline.json`; guard/ci then gate only NEW drift, with suppression + always visible. Adopt today, burn down at your own pace. +- **`docguard report`** — commit-stamped compliance-evidence bundle (guard verdict, findings by + code, CDD score, ALCOA+ attributes, fix history) with a tamper-evident sha256 integrity hash. + Also exposed as the `docguard_report` MCP tool. +- **Score history + `score --trend`** — `docguard ci` records every run to + `.docguard/history.jsonl`; the trend view shows the sparkline and delta over time. +- **Three machine formats for guard** — `--format json`, `--format sarif` (GitHub Code + Scanning), and `--format junit` (GitLab, Jenkins, Azure DevOps, CircleCI). +- **MCP server, stdio + team HTTP** — guard/score/explain/verify/report/diagnose as read-only + agent tools: `claude mcp add docguard -- npx docguard-cli mcp`. +- **Agent-file family sync** — `agents --sync` treats AGENTS.md as canonical and regenerates + CLAUDE.md / `.cursor/rules` / Copilot / Gemini variants with drift-proof source-hash markers. +- **`verify --semantic` and `verify --instructions`** — extract documented numbers/limits/enums + as agent verification tasks; audit the agent-instruction files themselves for contradictions + and stale pointers. +- **`docguard agent`** — one-shot ordered task graph with pre-filled code-truth, collapsing ~10 + agent round-trips into one call. See [CHANGELOG.md](CHANGELOG.md) for the full history. diff --git a/ROADMAP.md b/ROADMAP.md index f1516c6..04fb314 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -123,13 +123,16 @@ Deep integration with development tools and platforms. ## Phase 5: Dashboard 💭 -Web-based CDD governance for teams and organizations. +Web-based CDD governance for teams and organizations. The CLI-side +foundations shipped first (local-first, no SaaS dependency): score history + +`score --trend` records the trajectory per checkout, and `docguard report` +produces the commit-stamped compliance-evidence bundle. - [ ] Web dashboard showing CDD scores across repos -- [ ] Historical trend graphs +- [x] Historical trends — CLI-side via `.docguard/history.jsonl` + `score --trend` (dashboard graphs still future) - [ ] Team leaderboards - [ ] Drift alerts (Slack/email) -- [ ] Compliance reports (PDF export) +- [x] Compliance reports — `docguard report` (markdown/JSON evidence bundle with integrity hash; PDF export still future) --- diff --git a/cli/commands/ci.mjs b/cli/commands/ci.mjs index 0dea310..77c459a 100644 --- a/cli/commands/ci.mjs +++ b/cli/commands/ci.mjs @@ -6,11 +6,18 @@ * 0 = All pass, score meets threshold * 1 = Guard errors or score below threshold * 2 = Guard warnings only + * + * v0.33: each run appends one line to `.docguard/history.jsonl` (score, + * grade, commit, guard counts) so `docguard score --trend` can show the + * trajectory. Opt out with `--no-history`. The append is silent-on-failure — + * recording history must never fail the pipeline it records. */ import { c } from '../shared.mjs'; import { runGuardInternal } from './guard.mjs'; import { runScoreInternal } from './score.mjs'; +import { appendHistory } from '../writers/history.mjs'; +import { getHeadInfo, isGitRepo } from '../shared-git.mjs'; export function runCI(projectDir, config, flags) { const threshold = parseInt(flags.threshold || '0', 10); @@ -26,12 +33,41 @@ export function runCI(projectDir, config, flags) { // ── Run guard (internal — no subprocess) ── const guardData = runGuardInternal(projectDir, config); - const hasErrors = guardData.errors > 0; - const hasWarnings = guardData.warnings > 0; + // Severity-aware effective counts (M2): `guard` gates on these, so `ci` + // must too — a severity=low demotion or severity=high escalation has to + // produce the same verdict from both commands. + const hasErrors = guardData.effectiveErrors > 0; + const hasWarnings = guardData.effectiveWarnings > 0; // ── Get score ── const scoreData = runScoreInternal(projectDir, config); + // Status reflects EVERY gate, not just guard (L3): a threshold or + // --fail-on-warning failure exits 1 and must not be recorded as PASS in + // history or the JSON consumers parse. + const thresholdMet = threshold <= 0 || scoreData.score >= threshold; + const status = + hasErrors || !thresholdMet || (failOnWarning && hasWarnings) ? 'FAIL' + : hasWarnings ? 'WARN' + : 'PASS'; + + // ── Record history (unless opted out) ── + if (!flags.noHistory) { + const git = isGitRepo(projectDir) ? getHeadInfo(projectDir) : null; + appendHistory(projectDir, { + timestamp: new Date().toISOString(), + commit: git ? git.commit.slice(0, 12) : null, + score: scoreData.score, + grade: scoreData.grade, + errors: guardData.errors, + warnings: guardData.warnings, + baselineSuppressed: guardData.baselineSuppressed || 0, + passed: guardData.passed, + total: guardData.total, + status, + }); + } + // ── Output ── if (isJson) { const result = { @@ -44,14 +80,18 @@ export function runCI(projectDir, config, flags) { passed: guardData.passed, total: guardData.total, status: guardData.status, + baselineSuppressed: guardData.baselineSuppressed || 0, validators: guardData.validators.filter(v => v.status !== 'skipped'), }, threshold, - thresholdMet: threshold <= 0 || scoreData.score >= threshold, - status: hasErrors ? 'FAIL' : hasWarnings ? 'WARN' : 'PASS', + thresholdMet, + status, timestamp: new Date().toISOString(), }; - console.log(JSON.stringify(result, null, 2)); + // Machine output must survive a pipe: stdout.write + natural exit, never + // console.log + process.exit (>8 KB payloads truncate mid-flush — same + // class as the guard --format json bug fixed in v0.28). + process.stdout.write(JSON.stringify(result, null, 2) + '\n'); } else { // Text output const guardStatus = hasErrors @@ -61,20 +101,19 @@ export function runCI(projectDir, config, flags) { : `${c.green}✅ PASS${c.reset}`; console.log(` ${c.bold}Guard:${c.reset} ${guardStatus} (${guardData.passed}/${guardData.total})`); + if (guardData.baselineSuppressed > 0) { + console.log(` ${c.dim}📋 ${guardData.baselineSuppressed} pre-existing finding(s) suppressed by the committed baseline${c.reset}`); + } console.log(` ${c.bold}Score:${c.reset} ${scoreData.score}/100 (${scoreData.grade})`); if (threshold > 0) { - const met = scoreData.score >= threshold; - console.log(` ${c.bold}Threshold:${c.reset} ${met ? `${c.green}✅ ≥${threshold}` : `${c.red}❌ <${threshold}`}${c.reset}`); + console.log(` ${c.bold}Threshold:${c.reset} ${thresholdMet ? `${c.green}✅ ≥${threshold}` : `${c.red}❌ <${threshold}`}${c.reset}`); } console.log(''); } - // Exit code determination - if (hasErrors) process.exit(1); - if (threshold > 0 && scoreData.score < threshold) process.exit(1); - if (failOnWarning && hasWarnings) process.exit(1); - if (hasWarnings) process.exit(2); - process.exit(0); + // Exit code follows `status` exactly — one derivation, no drift between + // what history/JSON record and what the pipeline does. + process.exitCode = status === 'FAIL' ? 1 : status === 'WARN' ? 2 : 0; } diff --git a/cli/commands/guard.mjs b/cli/commands/guard.mjs index ea322d3..6641007 100644 --- a/cli/commands/guard.mjs +++ b/cli/commands/guard.mjs @@ -16,6 +16,8 @@ import { checkUpgradeStatus } from './upgrade.mjs'; import { changedFilesSince, isGitRepo } from '../shared-git.mjs'; import { extractSemanticClaims } from '../scanners/semantic-claims.mjs'; import { toSarif } from '../writers/sarif.mjs'; +import { toJUnit } from '../writers/junit.mjs'; +import { loadBaseline, saveBaseline, fingerprintFinding, BASELINE_FILE } from '../writers/baseline.mjs'; import { readFileSync, writeFileSync, existsSync } from 'node:fs'; import { resolve as resolvePath, relative as relativePath } from 'node:path'; import { fileURLToPath as fp } from 'node:url'; @@ -393,6 +395,42 @@ export function runGuardInternal(projectDir, config) { } } + // ── Adoption baseline (v0.33) ── + // If the repo committed `.docguard.baseline.json`, findings frozen at + // adoption time are suppressed BEFORE any tally — so exit codes, severity + // rollups, json/sarif/junit, ci, and report all gate only NEW drift. + // Suppression is visible (baselineSuppressed in the payload + a display + // note), applies only to findings-backed results (legacy string-only + // errors/warnings can't be fingerprinted), and `--no-baseline` + // (config.baseline === false) turns it off. + let baselineSuppressed = 0; + const baselineMap = config.baseline === false ? null : loadBaseline(projectDir); + if (baselineMap) { + // Occurrence budget: each fingerprint suppresses at most its frozen + // count (H2). Validators run in a fixed order, so consumption is + // deterministic — the same tree always suppresses the same instances. + const remaining = new Map(baselineMap); + for (const r of results) { + if (!Array.isArray(r.findings) || r.findings.length === 0) continue; + if (r.errors.length + r.warnings.length !== r.findings.length) continue; + const kept = r.findings.filter(f => { + const fp = fingerprintFinding(f); + const budget = remaining.get(fp) || 0; + if (budget <= 0) return true; + remaining.set(fp, budget - 1); + return false; + }); + const removed = r.findings.length - kept.length; + if (removed === 0) continue; + baselineSuppressed += removed; + r.findings = kept; + r.errors = kept.filter(f => f.severity === 'error').map(f => f.message); + r.warnings = kept.filter(f => f.severity !== 'error').map(f => f.message); + r.total = r.passed + kept.length; + Object.assign(r, classifyResult(r)); + } + } + const activeResults = results.filter(r => r.status !== 'skipped'); const totalErrors = activeResults.reduce((sum, r) => sum + r.errors.length, 0); const totalWarnings = activeResults.reduce((sum, r) => sum + r.warnings.length, 0); @@ -464,6 +502,7 @@ export function runGuardInternal(projectDir, config) { // things they've marked as high-severity. effectiveErrors, effectiveWarnings, + baselineSuppressed, coverage, semanticClaims, validators: results, @@ -555,6 +594,30 @@ export function runGuard(projectDir, config, flags) { console.log(`${c.cyan}⚡ docguard guard --changed-only${c.reset} ${c.dim}(${label})${c.reset}${escalatedNote}\n`); } + // ── `--update-baseline`: freeze the CURRENT full finding set ── + // Runs with the baseline disabled so the file captures everything visible + // today (updating through an active baseline would only ever shrink it). + if (flags.updateBaseline) { + // --changed-only rewrites config.validators to the 5-validator lite set; + // freezing THAT would silently shrink the committed team baseline to a + // subset (L1). Refuse the combination rather than corrupt the file. + if (flags.changedOnly) { + 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}`); + process.exitCode = 1; + return; + } + const fullData = runGuardInternal(projectDir, { ...config, baseline: false }); + const n = saveBaseline(projectDir, fullData.findings || []); + if (flags.format === 'json') { + process.stdout.write(JSON.stringify({ written: true, file: BASELINE_FILE, fingerprints: n, findings: (fullData.findings || []).length }, null, 2) + '\n'); + } else { + console.log(`${c.green}✅ Baseline written:${c.reset} ${BASELINE_FILE} (${n} fingerprint(s))`); + console.log(`${c.dim} Commit it. guard/ci now gate only NEW findings; --no-baseline shows everything.${c.reset}`); + } + process.exitCode = 0; + return; + } + const data = runGuardInternal(projectDir, config); // ── SARIF output (2.1.0) ── @@ -567,6 +630,17 @@ export function runGuard(projectDir, config, flags) { return; } + // ── JUnit XML output ── + // SARIF is GitHub's language; JUnit is everyone else's (GitLab + // artifacts:reports:junit, Jenkins junit step, Azure DevOps, CircleCI). + // Exit-code semantics identical to sarif/json. + if (flags.format === 'junit') { + const xml = toJUnit(data); + process.exitCode = data.effectiveErrors > 0 ? 1 : data.effectiveWarnings > 0 ? 2 : 0; + process.stdout.write(xml + '\n'); + return; + } + // ── JSON output ── if (flags.format === 'json') { // Use severity-aware effective counts for exit code; raw counts stay in the JSON @@ -671,6 +745,12 @@ export function runGuard(projectDir, config, flags) { console.log(` ${c.red}${c.bold}❌ FAIL${c.reset} ${c.red}— ${data.passed}/${data.total} passed, ${data.effectiveErrors} blocking issue(s)${warnSuffix}${c.reset}`); } + // Baseline suppression is always visible — a gate that hides findings + // silently is the false-green failure mode this tool exists to prevent. + if (data.baselineSuppressed > 0) { + console.log(` ${c.dim}📋 ${data.baselineSuppressed} pre-existing finding(s) suppressed by ${BASELINE_FILE} (--no-baseline to show)${c.reset}`); + } + // ── Next steps — every run ends with a suggested action (v0.27) ── // The field-report principle: whenever DocGuard calls out an issue it must // suggest what to do next; on a clean run it points at the next workflow step diff --git a/cli/commands/mcp.mjs b/cli/commands/mcp.mjs index c295176..5f8db04 100644 --- a/cli/commands/mcp.mjs +++ b/cli/commands/mcp.mjs @@ -27,6 +27,7 @@ import { resolve, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { runGuardInternal } from './guard.mjs'; import { runScoreInternal } from './score.mjs'; +import { buildReport } from './report.mjs'; import { loadConfig } from '../config.mjs'; import { CODES } from '../findings.mjs'; import { extractSemanticClaims, buildSemanticVerifyTasks } from '../scanners/semantic-claims.mjs'; @@ -110,6 +111,16 @@ const TOOLS = [ }, annotations: READONLY_ANNOTATIONS, }, + { + name: 'docguard_report', + title: 'Compliance-evidence bundle', + 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.', + inputSchema: { + type: 'object', + properties: { ...PROJECT_DIR_PROP }, + }, + annotations: READONLY_ANNOTATIONS, + }, { name: 'docguard_diagnose', title: 'Diagnose what to fix', @@ -169,6 +180,11 @@ const TOOL_HANDLERS = { }; }, + docguard_report(args, defaultDir) { + const { dir, config } = resolveTarget(args, defaultDir); + return buildReport(dir, config); + }, + docguard_diagnose(args, defaultDir) { const { dir, config } = resolveTarget(args, defaultDir); const data = runGuardInternal(dir, config); diff --git a/cli/commands/report.mjs b/cli/commands/report.mjs new file mode 100644 index 0000000..dc3807d --- /dev/null +++ b/cli/commands/report.mjs @@ -0,0 +1,200 @@ +/** + * Report Command — Compliance-evidence bundle for audits. + * + * `docguard report` runs guard + score internally and emits a deterministic + * evidence report: who/what/when (git commit, branch, tool version), the + * guard verdict per validator, the findings summary, the CDD score with its + * ALCOA+ data-integrity attributes, and the mechanical-fix history. An + * `integrity` sha256 over the canonical JSON payload makes the bundle + * tamper-evident — re-running `docguard report --format json` at the same + * commit reproduces the same evidence. The generation timestamp and the + * ALCOA+ section are excluded from the hash for that reason (both are + * wall-clock-relative; ALCOA's Contemporaneous attribute also depends on + * file mtimes, which reset on a fresh clone). + * + * Report is EVIDENCE, not a gate: it always exits 0. `guard` and `ci` remain + * the commands that fail builds. This split matters for auditors — evidence + * collection must not change behavior depending on what it observes. + * + * Output: markdown to stdout by default, `--format json` for the machine + * bundle, `--out ` to write either format to a file instead. + */ + +import { createHash } from 'node:crypto'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { resolve as resolvePath, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { c } from '../shared.mjs'; +import { runGuardInternal } from './guard.mjs'; +import { runScoreInternal, computeAlcoaCompliance } from './score.mjs'; +import { getHeadInfo, isGitRepo } from '../shared-git.mjs'; +import { loadFixMemory } from '../writers/fix-memory.mjs'; + +const _PKG = JSON.parse(readFileSync(resolvePath(dirname(fileURLToPath(import.meta.url)), '..', '..', 'package.json'), 'utf-8')); +const CLI_VERSION = _PKG.version; + +/** + * Build the evidence payload. Pure gather — no printing, no exit. The + * `integrity` hash covers everything EXCEPT `generatedAt` and the hash + * itself, so the same tree state always yields the same hash. + */ +export function buildReport(projectDir, config) { + const guardData = runGuardInternal(projectDir, config); + const scoreData = runScoreInternal(projectDir, config); + const alcoa = computeAlcoaCompliance(projectDir, config, scoreData.categories); + const git = isGitRepo(projectDir) ? getHeadInfo(projectDir) : null; + const fixMemory = loadFixMemory(projectDir); + + // Findings grouped by stable code — auditors care about "how many of + // which class", not the per-file noise. Codeless findings group as OTHER. + const byCode = new Map(); + for (const f of guardData.findings || []) { + const code = f.code || 'OTHER'; + const entry = byCode.get(code) || { code, severity: f.severity, count: 0, sample: null }; + entry.count++; + if (!entry.sample && f.message) entry.sample = f.message; + byCode.set(code, entry); + } + const findingsSummary = [...byCode.values()].sort((a, b) => b.count - a.count || a.code.localeCompare(b.code)); + + const payload = { + tool: { name: 'docguard', version: CLI_VERSION }, + project: { + name: config.projectName, + profile: config.profile || 'standard', + type: config.projectType || 'unknown', + }, + git: git ? { commit: git.commit, branch: git.branch, dirty: git.dirty } : null, + guard: { + status: guardData.status, + passed: guardData.passed, + total: guardData.total, + errors: guardData.errors, + warnings: guardData.warnings, + // Audit-critical (H3): evidence must disclose what a committed baseline + // is suppressing — "no findings" with a hidden baseline is false green. + baselineSuppressed: guardData.baselineSuppressed || 0, + validators: (guardData.validators || []) + .filter(v => v.status !== 'skipped') + .map(v => ({ name: v.name, status: v.status })), + }, + findings: findingsSummary, + score: { + score: scoreData.score, + grade: scoreData.grade, + categories: scoreData.categories, + }, + alcoa: { + score: alcoa.score, + met: alcoa.met, + total: alcoa.total, + attributes: alcoa.attributes.map(a => ({ + name: a.name, met: a.met, evidence: a.evidence, gap: a.gap, + })), + }, + fixHistory: { + entries: fixMemory.entries.length, + lastApplied: fixMemory.entries.length + ? fixMemory.entries.reduce((max, e) => (e.appliedAt > max ? e.appliedAt : max), '') + : null, + }, + }; + + // Integrity scope (M3): the hash covers the git-stable sections only. The + // ALCOA+ block is excluded because its Contemporaneous attribute derives + // from file mtimes vs now — it drifts with wall-clock time and resets on a + // fresh clone, which would break "same commit ⇒ same hash". + const { alcoa: _unhashed, ...hashable } = payload; + const integrity = 'sha256:' + createHash('sha256').update(JSON.stringify(hashable)).digest('hex'); + return { ...payload, generatedAt: new Date().toISOString(), integrity }; +} + +function toMarkdown(r) { + const lines = []; + const gitLine = r.git + ? `commit \`${r.git.commit.slice(0, 12)}\`${r.git.branch ? ` (${r.git.branch})` : ' (detached HEAD)'}${r.git.dirty ? ' — **uncommitted changes present**' : ''}` + : 'not a git repository'; + + lines.push(`# Documentation Compliance Report — ${r.project.name}`); + lines.push(''); + lines.push(`Generated ${r.generatedAt} by DocGuard v${r.tool.version} · ${gitLine}`); + lines.push(''); + lines.push('## Summary'); + lines.push(''); + lines.push('| Metric | Value |'); + lines.push('|--------|-------|'); + lines.push(`| CDD Score | ${r.score.score}/100 (${r.score.grade}) |`); + lines.push(`| Guard | ${r.guard.status.toUpperCase()} — ${r.guard.passed}/${r.guard.total} checks, ${r.guard.errors} error(s), ${r.guard.warnings} warning(s) |`); + if (r.guard.baselineSuppressed > 0) { + lines.push(`| Baseline | ⚠️ ${r.guard.baselineSuppressed} pre-existing finding(s) suppressed by \`.docguard.baseline.json\` — not reflected in the counts above |`); + } + lines.push(`| ALCOA+ data integrity | ${r.alcoa.score}% (${r.alcoa.met}/${r.alcoa.total} attributes) |`); + lines.push(`| Profile | ${r.project.profile} (${r.project.type}) |`); + lines.push(''); + + lines.push('## Validators'); + lines.push(''); + lines.push('| Validator | Status |'); + lines.push('|-----------|--------|'); + for (const v of r.guard.validators) { + const icon = v.status === 'pass' ? '✅' : v.status === 'warn' ? '⚠️' : v.status === 'na' ? '➖' : '❌'; + lines.push(`| ${v.name} | ${icon} ${v.status} |`); + } + lines.push(''); + + lines.push('## Findings'); + lines.push(''); + if (r.findings.length === 0) { + lines.push(r.guard.baselineSuppressed > 0 + ? `No new findings beyond the ${r.guard.baselineSuppressed} suppressed by the committed baseline (run \`docguard guard --no-baseline\` for the full picture).` + : 'No findings — documentation matches the implementation at this commit.'); + } else { + lines.push('| Code | Severity | Count | Example |'); + lines.push('|------|----------|------:|---------|'); + for (const f of r.findings) { + lines.push(`| ${f.code} | ${f.severity} | ${f.count} | ${(f.sample || '').replace(/\|/g, '\\|')} |`); + } + } + lines.push(''); + + lines.push('## ALCOA+ Attributes'); + lines.push(''); + lines.push('| Attribute | Met | Evidence / Gap |'); + lines.push('|-----------|-----|----------------|'); + for (const a of r.alcoa.attributes) { + lines.push(`| ${a.name} | ${a.met ? '✅' : '❌'} | ${(a.met ? a.evidence : a.gap) || '—'} |`); + } + lines.push(''); + + lines.push('## Fix History'); + lines.push(''); + lines.push(r.fixHistory.entries + ? `${r.fixHistory.entries} mechanical fix(es) on record (\`.docguard/fixed.json\`), last applied ${r.fixHistory.lastApplied}.` + : 'No mechanical fixes on record.'); + lines.push(''); + + lines.push('## Integrity'); + lines.push(''); + 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.`); + lines.push(''); + return lines.join('\n'); +} + +export function runReport(projectDir, config, flags) { + const report = buildReport(projectDir, config); + const isJson = flags.format === 'json'; + const output = isJson ? JSON.stringify(report, null, 2) : toMarkdown(report); + + if (flags.out) { + writeFileSync(resolvePath(projectDir, flags.out), output + '\n'); + // Chrome goes to stderr-style short confirm only in non-JSON mode; in + // JSON mode stay silent so scripted callers see nothing unexpected. + if (!isJson) console.log(`${c.green}✅ Report written to ${flags.out}${c.reset}`); + return report; + } + + // Machine/markdown output IS the artifact: write + natural exit (never + // console.log + process.exit — >8 KB payloads truncate through a pipe). + process.stdout.write(output + '\n'); + return report; +} diff --git a/cli/commands/score.mjs b/cli/commands/score.mjs index 844acf2..26ce5fe 100644 --- a/cli/commands/score.mjs +++ b/cli/commands/score.mjs @@ -11,6 +11,7 @@ import { validateSecurity } from '../validators/security.mjs'; import { runGuardInternal } from './guard.mjs'; import { extractSemanticClaims } from '../scanners/semantic-claims.mjs'; import { assessAgentReadability } from '../scanners/agent-readability.mjs'; +import { loadHistory, sparkline } from '../writers/history.mjs'; /** * Detect whether the project configures a test runner (the "Check 3" of the @@ -144,6 +145,10 @@ const WEIGHTS = { }; export function runScore(projectDir, config, flags) { + // v0.33: `--trend` renders the local score history recorded by `docguard + // ci` (.docguard/history.jsonl) instead of recomputing a score. + if (flags.trend) return runTrend(projectDir, config, flags); + // v0.16-P1: suppress banner in JSON mode so stdout stays parseable. // Was already fixed for guard/diagnose in v0.12; score/trace/diff missed // the pattern. Reported on a Python project where `score --format json` @@ -344,6 +349,52 @@ export function runScore(projectDir, config, flags) { console.log(` ${c.dim}📎 Badge: ![CDD Score](${badgeUrl})${c.reset}\n`); } +/** + * `score --trend` — render the score trajectory from `.docguard/history.jsonl` + * (written by `docguard ci`). Read-only display; exits 0 whether or not + * history exists — trend is information, not a gate. + */ +function runTrend(projectDir, config, flags) { + const isJson = flags.format === 'json'; + const entries = loadHistory(projectDir, 50); + + if (isJson) { + const latest = entries[entries.length - 1] || null; + const first = entries[0] || null; + process.stdout.write(JSON.stringify({ + project: config.projectName, + entries, + latest, + delta: latest && first ? latest.score - first.score : null, + }, null, 2) + '\n'); + return; + } + + console.log(`${c.bold}📈 DocGuard Score Trend — ${config.projectName}${c.reset}\n`); + if (entries.length === 0) { + console.log(` ${c.dim}No history yet. Run ${c.cyan}docguard ci${c.dim} to start recording`); + console.log(` score history to .docguard/history.jsonl (one line per run).${c.reset}\n`); + return; + } + + const scores = entries.map(e => e.score); + const latest = entries[entries.length - 1]; + const first = entries[0]; + const delta = latest.score - first.score; + const deltaStr = delta > 0 ? `${c.green}+${delta}${c.reset}` : delta < 0 ? `${c.red}${delta}${c.reset}` : '±0'; + + console.log(` ${sparkline(scores)} ${first.score} → ${c.bold}${latest.score}${c.reset} (${deltaStr}) over ${entries.length} run(s)\n`); + + const recent = entries.slice(-10); + for (const e of recent) { + const icon = e.status === 'PASS' ? '✅' : e.status === 'WARN' ? '⚠️ ' : '❌'; + const when = (e.timestamp || '').slice(0, 10); + const commit = e.commit ? ` ${c.dim}@${e.commit.slice(0, 7)}${c.reset}` : ''; + console.log(` ${icon} ${when} ${String(e.score).padStart(3)}/100 (${e.grade})${commit}`); + } + console.log(''); +} + /** * Internal scoring — returns data without printing. * Used by badge, ci, and other commands that need the score. @@ -363,8 +414,11 @@ export function runScoreInternal(projectDir, config) { * + Complete, Consistent, Enduring, Available * * Reference: WHO Technical Report Series, No. 996, 2016, Annex 5 + * + * Exported for `docguard report` — the evidence bundle embeds the same + * ALCOA+ table the score display renders, from one computation. */ -function computeAlcoaCompliance(projectDir, config, scores) { +export function computeAlcoaCompliance(projectDir, config, scores) { const attributes = []; // 1. Attributable — Can we trace who wrote/reviewed docs? diff --git a/cli/docguard.mjs b/cli/docguard.mjs index 3d36fe9..0df53d7 100755 --- a/cli/docguard.mjs +++ b/cli/docguard.mjs @@ -45,6 +45,7 @@ import { runImpact } from './commands/impact.mjs'; import { runExplain } from './commands/explain.mjs'; import { runFeedback } from './commands/feedback.mjs'; import { runVerify } from './commands/verify.mjs'; +import { runReport } from './commands/report.mjs'; import { runMemory } from './commands/memory.mjs'; import { runDemo } from './commands/demo.mjs'; 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 ${c.green}guard${c.reset} Validate against canonical docs (all validators) ${c.green}diff${c.reset} Show gaps between docs and code (add ${c.cyan}--since ${c.reset} for changed-file impact) ${c.green}sync${c.reset} Refresh code-truth doc sections — keeps memory always up to date - ${c.green}score${c.reset} CDD maturity score (0-100; ${c.cyan}--diff${c.reset} for delta between refs) + ${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) ${c.bold}Tools (situational, but day-to-day useful)${c.reset} ${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} ${c.green}explain${c.reset} Explain a validator key, warning text, or finding code (${c.cyan}docguard explain SEC001${c.reset}) ${c.green}verify${c.reset} Extract documented numbers/limits/enums for an agent to check vs code (${c.cyan}--semantic${c.reset}) ${c.green}feedback${c.reset} Report likely false positives back to DocGuard (local-first + 1-click prefilled issue) - ${c.green}mcp${c.reset} MCP server over stdio — guard/score/explain/verify/diagnose as agent tools + ${c.green}mcp${c.reset} MCP server over stdio — guard/score/explain/verify/report/diagnose as agent tools + ${c.green}report${c.reset} Compliance-evidence bundle — guard + score + ALCOA+ + integrity hash (${c.cyan}--format json${c.reset}, ${c.cyan}--out ${c.reset}) + ${c.green}ci${c.reset} Pipeline gate: guard + score in one command (${c.cyan}--threshold ${c.reset}, ${c.cyan}--fail-on-warning${c.reset}, ${c.cyan}--format json${c.reset}; records score history) ${c.green}memory${c.reset} Show what DocGuard remembers (${c.cyan}--diff${c.reset} drills into drift) ${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) ${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} ${c.bold}init --with ${c.reset} ${c.dim}— optional scaffolders, picked at init time${c.reset} ${c.dim}agents${c.reset} AGENTS.md / CLAUDE.md / .cursor/rules / Copilot instructions ${c.dim}hooks${c.reset} Git pre-commit / pre-push hooks - ${c.dim}ci${c.reset} GitHub Actions / pipeline config + ${c.dim}ci${c.reset} Run the CI gate (guard + score) once, right after init ${c.dim}badge${c.reset} Shields.io score badges for README ${c.dim}llms${c.reset} llms.txt generation ${c.dim}publish${c.reset} External doc-site scaffold (Mintlify) ${c.dim}— experimental${c.reset} ${c.bold}Deprecation aliases${c.reset} ${c.dim}— still work in v0.20.x with a yellow warning${c.reset} ${c.dim}setup${c.reset} → ${c.cyan}init --wizard${c.reset} - ${c.dim}agents · hooks · ci · badge · llms · publish${c.reset} → ${c.cyan}init --with ${c.reset} + ${c.dim}agents · hooks · badge · llms · publish${c.reset} → ${c.cyan}init --with ${c.reset} ${c.dim}impact${c.reset} → ${c.cyan}diff --since ${c.reset} ${c.dim}audit${c.reset} → ${c.green}guard${c.reset} ${c.dim}(permanent — no warning, no removal planned)${c.reset} ${c.dim}See docs-implementation/MIGRATION-v0.20.md for the full timeline.${c.reset} @@ -211,13 +214,15 @@ const COMMAND_HELP = { }, guard: { summary: 'Validate code against canonical docs (all validators).', - usage: 'docguard guard [--format json] [--changed-only] [--fail-on-warning]', + usage: 'docguard guard [--format json|sarif|junit] [--changed-only] [--fail-on-warning]', flags: [ - ['--format json', 'Machine-readable results for CI'], + ['--format json', 'Machine-readable results for CI (also: sarif, junit)'], ['--changed-only', 'Only validate docs/code touched in the working tree'], ['--fail-on-warning', 'Exit non-zero on warnings (strict CI)'], + ['--update-baseline', 'Freeze current findings to .docguard.baseline.json — adopt on a legacy repo without a red day one'], + ['--no-baseline', 'Ignore the committed baseline for this run (show everything)'], ], - examples: ['docguard guard', 'docguard guard --format json'], + examples: ['docguard guard', 'docguard guard --format json', 'docguard guard --update-baseline'], }, score: { summary: 'CDD maturity score (0–100).', @@ -428,6 +433,17 @@ async function main() { // avoid collision with `docguard init --profile `. `--show-timings` // is the long form for users who prefer explicit verbs. flags.timings = true; + } else if (args[i] === '--trend') { + flags.trend = true; + } else if (args[i] === '--no-history') { + flags.noHistory = true; + } else if (args[i] === '--update-baseline') { + flags.updateBaseline = true; + } else if (args[i] === '--no-baseline') { + flags.noBaseline = true; + } else if (args[i] === '--out' && args[i + 1]) { + flags.out = args[i + 1]; + i++; } else if (args[i] === '--quiet' || args[i] === '-q') { // v0.16-P5: suppress the banner + ensureSkills decorative line. // Useful inside git hooks (every commit prints the banner otherwise) @@ -567,18 +583,26 @@ async function main() { // `generate --plan` (and were already suppressed for `--plan --write`). // v0.29: 'sarif' joins 'json' — any machine format where stdout IS the // artifact belongs here, or the banner corrupts the payload. - const jsonMode = flags.format === 'json' || flags.format === 'sarif'; + // v0.33: 'junit' joins for the same reason (GitLab/Jenkins parse stdout XML). + const jsonMode = flags.format === 'json' || flags.format === 'sarif' || flags.format === 'junit'; // `agent` emits a machine task graph (JSON by default) — it must be banner- // free and side-effect-free like the other read-only commands. // `mcp`: stdout IS the JSON-RPC transport — any banner byte corrupts the stream. // `nudge-hook`: stdout is the Claude Code hook feedback channel — any banner // byte corrupts the JSON the hook runner parses. - const headless = jsonMode || flags.write || flags.checkOnly || flags.changedOnly || flags.quiet || flags.plan || command === 'agent' || command === 'mcp' || command === 'nudge-hook'; + // `report`: stdout IS the evidence artifact (markdown or JSON) — banner + // bytes would corrupt it for redirection/piping in both formats. + const headless = jsonMode || flags.write || flags.checkOnly || flags.changedOnly || flags.quiet || flags.plan || command === 'agent' || command === 'mcp' || command === 'nudge-hook' || command === 'report'; if (!headless) printBanner(); const config = loadConfig(projectDir); + // `--no-baseline` disables the committed adoption baseline for this run — + // threaded through config so guard, ci, report, and mcp all honor it the + // same way runGuardInternal sees everything else. + if (flags.noBaseline) config.baseline = false; + // Commands that only READ and REPORT — they must never mutate the working // tree. Scaffolding (ensureSkills → .agent/.specify, spawning `specify`) // belongs to setup/init/generate and the `init --with` family, where the @@ -598,6 +622,14 @@ async function main() { 'feedback', // verify only reads docs and emits a task list — pure report. 'verify', + // report gathers evidence (guard+score, read-only); --out writes only the + // user-named file — it must never scaffold or mutate the tree otherwise. + 'report', + // ci is the pipeline gate — it must never scaffold into the workspace it + // gates (review finding H1: bare `docguard ci` in text mode ran + // ensureSkills and wrote ~9 files before gating). Its only write is its + // own .docguard/history.jsonl, same carve-out as feedback. + 'ci', // mcp serves read-only tools over stdio — scaffolding writes are off-limits. 'mcp', // nudge-hook runs inside an agent's PostToolUse hook — it may write only @@ -625,7 +657,8 @@ async function main() { setup: { since: '0.20', replacement: 'docguard init --wizard' }, agents: { since: '0.20', replacement: 'docguard init --with agents' }, hooks: { since: '0.20', replacement: 'docguard init --with hooks' }, - ci: { since: '0.20', replacement: 'docguard init --with ci' }, + // `ci` was deprecated here in v0.20 → un-deprecated in v0.33: it is the + // documented pipeline gate (guard + score + threshold), not a scaffolder. badge: { since: '0.20', replacement: 'docguard init --with badge' }, llms: { since: '0.20', replacement: 'docguard init --with llms' }, publish: { since: '0.20', replacement: 'docguard init --with publish' }, @@ -720,7 +753,13 @@ async function main() { await runInit(projectDir, config, { ...flags, with: ['badge'], skipPrompts: true }); break; case 'ci': - await runInit(projectDir, config, { ...flags, with: ['ci'], skipPrompts: true }); + // v0.33: restored as a first-class command. The v0.20 deprecation + // routed `ci` through runInit --with ci, which (a) scaffolded missing + // docs INTO the CI workspace — a validate command mutating the tree — + // and (b) printed init chrome into `--format json` stdout, corrupting + // it for parsers. A pipeline gate must be read-only and machine-clean, + // so it dispatches straight to runCI like guard/score. + runCI(projectDir, config, flags); break; case 'fix': runFix(projectDir, config, flags); @@ -766,6 +805,12 @@ async function main() { // drift — the class regex/AST can't see). Read-only. runVerify(projectDir, config, flags); break; + case 'report': + // Compliance-evidence bundle (guard + score + ALCOA+ + fix history + + // integrity hash). Evidence, not a gate — always exits 0; guard/ci fail + // builds. Auditors need evidence collection that never self-censors. + runReport(projectDir, config, flags); + break; case 'mcp': // MCP stdio server — guard/score/explain/verify-claims/diagnose as tools // for MCP clients. Long-lived; resolves when stdin closes. diff --git a/cli/shared-git.mjs b/cli/shared-git.mjs index aaf6283..ef9ba6e 100644 --- a/cli/shared-git.mjs +++ b/cli/shared-git.mjs @@ -241,6 +241,29 @@ export function lastCommitHash(dir, filePath) { } } +/** + * Resolve HEAD identity for evidence reports: { commit, branch, dirty }. + * `branch` is null on a detached HEAD (common in CI checkouts); `dirty` is + * true when tracked files have uncommitted changes — evidence consumers need + * to know the report may not describe a reproducible tree. Returns null when + * the dir isn't a git repo or git is unavailable. + */ +export function getHeadInfo(dir) { + try { + const run = (args) => execFileSync('git', args, { + cwd: dir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'], + }).trim(); + const commit = run(['rev-parse', 'HEAD']); + if (!commit) return null; + const branchRaw = run(['rev-parse', '--abbrev-ref', 'HEAD']); + const branch = branchRaw === 'HEAD' ? null : branchRaw; + const dirty = run(['status', '--porcelain', '--untracked-files=no']) !== ''; + return { commit, branch, dirty }; + } catch { + return null; + } +} + /** * Resolve the absolute path to this repo's git hooks directory. * diff --git a/cli/validators/docs-coverage.mjs b/cli/validators/docs-coverage.mjs index 79820c9..f6e7d4f 100644 --- a/cli/validators/docs-coverage.mjs +++ b/cli/validators/docs-coverage.mjs @@ -43,6 +43,11 @@ const COMMON_DOTFILES = new Set([ '.babelrc', '.browserslistrc', '.stylelintrc', '.dockerignore', '.python-version', '.tool-versions', '.ruby-version', '.gitkeep', '.keep', + // DocGuard's own files — self-explanatory (embedded _comment / schema), + // and flagging them creates a warning the moment a team adopts the tool + // (e.g. `guard --update-baseline` writing the baseline instantly produced + // a DCV001 about the baseline file itself). + '.docguard.json', '.docguardignore', '.docguard.baseline.json', ]); // Generated tool artifacts (caches, coverage data, lock-data) that land at the diff --git a/cli/writers/baseline.mjs b/cli/writers/baseline.mjs new file mode 100644 index 0000000..d6c34c9 --- /dev/null +++ b/cli/writers/baseline.mjs @@ -0,0 +1,84 @@ +/** + * Adoption Baseline — `.docguard.baseline.json` (repo root, COMMITTED). + * + * The brownfield-adoption pattern (ESLint/semgrep-style): a legacy repo + * freezes its existing findings once (`guard --update-baseline`), commits the + * file, and from then on guard/ci gate only NEW drift. Suppressed findings + * are counted and displayed — never silently hidden — and the baseline is a + * reviewable diff in every PR that updates it. + * + * Root, not `.docguard/`: the state dir is gitignored, and a baseline only + * works if the whole team and CI share it. + * + * Fingerprints are content-addressed, not line-addressed: `code | location + * path (line numbers stripped) | message with digit-runs normalized to #`. + * Line numbers churn on every edit and messages embed volatile counts + * ("21 commits since…") — both would rot the baseline in a week. + */ + +import { createHash } from 'node:crypto'; +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +export const BASELINE_FILE = '.docguard.baseline.json'; + +/** Stable fingerprint for one finding. */ +export function fingerprintFinding(f) { + const code = f.code || 'UNCODED'; + const path = typeof f.location === 'string' ? f.location.replace(/:\d+$/, '') : ''; + const msg = String(f.message || '').replace(/\d+/g, '#').replace(/\s+/g, ' ').trim(); + return createHash('sha256').update(`${code}|${path}|${msg}`).digest('hex').slice(0, 16); +} + +/** + * Load the committed baseline as a Map of fingerprint → allowed occurrence + * count, or null when the project has none (the common case — zero overhead). + * + * Occurrence counts matter (review finding H2): two findings with the same + * code + file + message shape — e.g. two hardcoded passwords in one file — + * share a fingerprint. A count-less set would let one frozen instance + * suppress every FUTURE instance of that class in that file, a + * security-relevant false negative. With counts, freezing 1 suppresses 1; + * a second appearance surfaces and gates. + */ +export function loadBaseline(projectDir) { + const p = resolve(projectDir, BASELINE_FILE); + if (!existsSync(p)) return null; + try { + const data = JSON.parse(readFileSync(p, 'utf-8')); + if (!data || typeof data.fingerprints !== 'object' || data.fingerprints === null) return null; + const map = new Map(); + for (const [fp, n] of Object.entries(data.fingerprints)) { + const count = Number.isInteger(n) && n > 0 ? n : 0; + if (count > 0) map.set(fp, count); + } + return map.size > 0 ? map : null; + } catch { + // A malformed baseline must not silently un-gate CI: treat as absent so + // every finding surfaces (fail-open on visibility, fail-closed on hiding). + return null; + } +} + +/** + * Write the baseline from the current findings: fingerprint → occurrence + * count, keys sorted so the committed file diffs cleanly. Returns the number + * of distinct fingerprints. + */ +export function saveBaseline(projectDir, findings) { + const counts = {}; + for (const f of findings) { + const fp = fingerprintFinding(f); + counts[fp] = (counts[fp] || 0) + 1; + } + const fingerprints = Object.fromEntries(Object.keys(counts).sort().map(k => [k, counts[k]])); + const doc = { + _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', + version: 2, + generatedAt: new Date().toISOString(), + count: Object.keys(fingerprints).length, + fingerprints, + }; + writeFileSync(resolve(projectDir, BASELINE_FILE), JSON.stringify(doc, null, 2) + '\n'); + return Object.keys(fingerprints).length; +} diff --git a/cli/writers/history.mjs b/cli/writers/history.mjs new file mode 100644 index 0000000..d73007d --- /dev/null +++ b/cli/writers/history.mjs @@ -0,0 +1,82 @@ +/** + * Score History — local-first trend memory at `.docguard/history.jsonl`. + * + * `docguard ci` appends one line per run ({timestamp, commit, score, grade, + * errors, warnings, passed, total, status}); `docguard score --trend` reads + * it back and renders the trajectory. JSONL because append is the hot path: + * one O(1) write per CI run, and a truncated last line (crash mid-write) + * corrupts one entry, not the file. The rare trim rewrite goes through a + * temp-file + rename so a crash mid-trim can't truncate history; concurrent + * appends during a trim window can still lose an entry — acceptable for a + * trend log, not a ledger. + * + * Local-first by design: `.docguard/` is gitignored, so history accumulates + * per checkout. In ephemeral CI, persist it across runs with a cache/artifact + * step (see CI-RECIPES) — the file format is stable and merge-friendly. + */ + +import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs'; +import { resolve, dirname } from 'node:path'; + +const HISTORY_PATH = '.docguard/history.jsonl'; + +// Trim trigger: beyond this many entries the file is rewritten keeping the +// most recent MAX_ENTRIES. Generous — 1000 CI runs of ~150 bytes ≈ 150 KB. +const MAX_ENTRIES = 1000; + +/** + * Append one run entry. Silent no-op on failure (read-only checkouts, odd + * CI filesystems) — recording history must never fail the pipeline it's + * recording. + */ +export function appendHistory(projectDir, entry) { + try { + const p = resolve(projectDir, HISTORY_PATH); + mkdirSync(dirname(p), { recursive: true }); + appendFileSync(p, JSON.stringify(entry) + '\n'); + // Occasional trim, checked cheaply by size (~200 KB ≫ MAX_ENTRIES rows). + // Temp-file + rename: a crash mid-trim leaves the old file intact + // instead of a truncated one (L2). + if (statSync(p).size > 256 * 1024) { + const rows = loadHistory(projectDir, MAX_ENTRIES); + const tmp = p + '.tmp'; + writeFileSync(tmp, rows.map(r => JSON.stringify(r)).join('\n') + '\n'); + renameSync(tmp, p); + } + return true; + } catch { + return false; + } +} + +/** + * Read the last `limit` valid entries, oldest → newest. Malformed lines + * (partial writes, hand edits) are skipped, never thrown. + */ +export function loadHistory(projectDir, limit = 50) { + try { + const p = resolve(projectDir, HISTORY_PATH); + if (!existsSync(p)) return []; + const out = []; + for (const line of readFileSync(p, 'utf-8').split('\n')) { + if (!line.trim()) continue; + try { + const e = JSON.parse(line); + if (e && typeof e.score === 'number') out.push(e); + } catch { /* skip malformed line */ } + } + return out.slice(-limit); + } catch { + return []; + } +} + +/** + * Unicode sparkline over the score series (0–100 → ▁–█). Pure display. + */ +export function sparkline(scores) { + const BARS = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; + return scores + .map(s => BARS[Math.min(BARS.length - 1, Math.max(0, Math.floor((s / 100) * BARS.length)))]) + .join(''); +} diff --git a/cli/writers/junit.mjs b/cli/writers/junit.mjs new file mode 100644 index 0000000..2b543c1 --- /dev/null +++ b/cli/writers/junit.mjs @@ -0,0 +1,103 @@ +/** + * JUnit XML writer — `docguard guard --format junit`. + * + * SARIF covers GitHub Code Scanning; JUnit covers everything else an + * enterprise runs: GitLab CI (`artifacts:reports:junit`), Jenkins + * (`junit` step), Azure DevOps, CircleCI, Bamboo. One testcase per + * validator keeps the report readable in those UIs — a failed validator + * shows its findings (code + message + location) as the failure body. + * + * Mapping (deterministic): + * validator error findings → (red in every CI) + * validator crashed (fail, no + * structured findings) → from its string errors (red) + * warn-only validator → passing testcase + findings in + * (visible, non-gating) + * skipped / n/a → + * + * Zero npm dependencies — pure string assembly with strict XML escaping. + */ + +function esc(s) { + return String(s ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function findingLine(f) { + const code = f.code ? `[${f.code}] ` : ''; + const loc = f.location ? ` (${f.location})` : ''; + return `${code}${f.message}${loc}`; +} + +/** + * Build the JUnit XML document from runGuardInternal's data. + * `data.validators` entries: { name, status, findings? }; `data.findings` + * is the flat list with `validator` back-references — we group by the + * validator display name via each result's own findings when present, + * falling back to the flat list. + */ +export function toJUnit(data) { + const cases = []; + let failures = 0, errorCount = 0, skipped = 0; + + for (const v of data.validators || []) { + const vFindings = Array.isArray(v.findings) + ? v.findings + : (data.findings || []).filter(f => f.validator === v.key || f.validator === v.name); + const errors = vFindings.filter(f => f.severity === 'error'); + const warns = vFindings.filter(f => f.severity !== 'error'); + const attrs = `name="${esc(v.name)}" classname="docguard.guard"`; + + if (v.status === 'skipped' || v.status === 'na') { + skipped++; + cases.push(` `); + } else if (errors.length > 0) { + failures++; + const body = errors.map(findingLine).join('\n'); + cases.push( + ` \n` + + ` ${esc(body)}\n` + + ` ` + ); + } else if (v.status === 'fail') { + // A validator that failed WITHOUT structured error findings — the + // crash path (guard catches the throw and records string errors only). + // This must go red in CI, not render as a passing testcase (M1). + errorCount++; + const body = (v.errors || []).join('\n') || 'validator failed without structured findings'; + cases.push( + ` \n` + + ` ${esc(body)}\n` + + ` ` + ); + } else if (warns.length > 0) { + const body = warns.map(findingLine).join('\n'); + cases.push( + ` \n` + + ` ${esc(body)}\n` + + ` ` + ); + } else { + cases.push(` `); + } + } + + const total = (data.validators || []).length; + const suiteAttrs = + `name="docguard guard — ${esc(data.project || 'project')}" ` + + `tests="${total}" failures="${failures}" errors="${errorCount}" skipped="${skipped}" ` + + `timestamp="${esc(data.timestamp || '')}"`; + + return ( + `\n` + + `\n` + + ` \n` + + cases.join('\n') + (cases.length ? '\n' : '') + + ` \n` + + `` + ); +} diff --git a/docs-canonical/CI-RECIPES.md b/docs-canonical/CI-RECIPES.md index b2861bc..c1e0dcf 100644 --- a/docs-canonical/CI-RECIPES.md +++ b/docs-canonical/CI-RECIPES.md @@ -139,6 +139,83 @@ jobs: score-threshold: '70' # fail PRs that drop below 70/100 ``` +## Recipe 1b — GitLab CI / Jenkins (JUnit output) + +Not on GitHub? `guard --format junit` emits one testcase per validator, which +GitLab and Jenkins render natively in their test UIs: + +```yaml +# .gitlab-ci.yml +docguard: + image: node:20 + script: + - npx docguard-cli guard --format junit > docguard-junit.xml + allow_failure: + exit_codes: [2] # warnings stay visible but don't block the pipeline + artifacts: + when: always + reports: + junit: docguard-junit.xml +``` + +```groovy +// Jenkinsfile — gate on errors (exit 1); treat warnings-only (exit 2) as unstable +stage('DocGuard') { + steps { + sh ''' + set +e + npx docguard-cli guard --format junit > docguard-junit.xml + rc=$? + [ "$rc" -eq 2 ] && exit 0 # warnings: report, don't fail the stage + exit $rc + ''' + } + post { always { junit 'docguard-junit.xml' } } +} +``` + +Exit codes match `guard` (1 = errors, 2 = warnings). Both recipes gate on +errors while letting warnings-only runs pass with the findings visible in the +test report. + +## Recipe 4b — Score history across ephemeral CI runs + +`docguard ci` appends each run to `.docguard/history.jsonl` (local-first — +the directory is gitignored). CI workspaces are fresh every run, so restore +and save the file with a cache step to keep `docguard score --trend` and +`docguard report` seeing the full trajectory: + +```yaml +jobs: + ci: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/cache@v4 + with: + path: .docguard/history.jsonl + key: docguard-history-${{ github.ref_name }}-${{ github.run_id }} + restore-keys: docguard-history-${{ github.ref_name }}- + - run: npx docguard-cli ci --threshold 70 + - run: npx docguard-cli score --trend # print the trajectory in the job log +``` + +## Recipe 4c — Multi-repo scorecard (no extra tooling) + +For an org-wide view, run the gate per repo and collect the JSON — no +dedicated command needed: + +```bash +for d in repo-a repo-b repo-c; do + (cd "$d" && npx docguard-cli ci --format json --no-history \ + | jq -r '[.project, .score, .grade, .status] | @tsv') +done | column -t +``` + +Each line is `project score grade PASS|WARN|FAIL`. Pipe to a dashboard, +spreadsheet, or a scheduled Slack post. (A first-class cross-repo dashboard +is the Phase 5 roadmap item — this recipe is the zero-dependency version.) + ## Pre-commit hook (no GitHub Actions required) Run guard locally before every commit so you catch drift at typing time, not diff --git a/docs/commands.md b/docs/commands.md index 487c083..f50a8a5 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -38,6 +38,17 @@ npx docguard-cli diagnose --format prompt # Raw AI prompt (all issues combined) npx docguard-cli guard # Text output npx docguard-cli guard --format json # Structured JSON (the stable agent contract) npx docguard-cli guard --format sarif # SARIF 2.1.0 for GitHub Code Scanning +npx docguard-cli guard --format junit # JUnit XML for GitLab/Jenkins/Azure DevOps +npx docguard-cli guard --update-baseline # Freeze current findings (brownfield adoption) +npx docguard-cli guard --no-baseline # Ignore the committed baseline this run +``` + +**Adoption baseline:** on a legacy repo, `--update-baseline` writes +`.docguard.baseline.json` (commit it). From then on guard/ci suppress those +frozen findings — visibly — and gate only new drift. Fingerprints are stable +across line-number churn and volatile counts, so the baseline doesn't rot. + +```bash npx docguard-cli guard --verbose # Show all check details npx docguard-cli guard --changed-only # Pre-commit lite mode (fast subset) ``` @@ -181,7 +192,7 @@ never touched without `--force`. **MCP server over stdio** — DocGuard's read-only core as native agent tools (`docguard_guard`, `docguard_score`, `docguard_explain`, -`docguard_verify_claims`, `docguard_diagnose`). +`docguard_verify_claims`, `docguard_report`, `docguard_diagnose`). ```bash claude mcp add docguard -- npx docguard-cli mcp @@ -211,15 +222,32 @@ npx docguard-cli memory --pack # .docguard/context-pack.md (session-start co ## DevOps Commands +### `docguard report` + +**Compliance-evidence bundle for audits** — guard verdict, CDD score, ALCOA+ +data-integrity attributes, findings grouped by code, and fix history, stamped +with the git commit and a tamper-evident sha256 integrity hash. Evidence, not +a gate: always exits 0 (`guard`/`ci` fail builds). + +```bash +npx docguard-cli report # markdown to stdout +npx docguard-cli report --format json # machine bundle +npx docguard-cli report --out evidence.md # write to a file +``` + ### `docguard ci` -**Single command for CI/CD pipelines.** Runs guard + score internally (no subprocess). +**Single command for CI/CD pipelines.** Runs guard + score internally (no +subprocess). Read-only and machine-clean: it never scaffolds or mutates the +workspace it validates. Each run appends one line to `.docguard/history.jsonl` +so `docguard score --trend` can show the trajectory (opt out: `--no-history`). ```bash npx docguard-cli ci # Basic check npx docguard-cli ci --threshold 70 # Fail below score 70 npx docguard-cli ci --threshold 80 --fail-on-warning # Strict mode npx docguard-cli ci --format json # JSON for GitHub Actions +npx docguard-cli score --trend # Score history from past ci runs ``` ### `docguard hooks` diff --git a/docs/configuration.md b/docs/configuration.md index 56fcdaf..c9f35a9 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -111,6 +111,20 @@ Conventional doc folders (`docs/`, `doc/`, `documentation/`, `guides/`, that set with non-standard homes — it never replaces auto-detection. To exclude a conventional dir, list it in `.docguardignore`. +## Adoption baseline — `baseline` + +When a committed `.docguard.baseline.json` exists (written by +`docguard guard --update-baseline`), guard/ci suppress the frozen findings +and gate only new drift. Set `"baseline": false` in `.docguard.json` to +ignore the file entirely (same as always passing `--no-baseline`): + +```json +{ "baseline": false } +``` + +Suppression is always visible in output and in the `baselineSuppressed` +JSON field — nothing is silently hidden. + ## Muting a validator Two ways to turn a validator off, for two different intents: diff --git a/docs/faq.md b/docs/faq.md index c9940f9..94725c2 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -123,6 +123,18 @@ Yes — DocGuard ships a template at `templates/ci/github-actions.yml`. Copy it Only if you install hooks (`docguard hooks`). Without hooks, it's advisory only. +### I ran guard on our legacy repo and got dozens of findings. Now what? + +Freeze them and move forward: + +```bash +npx docguard-cli guard --update-baseline # writes .docguard.baseline.json — commit it +``` + +From then on guard/ci pass, suppress the frozen findings **visibly** +("N pre-existing finding(s) suppressed"), and gate only NEW drift. Burn the +baseline down at your own pace; `--no-baseline` shows the full picture anytime. + --- ## Technical diff --git a/schemas/docguard-config.schema.json b/schemas/docguard-config.schema.json index 5acfc00..e2a6dae 100644 --- a/schemas/docguard-config.schema.json +++ b/schemas/docguard-config.schema.json @@ -29,6 +29,11 @@ "enum": ["cli", "library", "webapp", "api", "unknown"], "description": "Project shape. Affects which validators run (e.g. webapp + api need env vars; cli/library can skip)." }, + "baseline": { + "type": "boolean", + "description": "Set false to ignore a committed .docguard.baseline.json entirely (equivalent to always passing --no-baseline). Default: the baseline auto-applies when the file exists.", + "default": true + }, "projectTypeConfig": { "type": "object", "description": "Per-type behavior knobs that override profile defaults.", diff --git a/tests/baseline.test.mjs b/tests/baseline.test.mjs new file mode 100644 index 0000000..70c3bcd --- /dev/null +++ b/tests/baseline.test.mjs @@ -0,0 +1,145 @@ +/** + * Adoption baseline — `.docguard.baseline.json` (v0.33). + * + * @req SC-BSL-001 — fingerprints are stable across line numbers and volatile counts + * @req SC-BSL-002 — guard --update-baseline freezes current findings; guard then passes + * @req SC-BSL-003 — suppression is visible (note + baselineSuppressed in JSON) + * @req SC-BSL-004 — --no-baseline restores the full view + * @req SC-BSL-005 — NEW findings still gate (the whole point) + * @req SC-BSL-006 — malformed baseline = absent (fail-open on visibility) + * @req SC-BSL-007 — ci honors the baseline (runGuardInternal path) + */ +import { describe, it, afterEach } from 'node:test'; +import { strict as assert } from 'node:assert'; +import { mkdtempSync, rmSync, writeFileSync, mkdirSync, existsSync, readFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { tmpdir } from 'node:os'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +import { fingerprintFinding, saveBaseline, loadBaseline, BASELINE_FILE } from '../cli/writers/baseline.mjs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const CLI = join(__dirname, '..', 'cli', 'docguard.mjs'); + +function makeFixture() { + const dir = mkdtempSync(join(tmpdir(), 'docguard-baseline-')); + writeFileSync(join(dir, '.docguard.json'), JSON.stringify({ projectName: 'bl-fixture', profile: 'standard' })); + mkdirSync(join(dir, 'docs-canonical'), { recursive: true }); + writeFileSync(join(dir, 'docs-canonical', 'ARCHITECTURE.md'), '# Architecture\n'); + return dir; +} + +function spawnCli(args, cwd) { + return spawnSync('node', [CLI, ...args], { + cwd, encoding: 'utf-8', env: { ...process.env, NO_COLOR: '1' }, + }); +} + +describe('fingerprintFinding — stability', () => { + it('is identical across line-number and volatile-count churn', () => { + const a = fingerprintFinding({ code: 'FRS002', location: 'docs/X.md:12', message: '21 code commits since last update' }); + const b = fingerprintFinding({ code: 'FRS002', location: 'docs/X.md:97', message: '48 code commits since last update' }); + assert.equal(a, b, 'line numbers and digit runs must not change the fingerprint'); + }); + + it('differs by code and by path', () => { + const base = { code: 'STR001', location: 'a.md', message: 'missing' }; + assert.notEqual(fingerprintFinding(base), fingerprintFinding({ ...base, code: 'STR002' })); + assert.notEqual(fingerprintFinding(base), fingerprintFinding({ ...base, location: 'b.md' })); + }); +}); + +describe('baseline adoption flow', () => { + let dir; + afterEach(() => { if (dir) rmSync(dir, { recursive: true, force: true }); }); + + it('freeze → pass → visible note → --no-baseline restores', () => { + dir = makeFixture(); + assert.equal(spawnCli(['guard'], dir).status, 1, 'bare fixture fails before baseline'); + + const upd = spawnCli(['guard', '--update-baseline'], dir); + assert.equal(upd.status, 0); + assert.ok(existsSync(join(dir, BASELINE_FILE)), 'baseline file written at repo root'); + + const after = spawnCli(['guard'], dir); + assert.equal(after.status, 0, 'frozen findings no longer gate'); + assert.match(after.stdout, /pre-existing finding\(s\) suppressed/); + + assert.equal(spawnCli(['guard', '--no-baseline'], dir).status, 1, '--no-baseline shows everything'); + }); + + it('JSON carries baselineSuppressed and ci honors the baseline', () => { + dir = makeFixture(); + spawnCli(['guard', '--update-baseline'], dir); + const g = JSON.parse(spawnCli(['guard', '--format', 'json'], dir).stdout); + assert.ok(g.baselineSuppressed > 0); + assert.equal(g.status, 'PASS'); + + const ci = spawnCli(['ci', '--no-history'], dir); + assert.equal(ci.status, 0, 'ci gates only new drift once baselined'); + }); + + it('NEW findings still gate after the freeze', () => { + dir = makeFixture(); + spawnCli(['guard', '--update-baseline'], dir); + assert.equal(spawnCli(['guard'], dir).status, 0); + // Introduce brand-new drift: delete a doc that existed at freeze time — + // its STR001 has a different fingerprint (path) than anything baselined. + rmSync(join(dir, 'docs-canonical', 'ARCHITECTURE.md')); + const res = spawnCli(['guard'], dir); + assert.equal(res.status, 1, 'a finding not in the baseline must still fail'); + assert.match(res.stdout, /ARCHITECTURE\.md/); + }); + + it('malformed baseline is treated as absent — nothing is hidden', () => { + dir = makeFixture(); + writeFileSync(join(dir, BASELINE_FILE), 'not json{{{'); + assert.equal(loadBaseline(dir), null); + assert.equal(spawnCli(['guard'], dir).status, 1, 'corrupt baseline must not un-gate CI'); + }); + + it('saveBaseline stores occurrence counts with sorted keys for clean diffs', () => { + dir = makeFixture(); + const f = { code: 'STR001', location: 'a.md', message: 'x' }; + const n = saveBaseline(dir, [f, { ...f }, { code: 'ENV001', location: 'b.md', message: 'y' }]); + assert.equal(n, 2, 'two distinct fingerprints'); + const doc = JSON.parse(readFileSync(join(dir, BASELINE_FILE), 'utf-8')); + const keys = Object.keys(doc.fingerprints); + assert.deepEqual(keys, [...keys].sort()); + assert.equal(doc.fingerprints[fingerprintFinding(f)], 2, 'duplicate instance recorded as count 2'); + }); + + it('H2 regression: a NEW instance of a baselined finding class still gates', () => { + dir = makeFixture(); + // Freeze exactly ONE occurrence of a fingerprint... + const f = { code: 'STR001', location: 'docs-canonical/DATA-MODEL.md', message: 'Missing required file: docs-canonical/DATA-MODEL.md' }; + saveBaseline(dir, [f]); + const loaded = loadBaseline(dir); + assert.equal(loaded.get(fingerprintFinding(f)), 1, 'baseline stores an occurrence budget, not a set'); + // ...and confirm the budget semantics end-to-end: the real guard run has + // MANY findings; a 1-count baseline for one of them suppresses exactly 1. + const before = JSON.parse(spawnCli(['guard', '--format', 'json', '--no-baseline'], dir).stdout); + const after = JSON.parse(spawnCli(['guard', '--format', 'json'], dir).stdout); + assert.equal(after.baselineSuppressed, 1, 'exactly one instance suppressed'); + assert.equal(before.findings.length - after.findings.length, 1); + }); + + it('H3 regression: report and ci disclose baseline suppression', () => { + dir = makeFixture(); + spawnCli(['guard', '--update-baseline'], dir); + const rep = JSON.parse(spawnCli(['report', '--format', 'json'], dir).stdout); + assert.ok(rep.guard.baselineSuppressed > 0, 'report JSON must carry baselineSuppressed'); + const repMd = spawnCli(['report'], dir).stdout; + assert.match(repMd, /suppressed by/, 'report markdown must disclose suppression'); + const ci = JSON.parse(spawnCli(['ci', '--no-history', '--format', 'json'], dir).stdout); + assert.ok(ci.guard.baselineSuppressed > 0, 'ci JSON must carry baselineSuppressed'); + }); + + it('L1 regression: --update-baseline refuses --changed-only', () => { + dir = makeFixture(); + const res = spawnCli(['guard', '--update-baseline', '--changed-only'], dir); + assert.equal(res.status, 1); + assert.ok(!existsSync(join(dir, BASELINE_FILE)), 'no partial baseline written'); + }); +}); diff --git a/tests/history.test.mjs b/tests/history.test.mjs new file mode 100644 index 0000000..ef60b57 --- /dev/null +++ b/tests/history.test.mjs @@ -0,0 +1,164 @@ +/** + * Score history + `score --trend` (v0.33). + * + * @req SC-HIS-001 — appendHistory/loadHistory roundtrip via .docguard/history.jsonl + * @req SC-HIS-002 — malformed lines are skipped, never thrown + * @req SC-HIS-003 — loadHistory returns [] when no history exists + * @req SC-HIS-004 — `docguard ci` appends one entry per run + * @req SC-HIS-005 — `docguard ci --no-history` opts out + * @req SC-HIS-006 — `score --trend` renders a friendly hint when empty, history when present + * @req SC-HIS-007 — `score --trend --format json` is pure parseable JSON + * @req SC-HIS-008 — ci --format json survives a pipe (stdout.write, not console.log+exit) + */ +import { describe, it, afterEach } from 'node:test'; +import { strict as assert } from 'node:assert'; +import { mkdtempSync, rmSync, writeFileSync, mkdirSync, readFileSync, existsSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { tmpdir } from 'node:os'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +import { appendHistory, loadHistory, sparkline } from '../cli/writers/history.mjs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const CLI = join(__dirname, '..', 'cli', 'docguard.mjs'); + +function makeFixture() { + const dir = mkdtempSync(join(tmpdir(), 'docguard-history-')); + writeFileSync(join(dir, '.docguard.json'), JSON.stringify({ + projectName: 'history-fixture', profile: 'standard', + })); + mkdirSync(join(dir, 'docs-canonical'), { recursive: true }); + writeFileSync(join(dir, 'docs-canonical', 'ARCHITECTURE.md'), '# Architecture\n'); + return dir; +} + +function spawnCli(args, cwd) { + return spawnSync('node', [CLI, ...args], { + cwd, encoding: 'utf-8', env: { ...process.env, NO_COLOR: '1' }, + }); +} + +describe('history writer', () => { + let dir; + afterEach(() => { if (dir) rmSync(dir, { recursive: true, force: true }); }); + + it('roundtrips entries oldest → newest', () => { + dir = makeFixture(); + appendHistory(dir, { timestamp: 't1', score: 50, grade: 'C', status: 'WARN' }); + appendHistory(dir, { timestamp: 't2', score: 80, grade: 'A', status: 'PASS' }); + const rows = loadHistory(dir); + assert.equal(rows.length, 2); + assert.equal(rows[0].score, 50); + assert.equal(rows[1].score, 80); + }); + + it('skips malformed lines and entries without a numeric score', () => { + dir = makeFixture(); + mkdirSync(join(dir, '.docguard'), { recursive: true }); + writeFileSync(join(dir, '.docguard', 'history.jsonl'), + '{"score":42,"grade":"C"}\nnot-json{{{\n{"noScore":true}\n{"score":90,"grade":"A"}\n'); + const rows = loadHistory(dir); + assert.equal(rows.length, 2); + assert.equal(rows[0].score, 42); + assert.equal(rows[1].score, 90); + }); + + it('returns [] with no history file', () => { + dir = makeFixture(); + assert.deepEqual(loadHistory(dir), []); + }); + + it('sparkline maps 0..100 to ▁..█', () => { + const line = sparkline([0, 50, 100]); + assert.equal(line.length, 3); + assert.equal(line[0], '▁'); + assert.equal(line[2], '█'); + }); +}); + +describe('docguard ci — history recording', () => { + let dir; + afterEach(() => { if (dir) rmSync(dir, { recursive: true, force: true }); }); + + it('appends one entry per run', () => { + dir = makeFixture(); + spawnCli(['ci'], dir); + spawnCli(['ci'], dir); + const rows = loadHistory(dir); + assert.equal(rows.length, 2); + assert.equal(typeof rows[0].score, 'number'); + assert.ok(rows[0].grade); + assert.ok(['PASS', 'WARN', 'FAIL'].includes(rows[0].status)); + }); + + it('--no-history opts out', () => { + dir = makeFixture(); + spawnCli(['ci', '--no-history'], dir); + assert.ok(!existsSync(join(dir, '.docguard', 'history.jsonl')), + '--no-history must not create the history file'); + }); + + it('H1 regression: bare `docguard ci` (text mode) never scaffolds into the workspace', () => { + dir = makeFixture(); + spawnCli(['ci', '--no-history'], dir); + assert.ok(!existsSync(join(dir, '.agent')), 'ci must not run ensureSkills (.agent scaffold)'); + assert.ok(!existsSync(join(dir, '.specify')), 'ci must not trigger the Spec Kit scaffold'); + }); + + it('L3 regression: a threshold failure is recorded as FAIL, not PASS', () => { + dir = makeFixture(); + const res = spawnCli(['ci', '--threshold', '101', '--format', 'json', '--no-history'], dir); + assert.equal(res.status, 1); + const parsed = JSON.parse(res.stdout); + assert.equal(parsed.status, 'FAIL', 'status must reflect the threshold gate'); + assert.equal(parsed.thresholdMet, false); + }); + + it('--format json is pure parseable JSON through a pipe and keeps exit codes', () => { + dir = makeFixture(); + const res = spawnCli(['ci', '--format', 'json'], dir); + const parsed = JSON.parse(res.stdout); // throws if banner bytes leaked + assert.equal(parsed.project, 'history-fixture'); + // bare fixture ⇒ missing required docs ⇒ guard errors ⇒ exit 1. (Before + // v0.33 the deprecated init-routing scaffolded the missing docs first — + // a CI gate mutating the workspace — and exited 2. Direct dispatch + // validates what's actually there.) + assert.equal(res.status, 1); + assert.equal(parsed.status, 'FAIL'); + }); +}); + +describe('docguard score --trend', () => { + let dir; + afterEach(() => { if (dir) rmSync(dir, { recursive: true, force: true }); }); + + it('shows a hint when no history exists (exit 0)', () => { + dir = makeFixture(); + const res = spawnCli(['score', '--trend'], dir); + assert.equal(res.status, 0); + assert.match(res.stdout, /No history yet/); + assert.match(res.stdout, /docguard ci/); + }); + + it('renders sparkline + recent runs once history exists', () => { + dir = makeFixture(); + spawnCli(['ci'], dir); + spawnCli(['ci'], dir); + const res = spawnCli(['score', '--trend'], dir); + assert.equal(res.status, 0); + assert.match(res.stdout, /over 2 run\(s\)/); + assert.match(res.stdout, /\/100/); + }); + + it('--format json returns {entries, latest, delta} as pure JSON', () => { + dir = makeFixture(); + spawnCli(['ci'], dir); + const res = spawnCli(['score', '--trend', '--format', 'json'], dir); + assert.equal(res.status, 0); + const parsed = JSON.parse(res.stdout); + assert.equal(parsed.entries.length, 1); + assert.equal(parsed.latest.score, parsed.entries[0].score); + assert.equal(parsed.delta, 0); + }); +}); diff --git a/tests/junit.test.mjs b/tests/junit.test.mjs new file mode 100644 index 0000000..34f704a --- /dev/null +++ b/tests/junit.test.mjs @@ -0,0 +1,100 @@ +/** + * `docguard guard --format junit` — JUnit XML for GitLab/Jenkins/Azure (v0.33). + * + * @req SC-JUN-001 — toJUnit maps validators to testcases (pass/failure/skipped) + * @req SC-JUN-002 — error findings become , warn-only becomes + * @req SC-JUN-003 — XML special characters are escaped (attributes AND bodies) + * @req SC-JUN-004 — CLI emits parseable XML with no banner bytes; exit codes match guard + */ +import { describe, it, afterEach } from 'node:test'; +import { strict as assert } from 'node:assert'; +import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { tmpdir } from 'node:os'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +import { toJUnit } from '../cli/writers/junit.mjs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const CLI = join(__dirname, '..', 'cli', 'docguard.mjs'); + +describe('toJUnit — mapping', () => { + it('maps pass/failure/warn/skipped validators to the right testcase shapes', () => { + const xml = toJUnit({ + project: 'fixture', + timestamp: '2026-07-15T00:00:00.000Z', + validators: [ + { name: 'Structure', key: 'structure', status: 'pass', findings: [] }, + { name: 'Security', key: 'security', status: 'fail', findings: [ + { code: 'SEC001', severity: 'error', message: 'Hardcoded secret', location: 'src/a.js:3' }, + ] }, + { name: 'Freshness', key: 'freshness', status: 'warn', findings: [ + { code: 'FRS002', severity: 'warn', message: 'Doc is stale', location: 'docs-canonical/X.md' }, + ] }, + { name: 'Schema-Sync', key: 'schemaSync', status: 'na', findings: [] }, + ], + findings: [], + }); + assert.match(xml, /^<\?xml version="1\.0" encoding="UTF-8"\?>/); + assert.match(xml, /tests="4" failures="1" errors="0"/); + assert.match(xml, //); + assert.match(xml, /\[SEC001\] Hardcoded secret \(src\/a\.js:3\)<\/failure>/); + assert.match(xml, /\[FRS002\] Doc is stale/); + assert.match(xml, /<\/testcase>/); + }); + + it('M1 regression: a crashed validator (fail, no findings) renders as , not a pass', () => { + const xml = toJUnit({ + project: 'fixture', + timestamp: 't', + validators: [ + { name: 'Traceability', key: 'traceability', status: 'fail', errors: ['boom: undefined is not a function'], warnings: [], findings: [] }, + ], + findings: [], + }); + assert.match(xml, /errors="1"/); + assert.match(xml, //); + assert.ok(!//.test(xml), + 'must not render as a bare passing testcase'); + }); + + it('escapes XML special characters in attributes and bodies', () => { + const xml = toJUnit({ + project: 'fix & "co"', + timestamp: 't', + validators: [ + { name: 'Docs-Sync', key: 'docsSync', status: 'fail', findings: [ + { code: 'DSY001', severity: 'error', message: 'route missing "docs"', location: null }, + ] }, + ], + findings: [], + }); + assert.ok(!/<\/api/.test(xml), 'raw < from message must not appear unescaped'); + assert.match(xml, /<\/api\?a=1&b=2>/); + assert.match(xml, /message="route <\/api\?a=1&b=2> missing "docs""/); + }); +}); + +describe('guard --format junit — CLI', () => { + let dir; + afterEach(() => { if (dir) rmSync(dir, { recursive: true, force: true }); }); + + it('emits parseable XML from byte 0 and exits 1 on a bare fixture (errors)', () => { + dir = mkdtempSync(join(tmpdir(), 'docguard-junit-')); + writeFileSync(join(dir, '.docguard.json'), JSON.stringify({ projectName: 'junit-fixture', profile: 'standard' })); + mkdirSync(join(dir, 'docs-canonical'), { recursive: true }); + writeFileSync(join(dir, 'docs-canonical', 'ARCHITECTURE.md'), '# Architecture\n'); + const res = spawnSync('node', [CLI, 'guard', '--format', 'junit'], { + cwd: dir, encoding: 'utf-8', env: { ...process.env, NO_COLOR: '1' }, + }); + assert.match(res.stdout, /^<\?xml version="1\.0"/, 'XML must start at byte 0 — no banner'); + assert.equal(res.status, 1, 'bare fixture has structure errors ⇒ exit 1'); + assert.match(res.stdout, /]*\/>/g) || []).length; + const closed = (res.stdout.match(/<\/testcase>/g) || []).length; + assert.equal(opens, selfClosed + closed, 'well-formed testcases'); + }); +}); diff --git a/tests/mcp-http.test.mjs b/tests/mcp-http.test.mjs index 86c626f..9ddc3d8 100644 --- a/tests/mcp-http.test.mjs +++ b/tests/mcp-http.test.mjs @@ -65,12 +65,12 @@ describe('docguard mcp --transport http', () => { assert.equal(body.result.serverInfo.name, 'docguard'); }); - it('tools/list returns the five read-only tools', async () => { + it('tools/list returns the six read-only tools', async () => { const res = await post(rpc('tools/list', {}, 2)); const body = await res.json(); const names = body.result.tools.map(t => t.name); assert.deepEqual(names.sort(), [ - 'docguard_diagnose', 'docguard_explain', 'docguard_guard', 'docguard_score', 'docguard_verify_claims', + 'docguard_diagnose', 'docguard_explain', 'docguard_guard', 'docguard_report', 'docguard_score', 'docguard_verify_claims', ]); }); diff --git a/tests/mcp.test.mjs b/tests/mcp.test.mjs index e194b5a..3e3514e 100644 --- a/tests/mcp.test.mjs +++ b/tests/mcp.test.mjs @@ -134,14 +134,14 @@ describe('docguard mcp', () => { client.send({ jsonrpc: '2.0', method: 'notifications/initialized' }); }); - it('tools/list exposes the five DocGuard tools with input schemas', async () => { + it('tools/list exposes the six DocGuard tools with input schemas', async () => { const res = await client.request({ jsonrpc: '2.0', id: 2, method: 'tools/list' }); assert.equal(res.id, 2); const tools = res.result.tools; - assert.equal(tools.length, 5); + assert.equal(tools.length, 6); assert.deepEqual( tools.map((t) => t.name).sort(), - ['docguard_diagnose', 'docguard_explain', 'docguard_guard', 'docguard_score', 'docguard_verify_claims'] + ['docguard_diagnose', 'docguard_explain', 'docguard_guard', 'docguard_report', 'docguard_score', 'docguard_verify_claims'] ); for (const t of tools) { assert.ok(t.description, `${t.name} must have a description`); @@ -151,6 +151,20 @@ describe('docguard mcp', () => { assert.deepEqual(explain.inputSchema.required, ['code']); }); + it('tools/call docguard_report returns the evidence bundle with integrity hash', async () => { + const res = await client.request({ + jsonrpc: '2.0', + id: 21, + method: 'tools/call', + params: { name: 'docguard_report', arguments: {} }, + }); + assert.equal(res.result.isError, undefined); + const body = JSON.parse(res.result.content[0].text); + assert.equal(body.tool.name, 'docguard'); + assert.match(body.integrity, /^sha256:[a-f0-9]{64}$/); + assert.equal(body.alcoa.total, 9); + }); + it('tools/call docguard_explain resolves STR001 from the CODES registry', async () => { const res = await client.request({ jsonrpc: '2.0', diff --git a/tests/report.test.mjs b/tests/report.test.mjs new file mode 100644 index 0000000..98cdcff --- /dev/null +++ b/tests/report.test.mjs @@ -0,0 +1,138 @@ +/** + * `docguard report` — compliance-evidence bundle (v0.33). + * + * @req SC-RPT-001 — buildReport returns the full evidence shape + * @req SC-RPT-002 — integrity hash is deterministic for the same tree state + * @req SC-RPT-003 — integrity hash excludes generatedAt (timestamps don't break reproducibility) + * @req SC-RPT-004 — report exits 0 even when guard has errors (evidence, not a gate) + * @req SC-RPT-005 — --format json emits pure parseable JSON (no banner bytes) + * @req SC-RPT-006 — markdown mode emits the report with no ANSI banner (headless) + * @req SC-RPT-007 — --out writes the artifact to a file + * @req SC-RPT-008 — findings are grouped by code with counts + */ +import { describe, it, afterEach } from 'node:test'; +import { strict as assert } from 'node:assert'; +import { mkdtempSync, rmSync, writeFileSync, mkdirSync, readFileSync, existsSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { tmpdir } from 'node:os'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +import { buildReport } from '../cli/commands/report.mjs'; +import { loadConfig } from '../cli/config.mjs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const CLI = join(__dirname, '..', 'cli', 'docguard.mjs'); + +/** Minimal project: a config + one canonical doc. Guard will flag plenty + * (missing required files) — which is exactly what the evidence tests need. */ +function makeFixture() { + const dir = mkdtempSync(join(tmpdir(), 'docguard-report-')); + writeFileSync(join(dir, '.docguard.json'), JSON.stringify({ + projectName: 'report-fixture', + profile: 'standard', + })); + mkdirSync(join(dir, 'docs-canonical'), { recursive: true }); + writeFileSync(join(dir, 'docs-canonical', 'ARCHITECTURE.md'), '# Architecture\n\nA fixture.\n'); + writeFileSync(join(dir, 'README.md'), '# report-fixture\n'); + return dir; +} + +function spawnCli(args, cwd) { + return spawnSync('node', [CLI, ...args], { + cwd, encoding: 'utf-8', env: { ...process.env, NO_COLOR: '1' }, + }); +} + +describe('buildReport — evidence payload shape', () => { + let dir; + afterEach(() => { if (dir) rmSync(dir, { recursive: true, force: true }); }); + + it('returns tool/project/guard/findings/score/alcoa/fixHistory/integrity', () => { + dir = makeFixture(); + const r = buildReport(dir, loadConfig(dir)); + assert.equal(r.tool.name, 'docguard'); + assert.match(r.tool.version, /^\d+\.\d+\.\d+/); + assert.equal(r.project.name, 'report-fixture'); + assert.equal(typeof r.guard.passed, 'number'); + assert.equal(typeof r.guard.total, 'number'); + assert.ok(Array.isArray(r.guard.validators)); + assert.ok(r.guard.validators.length > 0); + assert.ok(Array.isArray(r.findings)); + assert.equal(typeof r.score.score, 'number'); + assert.ok(r.score.grade); + assert.equal(r.alcoa.total, 9, 'ALCOA+ has 9 attributes'); + assert.equal(r.alcoa.attributes.length, 9); + assert.equal(r.fixHistory.entries, 0); + assert.equal(r.fixHistory.lastApplied, null); + assert.match(r.integrity, /^sha256:[a-f0-9]{64}$/); + assert.ok(r.generatedAt); + }); + + it('groups findings by code with counts (bare fixture ⇒ structure findings)', () => { + dir = makeFixture(); + const r = buildReport(dir, loadConfig(dir)); + assert.ok(r.findings.length > 0, 'a bare fixture must yield findings'); + for (const f of r.findings) { + assert.equal(typeof f.code, 'string'); + assert.ok(f.count >= 1); + assert.ok(['error', 'warn', 'info', undefined].includes(f.severity) || typeof f.severity === 'string'); + } + // Grouped: codes are unique + const codes = r.findings.map(f => f.code); + assert.equal(new Set(codes).size, codes.length, 'findings must be grouped — no duplicate codes'); + }); + + it('integrity is deterministic for the same tree and excludes generatedAt', async () => { + dir = makeFixture(); + const config = loadConfig(dir); + const r1 = buildReport(dir, config); + await new Promise(res => setTimeout(res, 15)); // force a different timestamp + const r2 = buildReport(dir, config); + assert.notEqual(r1.generatedAt, r2.generatedAt, 'timestamps should differ across runs'); + assert.equal(r1.integrity, r2.integrity, 'integrity must not depend on the timestamp'); + }); +}); + +describe('docguard report — CLI behavior', () => { + let dir; + afterEach(() => { if (dir) rmSync(dir, { recursive: true, force: true }); }); + + it('exits 0 even when guard finds errors (evidence, not a gate)', () => { + dir = makeFixture(); + const res = spawnCli(['report'], dir); + assert.equal(res.status, 0, `report must always exit 0 — got ${res.status}\n${res.stderr}`); + assert.match(res.stdout, /^# Documentation Compliance Report — report-fixture/, + 'markdown must start at byte 0 — no banner chrome before the artifact'); + assert.ok(!res.stdout.includes('['), 'no ANSI escapes in the artifact'); + }); + + it('--format json emits pure parseable JSON through a pipe', () => { + dir = makeFixture(); + const res = spawnCli(['report', '--format', 'json'], dir); + assert.equal(res.status, 0); + const parsed = JSON.parse(res.stdout); // throws if any banner byte leaked + assert.equal(parsed.project.name, 'report-fixture'); + assert.match(parsed.integrity, /^sha256:[a-f0-9]{64}$/); + }); + + it('--out writes the artifact to a file', () => { + dir = makeFixture(); + const res = spawnCli(['report', '--format', 'json', '--out', 'evidence.json'], dir); + assert.equal(res.status, 0); + const file = join(dir, 'evidence.json'); + assert.ok(existsSync(file), 'evidence.json must exist'); + const parsed = JSON.parse(readFileSync(file, 'utf-8')); + assert.equal(parsed.tool.name, 'docguard'); + }); + + it('markdown includes the summary, validators, ALCOA+ and integrity sections', () => { + dir = makeFixture(); + const res = spawnCli(['report'], dir); + assert.match(res.stdout, /## Summary/); + assert.match(res.stdout, /## Validators/); + assert.match(res.stdout, /## ALCOA\+ Attributes/); + assert.match(res.stdout, /## Integrity/); + assert.match(res.stdout, /sha256:[a-f0-9]{64}/); + }); +}); diff --git a/tests/v020-consolidation.test.mjs b/tests/v020-consolidation.test.mjs index 64bd4df..7fae7d5 100644 --- a/tests/v020-consolidation.test.mjs +++ b/tests/v020-consolidation.test.mjs @@ -107,11 +107,15 @@ describe('v0.20 — deprecation aliases (keep working + emit warning)', () => { // Each [command, expectedReplacement] pair. The command must still exit 0 // (or at least dispatch its underlying runner) and emit a deprecation warning // to stderr that mentions the v0.20 replacement. + // v0.33: `ci` is NOT in this list anymore — it was un-deprecated. The v0.20 + // routing sent the pipeline gate through runInit, which scaffolded missing + // docs into the CI workspace and printed init chrome into --format json. + // A gate must be read-only and machine-clean, so `ci` dispatches directly + // again (see the dedicated test below). const DEPRECATED_PAIRS = [ ['setup', 'init --wizard'], ['agents', 'init --with agents'], ['hooks', 'init --with hooks'], - ['ci', 'init --with ci'], ['badge', 'init --with badge'], ['llms', 'init --with llms'], ['publish', 'init --with publish'], @@ -135,6 +139,18 @@ describe('v0.20 — deprecation aliases (keep working + emit warning)', () => { }); } + it('`ci` dispatches directly — no deprecation warning, no init scaffolding (v0.33)', () => { + dir = makeFixture(); + const r = spawnSync('node', [CLI, 'ci', '--no-history'], { + cwd: dir, + encoding: 'utf-8', + }); + assert.ok(!(r.stderr + r.stdout).includes('Deprecated since'), + 'ci must not warn — it is a first-class command again'); + assert.ok(!(r.stderr + r.stdout).includes('DocGuard Init'), + 'ci must not route through init'); + }); + it('--quiet suppresses the deprecation warning', () => { dir = makeFixture(); const r = spawnSync('node', [CLI, 'badge', '--quiet'], {