From c5ac7899fa93e6d32d83c6e03c5c3e7b29ac3450 Mon Sep 17 00:00:00 2001 From: sokoly Date: Tue, 19 May 2026 17:44:19 -0400 Subject: [PATCH 1/8] feat(agent-context): layered CLAUDE.md doctrine + design spec + trace seed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR 1 of the agent-context-from-evidence design at docs/superpowers/specs/2026-05-19-agent-context-from-evidence-design.md: applies the lean-layered CLAUDE.md half of Anthropic's large-codebases guidance to this repo, mechanically enforced. Per-crate CLAUDE.md files (each <= 80 lines, local conventions only): - crates/evidence-core/CLAUDE.md - crates/cargo-evidence/CLAUDE.md - crates/evidence-mcp/CLAUDE.md Root CLAUDE.md adds a pointer paragraph to the per-module agent-context surface (CLI + MCP, landing in PRs 2-3 on the same branch). Trace chain seeded for this PR: - SYS-031 — per-module agent context surface (umbrella for all 4 PRs) - HLR-072 — repo demonstrates lean-layered CLAUDE.md doctrine - LLR-079 — layered_claude_md_doctrine enforces per-crate cap + scope - TEST-086 — every workspace crate ships a lean-layered CLAUDE.md The doctrine test in crates/evidence-core/tests/layered_claude_md_doctrine.rs asserts for every workspace crate under crates/: the file exists, has >= 10 non-blank lines, is under 80 lines, and contains its scoped `cargo test -p ` command. Adding a new workspace crate without a CLAUDE.md is a hard CI fail. Subsequent PRs on this branch land the queryable surface: - PR 2: evidence_core::context lib + `cargo evidence context` CLI - PR 3: `evidence_context` MCP tool - PR 4: `cargo evidence init --with-agent-context` scaffold for downstream Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 6 + cert/trace/hlr.toml | 22 + cert/trace/llr.toml | 20 + cert/trace/sys.toml | 27 ++ cert/trace/tests.toml | 15 + crates/cargo-evidence/CLAUDE.md | 49 +++ crates/evidence-core/CLAUDE.md | 49 +++ .../tests/layered_claude_md_doctrine.rs | 94 +++++ crates/evidence-mcp/CLAUDE.md | 53 +++ ...5-19-agent-context-from-evidence-design.md | 396 ++++++++++++++++++ 10 files changed, 731 insertions(+) create mode 100644 crates/cargo-evidence/CLAUDE.md create mode 100644 crates/evidence-core/CLAUDE.md create mode 100644 crates/evidence-core/tests/layered_claude_md_doctrine.rs create mode 100644 crates/evidence-mcp/CLAUDE.md create mode 100644 docs/superpowers/specs/2026-05-19-agent-context-from-evidence-design.md diff --git a/CLAUDE.md b/CLAUDE.md index 8c2d079..e3ab823 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,3 +28,9 @@ Mechanical enforcement: `walker_usage_locked` asserts (a) no unallowlisted `fs:: Default: every PR seeds its SYS/HLR/LLR/TEST entries in the first commit on the branch, before any implementation. The trace chain is the contract; the code implements it. **Exception — bidirectional contracts spanning two PRs.** When a single SYS-level claim covers both directions of a contract (e.g., forward-enrichment in one PR + reverse-verification in a follow-up), the *second* PR seeds the chain for both halves. The first PR ships functionality under an implicit trace obligation; the second PR's chain-seed discharges it for both. This is rare — only legitimate when the two halves form one logical deliverable and splitting the chain would force referencing UUIDs that don't yet exist. Do not generalize: if a later PR's scope is independent (new surface, different concern), seed its own chain from commit 1 as normal. + +## Module-level context for agents + +This repo follows the *lean root + layered subdirectory* `CLAUDE.md` pattern from [Anthropic's large-codebases guide](https://claude.com/blog/how-claude-code-works-in-large-codebases-best-practices-and-where-to-start). Root `CLAUDE.md` (this file) carries project-wide rules; each crate's `crates//CLAUDE.md` carries local conventions and the scoped test command for that crate. + +For per-module trace + boundary + floors context on any source file, call `evidence_context` (MCP) or `cargo evidence context `. Don't grep `cert/trace/*.toml` manually — the query is cheap and returns the requirements governing the file, their parent HLR / SYS roots, the tests that verify them, the diagnostic codes the module owns, and the floors it must respect. Design spec: `docs/superpowers/specs/2026-05-19-agent-context-from-evidence-design.md`. diff --git a/cert/trace/hlr.toml b/cert/trace/hlr.toml index 65d0c22..1336079 100644 --- a/cert/trace/hlr.toml +++ b/cert/trace/hlr.toml @@ -1800,3 +1800,25 @@ keypair), the check is a no-op. verification_methods = ["test"] traces_to = ["d469f0fa-0816-47a8-9452-b73e65f4cfd7"] surfaces = ["generate"] + +[[requirements]] +uid = "d33a1a39-c57a-48cc-9138-c4f8ff61f77d" +id = "HLR-072" +title = "Repo demonstrates lean-layered CLAUDE.md doctrine" +owner = "tool" +scope = "component" +description = """ +Each workspace crate under `crates/` ships a `CLAUDE.md` file that +(a) carries local conventions only — not a re-statement of root +rules; (b) documents its own scoped test command +(`cargo test -p `) so agents and humans avoid running +the full workspace suite for a single-crate change; (c) stays under +an 80-line cap so the file remains skimmable. The root `CLAUDE.md` +holds project-wide rules + a pointer to the per-module agent-context +surface; it must not absorb per-crate detail. The doctrine is +enforced mechanically by an integration test so adding a new +workspace crate without a `CLAUDE.md` fails CI. +""" +verification_methods = ["test"] +traces_to = ["d8d1f204-3909-418d-b62a-1e28edd088ed"] +surfaces = ["layered CLAUDE.md (root + crates/*/CLAUDE.md)"] diff --git a/cert/trace/llr.toml b/cert/trace/llr.toml index 115aa18..c599eff 100644 --- a/cert/trace/llr.toml +++ b/cert/trace/llr.toml @@ -2431,3 +2431,23 @@ the real cert/signing.pub. """ verification_methods = ["test"] emits = ["SIGN_PUBKEY_ANCHOR_MISMATCH"] + +[[requirements]] +uid = "288c920a-c667-4f84-9d0b-f4e8419ac141" +id = "LLR-079" +title = "layered_claude_md_doctrine enforces per-crate CLAUDE.md cap + scope" +owner = "tool" +traces_to = ["d33a1a39-c57a-48cc-9138-c4f8ff61f77d"] +modules = ["layered_claude_md_doctrine::every_workspace_crate_has_lean_layered_claude_md"] +description = """ +Integration test walks `crates/*/` (max_depth = 1, follow_links = +false) and asserts for every workspace crate: (a) `CLAUDE.md` +exists, (b) the file has at least 10 non-blank lines (no stub +files), (c) the file is under 80 lines (so root-vs-subdir scope +stays distinct — article's "lean, layered" doctrine), and (d) the +file contains the literal `cargo test -p ` so the +scoped test command is discoverable for agents working only in +that crate. Failures are accumulated and reported together so one +run shows every crate that needs fixing, not just the first. +""" +verification_methods = ["test"] diff --git a/cert/trace/sys.toml b/cert/trace/sys.toml index cce4203..42e3c04 100644 --- a/cert/trace/sys.toml +++ b/cert/trace/sys.toml @@ -789,3 +789,30 @@ verification_methods = [ "review", ] traces_to = [] + +[[requirements]] +uid = "d8d1f204-3909-418d-b62a-1e28edd088ed" +id = "SYS-031" +title = "Per-module agent context surface" +owner = "soi" +scope = "soi" +description = """ +The tool shall expose a per-module, trace-anchored context surface +that coding agents (and humans) can call before editing a source +file. Given a selector — file path, workspace crate, or Rust module +path — the surface shall return the requirements governing that +scope, their parent HLR / SYS roots, the tests that verify them, the +diagnostic codes the module owns, the floors that apply to its +crate, and the boundary policy. The surface shall be reachable +through (a) a CLI verb for humans and non-MCP agents, and (b) an +MCP tool method for agents whose harness speaks Model Context +Protocol. Downstream projects that adopt the tool via +`cargo evidence init` shall receive opt-in scaffolding (a starter +root `CLAUDE.md` + an `.claude/settings.json` snippet) that wires +the same surface for their own agents. The repository itself shall +demonstrate the canonical pattern via lean, layered `CLAUDE.md` +files (root for big picture; one per workspace crate for local +conventions and scoped test commands). +""" +verification_methods = ["test"] +traces_to = [] diff --git a/cert/trace/tests.toml b/cert/trace/tests.toml index 20a0d74..258ac68 100644 --- a/cert/trace/tests.toml +++ b/cert/trace/tests.toml @@ -1311,3 +1311,18 @@ test_selectors = [ "cli::generate::finalize::tests::resolve_signing_key_path_prefers_explicit_flag", "cli::generate::finalize::tests::resolve_signing_key_path_returns_none_when_neither_explicit_nor_env", ] + +[[tests]] +uid = "15234eb6-8e95-4779-b963-5829d18261a8" +id = "TEST-086" +title = "every workspace crate ships a lean-layered CLAUDE.md" +owner = "tool" +traces_to = ["288c920a-c667-4f84-9d0b-f4e8419ac141"] +test_selector = "layered_claude_md_doctrine::every_workspace_crate_has_lean_layered_claude_md" +description = """ +Walks crates/* and accumulates per-crate violations: missing +CLAUDE.md, fewer than 10 non-blank lines, more than 80 lines, or +missing the literal `cargo test -p ` scoped command. +Asserts the accumulated failure list is empty. Failure message +joins every offending crate so a single run names them all. +""" diff --git a/crates/cargo-evidence/CLAUDE.md b/crates/cargo-evidence/CLAUDE.md new file mode 100644 index 0000000..a7ff6bb --- /dev/null +++ b/crates/cargo-evidence/CLAUDE.md @@ -0,0 +1,49 @@ +# cargo-evidence — local conventions + +The user-facing binary: a Cargo subcommand (`cargo evidence `). All +CLI parsing, flag handling, and stdout shaping live here. Library logic +belongs in `evidence-core`. + +For per-module trace + boundary + floors context on any file in this crate, +call `evidence_context` (MCP) or `cargo evidence context `. + +## CLI layout + +- `src/cli//` — one module per verb (`check`, `generate`, `verify`, + `diff`, `init`, `schema`, `floors`, `rules`, `context`, `keygen`) +- `src/cli/output.rs` — JSONL emission helpers (`emit_jsonl` flushes per + event so partial-stream readers see one whole record at a time) +- `src/cli/parse.rs` — clap derive structs + +## Conventions + +- **Agent-facing verb is `check`.** Humans use `verify` / `generate`. Agents + default to `check` (auto-detects source vs bundle). Don't add new + agent-facing flows that bypass `check`. +- **JSONL invariants** (HLR-001, HLR-002): every `--format=jsonl` run emits + **exactly one** terminal as the last stdout line (`*_OK` / `*_FAIL` / + `*_ERROR`). Tracing logs + human prose go to stderr. The JSONL error path + keeps stderr silent so agents reading both streams don't see duplicate + data. +- **Terminals are hand-emitted**, not via `DiagnosticCode`. Register every + new terminal in `evidence_core::diagnostic::TERMINAL_CODES` — + `diagnostic_codes_locked` fails CI if you forget. +- **CLI-layer signals** (not from a `DiagnosticCode` impl) must be listed + in `evidence_core::diagnostic::HAND_EMITTED_CLI_CODES` so the bijection + test stays green. +- **No `process::exit()`.** Each verb returns a `Result`; `main()` maps + the result to an exit code. +- **`unwrap_used` / `expect_used` / `panic` / `todo` deny.** The + workspace `[lints.clippy]` block forbids them. Tests may opt into + `#[allow(clippy::expect_used, clippy::panic)]` where a `Result`- + returning pattern isn't ergonomic. + +## Scoped test command + +```bash +cargo test -p cargo-evidence --all-targets +``` + +(Includes the golden-fixture integration tests under `tests/fixtures/`. +Regenerate fixtures via `tools/regen-golden-fixtures.sh` — do not edit +them by hand.) diff --git a/crates/evidence-core/CLAUDE.md b/crates/evidence-core/CLAUDE.md new file mode 100644 index 0000000..16b02dc --- /dev/null +++ b/crates/evidence-core/CLAUDE.md @@ -0,0 +1,49 @@ +# evidence-core — local conventions + +Library crate. No `main`, no CLI parsing. Everything here is callable from +`cargo-evidence` (CLI), `evidence-mcp` (MCP server), and any downstream +consumer that wires the library in directly. + +For per-module trace + boundary + floors context on any file in this crate, +call `evidence_context` (MCP) or `cargo evidence context `. + +## Module groups + +- `trace/` — SYS / HLR / LLR / TEST parser, validator, matrix generator +- `hash/` — SHA-256 over files + directory trees (streaming I/O) +- `env/` — environment fingerprint capture +- `verify/` — bundle verification (re-hashes, cross-file checks) +- `policy/` — `Dal` enum + per-DAL `TracePolicy` derivation +- `boundary_check/` — `boundary.toml` enforcement +- `compliance/` — DO-178C Annex A objectives (per-DAL applicability) +- `coverage/` — coverage level + summary types +- `floors/` — measurement helpers for the ratchet +- `rules/` — diagnostic code registry (the self-describe surface) +- `diagnostic/` — `DiagnosticCode` + `FixHint` enums + terminal-code sets + +## Conventions + +- **Errors:** `thiserror` typed enums. Never `anyhow`. Each variant carries + the context for its message (paths, IDs, counts) via `#[source]` or + `#[from]`. The workspace's `disallowed_types` clippy rule bans + `anyhow::Error` here. +- **Determinism:** every output (Markdown matrices, JSON blobs) sorts via + `BTreeMap` / sorted iteration. Adding a `HashMap` in an output path is a + determinism regression. +- **Tests:** unit tests live in `src//tests.rs` with direct access + to internals. Integration tests in `tests/` use the public API only. +- **File-tree walks:** always `WalkDir::new(...).follow_links(false)`. Pinned + by `walker_usage_locked`. Single-directory non-recursive uses must be + allowlisted with written justification. +- **No `process::exit()`.** Library code returns `Result`; the binary + decides exit codes. + +## Scoped test command + +```bash +cargo test -p evidence-core --all-targets +``` + +(Use this when only this crate changed — `cargo test --workspace` rebuilds +`cargo-evidence` and spawns the MCP binary in `evidence-mcp/tests/`, adding +seconds.) diff --git a/crates/evidence-core/tests/layered_claude_md_doctrine.rs b/crates/evidence-core/tests/layered_claude_md_doctrine.rs new file mode 100644 index 0000000..df37391 --- /dev/null +++ b/crates/evidence-core/tests/layered_claude_md_doctrine.rs @@ -0,0 +1,94 @@ +//! Gate the lean-layered `CLAUDE.md` doctrine (LLR-079). +//! +//! Every workspace crate under `crates/` must ship a per-crate +//! `CLAUDE.md` carrying local conventions. The gate asserts: +//! +//! 1. The file exists. +//! 2. It is non-trivial (>= 10 non-blank lines so a stub doesn't pass). +//! 3. It mentions its own scoped test command +//! (`cargo test -p `) — the article's per-subdirectory +//! test-scoping anti-pattern fix. +//! 4. It is under 80 lines total so the file stays "local conventions +//! only," not a re-statement of root rules. (Article: "lean, +//! layered" — root for big picture, subdir for local.) +//! +//! Adding a new workspace crate without a `CLAUDE.md` is a hard fail. +//! Failure messages name the crate so the fix is obvious. + +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + reason = "test setup failures should panic immediately" +)] + +use std::fs; +use std::path::PathBuf; + +use walkdir::WalkDir; + +fn workspace_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("crates/") + .parent() + .expect("workspace root") + .to_path_buf() +} + +fn crate_dirs(root: &PathBuf) -> Vec<(String, PathBuf)> { + let crates_root = root.join("crates"); + WalkDir::new(&crates_root) + .follow_links(false) + .min_depth(1) + .max_depth(1) + .into_iter() + .filter_map(|e| e.ok()) + .filter(|e| e.file_type().is_dir()) + .map(|e| { + let name = e.file_name().to_string_lossy().into_owned(); + (name, e.into_path()) + }) + .collect() +} + +#[test] +fn every_workspace_crate_has_lean_layered_claude_md() { + let root = workspace_root(); + let mut failures: Vec = Vec::new(); + + for (name, dir) in crate_dirs(&root) { + let path = dir.join("CLAUDE.md"); + if !path.exists() { + failures.push(format!("crates/{name}/CLAUDE.md is missing")); + continue; + } + let body = fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("read crates/{name}/CLAUDE.md: {e}")); + let lines: Vec<&str> = body.lines().collect(); + let non_blank = lines.iter().filter(|l| !l.trim().is_empty()).count(); + if non_blank < 10 { + failures.push(format!( + "crates/{name}/CLAUDE.md is too thin ({non_blank} non-blank lines; need >= 10)" + )); + } + if lines.len() > 80 { + failures.push(format!( + "crates/{name}/CLAUDE.md is {} lines (cap is 80; trim local conventions or move project-wide rules to root)", + lines.len() + )); + } + let needle = format!("cargo test -p {name}"); + if !body.contains(&needle) { + failures.push(format!( + "crates/{name}/CLAUDE.md must document its scoped test command: `{needle}`" + )); + } + } + + assert!( + failures.is_empty(), + "lean-layered CLAUDE.md doctrine violations:\n - {}", + failures.join("\n - ") + ); +} diff --git a/crates/evidence-mcp/CLAUDE.md b/crates/evidence-mcp/CLAUDE.md new file mode 100644 index 0000000..0838e25 --- /dev/null +++ b/crates/evidence-mcp/CLAUDE.md @@ -0,0 +1,53 @@ +# evidence-mcp — local conventions + +MCP (Model Context Protocol) server. Stateless per-request — every tool +call resolves the workspace path, spawns a fresh `cargo evidence ` +subprocess, parses the result, and returns. The one piece of server- +lifetime state is a cached `VersionSkew` from the startup probe of +`cargo evidence --version`. + +For per-module trace + boundary + floors context on any file in this crate, +call `evidence_context` (MCP) or `cargo evidence context `. + +## Module layout + +- `server.rs` — `Server` struct, `ServerHandler` impl, one `#[tool]` method + per surface +- `server/responses.rs` — response shapers + (`jsonl_response_from_run_error`, `prepend_skew_signal`, etc.) +- `subprocess.rs` — `run_evidence()` spawner, `parse_jsonl`, timeout cap +- `schema.rs` — request / response types (`schemars`-derived) +- `version_probe.rs` — startup `cargo evidence --version` probe + skew + classification +- `workspace.rs` — workspace path resolution + `MCP_WORKSPACE_FALLBACK` + signal + +## Conventions + +- **Streaming verbs** (`check`, `doctor`, `floors`) go through + `Server::run_streaming_verb`. Skipping the helper is the only way to + miss the version-skew + workspace-fallback prepend. Don't. +- **Single-blob verbs** (`rules`, `diff`, `ping`, `context`) shape their + own responses but still call `skew_diagnostic` to populate + `warnings`. +- **`name = "evidence-mcp"`** on `#[tool_handler]` is load-bearing — + `rmcp`'s default identifies the server as `"rmcp"` in the `initialize` + response. Don't remove the override (LLR-062 pins it). +- **Version skew** is probed **once** at `Server::new()` and cached in an + `Arc`. Per-request skew checks read the cache; they don't + re-probe. +- **No new transport machinery** for new tools. Reuse `run_evidence` + + `parse_jsonl` + `server/responses.rs`. If a new verb truly needs new + plumbing, justify it in the LLR. +- **Subprocess timeout:** capped by `EVIDENCE_MCP_TIMEOUT_SECS` + (default 600s) — `evidence_check --mode=source` can run `cargo test + --workspace` for minutes. + +## Scoped test command + +```bash +cargo test -p evidence-mcp --all-targets +``` + +(Tests spawn the MCP binary via `assert_cmd`; expect ~10s overhead vs. +evidence-core's unit-only tests.) diff --git a/docs/superpowers/specs/2026-05-19-agent-context-from-evidence-design.md b/docs/superpowers/specs/2026-05-19-agent-context-from-evidence-design.md new file mode 100644 index 0000000..bbe1345 --- /dev/null +++ b/docs/superpowers/specs/2026-05-19-agent-context-from-evidence-design.md @@ -0,0 +1,396 @@ +# Agent-context from Evidence — design spec + +**Date:** 2026-05-19 +**Status:** Draft — pending user review +**Goal:** Make `cargo-evidence`'s trace + boundary + floors graph, surfaced +through `evidence-mcp` and a new `cargo evidence context` CLI verb, serve as +the per-module context substrate that coding agents read before editing — +both for this repo and for any project that adopts `cargo-evidence`. + +This spec follows the brainstorming → writing-plans → implementation flow. +The writing-plans skill consumes this file to produce the per-PR +implementation plan. + +--- + +## 1. Background + +Anthropic's [*How Claude Code Works in Large +Codebases*](https://claude.com/blog/how-claude-code-works-in-large-codebases-best-practices-and-where-to-start) +identifies seven extension points: `CLAUDE.md` files, hooks, skills, plugins, +LSP, MCP servers, and subagents. Two of its core recommendations are directly +relevant here: + +1. **Lean, layered `CLAUDE.md`.** Root file = big picture; subdirectory files + = local conventions. Loading everything into root degrades performance; + per-subdirectory init keeps context focused. +2. **MCP servers exposing structured search.** "The most sophisticated teams + build MCP servers exposing structured search as a tool Claude can call + directly," reducing reliance on grep-based exploration. + +This project already has the underlying data: + +- `cert/trace/{sys,hlr,llr,tests}.toml` — every LLR carries `modules = + ["evidence_core::trace::..."]`, `emits = ["DIAG_CODE", ...]`, and + `traces_to = [HLR-uid, ...]`. The trace graph is a *queryable per-module + spec*: given any source path, it can return the requirements governing it, + the tests verifying it, the diagnostic codes it owns, and the SYS root that + justifies it. +- `cert/boundary.toml` — per-crate DAL, in-scope set, forbidden + dependencies. +- `cert/floors.toml` — per-dimension regression gate. +- `evidence-mcp` — already exposes six tools (`check`, `rules`, `doctor`, + `floors`, `diff`, `ping`). + +The gap is the bridge: today an agent editing `crates/x/src/y.rs` cannot +directly ask "what is the trace context for this file?" — it has to grep +TOML. Downstream projects that adopt `cargo-evidence` get the data but no +agent-facing scaffolding. + +## 2. Goals & non-goals + +### Goals + +- **G1.** From an MCP-connected agent, a single call returns the trace + + boundary + floors slice for any selector (file / crate / module). +- **G2.** Humans and non-MCP agents reach the same data via `cargo evidence + context`. +- **G3.** This repo demonstrates the pattern: root `CLAUDE.md` stays lean + + three per-crate `CLAUDE.md` files carry local conventions only. +- **G4.** A project that runs `cargo evidence init` can opt into the same + scaffolding (a starter root `CLAUDE.md`, an `.claude/settings.json` + snippet) without obligating any agent-facing markdown beyond what they + ask for. +- **G5.** All four PRs are independently revertible and individually + valuable. + +### Non-goals + +- **N1.** Auto-generated per-module `CLAUDE.md` from the trace. Mixing + generated and hand-written conventions risks the article's + "everything-in-`CLAUDE.md`" anti-pattern. Structured data belongs behind a + query, not in `CLAUDE.md`. +- **N2.** A Stop-hook recipe that turns `check` GAPs into proposed + `CLAUDE.md` edits. Plausible follow-up; out of scope here. +- **N3.** Packaging `evidence-mcp` as a Claude Code plugin. Worth a + separate spec once PRs 1–4 land. +- **N4.** LSP integration. Orthogonal to what `cargo-evidence` uniquely + provides. +- **N5.** Backfilling `CLAUDE.md` files into already-adopted downstream + projects automatically. Adoption is opt-in via `init` or by hand. + +## 3. Surfaces + +### 3.1 MCP tool — `evidence_context` + +Request: + +```jsonc +{ + "workspace_path": "/optional/abs", + "selector": "" +} +``` + +`selector` semantics (resolution order, first match wins): + +1. **File** — a relative path under `crates//...` or an absolute + path inside `workspace_path`. +2. **Crate** — a workspace crate name (matches `[package].name` in + `crates/*/Cargo.toml`). +3. **Module** — a Rust module path (`evidence_core::trace`) matched against + each requirement's `modules` field. +4. **Null** (omitted) — returns the workspace overview only. + +Response (single JSON blob, ordered fields): + +```jsonc +{ + "selector": { "kind": "file|crate|module|workspace", "input": "...", "resolved": "..." }, + "crate": "evidence-core", + "dal": "D", + "requirements": [ { "id":"LLR-001", "uid":"...", "layer":"llr", + "title":"...", "description":"...", + "modules":[...], "emits":[...], + "traces_to":[...], "verification_methods":[...] } ], + "parents": [ { "id":"HLR-001", "uid":"...", "layer":"hlr", + "title":"...", "traces_to":[""] } ], + "tests": [ { "id":"TEST-001", "uid":"...", "name":"...", + "selector":"...", "traces_to":[...] } ], + "diagnostic_codes": [ "VERIFY_OK", "VERIFY_FAIL", "VERIFY_ERROR" ], + "floors": [ { "dimension":"test_count", "current":42, "floor":40 } ], + "boundary": { "in_scope":true, "forbidden_deps":[...] }, + "conventions": { "nearest_claude_md":"crates/evidence-core/CLAUDE.md" }, + "warnings": [ /* CONTEXT_NO_REQUIREMENTS_FOR_SELECTOR, etc. */ ] +} +``` + +Properties: + +- **Pure inspection.** No `cargo test`. Reads `cert/`, `Cargo.toml`, + source paths. +- **Cheap.** Designed to be called on every agent loop iteration. Order of + magnitude: tens of milliseconds. +- **Stable wire shape.** Byte-locked against a golden fixture at + `crates/evidence-mcp/tests/fixtures/golden_context_response.json` + (regen via the existing `tools/regen-golden-fixtures.sh` pattern). + +### 3.2 CLI verb — `cargo evidence context` + +```bash +cargo evidence context crates/evidence-core/src/trace.rs # file selector +cargo evidence context --crate evidence-mcp # crate selector +cargo evidence context --module evidence_core::trace --json # module selector +cargo evidence context # workspace overview +``` + +Output: + +- Default — human table summarizing the requirements, tests, diagnostic + codes, floors, and the nearest `CLAUDE.md` path. +- `--json` — the same JSON blob the MCP tool returns. Byte-locked to a + golden fixture at + `crates/cargo-evidence/tests/fixtures/golden_context.json`. + +Exit codes (matches the rest of the CLI): + +- `0` — `CONTEXT_OK`. Context resolved successfully. Also `0` for + `CONTEXT_NO_TRACE_CONFIGURED` (non-adopter graceful path, mirroring + `floors`'s "not configured" behavior). +- `1` — `CONTEXT_ERROR`. Runtime error (trace files unreadable, IO + failure, parse failure). +- `2` — `CONTEXT_FAIL`. Selector invalid for the workspace + (`CONTEXT_SELECTOR_OUT_OF_SCOPE`). + +### 3.3 Per-crate `CLAUDE.md` in this repo + +Each ≤60 lines, local conventions only, no re-statement of root rules. + +- **`crates/evidence-core/CLAUDE.md`** + - Library crate (no binary). + - Module groups: `trace`, `hash`, `env`, `verify`, `policy`, `boundary_check`, `compliance`, `coverage`, `floors`, `rules`, `diagnostic`. + - Convention: `thiserror` for errors; never `anyhow`. Unit tests live in `src/*/tests.rs`; integration in `tests/`. + - Scoped test command: `cargo test -p evidence-core --all-targets`. + +- **`crates/cargo-evidence/CLAUDE.md`** + - Cargo subcommand binary; user-facing entry point. + - CLI layout: `src/cli//...`. + - Agent-facing verb is `check`; humans get `verify`, `generate`, `diff`, + `floors`, `rules`, `context`. + - JSONL invariants: every `--format=jsonl` run emits exactly one + terminal (`*_OK` / `*_FAIL` / `*_ERROR`) as the last stdout line; one + JSON object per line, error prose on stderr only. + - Scoped test command: `cargo test -p cargo-evidence --all-targets`. + +- **`crates/evidence-mcp/CLAUDE.md`** + - MCP wrapper over the CLI. Stateless per-request; one-shot + version-skew probe at startup. + - Subprocess pattern: `subprocess::run_evidence`; streaming verbs go + through `Server::run_streaming_verb`. + - Six tools today; `evidence_context` is the seventh. + - Scoped test command: `cargo test -p evidence-mcp --all-targets`. + +Root `CLAUDE.md` gets one new short paragraph: a pointer to +`cargo evidence context` and `evidence_context`. + +### 3.4 Init scaffolding for downstream — `cargo evidence init --with-agent-context` + +`init` (existing) emits `cert/boundary.toml`, `cert/profiles/*`, +`cert/trace/*.toml`. + +New (opt-in) emissions: + +- `CLAUDE.md` at workspace root — starter template (≤30 lines): one line + per project rule the user wrote, plus a pointer paragraph to + `cargo evidence context` / `evidence-mcp`. +- `.claude/settings.json` (or merged with existing) — registers + `evidence-mcp` as an MCP server and adds a `permissions.deny` entry for + the default `evidence/` output dir. + +Flags: + +- `--with-agent-context` — emit (the default). +- `--no-agent-context` — skip. +- Existing files are **never overwritten** (matches `init`'s current + behavior); the command prints which files it skipped and the diff the + user would need to apply by hand. + +## 4. Architecture + +### 4.1 Library layer (`evidence-core`) + +New module `evidence_core::context`: + +- `pub fn resolve_selector(workspace_root: &Path, raw: Option<&str>) -> Result` +- `pub fn context_for(workspace_root: &Path, selector: &ResolvedSelector) -> Result` + +Internally composes: + +- `evidence_core::trace::read_all_trace_files` (existing). +- A new resolver that walks `crates/*/Cargo.toml` to map crate name ↔ + directory ↔ package name ↔ root module name. +- A new index: `BTreeMap>` keyed by + prefix-match against each LLR's `modules` field. +- A floors-slice helper that filters `cert/floors.toml` to dimensions + semantically scoped to the resolved crate (`test_count`, + `library_panics`, etc.). + +Errors are typed via `thiserror`: + +```rust +#[derive(thiserror::Error, Debug)] +pub enum ContextError { + #[error("selector {0:?} is outside the workspace")] + SelectorOutOfScope(String), + #[error("trace not configured at {0}")] + TraceNotConfigured(PathBuf), + #[error("trace read failed")] + TraceRead(#[from] TraceError), + /* ... */ +} +``` + +Each variant carries the context for its message; no `From` +back door. + +### 4.2 CLI layer (`cargo-evidence`) + +New module `cli::context`: + +- Parses flags into a `ResolvedSelector`. +- Calls `evidence_core::context::context_for`. +- Renders human table or `--json` blob. +- Emits hand-built terminals: `CONTEXT_OK`, `CONTEXT_FAIL`, + `CONTEXT_ERROR`. Registered in `TERMINAL_CODES` per the existing + diagnostic-code locking. + +### 4.3 MCP layer (`evidence-mcp`) + +New tool method `evidence_context` on `Server`. Unlike the streaming verbs +(`check`, `doctor`, `floors`), this is a single-blob response — it follows +the `evidence_diff` shape: + +```rust +#[tool(name = "evidence_context", description = "...")] +pub async fn evidence_context( + &self, + Parameters(req): Parameters, +) -> Result, String> { ... } +``` + +Implementation: spawns `cargo evidence context --json` via the +existing `subprocess::run_evidence`, deserializes the blob, prepends +workspace-fallback + version-skew warnings via the existing helpers. No +new transport machinery. + +### 4.4 Init layer (`cargo-evidence`) + +`cli::init` extended to (conditionally) write the new files. Templates +live as `const &str` in the crate, not on disk, so the binary is +self-contained. + +## 5. Data flow — an agent session + +``` +1. Agent opens session in crates/evidence-mcp/. + Harness walks up: loads crates/evidence-mcp/CLAUDE.md, then root CLAUDE.md. +2. Agent calls evidence_context({ selector: "" }). +3. Response: governing LLRs, test selectors, diagnostic codes, floors, + boundary, nearest CLAUDE.md path. +4. Agent edits; knows precisely which `cargo test -p -- ` + to re-run. +5. Agent calls evidence_check(--mode=source) to validate. +6. On GAP, agent reads root_cause_uid, calls evidence_context on the + owning LLR's modules[0] to re-orient. +``` + +## 6. Diagnostic codes (new) + +Each must be (a) backed by `DiagnosticCode::code()`, and (b) claimed by at +least one LLR's `emits` list — per `diagnostic_codes_locked`. + +| Code | Severity | Layer | Meaning | +|--------------------------------------------|----------|----------|---------| +| `CONTEXT_OK` | Info | terminal | Selector resolved, response built. | +| `CONTEXT_FAIL` | Error | terminal | Selector invalid or no resolution possible. | +| `CONTEXT_ERROR` | Error | terminal | Runtime failure (trace files unreadable, etc.). | +| `CONTEXT_NO_REQUIREMENTS_FOR_SELECTOR` | Warning | content | Selector resolved but matches zero requirements (signal: untraced module). | +| `CONTEXT_SELECTOR_OUT_OF_SCOPE` | Error | content | Selector resolves outside the workspace. | +| `CONTEXT_NO_TRACE_CONFIGURED` | Info | content | `cert/trace/` missing — non-adopter graceful path. | +| `CONTEXT_AMBIGUOUS_SELECTOR` | Warning | content | Input matched multiple kinds; resolver picked the highest-priority one. | + +## 7. Tests + +- **Unit (`evidence-core/src/context/tests.rs`):** selector classifier + cases, LLR-by-`modules` lookup (exact + prefix), parent rollup, floors + per-crate slicing, error variants. ≤80 lines per test fn. +- **Integration (`crates/cargo-evidence/tests/cli_context.rs`):** spawn + `cargo evidence context --json`; assert against golden + `crates/cargo-evidence/tests/fixtures/golden_context.json`. +- **MCP integration (`crates/evidence-mcp/tests/context_roundtrip.rs`):** + spawn the MCP binary, send `tools/call` for `evidence_context`, assert + shape and skew-signal forwarding. +- **Trace gating:** `diagnostic_codes_locked` covers the new + `CONTEXT_*` codes automatically once they're in `RULES` + claimed by + LLRs. +- **Walker gating:** any new dir walks use + `WalkDir::new(...).follow_links(false)` (`walker_usage_locked`). +- **CRLF gating:** golden fixtures live under `tests/fixtures/` with the + existing `** binary` `.gitattributes` rule. + +## 8. Compatibility + +- **Determinism.** Response key order is fixed via `BTreeMap`-backed + serialization; arrays are sorted by stable keys (`id` for + requirements, `name` for tests). The golden fixture pins the order + byte-for-byte. +- **Schema version.** New `ContextReport` is in + `evidence_core::context`; not part of any existing on-disk schema, so + no `schema_versions.rs` bump is required. If we choose to embed it + into the bundle later, it gets its own schema and version. +- **Backwards compatibility.** Pure additions to the CLI surface, MCP + surface, and `init`. No existing behavior changes. +- **MSRV.** No new toolchain requirements; uses only crates already in + the workspace. + +## 9. Risks & mitigations + +| Risk | Mitigation | +|------|------------| +| `evidence_context` becomes a dumping ground for every agent-helpful field; response bloats. | Hard-cap the response schema in the spec (above). New fields require a new MCP tool method, not a wider response. | +| Per-crate `CLAUDE.md` duplicates root content over time. | Lint helper in PR 1: simple regex check that flags duplicated workspace-wide rules. Reviewer-enforced for now; promote to CI gate if drift returns. | +| Downstream `init` scaffolding writes over a user's existing `CLAUDE.md`. | `init` already refuses to overwrite; new files use the same guard. Skipped writes are logged. | +| Selector resolution ambiguity (file path also valid as crate name). | Fixed priority: file > crate > module. Surface `CONTEXT_AMBIGUOUS_SELECTOR` warning. | +| Trace TOML grows; lookups slow. | Index built once per call, not per request fan-out. Lookups are `BTreeMap` `range` queries. Re-measure if cumulative LLR count crosses 500. | +| MCP server CWD-fallback misroutes context. | Inherit the existing `MCP_WORKSPACE_FALLBACK` signal path; surfaced in `warnings`. | + +## 10. Scope split — one issue per PR + +| PR | Scope | Trace seed | Independently valuable | +|----|-------|------------|------------------------| +| **PR 1** | This spec + layered `CLAUDE.md` (root pointer + 3 per-crate) + new SYS/HLR/LLR/TEST chain | Yes — full chain seeded in commit 1 | Yes — improves agent context in this repo without new code | +| **PR 2** | `evidence_core::context` library + `cargo evidence context` CLI + golden fixture + tests | Implements LLRs from PR 1 | Yes — humans + non-MCP agents benefit | +| **PR 3** | `evidence_context` MCP tool + roundtrip tests | Implements MCP-tool LLR from PR 1 | Yes — MCP users get full benefit | +| **PR 4** | `cargo evidence init --with-agent-context` + `.claude/settings.json` snippet template + tests | Implements init-scaffold LLR from PR 1 | Yes — downstream users get scaffolding | + +## 11. Explicitly out of scope + +- Auto-generated per-module `CLAUDE.md`. +- Stop-hook recipe. +- Plugin packaging. +- LSP integration. +- Backfilling existing downstream projects. + +## 12. Open questions to revisit during writing-plans + +- Should `--with-agent-context` default to on or off in PR 4? Leaning + **on** (the article argues for low-friction adoption), but the project's + "no excessive .md documents" rule suggests caution. The user opted into + `cargo evidence init` already, so emitting a single 30-line `CLAUDE.md` + is consistent with that opt-in. +- Should the workspace overview (selector = null) embed a high-level + crate map, or just list crate names + DAL? Leaning **embed**, capped at + ~10 lines per crate to keep the response small. +- Should the response include git status (current branch, dirty bit)? + Tempting (it's free via `GitSnapshot::capture`) but it's not strictly + *context* — it's *state*. Leaning **no** for v1; add later if asked. From c8f88eebfbdf8503823aedc150873200995a96c1 Mon Sep 17 00:00:00 2001 From: sokoly Date: Tue, 19 May 2026 21:02:22 -0400 Subject: [PATCH 2/8] feat(agent-context): evidence_core::context + cargo evidence context CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR 2 of the agent-context-from-evidence design at docs/superpowers/specs/2026-05-19-agent-context-from-evidence-design.md: implements the per-module trace + boundary + floors + nearest CLAUDE.md slice an agent (or human) can query before editing a source file. Library (`evidence_core::context`, 5 sub-files under `src/context/`): - `resolve_selector(workspace_root, raw)` classifies the input as File > Crate > Module > Workspace (priority order on ambiguity). - `context_for(workspace_root, selector)` composes a `ContextReport` by reading `cert/trace/*.toml` once, filtering LLRs whose `modules` field overlaps the selector's module space, rolling each LLR's `traces_to` up to HLR + SYS parents, collecting verifying tests, aggregating every `LLR.emits`, slicing per-crate floor / ceiling rows, and pointing at the nearest layered CLAUDE.md. - `ContextReport` matches design spec §3.1 exactly; round-tripped through serde in unit tests; sorted deterministically (requirements by id, tests by name, codes alphabetically). - `ContextError` is `thiserror`-typed with seven variants and a `content_code()` mapping to the `CONTEXT_*` content codes. CLI (`cargo evidence context [] [--crate|--module] [--json|--format=jsonl]`): - Three output shapes: human table (default), single pretty JSON, streaming JSONL (report blob + one diagnostic per warning + a `CONTEXT_OK` / `CONTEXT_FAIL` / `CONTEXT_ERROR` terminal). - Exit codes: 0 for `CONTEXT_OK` and the graceful `CONTEXT_NO_TRACE_CONFIGURED` path, 1 for `CONTEXT_ERROR`, 2 for `CONTEXT_FAIL`. - Three hand-built terminals registered in `TERMINAL_CODES`; four content codes registered in `RULES` under the new `Context` domain + `HAND_EMITTED_CLI_CODES`. Each claimed by an LLR's `emits` field per the bijection invariants. Tests: - 15 unit tests under `crates/evidence-core/src/context/tests.rs` cover resolver classification (6 cases), lookup composition (7), and error variants (2). - 4 integration tests at `crates/cargo-evidence/tests/cli_context.rs`: golden byte-diff (`tests/fixtures/golden_context.json`, regenerated via `tools/regen-golden-fixtures.sh`), non-adopter graceful path, invalid-selector failure path, and a human-mode smoke test. Trace seed (implements the LLRs PR 1 set up): - HLR-073 — cargo evidence context CLI verb returns per-module trace slice. - LLR-080 — context::resolver classifies selectors with documented priority. - LLR-081 — context::lookup composes ContextReport from trace/boundary/floors/CLAUDE.md. - LLR-082 — cli::context wires CLI verb to context_for + emits CONTEXT_* terminals. - LLR-083 — context content codes register in RULES and gate the golden wire shape. - TEST-087, TEST-088, TEST-089, TEST-090. Surface catalog: - Adds "context" to `KNOWN_SURFACES` (Group 1 — CLI verbs). - Adds "layered CLAUDE.md (root + crates/*/CLAUDE.md)" (Group 2 — observable contracts) to claim HLR-072's surface, closing the bijection gap PR 1 left. Floors bump (`cert/floors.toml`): - workspace: diagnostic_codes 154→161, terminal_codes 15→18, trace_sys 30→31, trace_hlr 71→73, trace_llr 78→83, trace_test 83→88, known_surfaces 23→25. - per_crate.evidence-core test_count 367→383 (15 new context unit tests + 1 from PR 1's doctrine test). - per_crate.cargo-evidence test_count 154→158 (4 new cli_context integration tests). Module size discipline (workspace 500-line cap): - Split `crates/evidence-core/src/rules.rs` constructors into `rules/constructors.rs` (440 lines, was 512). - Split `crates/cargo-evidence/src/cli/args.rs`'s schema clap types into `args/schema.rs` (474 lines, was 508). Subsequent PR on this branch: - PR 3: `evidence_context` MCP tool — single-blob response wrapping the same `ContextReport`, byte-locked against a parallel golden. - PR 4: `cargo evidence init --with-agent-context` scaffold. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitattributes | 7 +- cert/floors.toml | 21 +- cert/trace/hlr.toml | 26 + cert/trace/llr.toml | 117 ++ cert/trace/tests.toml | 95 ++ crates/cargo-evidence/src/cli.rs | 1 + crates/cargo-evidence/src/cli/args.rs | 75 +- crates/cargo-evidence/src/cli/args/schema.rs | 50 + crates/cargo-evidence/src/cli/context.rs | 305 ++++ crates/cargo-evidence/src/cli/rules.rs | 1 + crates/cargo-evidence/src/main.rs | 21 +- crates/cargo-evidence/tests/cli_context.rs | 199 +++ .../tests/fixtures/golden_context.json | 1445 +++++++++++++++++ .../tests/fixtures/golden_rules.json | 49 + crates/cargo-evidence/tests/rules_cmd.rs | 1 + crates/evidence-core/src/context.rs | 37 + crates/evidence-core/src/context/error.rs | 81 + crates/evidence-core/src/context/lookup.rs | 421 +++++ crates/evidence-core/src/context/report.rs | 219 +++ crates/evidence-core/src/context/resolver.rs | 298 ++++ crates/evidence-core/src/context/tests.rs | 272 ++++ crates/evidence-core/src/diagnostic.rs | 3 + crates/evidence-core/src/lib.rs | 1 + crates/evidence-core/src/rules.rs | 76 +- .../evidence-core/src/rules/constructors.rs | 86 + crates/evidence-core/src/rules/domain_map.rs | 1 + .../evidence-core/src/rules/hand_emitted.rs | 4 + crates/evidence-core/src/trace.rs | 2 +- crates/evidence-core/src/trace/surfaces.rs | 4 +- .../tests/layered_claude_md_doctrine.rs | 4 +- tools/regen-golden-fixtures.sh | 8 +- 31 files changed, 3810 insertions(+), 120 deletions(-) create mode 100644 crates/cargo-evidence/src/cli/args/schema.rs create mode 100644 crates/cargo-evidence/src/cli/context.rs create mode 100644 crates/cargo-evidence/tests/cli_context.rs create mode 100644 crates/cargo-evidence/tests/fixtures/golden_context.json create mode 100644 crates/evidence-core/src/context.rs create mode 100644 crates/evidence-core/src/context/error.rs create mode 100644 crates/evidence-core/src/context/lookup.rs create mode 100644 crates/evidence-core/src/context/report.rs create mode 100644 crates/evidence-core/src/context/resolver.rs create mode 100644 crates/evidence-core/src/context/tests.rs create mode 100644 crates/evidence-core/src/rules/constructors.rs diff --git a/.gitattributes b/.gitattributes index 6abfc0d..dc28cc5 100644 --- a/.gitattributes +++ b/.gitattributes @@ -21,7 +21,8 @@ crates/evidence-core/tests/fixtures/compliance/** binary Cargo.lock text eol=lf rust-toolchain.toml text eol=lf -# Golden wire-shape fixture for `cargo evidence rules --json`. Keep -# literal bytes so cross-OS checkouts don't mangle line endings and -# trip the byte-diff test. +# Golden wire-shape fixtures byte-diffed against the CLI output — +# keep literal bytes so cross-OS checkouts don't mangle line endings +# and trip the byte-diff tests. crates/cargo-evidence/tests/fixtures/golden_rules.json binary +crates/cargo-evidence/tests/fixtures/golden_context.json binary diff --git a/cert/floors.toml b/cert/floors.toml index 3823fa2..9242460 100644 --- a/cert/floors.toml +++ b/cert/floors.toml @@ -42,28 +42,29 @@ schema_version = 1 # evidence_core::RULES length — every diagnostic code the tool can emit, # hand-curated. Lives in a single crate, so workspace-wide. -diagnostic_codes = 154 +diagnostic_codes = 161 # evidence_core::TERMINAL_CODES length — hand-emitted terminals # (VERIFY_OK / VERIFY_FAIL / VERIFY_ERROR / CLI_SUBCOMMAND_ERROR / -# DOCTOR_OK / DOCTOR_FAIL / KEYGEN_OK / KEYGEN_FAIL). -terminal_codes = 15 +# DOCTOR_OK / DOCTOR_FAIL / KEYGEN_OK / KEYGEN_FAIL / +# CONTEXT_OK / CONTEXT_FAIL / CONTEXT_ERROR). +terminal_codes = 18 # cert/trace/sys.toml — System Requirements. -trace_sys = 30 +trace_sys = 31 # cert/trace/hlr.toml — High-Level Requirements. -trace_hlr = 71 +trace_hlr = 73 # cert/trace/llr.toml — Low-Level Requirements. -trace_llr = 78 +trace_llr = 83 # cert/trace/tests.toml — Test Cases. -trace_test = 83 +trace_test = 88 # evidence_core::trace::surfaces::KNOWN_SURFACES length — hand-curated # catalog of CLI verbs + named observable contracts. Matching HLR # coverage is enforced by `require_hlr_surface_bijection`; this floor # guards against silently shrinking the catalog itself (which would # relax the bijection without firing the check). -known_surfaces = 23 +known_surfaces = 25 # -------------------------------------------------------------------- # Per-crate-true dimensions: one table per in-scope crate. @@ -78,10 +79,10 @@ known_surfaces = 23 [per_crate.evidence-core] # `#[test]` attribute count inside crates/evidence-core/**/*.rs. -test_count = 367 +test_count = 383 [per_crate.cargo-evidence] -test_count = 154 +test_count = 158 [per_crate.evidence-mcp] test_count = 45 diff --git a/cert/trace/hlr.toml b/cert/trace/hlr.toml index 1336079..98294fd 100644 --- a/cert/trace/hlr.toml +++ b/cert/trace/hlr.toml @@ -1822,3 +1822,29 @@ workspace crate without a `CLAUDE.md` fails CI. verification_methods = ["test"] traces_to = ["d8d1f204-3909-418d-b62a-1e28edd088ed"] surfaces = ["layered CLAUDE.md (root + crates/*/CLAUDE.md)"] + +[[requirements]] +uid = "5ccff935-87c2-4b57-a78b-459d1eb81ad2" +id = "HLR-073" +title = "cargo evidence context CLI verb returns per-module trace slice" +owner = "tool" +scope = "component" +description = """ +`cargo evidence context []` returns a structured per-module +context blob: the LLR-level requirements governing the selector +(file path under `crates//...`, workspace crate name, or +Rust module path), their parent HLR / SYS rollup, the tests that +verify them, the diagnostic codes those LLRs own, the per-crate +floors / ceilings, the boundary slice, and the nearest layered +`CLAUDE.md`. Selector resolution prioritises file > crate > module +on ambiguity. The graceful-degradation path (no `cert/trace/`) maps +to `CONTEXT_NO_TRACE_CONFIGURED` (info) + `CONTEXT_OK` (exit 0); +invalid selectors emit `CONTEXT_SELECTOR_OUT_OF_SCOPE` + the +`CONTEXT_FAIL` terminal (exit 2); runtime / I/O failures emit +`CONTEXT_ERROR` (exit 1). Output is text (default), `--json` (single +blob), or `--format=jsonl` (report line + per-warning diag + +terminal). The wire shape is byte-locked against a golden fixture. +""" +verification_methods = ["test"] +traces_to = ["d8d1f204-3909-418d-b62a-1e28edd088ed"] +surfaces = ["context"] diff --git a/cert/trace/llr.toml b/cert/trace/llr.toml index c599eff..4e7d882 100644 --- a/cert/trace/llr.toml +++ b/cert/trace/llr.toml @@ -2451,3 +2451,120 @@ that crate. Failures are accumulated and reported together so one run shows every crate that needs fixing, not just the first. """ verification_methods = ["test"] + +[[requirements]] +uid = "7ad19cf7-98ae-40e0-8fcb-76c3a6077883" +id = "LLR-080" +title = "context::resolver classifies selectors with File>Crate>Module priority" +owner = "tool" +traces_to = ["5ccff935-87c2-4b57-a78b-459d1eb81ad2"] +modules = [ + "evidence_core::context::resolver", + "evidence_core::context::resolve_selector", +] +description = """ +`resolve_selector(workspace_root, raw)` returns a `ResolvedSelector` +variant: `Workspace` for `None`/empty input, `File` for a +workspace-relative or absolute path under `crates//...`, +`Crate` for a string matching a `[package].name` in +`crates/*/Cargo.toml`, or `Module` for a `::`-separated path of +valid Rust identifiers. On ambiguity (the same input matches more +than one kind) the highest-priority match wins and the resolver +records the skipped alternates in `ambiguities` so the lookup phase +attaches a `CONTEXT_AMBIGUOUS_SELECTOR` warning. Inputs that match +no kind return `ContextError::SelectorOutOfScope`. +""" +verification_methods = ["test"] + +[[requirements]] +uid = "fd1c2f88-9511-430f-a30b-f9f81a20407e" +id = "LLR-081" +title = "context::lookup composes ContextReport from trace/boundary/floors/CLAUDE.md" +owner = "tool" +traces_to = ["5ccff935-87c2-4b57-a78b-459d1eb81ad2"] +modules = [ + "evidence_core::context::lookup", + "evidence_core::context::context_for", + "evidence_core::context::report", +] +description = """ +`context_for(workspace_root, selector)` builds a `ContextReport` by +reading `cert/trace/*.toml` once, then for the selector: filtering +LLRs whose `modules` field overlaps the selector's module-prefix +space, rolling each LLR's `traces_to` up to HLR + SYS parents, +collecting verifying tests by traceable LLR UID, aggregating every +diagnostic code claimed in `LLR.emits`, slicing per-crate floor / +ceiling rows from `cert/floors.toml`, attaching the boundary +`in_scope` + `forbidden_deps` slice from `cert/boundary.toml`, and +emitting the nearest `CLAUDE.md` path (per-crate when the selector +resolves into a crate, root otherwise). Output is deterministically +sorted: requirements by `id`, tests by `name`, codes alphabetically. +The wire shape mirrors design spec §3.1 exactly and is round-tripped +through serde in unit tests. +""" +verification_methods = ["test"] + +[[requirements]] +uid = "dde74fce-62f8-4f33-b161-e21ee8b4627d" +id = "LLR-082" +title = "cli::context wires CLI verb to context_for + emits CONTEXT_* terminals" +owner = "tool" +traces_to = ["5ccff935-87c2-4b57-a78b-459d1eb81ad2"] +modules = [ + "cargo_evidence::cli::context", + "cargo_evidence::cli::context::cmd_context", +] +description = """ +`cmd_context(positional, --crate, --module, format)` parses the +selector inputs (positional wins; otherwise `--crate` then +`--module`), calls `evidence_core::context::resolve_selector`, then +`context_for`, and renders the result. Human mode prints a compact +table; `--json` emits the report as a single pretty-printed blob; +`--format=jsonl` emits one compact report-line on stdout, one +`Diagnostic` per `ContextWarning` (severity = warning), and a +hand-built `CONTEXT_OK` / `CONTEXT_FAIL` / `CONTEXT_ERROR` terminal +on the last line. Exit codes: 0 for `CONTEXT_OK` (also the +`CONTEXT_NO_TRACE_CONFIGURED` graceful path), 1 for `CONTEXT_ERROR`, +2 for `CONTEXT_FAIL`. The three terminals are registered in +`evidence_core::TERMINAL_CODES`. +""" +verification_methods = ["test"] +emits = [ + "CONTEXT_OK", + "CONTEXT_FAIL", + "CONTEXT_ERROR", +] + +[[requirements]] +uid = "ca6f202f-8957-4ba3-adb5-fbc7054e9197" +id = "LLR-083" +title = "context content codes register in RULES and gate the golden wire shape" +owner = "tool" +traces_to = ["5ccff935-87c2-4b57-a78b-459d1eb81ad2"] +modules = [ + "evidence_core::context::lookup", + "evidence_core::context::error", + "cargo_evidence::cli::context", +] +description = """ +The four content-level `CONTEXT_*` codes are registered in +`evidence_core::RULES` under the `Context` domain. The CLI's +`cmd_context` emits each at the right point: `CONTEXT_AMBIGUOUS_SELECTOR` +(warning) when the resolver records skipped alternates; +`CONTEXT_NO_REQUIREMENTS_FOR_SELECTOR` (warning) when a non-workspace +selector matches zero LLRs; `CONTEXT_SELECTOR_OUT_OF_SCOPE` (error) +when the resolver rejects an unrecognized input; +`CONTEXT_NO_TRACE_CONFIGURED` (info) when `cert/trace/` is missing. +A golden fixture at +`crates/cargo-evidence/tests/fixtures/golden_context.json` is byte- +diffed by the integration test, regenerated via +`tools/regen-golden-fixtures.sh`. Drift in the wire shape fires the +fixture diff with a line-numbered error. +""" +verification_methods = ["test"] +emits = [ + "CONTEXT_AMBIGUOUS_SELECTOR", + "CONTEXT_NO_REQUIREMENTS_FOR_SELECTOR", + "CONTEXT_NO_TRACE_CONFIGURED", + "CONTEXT_SELECTOR_OUT_OF_SCOPE", +] diff --git a/cert/trace/tests.toml b/cert/trace/tests.toml index 258ac68..c427a04 100644 --- a/cert/trace/tests.toml +++ b/cert/trace/tests.toml @@ -1326,3 +1326,98 @@ missing the literal `cargo test -p ` scoped command. Asserts the accumulated failure list is empty. Failure message joins every offending crate so a single run names them all. """ + +[[tests]] +uid = "27d05430-5ccf-4b77-a303-d6c0555c19c9" +id = "TEST-087" +title = "context::resolver classifies every selector kind with documented priority" +owner = "tool" +traces_to = ["7ad19cf7-98ae-40e0-8fcb-76c3a6077883"] +description = """ +Six co-located cases pin the resolver invariants in one place: +`raw = None` → Workspace; empty/whitespace input → Workspace; a +relative path under `crates/evidence-core/src/...` → File with the +expected crate_name; a bare `evidence-core` token → Crate; a +`evidence_core::trace` token → Module; an unrecognized +`not-a-crate` / `does/not/exist.rs` → SelectorOutOfScope. Each +case asserts the variant and the carried fields verbatim so a +priority regression names the failing case. +""" +test_selectors = [ + "context::tests::resolve_none_returns_workspace", + "context::tests::resolve_empty_string_returns_workspace", + "context::tests::resolve_file_under_crates_dir", + "context::tests::resolve_crate_by_package_name", + "context::tests::resolve_module_by_dotted_path", + "context::tests::resolve_unknown_returns_out_of_scope", + "context::tests::selector_out_of_scope_preserves_raw_input", +] + +[[tests]] +uid = "ff93d679-d83a-4dca-aa29-b6bf389c0f25" +id = "TEST-088" +title = "context::lookup composes ContextReport from the live trace + boundary + floors" +owner = "tool" +traces_to = ["fd1c2f88-9511-430f-a30b-f9f81a20407e"] +description = """ +Six co-located cases run `context_for` against the repo's own +`cert/` data: workspace overview carries the root CLAUDE.md; file +selector carries the per-crate CLAUDE.md; file selector pulls +non-empty requirements + a parent rollup; crate selector carries +per_crate_floor `test_count` and per_crate_ceiling library_panics +rows; crate selector aggregates a non-empty + alphabetically-sorted +diagnostic_codes set; boundary slice reports in_scope = true for +in-tree crates. A seventh case round-trips the ContextReport +through serde_json so the wire shape stays stable. +""" +test_selectors = [ + "context::tests::workspace_overview_carries_root_claude_md", + "context::tests::file_selector_carries_per_crate_claude_md", + "context::tests::file_selector_pulls_requirements_and_parents", + "context::tests::crate_selector_carries_per_crate_floor_rows", + "context::tests::requirements_emit_set_aggregates_diagnostic_codes", + "context::tests::boundary_slice_reports_in_scope_for_workspace_crate", + "context::tests::context_report_round_trips_via_serde_json", +] + +[[tests]] +uid = "27e2ee62-9591-4353-9ced-2f009ba7de97" +id = "TEST-089" +title = "context::error variants map to documented CONTEXT_* codes" +owner = "tool" +traces_to = ["ca6f202f-8957-4ba3-adb5-fbc7054e9197"] +description = """ +Two cases pin the error path: `missing_trace_root_returns_trace_not_configured` +asserts the variant + carried path for a tempdir with no +`cert/trace/`, the graceful path the CLI translates into +`CONTEXT_NO_TRACE_CONFIGURED` + `CONTEXT_OK` (exit 0). +`selector_out_of_scope_preserves_raw_input` asserts the variant +carries the raw selector string verbatim so the CLI can echo it +back in a fix hint. +""" +test_selectors = [ + "context::tests::missing_trace_root_returns_trace_not_configured", + "context::tests::selector_out_of_scope_preserves_raw_input", +] + +[[tests]] +uid = "e92cf358-fdbb-4213-8362-a66c89331653" +id = "TEST-090" +title = "cargo evidence context CLI is byte-locked against the golden fixture" +owner = "tool" +traces_to = ["ca6f202f-8957-4ba3-adb5-fbc7054e9197"] +description = """ +Three co-located cases drive the CLI: a `--json` invocation with a +known selector is byte-diffed against +`crates/cargo-evidence/tests/fixtures/golden_context.json` — +regenerate via `tools/regen-golden-fixtures.sh`; a `--format=jsonl` +invocation against an empty tempdir asserts the +`CONTEXT_NO_TRACE_CONFIGURED` + `CONTEXT_OK` terminal pair (exit 0); +a `--format=jsonl` invocation with a typo'd selector asserts the +`CONTEXT_SELECTOR_OUT_OF_SCOPE` + `CONTEXT_FAIL` pair (exit 2). +""" +test_selectors = [ + "cli_context::golden_context_json_byte_diff", + "cli_context::context_jsonl_non_adopter_graceful_path", + "cli_context::context_jsonl_invalid_selector_emits_fail_terminal", +] diff --git a/crates/cargo-evidence/src/cli.rs b/crates/cargo-evidence/src/cli.rs index 12181a5..a78d2b9 100644 --- a/crates/cargo-evidence/src/cli.rs +++ b/crates/cargo-evidence/src/cli.rs @@ -13,6 +13,7 @@ pub mod args; pub mod check; +pub mod context; pub mod diff; pub mod doctor; pub mod floors; diff --git a/crates/cargo-evidence/src/cli/args.rs b/crates/cargo-evidence/src/cli/args.rs index d94375a..7c903de 100644 --- a/crates/cargo-evidence/src/cli/args.rs +++ b/crates/cargo-evidence/src/cli/args.rs @@ -364,6 +364,43 @@ pub enum Commands { config: Option, }, + /// Return the per-module trace + boundary + floors slice an agent + /// needs before editing a source file. + /// + /// Selector resolution order (priority on ambiguity, first match + /// wins): file > crate > module > workspace. + /// + /// - Positional argument: workspace-relative file path under + /// `crates//...`, a workspace crate name, or a Rust + /// module path (`evidence_core::trace`). + /// - `--crate ` and `--module ` are equivalent + /// alternative entry points to disambiguate when a name could + /// match more than one kind. + /// - With no arguments, returns the workspace overview (root + /// `CLAUDE.md` pointer + workspace-wide floors). + /// + /// Output is text (default), `--json` (single blob), or + /// `--format=jsonl` (one report line + one diagnostic per warning + /// + a `CONTEXT_OK` / `CONTEXT_FAIL` / `CONTEXT_ERROR` terminal). + Context { + /// File / crate / module selector. Mutually compatible with + /// `--crate` / `--module`; positional wins when given. + selector: Option, + + /// Disambiguate as a workspace crate name. + #[arg(long = "crate")] + crate_flag: Option, + + /// Disambiguate as a Rust module path + /// (e.g. `evidence_core::trace`). + #[arg(long = "module")] + module_flag: Option, + + /// Emit a single pretty-printed JSON blob on stdout. + #[arg(long)] + json: bool, + }, + /// Trace management utilities Trace { /// Validate trace links between HLR, LLR, and Tests @@ -410,43 +447,9 @@ pub enum Commands { }, } -#[derive(Subcommand)] -#[allow( - missing_docs, - reason = "clap-derive: variant help is carried by `///` doc comments already present on each variant" -)] -pub enum SchemaCommands { - /// Print schema to stdout - Show { - /// Schema name (index, env, commands, hashes) - schema: SchemaName, - }, - - /// Validate a JSON file against its schema - Validate { - /// Path to the JSON file to validate - file: PathBuf, - }, -} +mod schema; -#[derive(Clone, Copy, ValueEnum)] -#[allow( - missing_docs, - reason = "clap-derive ValueEnum: variant names are themselves the `--schema ` surface" -)] -pub enum SchemaName { - Index, - Env, - Commands, - Hashes, - /// Alias for deterministic-manifest.json. - #[value(name = "deterministic-manifest", alias = "manifest")] - DeterministicManifest, - /// Wire-format schema for `--format=jsonl` output. Not a bundle - /// file — `schema validate` will not match it by filename; use - /// `schema show diagnostic` to read the source. - Diagnostic, -} +pub use schema::{SchemaCommands, SchemaName}; // ============================================================================ // Environment Detection diff --git a/crates/cargo-evidence/src/cli/args/schema.rs b/crates/cargo-evidence/src/cli/args/schema.rs new file mode 100644 index 0000000..06e38d1 --- /dev/null +++ b/crates/cargo-evidence/src/cli/args/schema.rs @@ -0,0 +1,50 @@ +//! `cargo evidence schema {show|validate}` clap types — split from +//! the parent `args.rs` facade to stay under the workspace 500-line +//! file-size limit. +//! +//! `SchemaCommands` is the nested subcommand variant under +//! `Commands::Schema`; `SchemaName` is the value-enum that backs +//! `schema show `. Both stay in `cargo_evidence::cli::args` +//! via the `pub use` re-export in the parent module. + +use std::path::PathBuf; + +use clap::{Subcommand, ValueEnum}; + +#[derive(Subcommand)] +#[allow( + missing_docs, + reason = "clap-derive: variant help is carried by `///` doc comments already present on each variant" +)] +pub enum SchemaCommands { + /// Print schema to stdout + Show { + /// Schema name (index, env, commands, hashes) + schema: SchemaName, + }, + + /// Validate a JSON file against its schema + Validate { + /// Path to the JSON file to validate + file: PathBuf, + }, +} + +#[derive(Clone, Copy, ValueEnum)] +#[allow( + missing_docs, + reason = "clap-derive ValueEnum: variant names are themselves the `--schema ` surface" +)] +pub enum SchemaName { + Index, + Env, + Commands, + Hashes, + /// Alias for deterministic-manifest.json. + #[value(name = "deterministic-manifest", alias = "manifest")] + DeterministicManifest, + /// Wire-format schema for `--format=jsonl` output. Not a bundle + /// file — `schema validate` will not match it by filename; use + /// `schema show diagnostic` to read the source. + Diagnostic, +} diff --git a/crates/cargo-evidence/src/cli/context.rs b/crates/cargo-evidence/src/cli/context.rs new file mode 100644 index 0000000..a4ecc66 --- /dev/null +++ b/crates/cargo-evidence/src/cli/context.rs @@ -0,0 +1,305 @@ +//! `cargo evidence context [] [--crate|--module] [--json|--format=jsonl]` +//! +//! Returns the per-module trace + boundary + floors + `CLAUDE.md` +//! slice an agent needs before editing a file. Pure inspection — +//! never spawns `cargo test`, never writes to disk. +//! +//! Three output shapes (mirroring `floors` / `rules`): +//! +//! - **human** (default): a compact summary table. +//! - **json** (`--json` / `--format=json`): the full +//! [`ContextReport`] as pretty JSON on stdout. +//! - **jsonl** (`--format=jsonl`): the report serialized as a +//! single JSON object on the first line, followed by one +//! `CONTEXT_*` warning per warning on the report, and a +//! `CONTEXT_OK` / `CONTEXT_FAIL` / `CONTEXT_ERROR` terminal. +//! +//! Exit codes: +//! +//! - `0` — `CONTEXT_OK` (report built; or the graceful +//! `CONTEXT_NO_TRACE_CONFIGURED` info path for non-adopters). +//! - `1` — `CONTEXT_ERROR` (runtime / I/O failure). +//! - `2` — `CONTEXT_FAIL` (selector invalid or out-of-scope). + +use std::path::PathBuf; + +use anyhow::Result; + +use evidence_core::context::{ + ContextError, ContextReport, ContextWarning, context_for, resolve_selector, +}; +use evidence_core::diagnostic::{Diagnostic, Severity}; + +use super::args::{EXIT_ERROR, EXIT_SUCCESS, EXIT_VERIFICATION_FAILURE, OutputFormat}; +use super::output::{emit_json, emit_jsonl}; + +/// Entrypoint for `cargo evidence context`. +pub fn cmd_context( + positional: Option, + crate_flag: Option, + module_flag: Option, + format: OutputFormat, +) -> Result { + let workspace = std::env::current_dir()?; + let raw = pick_selector_input(positional, crate_flag, module_flag); + + let selector = match resolve_selector(&workspace, raw.as_deref()) { + Ok(s) => s, + Err(err) => return handle_resolver_error(err, format), + }; + + let report = match context_for(&workspace, &selector) { + Ok(r) => r, + Err(ContextError::TraceNotConfigured(path)) => { + return handle_trace_not_configured(path, format); + } + Err(err) => return handle_runtime_error(err, format), + }; + + render(&report, format) +} + +fn pick_selector_input( + positional: Option, + crate_flag: Option, + module_flag: Option, +) -> Option { + if let Some(p) = positional.filter(|p| !p.is_empty()) { + return Some(p); + } + if let Some(c) = crate_flag.filter(|c| !c.is_empty()) { + return Some(c); + } + if let Some(m) = module_flag.filter(|m| !m.is_empty()) { + return Some(m); + } + None +} + +fn render(report: &ContextReport, format: OutputFormat) -> Result { + match format { + OutputFormat::Jsonl => emit_jsonl_stream(report), + OutputFormat::Json => { + emit_json(report)?; + Ok(EXIT_SUCCESS) + } + OutputFormat::Human => { + print_human(report); + Ok(EXIT_SUCCESS) + } + } +} + +fn emit_jsonl_stream(report: &ContextReport) -> Result { + // The report itself goes out as the first line so an agent reading + // jsonl sees the structured blob before any per-warning diagnostic. + // Use the same emit_jsonl helper as everywhere else so the per-line + // flush stays consistent. + let report_line = serde_json::to_string(report)?; + let stdout = std::io::stdout(); + { + use std::io::Write; + let mut h = stdout.lock(); + writeln!(h, "{}", report_line)?; + h.flush()?; + } + for warning in &report.warnings { + emit_jsonl(&warning_to_diag(warning))?; + } + emit_jsonl(&terminal_ok(format!( + "context resolved for {} ({})", + report.selector.kind, + if report.selector.resolved.is_empty() { + "".to_string() + } else { + report.selector.resolved.clone() + } + )))?; + Ok(EXIT_SUCCESS) +} + +fn warning_to_diag(w: &ContextWarning) -> Diagnostic { + Diagnostic { + code: w.code.clone(), + severity: Severity::Warning, + message: w.message.clone(), + location: None, + fix_hint: None, + subcommand: Some("context".to_string()), + root_cause_uid: None, + } +} + +fn terminal(code: &'static str, severity: Severity, message: String) -> Diagnostic { + Diagnostic { + code: code.to_string(), + severity, + message, + location: None, + fix_hint: None, + subcommand: Some("context".to_string()), + root_cause_uid: None, + } +} + +fn terminal_ok(msg: String) -> Diagnostic { + terminal("CONTEXT_OK", Severity::Info, msg) +} + +fn terminal_fail(msg: String) -> Diagnostic { + terminal("CONTEXT_FAIL", Severity::Error, msg) +} + +fn terminal_error(msg: String) -> Diagnostic { + terminal("CONTEXT_ERROR", Severity::Error, msg) +} + +fn handle_resolver_error(err: ContextError, format: OutputFormat) -> Result { + let code = err.content_code(); + let msg = err.to_string(); + if format == OutputFormat::Jsonl { + emit_jsonl(&Diagnostic { + code: code.to_string(), + severity: Severity::Error, + message: msg.clone(), + location: None, + fix_hint: None, + subcommand: Some("context".to_string()), + root_cause_uid: None, + })?; + emit_jsonl(&terminal_fail(msg))?; + } else { + eprintln!("error: {}", msg); + } + Ok(EXIT_VERIFICATION_FAILURE) +} + +fn handle_trace_not_configured(path: PathBuf, format: OutputFormat) -> Result { + let msg = format!( + "no trace configured at {} — context surface is not configured for this project", + path.display() + ); + if format == OutputFormat::Jsonl { + emit_jsonl(&Diagnostic { + code: "CONTEXT_NO_TRACE_CONFIGURED".to_string(), + severity: Severity::Info, + message: msg.clone(), + location: None, + fix_hint: None, + subcommand: Some("context".to_string()), + root_cause_uid: None, + })?; + emit_jsonl(&terminal_ok(msg))?; + } else if format == OutputFormat::Json { + emit_json(&ContextReport::workspace_default())?; + } else { + eprintln!("info: {}", msg); + } + Ok(EXIT_SUCCESS) +} + +fn handle_runtime_error(err: ContextError, format: OutputFormat) -> Result { + let msg = err.to_string(); + if format == OutputFormat::Jsonl { + emit_jsonl(&Diagnostic { + code: err.content_code().to_string(), + severity: Severity::Error, + message: msg.clone(), + location: None, + fix_hint: None, + subcommand: Some("context".to_string()), + root_cause_uid: None, + })?; + emit_jsonl(&terminal_error(msg))?; + } else { + eprintln!("error: {}", msg); + } + Ok(EXIT_ERROR) +} + +fn print_human(report: &ContextReport) { + println!( + "selector: {} ({})", + report.selector.kind, + if report.selector.resolved.is_empty() { + "".to_string() + } else { + report.selector.resolved.clone() + } + ); + println!( + "crate: {}", + if report.crate_name.is_empty() { + "".to_string() + } else { + report.crate_name.clone() + } + ); + println!("dal: {}", report.dal); + println!(); + println!( + "requirements ({}): {}", + report.requirements.len(), + ids_summary(report.requirements.iter().map(|r| r.id.as_str())) + ); + println!( + "parents ({}): {}", + report.parents.len(), + ids_summary(report.parents.iter().map(|p| p.id.as_str())) + ); + println!( + "tests ({}): {}", + report.tests.len(), + ids_summary(report.tests.iter().map(|t| t.id.as_str())) + ); + println!( + "codes ({}): {}", + report.diagnostic_codes.len(), + ids_summary(report.diagnostic_codes.iter().map(String::as_str)) + ); + println!("floors ({} row(s))", report.floors.len()); + for f in &report.floors { + println!( + " {}/{}: current={} limit={}", + f.kind, f.dimension, f.current, f.floor + ); + } + println!( + "boundary: in_scope={} forbidden_deps={}", + report.boundary.in_scope, + report.boundary.forbidden_deps.len() + ); + println!( + "conventions: nearest_claude_md={}", + report + .conventions + .nearest_claude_md + .as_deref() + .unwrap_or("") + ); + if !report.warnings.is_empty() { + println!(); + println!("warnings ({}):", report.warnings.len()); + for w in &report.warnings { + println!(" [{}] {}", w.code, w.message); + } + } +} + +fn ids_summary<'a>(iter: impl Iterator) -> String { + let mut v: Vec<&str> = iter.collect(); + if v.is_empty() { + return "".to_string(); + } + let max = 6; + if v.len() > max { + let tail = format!("(+{} more)", v.len() - max); + v.truncate(max); + let mut joined = v.join(", "); + joined.push(' '); + joined.push_str(&tail); + joined + } else { + v.join(", ") + } +} diff --git a/crates/cargo-evidence/src/cli/rules.rs b/crates/cargo-evidence/src/cli/rules.rs index 9d05a2b..1fb1b56 100644 --- a/crates/cargo-evidence/src/cli/rules.rs +++ b/crates/cargo-evidence/src/cli/rules.rs @@ -87,6 +87,7 @@ fn domain_label(d: evidence_core::Domain) -> &'static str { evidence_core::Domain::Check => "check", evidence_core::Domain::Cli => "cli", evidence_core::Domain::Cmd => "cmd", + evidence_core::Domain::Context => "context", evidence_core::Domain::Coverage => "coverage", evidence_core::Domain::Doctor => "doctor", evidence_core::Domain::Env => "env", diff --git a/crates/cargo-evidence/src/main.rs b/crates/cargo-evidence/src/main.rs index 691b54c..ff91e6e 100644 --- a/crates/cargo-evidence/src/main.rs +++ b/crates/cargo-evidence/src/main.rs @@ -27,6 +27,7 @@ mod cli; use cli::args::{CargoCli, Commands, EXIT_ERROR, EvidenceArgs, OutputFormat, SchemaCommands}; use cli::check::cmd_check; +use cli::context::cmd_context; use cli::diff::cmd_diff; use cli::doctor::cmd_doctor; use cli::floors::cmd_floors; @@ -153,6 +154,7 @@ fn dispatch(args: EvidenceArgs) -> anyhow::Result { Some(Commands::Generate { .. }) | None => "generate", Some(Commands::Verify { .. }) => "verify", Some(Commands::Check { .. }) => "check", + Some(Commands::Context { .. }) => "context", Some(Commands::Diff { .. }) => "diff", Some(Commands::Doctor { .. }) => "doctor", Some(Commands::Init { .. }) => "init", @@ -175,7 +177,15 @@ fn dispatch(args: EvidenceArgs) -> anyhow::Result { if args.format == OutputFormat::Jsonl && !matches!( subcommand_name, - "verify" | "check" | "trace" | "doctor" | "init" | "floors" | "generate" | "keygen" + "verify" + | "check" + | "trace" + | "doctor" + | "init" + | "floors" + | "generate" + | "keygen" + | "context" ) { return emit_unsupported_jsonl_terminal(subcommand_name); @@ -217,6 +227,15 @@ fn dispatch(args: EvidenceArgs) -> anyhow::Result { let format = OutputFormat::resolve(args.format, args.json); cmd_check(mode, path, format, args.quiet) } + Some(Commands::Context { + selector, + crate_flag, + module_flag, + json, + }) => { + let format = OutputFormat::resolve(args.format, args.json || json); + cmd_context(selector, crate_flag, module_flag, format) + } Some(Commands::Doctor { json }) => { // Global `--format=jsonl` (or `--json`) flips to JSONL // mode; default is the human `[✓]/[⚠]/[✗]` summary. diff --git a/crates/cargo-evidence/tests/cli_context.rs b/crates/cargo-evidence/tests/cli_context.rs new file mode 100644 index 0000000..80c50aa --- /dev/null +++ b/crates/cargo-evidence/tests/cli_context.rs @@ -0,0 +1,199 @@ +//! Integration tests for `cargo evidence context [] [--json|--format=jsonl]`. +//! +//! Pins three properties: +//! +//! 1. **Wire shape stability** — `context --json` against a known +//! selector is byte-diffed against +//! `tests/fixtures/golden_context.json`. The fixture is regenerated +//! via `tools/regen-golden-fixtures.sh`; the byte-diff catches any +//! accidental rename or reorder of report fields. +//! 2. **Graceful non-adopter path** — `context --format=jsonl` against +//! an empty tempdir (no `cert/trace/`) emits +//! `CONTEXT_NO_TRACE_CONFIGURED` (info) + `CONTEXT_OK` and exits 0. +//! 3. **Invalid selector path** — a typo'd selector emits +//! `CONTEXT_SELECTOR_OUT_OF_SCOPE` + the `CONTEXT_FAIL` terminal +//! and exits 2. +//! +//! Selector chosen for the golden run: the crate `cargo-evidence`. +//! Per-file selectors would tie the fixture to a single source path +//! that any future refactor breaks; per-module selectors don't +//! exercise the per-crate floor slice. The crate selector exercises +//! every report field including floors, boundary, requirements, +//! parents, tests, and diagnostic codes. + +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + reason = "test setup failures should panic immediately" +)] + +use std::path::PathBuf; + +use assert_cmd::Command; +use serde_json::Value; +use tempfile::TempDir; + +const GOLDEN_CONTEXT: &[u8] = include_bytes!("fixtures/golden_context.json"); + +fn workspace_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("crates/") + .parent() + .expect("workspace root") + .to_path_buf() +} + +fn cargo_evidence() -> Command { + #[allow(deprecated)] + Command::cargo_bin("cargo-evidence").unwrap() +} + +/// Byte-diff `cargo evidence context --crate cargo-evidence --json` +/// against the committed fixture. Any field rename, order change, or +/// dropped row fires this with a line-numbered diff. Regenerate +/// intentionally via `tools/regen-golden-fixtures.sh`. +#[test] +fn golden_context_json_byte_diff() { + let out = cargo_evidence() + .current_dir(workspace_root()) + .args(["evidence", "context", "--crate", "cargo-evidence", "--json"]) + .output() + .expect("spawn"); + assert!( + out.status.success(), + "context --crate cargo-evidence --json must exit 0; stderr={}", + String::from_utf8_lossy(&out.stderr) + ); + + if out.stdout != GOLDEN_CONTEXT { + let current = String::from_utf8_lossy(&out.stdout); + let golden = String::from_utf8_lossy(GOLDEN_CONTEXT); + let mut diverge_line: Option<(usize, String, String)> = None; + for (idx, (a, b)) in current.lines().zip(golden.lines()).enumerate() { + if a != b { + diverge_line = Some((idx + 1, a.to_string(), b.to_string())); + break; + } + } + match diverge_line { + Some((lineno, current_line, golden_line)) => panic!( + "context --json diverged from golden at line {}:\n \ + current: {}\n golden: {}\n\n\ + Regenerate with `tools/regen-golden-fixtures.sh` if the change is intentional.", + lineno, current_line, golden_line + ), + None => panic!( + "context --json length diverged from golden (current {} bytes, golden {} bytes). \ + Regenerate with `tools/regen-golden-fixtures.sh` if the change is intentional.", + out.stdout.len(), + GOLDEN_CONTEXT.len() + ), + } + } +} + +/// The non-adopter graceful path: an empty tempdir (no `cert/trace/`) +/// must emit the info-level `CONTEXT_NO_TRACE_CONFIGURED` diagnostic +/// followed by the `CONTEXT_OK` terminal and exit 0. Mirrors the floors +/// path: downstream projects without a trace setup get a clean run. +#[test] +fn context_jsonl_non_adopter_graceful_path() { + let tmp = TempDir::new().expect("tempdir"); + let out = cargo_evidence() + .current_dir(tmp.path()) + .args(["evidence", "--format=jsonl", "context"]) + .output() + .expect("spawn"); + assert_eq!( + out.status.code(), + Some(0), + "non-adopter path must exit 0; stdout={}\nstderr={}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + let stdout = String::from_utf8(out.stdout).expect("valid utf-8"); + let lines: Vec = stdout + .lines() + .filter(|l| !l.trim().is_empty()) + .map(|l| serde_json::from_str(l).expect("each line is JSON")) + .collect(); + assert!( + lines.len() >= 2, + "expected at least the info diagnostic + terminal, got {} lines: {:?}", + lines.len(), + lines + ); + let info = lines + .iter() + .find(|v| v["code"] == "CONTEXT_NO_TRACE_CONFIGURED") + .expect("CONTEXT_NO_TRACE_CONFIGURED diagnostic must be present"); + assert_eq!(info["severity"], "info"); + assert_eq!(info["subcommand"], "context"); + let terminal = lines.last().expect("last line is terminal"); + assert_eq!(terminal["code"], "CONTEXT_OK"); + assert_eq!(terminal["severity"], "info"); +} + +/// A selector that doesn't resolve to any file / crate / module +/// surfaces `CONTEXT_SELECTOR_OUT_OF_SCOPE` (error) followed by the +/// `CONTEXT_FAIL` terminal and exits 2. +#[test] +fn context_jsonl_invalid_selector_emits_fail_terminal() { + let out = cargo_evidence() + .current_dir(workspace_root()) + .args([ + "evidence", + "--format=jsonl", + "context", + "completely-bogus-selector", + ]) + .output() + .expect("spawn"); + assert_eq!( + out.status.code(), + Some(2), + "invalid selector must exit 2; stdout={}\nstderr={}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + let stdout = String::from_utf8(out.stdout).expect("valid utf-8"); + let lines: Vec = stdout + .lines() + .filter(|l| !l.trim().is_empty()) + .map(|l| serde_json::from_str(l).expect("each line is JSON")) + .collect(); + assert!( + lines.len() >= 2, + "expected at least the error diagnostic + terminal, got {} lines", + lines.len() + ); + let first = &lines[0]; + assert_eq!(first["code"], "CONTEXT_SELECTOR_OUT_OF_SCOPE"); + assert_eq!(first["severity"], "error"); + let terminal = lines.last().expect("last line is terminal"); + assert_eq!(terminal["code"], "CONTEXT_FAIL"); + assert_eq!(terminal["subcommand"], "context"); +} + +/// Human-mode invocation against the workspace exits 0 and prints a +/// header line — smoke test that catches a panic in the human +/// renderer. +#[test] +fn context_human_mode_workspace_overview_exits_zero() { + let out = cargo_evidence() + .current_dir(workspace_root()) + .args(["evidence", "context"]) + .output() + .expect("spawn"); + assert!( + out.status.success(), + "context (workspace, human) must exit 0; stderr={}", + String::from_utf8_lossy(&out.stderr) + ); + let stdout = String::from_utf8(out.stdout).expect("valid utf-8"); + assert!(stdout.contains("selector:"), "missing 'selector:' header"); + assert!(stdout.contains("crate:"), "missing 'crate:' header"); + assert!(stdout.contains("dal:"), "missing 'dal:' header"); +} diff --git a/crates/cargo-evidence/tests/fixtures/golden_context.json b/crates/cargo-evidence/tests/fixtures/golden_context.json new file mode 100644 index 0000000..dfea38e --- /dev/null +++ b/crates/cargo-evidence/tests/fixtures/golden_context.json @@ -0,0 +1,1445 @@ +{ + "selector": { + "kind": "crate", + "input": "cargo-evidence", + "resolved": "cargo-evidence" + }, + "crate": "cargo-evidence", + "dal": "D", + "requirements": [ + { + "id": "LLR-001", + "uid": "ef688fd5-21e0-4cf7-9ed5-b1819b5ae50d", + "layer": "llr", + "title": "cmd_verify_jsonl emits a terminal on every exit path", + "description": "Each arm of cmd_verify_jsonl (pass, fail, skipped, runtime error,\nstrict-signature-missing) emits exactly one hand-built terminal\n(VERIFY_OK, VERIFY_FAIL, or VERIFY_ERROR) as its last stdout line\nbefore returning the matching exit code.\n", + "modules": [ + "cargo_evidence::cli::verify::cmd_verify_jsonl" + ], + "emits": [ + "VERIFY_OK", + "VERIFY_FAIL", + "VERIFY_ERROR" + ], + "traces_to": [ + "f6d35f65-4a38-4ad9-ba2a-b5c4e3d7da41" + ], + "verification_methods": [ + "test" + ] + }, + { + "id": "LLR-002", + "uid": "36491f32-293f-4f39-a2f5-cdb7e746330c", + "layer": "llr", + "title": "emit_jsonl flushes stdout per event", + "description": "The emit_jsonl helper locks stdout, writes one compact JSON line +\n'\\n', and calls handle.flush() before returning. This is the only\npath that writes JSONL to stdout; tracing is routed to stderr by\ninit_tracing.\n", + "modules": [ + "cargo_evidence::cli::output::emit_jsonl" + ], + "emits": [], + "traces_to": [ + "da30287c-8223-448b-ad94-7a09cc981347" + ], + "verification_methods": [ + "test" + ] + }, + { + "id": "LLR-015", + "uid": "bb176fd9-7859-4ec1-bbc0-088d2518dc54", + "layer": "llr", + "title": "cmd_verify_jsonl strict-mode ed25519 signature guard", + "description": "When strict is true and neither BUNDLE.sig nor --verify-key is\npresent, cmd_verify_jsonl emits VERIFY_STRICT_SIGNATURE_MISSING as a\nfinding followed by VERIFY_FAIL terminal, returning\nEXIT_VERIFICATION_FAILURE (2). When --verify-key is supplied but the\nkey file is unreadable (I/O fault) or has invalid content (bad hex,\nwrong length, invalid ed25519 point) the run terminates with\nVERIFY_ERROR (exit 1) carrying the structured cause:\nVERIFY_RUNTIME_READ_VERIFY_KEY for I/O, SIGN_INVALID_KEY /\nSIGN_INVALID_SIGNATURE_HEX for parse. Successful key load with a\npresent BUNDLE.sig that fails ed25519 verification surfaces\nVERIFY_SIGNATURE_INVALID + VERIFY_FAIL.\n", + "modules": [ + "cargo_evidence::cli::verify::cmd_verify_jsonl" + ], + "emits": [ + "VERIFY_SIGNATURE_INVALID", + "SIGN_INVALID_KEY", + "SIGN_INVALID_SIGNATURE_HEX", + "SIGN_READ_FAILED", + "SIGN_WRITE_FAILED" + ], + "traces_to": [ + "904ad11d-7b76-4707-8a08-cab8224b64c9" + ], + "verification_methods": [ + "test" + ] + }, + { + "id": "LLR-016", + "uid": "77b0a3dc-2b31-4d25-a375-2bc51f5f6497", + "layer": "llr", + "title": "terminal_{ok,fail,error} construct fixed-code diagnostics", + "description": "The three terminal-builder helpers in cli/verify.rs hand-construct\nDiagnostic { code = VERIFY_OK / VERIFY_FAIL / VERIFY_ERROR, severity,\nmessage, location: None, fix_hint: None, subcommand: None }. The\ncode strings correspond 1:1 to the exit-code mapping in Schema\nRule 1.\n", + "modules": [ + "cargo_evidence::cli::verify::terminal_ok", + "cargo_evidence::cli::verify::terminal_fail", + "cargo_evidence::cli::verify::terminal_error" + ], + "emits": [], + "traces_to": [ + "f11c7b59-7dea-490f-a355-bb07459bb49b" + ], + "verification_methods": [ + "test" + ] + }, + { + "id": "LLR-017", + "uid": "c5f2b95b-5555-4261-a92c-76ce3ec2e5cf", + "layer": "llr", + "title": "OutputFormat::resolve folds --json + --format precedence", + "description": "resolve returns format_flag unchanged if it is not Human; otherwise\nit returns Json when json_flag is true, else Human. Explicit\n--format always wins over the legacy --json bool.\n", + "modules": [ + "cargo_evidence::cli::args::OutputFormat::resolve" + ], + "emits": [ + "VERIFY_INVALID_FORMAT" + ], + "traces_to": [ + "cb2b1464-3b2b-43a2-bffe-a41c16efefe6" + ], + "verification_methods": [ + "test" + ] + }, + { + "id": "LLR-018", + "uid": "dfc19912-4b1f-4a0c-8e18-a66d6e0738c3", + "layer": "llr", + "title": "cmd_schema_show writes embedded schema source to stdout", + "description": "cmd_schema_show maps the CLI SchemaName to evidence_core::schema::Schema\nand prints the include_str!()'d source verbatim. Schema::for_filename\ndeliberately returns None for diagnostic.schema.json so schema\nvalidate never matches it by filename.\n", + "modules": [ + "cargo_evidence::cli::schema::cmd_schema_show", + "evidence_core::schema::Schema::source" + ], + "emits": [ + "SCHEMA_COMPILE_FAILED", + "SCHEMA_INSTANCE_INVALID", + "SCHEMA_PARSE_FAILED" + ], + "traces_to": [ + "9ea226b0-852f-4047-a953-2ab0f4778da1" + ], + "verification_methods": [ + "test" + ] + }, + { + "id": "LLR-020", + "uid": "00578a4a-d6fe-420a-92c3-3cf4cb3387d6", + "layer": "llr", + "title": "dispatch guard emits CLI_SUBCOMMAND_ERROR terminal", + "description": "When --format=jsonl is passed to any non-verify subcommand,\ndispatch calls emit_unsupported_jsonl_terminal, which streams\nCLI_UNSUPPORTED_FORMAT (finding) then CLI_SUBCOMMAND_ERROR\n(terminal, with subcommand field set), returning EXIT_ERROR. Stderr\nstays silent.\n", + "modules": [ + "cargo_evidence::main::emit_unsupported_jsonl_terminal" + ], + "emits": [ + "CLI_UNSUPPORTED_FORMAT" + ], + "traces_to": [ + "62498de7-e6b9-4805-8b6c-afead78b33ac" + ], + "verification_methods": [ + "test" + ] + }, + { + "id": "LLR-023", + "uid": "6be1b61b-0aeb-4c05-b2ef-f82fc7c00e55", + "layer": "llr", + "title": "default_trace_roots picks cert/trace or cert/trace", + "description": "default_trace_roots returns Option> by probing for\n./cert/trace/ then ./cert/trace/. cmd_trace calls it only when\n--trace-roots is absent; explicit flag always wins. Logs chosen root\nvia tracing::info!.\n", + "modules": [ + "cargo_evidence::cli::trace::default_trace_roots" + ], + "emits": [], + "traces_to": [ + "911fe168-04d7-49ee-99f4-d7a5a7a6fb1c" + ], + "verification_methods": [ + "test" + ] + }, + { + "id": "LLR-025", + "uid": "7df3b426-c9ea-426b-8fb9-64ce247312a0", + "layer": "llr", + "title": "cmd_check dispatches on --mode and argument shape", + "description": "cmd_check reads --mode (Auto/Source/Bundle, default Auto) and the\noptional path arg (default \".\"). Auto precedence: SHA256SUMS at\nPATH → bundle mode; else Cargo.toml at PATH → source mode; else\nCLI_INVALID_ARGUMENT + VERIFY_FAIL terminal. Explicit mode always\nwins; mismatch between mode and shape also errors cleanly.\n", + "modules": [ + "cargo_evidence::cli::args::CheckMode", + "cargo_evidence::cli::check::cmd_check" + ], + "emits": [ + "CHECK_TEST_RUNTIME_FAILURE", + "CLI_INVALID_ARGUMENT" + ], + "traces_to": [ + "6c6eeac3-d491-42da-940f-97714e78e351" + ], + "verification_methods": [ + "test" + ] + }, + { + "id": "LLR-029", + "uid": "0835d2e8-3a84-4a4a-97e0-46985be02892", + "layer": "llr", + "title": "RULES const + rules subcommand + rules_json()", + "description": "`evidence_core::rules` defines `RuleEntry { code, severity, domain,\nhas_fix_hint, terminal }`, the `Domain` enum (closed over code\nprefixes), and `pub const RULES: &[RuleEntry]` exhaustively listing\nevery emittable code. `rules_json()` returns a deterministic\nserialized form sorted by code. `cargo_evidence::cli::rules::cmd_rules`\nrenders either the JSON array (`--json`) or a human-readable table.\n`--format=jsonl` is not supported (single JSON blob, not a stream)\nand falls through to the existing `CLI_UNSUPPORTED_FORMAT` guard.\n", + "modules": [ + "evidence_core::rules::RULES", + "evidence_core::rules::RuleEntry", + "evidence_core::rules::Domain", + "evidence_core::rules::rules_json", + "cargo_evidence::cli::rules::cmd_rules" + ], + "emits": [], + "traces_to": [ + "17cd860a-cf97-4eba-b54f-1f7d2563e74b" + ], + "verification_methods": [ + "test" + ] + }, + { + "id": "LLR-036", + "uid": "74f50e68-3a78-4557-a7c3-13340e52543a", + "layer": "llr", + "title": "cmd_floors runs the gate + JSONL support", + "description": "`cargo evidence floors [--format={human,jsonl}] [--json] [--base ]`\nreads cert/floors.toml, computes current measurements via\n`evidence_core::floors`, compares each entry against its floor / ceiling,\nand prints a per-dimension table in human mode, a deterministic\nJSON array in `--json` mode, or one JSONL diagnostic per dimension in\n`--format=jsonl` mode. Human + JSONL paths terminate with `FLOORS_OK`\nor `FLOORS_FAIL`; any below-floor / above-ceiling emits\n`FLOORS_BELOW_MIN` naming the dimension, current value, and committed\nthreshold; exit 2 if any failure. Non-failure dimensions emit\n`FLOORS_DIMENSION_OK` in the JSONL stream. A `--base` flag scopes the\n`[delta_ceilings]` check to the PR diff.\n", + "modules": [ + "cargo_evidence::cli::floors::cmd_floors" + ], + "emits": [ + "FLOORS_BELOW_MIN", + "FLOORS_DIMENSION_OK", + "FLOORS_OK", + "FLOORS_FAIL" + ], + "traces_to": [ + "a3073b5a-ee61-4bdd-bdfe-c3154274e5ea" + ], + "verification_methods": [ + "test" + ] + }, + { + "id": "LLR-042", + "uid": "61f2c36a-0d47-4485-acca-f2d4403ec339", + "layer": "llr", + "title": "cmd_trace stream-emits one Diagnostic per LinkError", + "description": "When `trace --validate --format=jsonl` hits\n`Err(TraceValidationError::Link { errors })`, the CLI handler\niterates `errors` and calls `emit_jsonl` once per variant with\n`code = link_error.code()`, `severity = Error`, `message =\nlink_error.to_string()`, and a variant-specific `Location`.\nLegacy `--json` mode keeps the prior aggregate shape (single\nresult object with a concatenated `message`) so shell consumers\nthat piped through `jq .message` aren't broken. Test-selector\nresolution failures surface as `TRACE_SELECTOR_UNRESOLVED`\nalongside Link-phase variants.\n", + "modules": [ + "cargo_evidence::cli::trace::cmd_trace" + ], + "emits": [ + "TRACE_REGISTER_FAILED", + "TRACE_SELECTOR_UNRESOLVED", + "VERIFY_FAIL", + "VERIFY_OK" + ], + "traces_to": [ + "b7265ced-3c5b-4558-92d1-5c54cba8657f" + ], + "verification_methods": [ + "test" + ] + }, + { + "id": "LLR-048", + "uid": "ec50f98b-9a1d-4f7b-bc33-df56c7faab2a", + "layer": "llr", + "title": "cmd_doctor subcommand implementation", + "description": "`cmd_doctor(args) -> Result<()>` in\n`crates/cargo-evidence/src/cli/doctor.rs` runs each rigor check\nin a fixed deterministic order, emitting one `Diagnostic` per\ncheck via `evidence_core::diagnostic::emit_jsonl` with a `DOCTOR_*`\ncode. Severity is derived per check: trace / floors / boundary\nfailures are `error`; CI / merge-style / override-doc gaps are\n`warning`. A check that passes emits an `info`-severity\n`DOCTOR_*_OK` diagnostic so the stream shape stays \"one line\nper check\" and downstream tooling can detect truncation.\n\nStream terminates with exactly one terminal diagnostic:\n- `DOCTOR_OK` + exit 0 when no `error`-severity diagnostic fired\n- `DOCTOR_FAIL` + exit 2 when at least one `error` fired\nPer Schema Rule 1 + the existing `TERMINAL_CODES` contract.\n\n`generate` wires doctor via a `precheck_doctor(&config)` call in\nthe `Profile::Cert | Profile::Record` branch; the call returns\n`Err(BuilderError::DoctorFailed { codes: Vec<&'static str> })`\nwhen the audit fails, and the main dispatch translates the error\ninto a `GENERATE_ERROR` terminal whose diagnostic enumerates the\nper-check codes so the auditor gets full detail without re-\nrunning doctor.\n", + "modules": [ + "cargo_evidence::cli::doctor" + ], + "emits": [ + "DOCTOR_OK", + "DOCTOR_FAIL", + "DOCTOR_CHECK_PASSED", + "DOCTOR_TRACE_INVALID", + "DOCTOR_TRACE_EMPTY", + "DOCTOR_FLOORS_MISSING", + "DOCTOR_FLOORS_VIOLATED", + "DOCTOR_FLOORS_SLACK", + "DOCTOR_FLOORS_BOUNDARY_MISMATCH", + "DOCTOR_BOUNDARY_MISSING", + "DOCTOR_CI_INTEGRATION_MISSING", + "DOCTOR_MERGE_STYLE_RISK", + "DOCTOR_MERGE_STYLE_UNKNOWN", + "DOCTOR_OVERRIDE_PROTOCOL_UNDOCUMENTED", + "DOCTOR_QUALIFICATION_MISSING" + ], + "traces_to": [ + "57992e98-fb1e-4248-b340-6dd454db95c1" + ], + "verification_methods": [ + "test" + ] + }, + { + "id": "LLR-049", + "uid": "548b7b36-1d03-487d-a6a1-f2b37bdee2a1", + "layer": "llr", + "title": "Pre-release detection at build time + verify-side refusal", + "description": "Four coupled sites:\n\n- `crates/evidence-core/src/env/capture.rs` adds a module-scope\n `TOOL_IS_PRERELEASE: bool` computed from\n `env!(\"CARGO_PKG_VERSION\")` by byte-scanning for `-` in\n const context. `env_fingerprint` appends `tool_prerelease:\n TOOL_IS_PRERELEASE` to the returned `EnvFingerprint`.\n- `crates/evidence-core/src/env/fingerprint.rs` grows\n `tool_prerelease: bool` with `#[serde(default)]` so\n pre-PR-#60 bundles deserialize as `false`.\n- `crates/evidence-core/src/verify/errors.rs` adds\n `VerifyError::PrereleaseToolDetected { profile,\n engine_crate_version }` with `DiagnosticCode` returning\n `\"VERIFY_PRERELEASE_TOOL\"`.\n- `crates/evidence-core/src/verify/bundle.rs` reads\n `env.tool_prerelease` after `check_env_vs_index` and pushes\n the error on every profile — library stays policy-free.\n- `crates/cargo-evidence/src/cli/verify.rs::cmd_verify_jsonl`\n partitions the error bag: `VERIFY_PRERELEASE_TOOL` on\n `Profile::Dev` → Warning stream + `VERIFY_OK` terminal +\n exit 0; same code on Cert/Record → Error + `VERIFY_FAIL` +\n exit 2. Every other VerifyError stays error-severity.\n- `crates/cargo-evidence/src/cli/generate/phases.rs::init_builder`\n emits a warning-severity `VERIFY_PRERELEASE_TOOL` in\n cert/record profile when `TOOL_IS_PRERELEASE` is true. Same\n init_builder path emits `ENV_ENGINE_RELEASE_PROVENANCE`\n (Warning) on cert/record when `TOOL_BUILD_SOURCE_IS_RELEASE`\n is true — a `release-v` engine_git_sha fallback\n carries weaker provenance than a git-source SHA and a\n cert-grade run should prefer `cargo install --git`.\n", + "modules": [ + "evidence_core::env::capture", + "evidence_core::verify::bundle", + "evidence_core::verify::errors", + "cargo_evidence::cli::verify", + "cargo_evidence::cli::generate::phases" + ], + "emits": [ + "VERIFY_PRERELEASE_TOOL", + "ENV_ENGINE_RELEASE_PROVENANCE" + ], + "traces_to": [ + "014adc4d-c545-403c-b4f2-d702a49dff30" + ], + "verification_methods": [ + "test" + ] + }, + { + "id": "LLR-052", + "uid": "4bd86c6c-1743-4a78-9d17-c284b3ad8e04", + "layer": "llr", + "title": "resolve_llr_backlinks enriches TestOutcomeRecord + check_llr_test_selectors asserts reverse", + "description": "`evidence_core::trace::test_backlinks::resolve_llr_backlinks`\nmutates a slice of `TestOutcomeRecord` in place, filling each\nrecord's `requirement_uids` with the deduplicated union of\n`TestEntry.traces_to` across every TestEntry whose\n`all_selectors()` matches `{module_path}::{name}` (exact or\n`::`-boundary prefix, sibling-module-no-match pinned by test).\n\n`cargo_evidence::cli::generate::test_outcomes::enrich_and_write_test_outcomes`\naccumulates `TestEntry` across every configured trace root,\ncalls `EvidenceBuilder::enrich_test_outcomes_with_llrs`, then\nwrites `tests/test_outcomes.jsonl` with the populated records.\nRuns in Phase 6b after trace validation.\n\n`evidence_core::verify::llr_selectors::check_llr_test_selectors`\nreads `bundle/trace/llr.toml` + `tests/test_outcomes.jsonl`\ninside `verify_bundle_with_key`, accumulates the set of\n`requirement_uids` across all records, then for every LLR with\n`verification_methods.contains(\"test\")` AND a non-None `uid`,\nasserts the uid is present in the set. Missing → push\n`VerifyError::LlrTestSelectorUnresolved { llr_uid, llr_id }`.\nSkip silently when the JSONL file is absent (older bundles).\nEmpty-string uids are treated the same as `None` for\ndefense-in-depth against corrupt trace data.\n\n`cargo_evidence::cli::verify::skipped_notices::maybe_emit_llr_check_skipped_no_outcomes`\nemits `VERIFY_LLR_CHECK_SKIPPED_NO_OUTCOMES` (Info) on the\n`VerifyResult::Pass` path when `tests/test_outcomes.jsonl` is\nabsent — the library-side check silently returned; this notice\nsurfaces that skip so an auditor can distinguish \"check ran and\npassed\" from \"check didn't run.\"\n", + "modules": [ + "evidence_core::trace::test_backlinks::resolve_llr_backlinks", + "evidence_core::bundle::outcome_record::TestOutcomeRecord", + "evidence_core::verify::llr_selectors::check_llr_test_selectors", + "cargo_evidence::cli::generate::test_outcomes::enrich_and_write_test_outcomes" + ], + "emits": [ + "VERIFY_LLR_CHECK_SKIPPED_NO_OUTCOMES", + "VERIFY_LLR_TEST_SELECTOR_UNRESOLVED" + ], + "traces_to": [ + "e4f127fb-38c5-4f77-971f-143f774716f8" + ], + "verification_methods": [ + "test" + ] + }, + { + "id": "LLR-053", + "uid": "ed4284df-5a2e-4241-bbd9-e744edd43f79", + "layer": "llr", + "title": "coverage subprocess + llvm-cov JSON parser + bundle write", + "description": "`evidence_core::coverage::report::CoverageReport` models the\ntyped wire shape of `coverage/coverage_summary.json`. Top-level\nfields: `schema_version: String` (pinned to\n`schema_versions::COVERAGE`), `measurements: Vec`.\nEach `Measurement` carries a `level: CoverageLevel` enum with\nfour variants (`statement`, `branch`, `pattern_decision`,\n`mcdc` — the latter two reserved, not emitted), plus\n`engine: String`, `engine_version: String`, and\n`per_file: Vec`. Each `FileMeasurement` has a\nworkspace-relative `path: String`, `lines: LineCoverage\n{covered, total}`, `branches: Option`, `decisions: Vec`, and\n`conditions: Vec`. `branches` is `Some` at\n`CoverageLevel::Branch` and `None` at `Statement` —\naggregation over a Branch measurement reads the `branches`\nfield, never `lines`. `decisions` / `conditions` stay reserved\nfor v2 pattern-decision / MC/DC; both are empty today\nregardless of level.\n\n`evidence_core::coverage::llvm_cov_json::parse_llvm_cov_export`\nconsumes `cargo-llvm-cov`'s JSON export format (documented\nshape: `{data: [{files: [...], functions: [...], totals: {...}}],\nversion, cargo_llvm_cov: {version}}`), normalizes absolute file\npaths to workspace-relative, and returns a `CoverageReport` with\none measurement per requested level. Filename normalization\nstrips the workspace root prefix deterministically so hashes are\ncross-host stable.\n\n`cargo_evidence::cli::generate::coverage_phase::run_coverage_phase`\nis the new Phase 5b of `cmd_generate`. It is invoked after the\nplain `cargo test` phase when `CoverageChoice != None`. The\nfunction spawns `cargo llvm-cov --workspace --json --output-path\n --lcov --output-path ` (via\n`std::process::Command`), parses the JSON, and stages both files\ninto the builder via `EvidenceBuilder::set_coverage_report` and\n`EvidenceBuilder::stage_coverage_lcov`. On ENOENT (binary\nmissing) the phase emits a `COVERAGE_LLVMCOV_MISSING`\ndiagnostic whose severity is Warning for `Profile::Dev` and\nError for `Profile::Cert` / `Profile::Record`; on JSON parse\nfailure it emits `COVERAGE_PARSE_FAILED` (always Error).\nSuccessful completion emits `COVERAGE_OK` (Info non-terminal)\nnaming the engine version and per-level line counts.\n", + "modules": [ + "evidence_core::coverage::report::CoverageReport", + "evidence_core::coverage::llvm_cov_json::parse_llvm_cov_export", + "cargo_evidence::cli::generate::coverage_phase::run_coverage_phase" + ], + "emits": [ + "COVERAGE_LLVMCOV_MISSING", + "COVERAGE_OK", + "COVERAGE_PARSE_FAILED" + ], + "traces_to": [ + "468a6894-7121-4936-8432-94163e35d4a1" + ], + "verification_methods": [ + "test" + ] + }, + { + "id": "LLR-055", + "uid": "4e98e1d9-6017-425e-b613-52853e97c2f3", + "layer": "llr", + "title": "Wire derived entries at cli/trace.rs + cli/generate/phases.rs", + "description": "At `cli/trace.rs:120` and `cli/generate/phases.rs:313`, replace\nthe `&[]` placeholder in the `validate_trace_links_with_policy`\ncall with `derived.as_ref().map(|d| d.requirements.as_slice()).unwrap_or(&[])`\n(pulling `derived` from the `TraceFiles` destructure in\nboth places). The doctor's `check_trace` at\n`cli/doctor/checks.rs:43-45` already does the correct thing\n(accumulates `files.derived.map(|d| d.requirements)` across\nevery root); this LLR brings the other two callsites to\nparity. No new diagnostic codes — existing Link-phase errors\n(`TRACE_DANGLING_LINK`, etc.) already fire correctly once the\nderived slice is present.\n", + "modules": [ + "cargo_evidence::cli::trace::cmd_trace", + "cargo_evidence::cli::generate::phases::validate_trace_links_phase" + ], + "emits": [], + "traces_to": [ + "fb07d8c2-5d21-4989-b21e-09b62caa0299" + ], + "verification_methods": [ + "test" + ] + }, + { + "id": "LLR-057", + "uid": "a95ed852-94ba-499b-bd17-76d26b75ebe5", + "layer": "llr", + "title": "aggregate_lines_percent sums per_file[].lines over a Statement-level Measurement", + "description": "`aggregate_lines_percent(m: &Measurement) -> f64` computes\n`sum(per_file[].lines.covered) / sum(per_file[].lines.total)\n* 100.0`. Used by the Statement-level arm of\n`threshold_violations` (enforcement side) and by\n`EvidenceBuilder::coverage_statement_percent` (compliance-\nreport side). The two callsites are textually similar but\nnon-shared because the enforcement arm lives in\n`cargo-evidence` (needs the CLI-side policy context) and the\ncompliance arm lives in `evidence-core` (populates the wire\n`CoverageSummary`). Both are asserted by the same test\ntriplet to prevent drift.\n\nZero denominator (no executable lines across all files)\nreturns `0.0`. Without this guard, `u64::sum() / u64::sum()`\nwould panic on division-by-zero in release mode and return\nNaN in f64 — either one breaks the `< threshold` comparison\nsilently (NaN comparisons always return false, so a NaN\n\"current\" would never trigger a violation, hiding the gap).\n\nThe function has no parameter for the measurement level\nbecause the level is implicit in the caller's dispatch —\npassing a Branch-level `Measurement` here would sum that\nmeasurement's `lines` field, which is populated but\nsemantically secondary at Branch level. The dispatch in\n`threshold_violations` is what binds \"Statement level uses\nlines\" to the policy.\n", + "modules": [ + "cargo_evidence::cli::generate::coverage_phase::aggregate_lines_percent", + "evidence_core::bundle::builder_coverage::aggregate_lines" + ], + "emits": [], + "traces_to": [ + "bebe9227-5f47-4eb8-89a7-f2157fb96e6c" + ], + "verification_methods": [ + "test" + ] + }, + { + "id": "LLR-058", + "uid": "84c30007-3c84-4d23-bdf8-f540339b507a", + "layer": "llr", + "title": "aggregate_branches_percent sums per_file[].branches over a Branch-level Measurement", + "description": "`aggregate_branches_percent(m: &Measurement) -> f64` computes\n`sum(per_file[].branches.map_or(0, |b| b.covered)) /\nsum(per_file[].branches.map_or(0, |b| b.total)) * 100.0`.\nFiles where `branches` is `None` contribute `0/0` (no\ncontribution) rather than `0/total` (which would pull the\naggregate toward zero). This distinction matters when a\nBranch-level measurement is assembled from a mixed parser\nrun where some files legitimately lacked branch data.\n\n**This function is the core of the bugfix.** Pre-fix, the\nBranch-level arm of `threshold_violations` called a shared\n`aggregate_percent` that always summed `lines.*`. A file at\n95% lines / 50% branches looked like 95% branch coverage —\nDAL-B's 85% branch minimum passed spuriously, and the tool\ncertified a bundle whose claim it could not mechanically\ndefend.\n\nThe symmetry with `aggregate_lines_percent` is deliberate but\nnot DRY: collapsing them into a generic `aggregate_percent(m,\n|f| &f.lines)` would re-introduce the risk of a caller\npassing the wrong field-accessor at call site. Two\nsingle-purpose functions make the Branch vs Statement\ndistinction unmissable at every callsite.\n\nZero denominator → `0.0`, same rationale as LLR-057.\n", + "modules": [ + "cargo_evidence::cli::generate::coverage_phase::aggregate_branches_percent", + "evidence_core::bundle::builder_coverage::aggregate_branches" + ], + "emits": [], + "traces_to": [ + "bebe9227-5f47-4eb8-89a7-f2157fb96e6c" + ], + "verification_methods": [ + "test" + ] + }, + { + "id": "LLR-059", + "uid": "b1377fb5-f879-4edc-9c50-46c5e67c71ad", + "layer": "llr", + "title": "threshold_violations dispatches to level-appropriate aggregator; strict < comparison", + "description": "`threshold_violations(report: &CoverageReport, thresholds:\nDalCoverageThresholds) -> Vec` iterates\nover each `(level, Option)` threshold entry:\n\n 1. Skip if `thresholds._percent` is `None` (DAL-D or\n dev). No violation recorded.\n 2. Skip if no `Measurement` of that level exists in the\n report. No violation recorded — \"measurement absent\" is\n a config-layer issue, not a coverage-layer failure.\n Treating absence as 0% would mask the true problem\n (auditor gets COVERAGE_BELOW_THRESHOLD when they\n should have gotten a \"configure your coverage=branch\n flag\" signal).\n 3. Compute `current = aggregate__percent(m)`.\n 4. If `current < f64::from(threshold)`, append a\n `ThresholdViolation { dimension, current_percent,\n threshold_percent }`.\n\nComparison is strict `<`, not `<=`. DO-178C A-7 minima are\nstated inclusively (\"statement coverage shall be ≥ 90% at\nDAL-C\"), so equality is compliant. This is a BVA case the\ntest triplet pins — `current == threshold` must not fire.\n\nThe two-level dispatch (statement uses\n`aggregate_lines_percent`, branch uses\n`aggregate_branches_percent`) is what enforces the level/\nfield binding at the policy layer. The dispatcher is the\nonly place in the threshold-enforcement chain where that\nbinding is decided; changing it to use a shared aggregator\nwould be the regression path.\n", + "modules": [ + "cargo_evidence::cli::generate::coverage_phase::threshold_violations" + ], + "emits": [ + "COVERAGE_BELOW_THRESHOLD" + ], + "traces_to": [ + "bebe9227-5f47-4eb8-89a7-f2157fb96e6c" + ], + "verification_methods": [ + "test" + ] + }, + { + "id": "LLR-060", + "uid": "a88f2efa-7aa7-4bfc-9554-610a906f2e2e", + "layer": "llr", + "title": "load_max_dal: max over dal_map values, fall back to default_dal on empty in_scope, D on load failure", + "description": "`load_max_dal(workspace: &Path) -> (Dal, bool)`:\n\n 1. Load `/cert/boundary.toml` via\n `BoundaryConfig::load`. On any load error (missing file,\n parse failure), return `(Dal::D, false)`. The `false`\n sentinel feeds the caller's fallback-note string; the\n matching `check_boundary` diagnostic names the root cause\n so this code stays silent on the load-fail path.\n 2. Compute `cfg.dal_map().values().copied().max()` — the\n highest DAL across every in-scope crate (each in_scope\n entry mapped to its per-crate override or `default_dal`\n when no override is set; see `BoundaryConfig::dal_map`).\n 3. If `max()` returns `None` (empty `in_scope`), fall back to\n `cfg.dal.default_dal`. This preserves user intent in the\n pre-scoping state — a boundary.toml with `in_scope = []`\n and `default_dal = \"A\"` is legitimate (project hasn't\n scoped crates yet); silently downgrading to `Dal::D` would\n surprise the auditor.\n 4. Return `(dal, true)`.\n\nThe `unwrap_or(cfg.dal.default_dal)` is **not** a second bug-\nopportunity — `default_dal` is already visited by every\ndal_map entry that lacks an override, so the fallback affects\nonly the truly-empty-in_scope edge case.\n\nDesign note: the `Dal::D` hard floor on load failure is\nstricter than `cfg.dal.default_dal` fallback on empty in_scope.\nThis asymmetry is deliberate — a missing/corrupt boundary.toml\nmeans configuration intent is unknown, so the safest floor is\nD. An empty `in_scope` with a loaded config means intent is\nknown; honoring it is the right behavior.\n", + "modules": [ + "cargo_evidence::cli::doctor::checks::load_max_dal" + ], + "emits": [], + "traces_to": [ + "79afc7a1-22dd-450e-8f0c-c29c93b48905" + ], + "verification_methods": [ + "test" + ] + }, + { + "id": "LLR-061", + "uid": "d67f45bf-c9d0-4f2a-87d1-0dfbf14d9262", + "layer": "llr", + "title": "validate_trace_links_phase returns TraceValidationResult; write_compliance_reports takes trace_validation_passed: bool", + "description": "`TraceValidationResult { passed: bool, short_circuit:\nOption }` is the return type of\n`validate_trace_links_phase`. Three-state encoding:\n\n - `Passed = { passed: true, short_circuit: None }` — every\n root validated; A3-6 claim \"Met\" is defensible.\n - `WarnedContinuing = { passed: false, short_circuit: None }`\n — non-strict profile saw a failure; caller threads\n `passed: false` to compliance; A3-6 claim becomes\n \"Partial\" per `determine_objective_status`.\n - `StrictFailed = { passed: false, short_circuit: Some(code)\n }` — strict profile short-circuited; caller returns\n `code` before running any later phase, so compliance JSON\n is not written for this run.\n\n`write_compliance_reports` signature adds a required\n`trace_validation_passed: bool` parameter forwarded into\n`CrateEvidence.trace_validation_passed`. The signature change\nis load-bearing: removing the parameter to re-introduce a\nhardcoded literal would be a visible PR diff, not a silent\nin-function change.\n\n`read_all_trace_files` read-failure path (missing or\ncorrupt trace TOML) also flips `passed = false` under\nnon-strict profile — the pre-fix contract was\nnon-observable (returned `Ok(None)` either way); post-fix the\nwarned-continue semantics cover both validation failures and\nread failures symmetrically.\n", + "modules": [ + "cargo_evidence::cli::generate::phases::trace_validation::TraceValidationResult", + "cargo_evidence::cli::generate::phases::trace_validation::validate_trace_links_phase", + "cargo_evidence::cli::generate::phases::write_compliance_reports" + ], + "emits": [], + "traces_to": [ + "2087d91a-12ba-4b3a-ab43-632f670bad49" + ], + "verification_methods": [ + "test" + ] + }, + { + "id": "LLR-069", + "uid": "4abf426c-da46-4307-ba7d-9d7dbfd86f63", + "layer": "llr", + "title": "verify --verify-key I/O failure emits VERIFY_RUNTIME_READ_VERIFY_KEY + VERIFY_ERROR terminal", + "description": "`cmd_verify_jsonl` and `cmd_verify` both load the\n`--verify-key` file before calling `verify_bundle_with_key`.\nWhen `fs::read(path)` fails (missing or unreadable key), the\nverify pipeline has already started its user-visible run, so\nSchema Rule 1 (HLR-001) requires exactly one terminal on the\n`--format=jsonl` stream. The handler emits a structured\n`VERIFY_RUNTIME_READ_VERIFY_KEY` finding (path + os-error\nmessage), then the `VERIFY_ERROR` terminal, then exits 1.\n\nThe code is distinct from `VERIFY_RUNTIME_READ_FILE` because\nthe failure precedes any bundle-file access — the path\ncarried in the diagnostic is the user-supplied key path, not\na bundle-relative path. The bijection between\n`VerifyRuntimeError` variants and `VERIFY_RUNTIME_*` codes\nstays one-to-one.\n\nFor `--format=human` and `--format=json` the same shape\napplies via `fail_verify(&bundle_path, \"verify_key\", ...,\nEXIT_ERROR)` so all three formats agree (HLR-016): the\nrun records a `verify_key` check at status `fail` with the\nos-error message, prints `error: reading verify key from\n` to stderr (human) or wraps the failure in\n`VerifyOutput { success: false, error: Some(msg) }` (json),\nand exits 1.\n", + "modules": [ + "evidence_core::verify::bundle", + "cargo_evidence::cli::verify" + ], + "emits": [ + "VERIFY_RUNTIME_READ_VERIFY_KEY" + ], + "traces_to": [ + "f6d35f65-4a38-4ad9-ba2a-b5c4e3d7da41" + ], + "verification_methods": [ + "test" + ] + }, + { + "id": "LLR-073", + "uid": "08152f7f-56c4-47f7-ae99-f03d0e114a67", + "layer": "llr", + "title": "check_dal_a_mcdc_evidence + enforce_dal_qualification gate", + "description": "Library side: `evidence_core::check_dal_a_mcdc_evidence(dal_map,\nauxiliary_mcdc_tool)` returns `Ok(())` when no in-scope crate is\nat DAL-A OR an `AuxiliaryMcdcTool` reference is present.\nReturns `BoundaryCheckError::DalAMissingAuxiliaryMcdc { dal_a_crates,\ncount }` (code `BOUNDARY_DAL_A_MISSING_AUXILIARY_MCDC`)\notherwise. Library is policy-free — the dev/cert severity split\nlives at the CLI layer.\n\nCLI side: `enforce_dal_qualification(derived, profile,\njson_output)` is called from `cmd_generate` immediately after\n`enforce_boundary_policy`. On `Profile::Dev` a violation emits\na one-line Warning to stderr and the run proceeds. On\n`Profile::Cert` / `Profile::Record` the violation triggers the\nstandard failure envelope (`fail` helper) and returns\n`Ok(Some(EXIT_ERROR))` so the orchestrator short-circuits\nbefore bundle assembly.\n\nThe message names every offender crate, points at the three\nremediation paths (record auxiliary tool, lower DAL, wait for\nupstream rustc), and cites rust-lang/rust#144999 +\nrust-lang/rust#124144 so the auditor can verify the upstream\nstate independently.\n\n`AuxiliaryMcdcTool` is a public type in\n`evidence_core::policy::dal` carrying `name`, optional\n`qualification_id`, and optional `report` (bundle-relative\npath). `boundary.toml` reads it under `[dal.auxiliary_mcdc_tool]`\nvia serde.\n", + "modules": [ + "evidence_core::boundary_check::check_dal_a_mcdc_evidence", + "evidence_core::policy::dal::AuxiliaryMcdcTool", + "cargo_evidence::cli::generate::policy::enforce_dal_qualification" + ], + "emits": [ + "BOUNDARY_DAL_A_MISSING_AUXILIARY_MCDC" + ], + "traces_to": [ + "c222804b-4d4f-4dfd-8765-2fd51a730ecc" + ], + "verification_methods": [ + "test" + ] + }, + { + "id": "LLR-075", + "uid": "76f2567f-3d6d-4633-9d2c-00d03cc2b529", + "layer": "llr", + "title": "main.rs --help intercept reuses clap's render tree", + "description": "`crates/cargo-evidence/src/main.rs`'s `-h` / `--help` intercept builds\na `clap::Command` for the `EvidenceArgs` parser and renders its\nlong-help directly, instead of printing a hand-typed redirect stub.\n\nConcretely: the intercept calls `EvidenceArgs::command()` (the\nclap-generated `CommandFactory` impl), prepends a one-line banner\nnaming the binary version and the dual invocation form\n(`cargo evidence` + direct), then writes the long-form help via\n`cmd.write_long_help(&mut stdout)`. The result includes clap's auto-\ngenerated `Commands:` block — every variant of the `Commands` enum\nin `cli::args` appears with its `#[command(about = …)]` description.\n\nOutput stays under 80-column wrap-friendly when terminal width is\nfixed (clap's default render); this is a UX courtesy, not a\ntestable contract — the test pins only that each subcommand name\nappears at least once in the rendered output.\n\nExit code: `0` on success, matching clap's auto-help convention.\n\nThe intercept lives at the top of `main()` (before the\n`CargoCli::parse()` call) because the cargo subcommand dispatch form\n(`cargo-evidence evidence …`) and the direct form\n(`cargo-evidence --help`) take different argv shapes — clap's\n`CargoCli::parse()` accepts only the dispatch form. The intercept's\n`std::env::args().nth(1)` peek catches the direct form before clap\nerrors out.\n", + "modules": [ + "cargo_evidence::main" + ], + "emits": [], + "traces_to": [ + "34234f8b-9f0b-47d4-829e-aed0ac96d51c" + ], + "verification_methods": [ + "test" + ] + }, + { + "id": "LLR-077", + "uid": "2edcdfa5-d7e7-4115-a3fa-8ed36354aa1b", + "layer": "llr", + "title": "cmd_keygen create + rotate dispatch", + "description": "cmd_keygen branches on the `rotate` flag. The create branch refuses if\neither signing.key or signing.pub already exists (Outcome::RefusedExists\n→ KEYGEN_KEY_EXISTS finding + KEYGEN_FAIL terminal, exit 1). The rotate\nbranch refuses if either file is missing (Outcome::RefusedRotateMissing\n→ same code pair) and refuses up-front if --reason is missing (top-level\nanyhow error). On success, both branches generate a fresh keypair via\nevidence_core::{generate_signing_key, write_signing_key,\nwrite_verifying_key}, best-effort chmod 600 on signing.key (Unix only),\nand emit KEYGEN_OK; the rotate branch additionally appends a single\nline to KEY-ROTATION-LOG carrying timestamp + new pubkey hex + reason.\n", + "modules": [ + "cargo_evidence::cli::keygen::cmd_keygen" + ], + "emits": [ + "KEYGEN_OK", + "KEYGEN_FAIL", + "KEYGEN_KEY_EXISTS" + ], + "traces_to": [ + "b874c97f-dbcd-424f-a1fe-15b16cf36876" + ], + "verification_methods": [ + "test" + ] + }, + { + "id": "LLR-078", + "uid": "3f015ac7-8a76-405b-a180-a2f0183a4b85", + "layer": "llr", + "title": "finalize::check_pubkey_anchor refuses on mismatch", + "description": "After writing BUNDLE.sig, the finalize phase calls check_pubkey_anchor\nwith the just-used signing key. When `cert/signing.pub` exists the\nfunction reads it via evidence_core::read_verifying_key and compares\nits 32-byte representation to signing_key.verifying_key().to_bytes().\nMismatch returns an anyhow error carrying SIGN_PUBKEY_ANCHOR_MISMATCH\nand the two paths involved; missing anchor file is a no-op (Ok(())).\nThe inner check_pubkey_anchor_at takes the anchor path explicitly so\nunit tests can drive comparisons against tempdir fixtures rather than\nthe real cert/signing.pub.\n", + "modules": [ + "cargo_evidence::cli::generate::finalize::check_pubkey_anchor" + ], + "emits": [ + "SIGN_PUBKEY_ANCHOR_MISMATCH" + ], + "traces_to": [ + "14e47afb-0e80-45e3-b795-c263671a221a" + ], + "verification_methods": [ + "test" + ] + }, + { + "id": "LLR-082", + "uid": "dde74fce-62f8-4f33-b161-e21ee8b4627d", + "layer": "llr", + "title": "cli::context wires CLI verb to context_for + emits CONTEXT_* terminals", + "description": "`cmd_context(positional, --crate, --module, format)` parses the\nselector inputs (positional wins; otherwise `--crate` then\n`--module`), calls `evidence_core::context::resolve_selector`, then\n`context_for`, and renders the result. Human mode prints a compact\ntable; `--json` emits the report as a single pretty-printed blob;\n`--format=jsonl` emits one compact report-line on stdout, one\n`Diagnostic` per `ContextWarning` (severity = warning), and a\nhand-built `CONTEXT_OK` / `CONTEXT_FAIL` / `CONTEXT_ERROR` terminal\non the last line. Exit codes: 0 for `CONTEXT_OK` (also the\n`CONTEXT_NO_TRACE_CONFIGURED` graceful path), 1 for `CONTEXT_ERROR`,\n2 for `CONTEXT_FAIL`. The three terminals are registered in\n`evidence_core::TERMINAL_CODES`.\n", + "modules": [ + "cargo_evidence::cli::context", + "cargo_evidence::cli::context::cmd_context" + ], + "emits": [ + "CONTEXT_OK", + "CONTEXT_FAIL", + "CONTEXT_ERROR" + ], + "traces_to": [ + "5ccff935-87c2-4b57-a78b-459d1eb81ad2" + ], + "verification_methods": [ + "test" + ] + }, + { + "id": "LLR-083", + "uid": "ca6f202f-8957-4ba3-adb5-fbc7054e9197", + "layer": "llr", + "title": "context content codes register in RULES and gate the golden wire shape", + "description": "The four content-level `CONTEXT_*` codes are registered in\n`evidence_core::RULES` under the `Context` domain. The CLI's\n`cmd_context` emits each at the right point: `CONTEXT_AMBIGUOUS_SELECTOR`\n(warning) when the resolver records skipped alternates;\n`CONTEXT_NO_REQUIREMENTS_FOR_SELECTOR` (warning) when a non-workspace\nselector matches zero LLRs; `CONTEXT_SELECTOR_OUT_OF_SCOPE` (error)\nwhen the resolver rejects an unrecognized input;\n`CONTEXT_NO_TRACE_CONFIGURED` (info) when `cert/trace/` is missing.\nA golden fixture at\n`crates/cargo-evidence/tests/fixtures/golden_context.json` is byte-\ndiffed by the integration test, regenerated via\n`tools/regen-golden-fixtures.sh`. Drift in the wire shape fires the\nfixture diff with a line-numbered error.\n", + "modules": [ + "evidence_core::context::lookup", + "evidence_core::context::error", + "cargo_evidence::cli::context" + ], + "emits": [ + "CONTEXT_AMBIGUOUS_SELECTOR", + "CONTEXT_NO_REQUIREMENTS_FOR_SELECTOR", + "CONTEXT_NO_TRACE_CONFIGURED", + "CONTEXT_SELECTOR_OUT_OF_SCOPE" + ], + "traces_to": [ + "5ccff935-87c2-4b57-a78b-459d1eb81ad2" + ], + "verification_methods": [ + "test" + ] + } + ], + "parents": [ + { + "id": "HLR-001", + "uid": "f6d35f65-4a38-4ad9-ba2a-b5c4e3d7da41", + "layer": "hlr", + "title": "Every --format=jsonl run emits exactly one terminal event", + "traces_to": [ + "0e42b90f-88d7-408f-8843-0a3335279360" + ] + }, + { + "id": "HLR-002", + "uid": "da30287c-8223-448b-ad94-7a09cc981347", + "layer": "hlr", + "title": "stdout under --format=jsonl is strict JSONL", + "traces_to": [ + "0e42b90f-88d7-408f-8843-0a3335279360" + ] + }, + { + "id": "HLR-015", + "uid": "904ad11d-7b76-4707-8a08-cab8224b64c9", + "layer": "hlr", + "title": "Strict-mode verify requires an ed25519 signature", + "traces_to": [ + "31ad3334-3d64-4fff-9f7a-fe7d5c709288" + ] + }, + { + "id": "HLR-016", + "uid": "f11c7b59-7dea-490f-a355-bb07459bb49b", + "layer": "hlr", + "title": "Exit codes map to terminal events", + "traces_to": [ + "0e42b90f-88d7-408f-8843-0a3335279360" + ] + }, + { + "id": "HLR-017", + "uid": "cb2b1464-3b2b-43a2-bffe-a41c16efefe6", + "layer": "hlr", + "title": "--format resolution folds legacy --json", + "traces_to": [ + "0e42b90f-88d7-408f-8843-0a3335279360" + ] + }, + { + "id": "HLR-018", + "uid": "9ea226b0-852f-4047-a953-2ab0f4778da1", + "layer": "hlr", + "title": "schema show diagnostic prints the embedded source", + "traces_to": [ + "0e42b90f-88d7-408f-8843-0a3335279360" + ] + }, + { + "id": "HLR-020", + "uid": "62498de7-e6b9-4805-8b6c-afead78b33ac", + "layer": "hlr", + "title": "Dispatch guards unwired --format=jsonl subcommands", + "traces_to": [ + "0e42b90f-88d7-408f-8843-0a3335279360" + ] + }, + { + "id": "HLR-023", + "uid": "911fe168-04d7-49ee-99f4-d7a5a7a6fb1c", + "layer": "hlr", + "title": "Default --trace-roots discovery", + "traces_to": [ + "daf04641-ce24-4426-9c39-9f02b6c511cf" + ] + }, + { + "id": "HLR-025", + "uid": "6c6eeac3-d491-42da-940f-97714e78e351", + "layer": "hlr", + "title": "check auto-detects argument shape", + "traces_to": [ + "aa13d9a6-042e-4bc9-9d9e-c191128fb857" + ] + }, + { + "id": "HLR-029", + "uid": "17cd860a-cf97-4eba-b54f-1f7d2563e74b", + "layer": "hlr", + "title": "RULES is the single source of truth for the diagnostic vocabulary", + "traces_to": [ + "589a4478-b9b6-4eb3-99aa-5e5e0c631242" + ] + }, + { + "id": "HLR-036", + "uid": "a3073b5a-ee61-4bdd-bdfe-c3154274e5ea", + "layer": "hlr", + "title": "CI enforces floors and ceilings on every push", + "traces_to": [ + "a289fe83-d842-4708-b920-39cbcebd1aa1" + ] + }, + { + "id": "HLR-042", + "uid": "b7265ced-3c5b-4558-92d1-5c54cba8657f", + "layer": "hlr", + "title": "`trace --validate` emits one JSONL event per Link-phase sub-error", + "traces_to": [ + "5e9988ce-1351-4e39-98af-835e96e5fc17" + ] + }, + { + "id": "HLR-048", + "uid": "57992e98-fb1e-4248-b340-6dd454db95c1", + "layer": "hlr", + "title": "cargo evidence doctor audits downstream rigor via a checklist of typed diagnostic codes", + "traces_to": [ + "abba0183-f506-4fe5-81d1-afdc3bcac1ca" + ] + }, + { + "id": "HLR-049", + "uid": "014adc4d-c545-403c-b4f2-d702a49dff30", + "layer": "hlr", + "title": "Pre-release builds embed a tool_prerelease flag in env.json and verify refuses such bundles under cert/record", + "traces_to": [ + "2758e039-4943-4a9a-ad3d-9e0b0d0da7d8" + ] + }, + { + "id": "HLR-052", + "uid": "e4f127fb-38c5-4f77-971f-143f774716f8", + "layer": "hlr", + "title": "Bundle records per-test requirement_uids; verify asserts every LLR is test-verified", + "traces_to": [ + "5478751b-b547-49b7-b2fc-ae3963668a81" + ] + }, + { + "id": "HLR-053", + "uid": "468a6894-7121-4936-8432-94163e35d4a1", + "layer": "hlr", + "title": "generate --coverage flag invokes cargo-llvm-cov and writes a typed coverage report into the bundle", + "traces_to": [ + "5c6e07f1-da4a-4aec-9647-426304deadb5" + ] + }, + { + "id": "HLR-055", + "uid": "fb07d8c2-5d21-4989-b21e-09b62caa0299", + "layer": "hlr", + "title": "cmd_trace + cmd_generate pass derived.toml requirements into Link-phase validation", + "traces_to": [ + "f5aac59f-9dbd-48f7-80d1-2e1cc1a2fdd1" + ] + }, + { + "id": "HLR-056", + "uid": "bebe9227-5f47-4eb8-89a7-f2157fb96e6c", + "layer": "hlr", + "title": "generate compares per-level coverage aggregate against Dal::coverage_thresholds", + "traces_to": [ + "895097b2-6d08-4ef7-9305-143a87424684" + ] + }, + { + "id": "HLR-057", + "uid": "79afc7a1-22dd-450e-8f0c-c29c93b48905", + "layer": "hlr", + "title": "cli::doctor derives its trace DAL via load_max_dal, not default_dal", + "traces_to": [ + "71c07b24-9d90-4ad5-ae9a-a7c2fe55c93c" + ] + }, + { + "id": "HLR-058", + "uid": "2087d91a-12ba-4b3a-ab43-632f670bad49", + "layer": "hlr", + "title": "generate threads trace_validation_passed bool from Phase 6 to write_compliance_reports", + "traces_to": [ + "1eaaa60c-e8e0-4209-8ca5-51108495df61" + ] + }, + { + "id": "HLR-066", + "uid": "c222804b-4d4f-4dfd-8765-2fd51a730ecc", + "layer": "hlr", + "title": "DAL-A in-scope crate without auxiliary MC/DC tool reference fails cert/record generate", + "traces_to": [ + "5af6e706-6aef-411d-811d-747633876695" + ] + }, + { + "id": "HLR-068", + "uid": "34234f8b-9f0b-47d4-829e-aed0ac96d51c", + "layer": "hlr", + "title": "Direct cargo-evidence --help invocation lists subcommands, not a redirect stub", + "traces_to": [ + "385c2c4c-748c-4486-bd8c-7622b12c9273" + ] + }, + { + "id": "HLR-070", + "uid": "b874c97f-dbcd-424f-a1fe-15b16cf36876", + "layer": "hlr", + "title": "cargo evidence keygen: explicit create + rotate", + "traces_to": [ + "d469f0fa-0816-47a8-9452-b73e65f4cfd7" + ] + }, + { + "id": "HLR-071", + "uid": "14e47afb-0e80-45e3-b795-c263671a221a", + "layer": "hlr", + "title": "Generate refuses on signing.pub anchor mismatch", + "traces_to": [ + "d469f0fa-0816-47a8-9452-b73e65f4cfd7" + ] + }, + { + "id": "HLR-073", + "uid": "5ccff935-87c2-4b57-a78b-459d1eb81ad2", + "layer": "hlr", + "title": "cargo evidence context CLI verb returns per-module trace slice", + "traces_to": [ + "d8d1f204-3909-418d-b62a-1e28edd088ed" + ] + }, + { + "id": "SYS-002", + "uid": "0e42b90f-88d7-408f-8843-0a3335279360", + "layer": "sys", + "title": "Machine-readable diagnostic stream", + "traces_to": [] + }, + { + "id": "SYS-004", + "uid": "5af6e706-6aef-411d-811d-747633876695", + "layer": "sys", + "title": "Policy-gated evidence emission", + "traces_to": [] + }, + { + "id": "SYS-005", + "uid": "31ad3334-3d64-4fff-9f7a-fe7d5c709288", + "layer": "sys", + "title": "Refusal on integrity guarantees unmet", + "traces_to": [] + }, + { + "id": "SYS-006", + "uid": "daf04641-ce24-4426-9c39-9f02b6c511cf", + "layer": "sys", + "title": "Self-enforcement of the trace contract", + "traces_to": [] + }, + { + "id": "SYS-007", + "uid": "aa13d9a6-042e-4bc9-9d9e-c191128fb857", + "layer": "sys", + "title": "Single agent-facing command reports pass/gap coverage", + "traces_to": [] + }, + { + "id": "SYS-008", + "uid": "589a4478-b9b6-4eb3-99aa-5e5e0c631242", + "layer": "sys", + "title": "Tool self-describes its diagnostic vocabulary and refuses contract drift", + "traces_to": [] + }, + { + "id": "SYS-009", + "uid": "a289fe83-d842-4708-b920-39cbcebd1aa1", + "layer": "sys", + "title": "Tool ratchets its own rigor floors — no silent regression", + "traces_to": [] + }, + { + "id": "SYS-011", + "uid": "5e9988ce-1351-4e39-98af-835e96e5fc17", + "layer": "sys", + "title": "Diagnostic stream shall expose structured codes per Link-phase failure, not an opaque prose envelope", + "traces_to": [] + }, + { + "id": "SYS-016", + "uid": "abba0183-f506-4fe5-81d1-afdc3bcac1ca", + "layer": "sys", + "title": "The tool shall provide a mechanical rigor-audit subcommand that blocks cert-profile bundle generation on any FAIL finding", + "traces_to": [] + }, + { + "id": "SYS-017", + "uid": "2758e039-4943-4a9a-ad3d-9e0b0d0da7d8", + "layer": "sys", + "title": "Bundles produced by a pre-release build of the tool shall be mechanically rejected as cert/record evidence", + "traces_to": [] + }, + { + "id": "SYS-018", + "uid": "385c2c4c-748c-4486-bd8c-7622b12c9273", + "layer": "sys", + "title": "Agent-facing verbs shall be callable over the Model Context Protocol without shelling out", + "traces_to": [] + }, + { + "id": "SYS-020", + "uid": "5478751b-b547-49b7-b2fc-ae3963668a81", + "layer": "sys", + "title": "Bundle shall carry bidirectional requirement ↔ test traceability at atom level", + "traces_to": [] + }, + { + "id": "SYS-021", + "uid": "5c6e07f1-da4a-4aec-9647-426304deadb5", + "layer": "sys", + "title": "Bundle shall carry structural coverage evidence when cert profile is used", + "traces_to": [] + }, + { + "id": "SYS-023", + "uid": "f5aac59f-9dbd-48f7-80d1-2e1cc1a2fdd1", + "layer": "sys", + "title": "Trace validation shall resolve references to every configured requirement layer, not a subset", + "traces_to": [] + }, + { + "id": "SYS-024", + "uid": "895097b2-6d08-4ef7-9305-143a87424684", + "layer": "sys", + "title": "Tool shall enforce DO-178C A-7 minimum coverage percentages per DAL", + "traces_to": [] + }, + { + "id": "SYS-025", + "uid": "71c07b24-9d90-4ad5-ae9a-a7c2fe55c93c", + "layer": "sys", + "title": "Tool's downstream-audit surfaces shall derive trace policy from the max DAL across per-crate overrides", + "traces_to": [] + }, + { + "id": "SYS-026", + "uid": "1eaaa60c-e8e0-4209-8ca5-51108495df61", + "layer": "sys", + "title": "Compliance claims shall reflect the actual outcome of the supporting check, not a hardcoded assumption", + "traces_to": [] + }, + { + "id": "SYS-030", + "uid": "d469f0fa-0816-47a8-9452-b73e65f4cfd7", + "layer": "sys", + "title": "Project signing keypair lifecycle", + "traces_to": [] + }, + { + "id": "SYS-031", + "uid": "d8d1f204-3909-418d-b62a-1e28edd088ed", + "layer": "sys", + "title": "Per-module agent context surface", + "traces_to": [] + } + ], + "tests": [ + { + "id": "TEST-080", + "uid": "313c6de1-f7a2-43cb-8e9f-887dc09f87c7", + "name": "DAL-A MC/DC fail-loud at cert/record; warn-only at dev; bypass when auxiliary tool set", + "selectors": [ + "dal_qualification_gate::test_generate_dev_profile_warns_on_dal_a_without_mcdc_tool_but_continues", + "evidence_core::boundary_check::tests::dal_a_mcdc_check_fires_on_dal_a_without_tool", + "evidence_core::boundary_check::tests::dal_a_mcdc_check_passes_when_auxiliary_tool_set", + "evidence_core::boundary_check::tests::dal_a_mcdc_check_passes_when_no_dal_a_in_scope" + ], + "traces_to": [ + "08152f7f-56c4-47f7-ae99-f03d0e114a67" + ] + }, + { + "id": "TEST-052", + "uid": "da6f4884-9fdc-45bc-b6f7-21f115103ee2", + "name": "Dangling LLR (test-verified, no matching test record) fires VERIFY_LLR_TEST_SELECTOR_UNRESOLVED", + "selectors": [ + "verify::llr_selectors::tests::llr_present_in_requirement_uids_passes", + "verify::llr_selectors::tests::llr_with_no_matching_test_outcome_fires_unresolved" + ], + "traces_to": [ + "4bd86c6c-1743-4a78-9d17-c284b3ad8e04" + ] + }, + { + "id": "TEST-055", + "uid": "03576b1a-f72e-4ab9-a8f8-f5d1110f85c2", + "name": "Derived-requirement traces_to references resolve in cmd_trace validation", + "selectors": [ + "derived_trace_validation::derived_uids_resolve_in_trace_validation" + ], + "traces_to": [ + "4e98e1d9-6017-425e-b613-52853e97c2f3" + ] + }, + { + "id": "TEST-016", + "uid": "a8bc1428-d088-40b5-8b9f-84c33667fc4c", + "name": "Finding path ends with VERIFY_FAIL terminal on exit 2", + "selectors": [ + "verify_jsonl::verify_finding_emits_terminal_fail_and_exit_two" + ], + "traces_to": [ + "77b0a3dc-2b31-4d25-a375-2bc51f5f6497" + ] + }, + { + "id": "TEST-049", + "uid": "2856e53c-8c90-42e8-ad9b-3da6f3a14a25", + "name": "Pre-release tool flag propagates into env.json, blocks verify under cert/record, passes dev", + "selectors": [ + "evidence_core::env::capture::prerelease_tests::classifies_semver_shapes", + "evidence_core::env::fingerprint::tests::tool_prerelease_explicit_true_roundtrips", + "verify_prerelease::cert_profile_blocks_with_verify_fail", + "verify_prerelease::dev_profile_warning_passes" + ], + "traces_to": [ + "548b7b36-1d03-487d-a6a1-f2b37bdee2a1" + ] + }, + { + "id": "TEST-023", + "uid": "f9278d5b-373f-4493-a607-0e105ac84a0a", + "name": "Trace defaults to cert/trace when --trace-roots is absent", + "selectors": [ + "trace_discovery::trace_defaults_to_tool_trace_when_flag_absent" + ], + "traces_to": [ + "6be1b61b-0aeb-4c05-b2ef-f82fc7c00e55" + ] + }, + { + "id": "TEST-020", + "uid": "ba80f1d3-65a0-44b7-afc7-85ca0fd1bc3c", + "name": "Unwired --format=jsonl on an unwired subcommand hard-errors with terminal", + "selectors": [ + "verify_jsonl::unwired_diff_jsonl_is_rejected" + ], + "traces_to": [ + "00578a4a-d6fe-420a-92c3-3cf4cb3387d6" + ] + }, + { + "id": "TEST-017", + "uid": "383598b3-4a32-470c-a73e-c4645e22fe6c", + "name": "Verify with --json on nonexistent bundle returns JSON", + "selectors": [ + "cli::test_verify_json_nonexistent" + ], + "traces_to": [ + "c5f2b95b-5555-4261-a92c-76ce3ec2e5cf" + ] + }, + { + "id": "TEST-058", + "uid": "78b4217f-700b-4058-959b-5f8c1e7170f4", + "name": "aggregate_branches_percent normal + robustness + BVA (incl. bug-fix anchor)", + "selectors": [ + "evidence_core::bundle::builder_coverage::tests::aggregate_branches_bva_zero_zero_single_file_returns_zero", + "evidence_core::bundle::builder_coverage::tests::aggregate_branches_reads_branches_not_lines", + "evidence_core::bundle::builder_coverage::tests::aggregate_branches_robustness_all_none_returns_zero", + "evidence_core::coverage::thresholds::tests::aggregate_branches_bva_all_covered_is_hundred", + "evidence_core::coverage::thresholds::tests::aggregate_branches_bva_zero_zero_single_file_returns_zero", + "evidence_core::coverage::thresholds::tests::aggregate_branches_ignores_line_counts_at_branch_level", + "evidence_core::coverage::thresholds::tests::aggregate_branches_normal_two_files_sum_to_half", + "evidence_core::coverage::thresholds::tests::aggregate_branches_robustness_all_none_returns_zero", + "evidence_core::coverage::thresholds::tests::aggregate_branches_robustness_empty_per_file_returns_zero", + "evidence_core::coverage::thresholds::tests::aggregate_branches_robustness_none_file_contributes_nothing" + ], + "traces_to": [ + "84c30007-3c84-4d23-bdf8-f540339b507a" + ] + }, + { + "id": "TEST-057", + "uid": "33d5f462-f1b6-4176-8fe5-47de0cacb083", + "name": "aggregate_lines_percent normal + robustness + BVA", + "selectors": [ + "evidence_core::bundle::builder_coverage::tests::aggregate_lines_sums_line_field_only", + "evidence_core::coverage::thresholds::tests::aggregate_lines_bva_forty_two_over_hundred_is_exact", + "evidence_core::coverage::thresholds::tests::aggregate_lines_bva_one_over_one_is_hundred", + "evidence_core::coverage::thresholds::tests::aggregate_lines_bva_zero_over_one_is_zero", + "evidence_core::coverage::thresholds::tests::aggregate_lines_normal_three_files_weighted_average", + "evidence_core::coverage::thresholds::tests::aggregate_lines_robustness_empty_per_file_returns_zero", + "evidence_core::coverage::thresholds::tests::aggregate_lines_robustness_single_file_zero_total_returns_zero" + ], + "traces_to": [ + "a95ed852-94ba-499b-bd17-76d26b75ebe5" + ] + }, + { + "id": "TEST-085", + "uid": "eb395589-4eb1-4052-b215-2d424085e694", + "name": "anchor consistency: missing / match / mismatch", + "selectors": [ + "cli::generate::finalize::tests::anchor_absent_is_ok", + "cli::generate::finalize::tests::anchor_match_is_ok", + "cli::generate::finalize::tests::anchor_mismatch_refuses", + "cli::generate::finalize::tests::resolve_signing_key_path_prefers_explicit_flag", + "cli::generate::finalize::tests::resolve_signing_key_path_returns_none_when_neither_explicit_nor_env" + ], + "traces_to": [ + "3f015ac7-8a76-405b-a180-a2f0183a4b85" + ] + }, + { + "id": "TEST-090", + "uid": "e92cf358-fdbb-4213-8362-a66c89331653", + "name": "cargo evidence context CLI is byte-locked against the golden fixture", + "selectors": [ + "cli_context::context_jsonl_invalid_selector_emits_fail_terminal", + "cli_context::context_jsonl_non_adopter_graceful_path", + "cli_context::golden_context_json_byte_diff" + ], + "traces_to": [ + "ca6f202f-8957-4ba3-adb5-fbc7054e9197" + ] + }, + { + "id": "TEST-048", + "uid": "11c798f0-62c9-4669-9cc5-2f596544cfb9", + "name": "cargo evidence doctor passes on rigorous fixture, fails on sloppy fixture, blocks cert generate", + "selectors": [ + "doctor_cmd::cert_generate_blocks_on_doctor_fail", + "doctor_cmd::current_workspace_passes_doctor", + "doctor_cmd::rigorous_fixture_passes", + "doctor_cmd::sloppy_fixture_fails_with_named_codes" + ], + "traces_to": [ + "ec50f98b-9a1d-4f7b-bc33-df56c7faab2a" + ] + }, + { + "id": "TEST-036", + "uid": "e5ec5aab-828f-4540-8bc6-20c586adf45f", + "name": "cargo evidence floors fires FLOORS_BELOW_MIN on a tampered floor", + "selectors": [ + "floors_gate::floors_gate_fires_on_below_min_floor" + ], + "traces_to": [ + "74f50e68-3a78-4557-a7c3-13340e52543a" + ] + }, + { + "id": "TEST-053", + "uid": "1a56e1f7-4d7b-42a6-b242-16d4df9d74b9", + "name": "cargo llvm-cov export parser + graceful degradation on missing binary", + "selectors": [ + "coverage_e2e::coverage_line_on_dev_degrades_gracefully_when_llvmcov_missing", + "coverage_e2e::coverage_none_does_not_invoke_llvmcov", + "evidence_core::coverage::llvm_cov_json::tests::normalizes_absolute_paths_to_workspace_relative", + "evidence_core::coverage::llvm_cov_json::tests::parses_trivial_export", + "evidence_core::coverage::report::tests::coverage_report_roundtrips_json" + ], + "traces_to": [ + "ed4284df-5a2e-4241-bbd9-e744edd43f79" + ] + }, + { + "id": "TEST-082", + "uid": "281db1d7-43e7-4b4e-8cf8-be6d12133dd9", + "name": "cargo-evidence --help direct invocation lists every subcommand", + "selectors": [ + "help_listing::test_cargo_evidence_dash_h_exits_zero", + "help_listing::test_cargo_evidence_help_lists_subcommands" + ], + "traces_to": [ + "76f2567f-3d6d-4633-9d2c-00d03cc2b529" + ] + }, + { + "id": "TEST-028", + "uid": "4650b356-6c50-4ea8-b548-e650ce923973", + "name": "check bundle mode byte-matches verify --format=jsonl", + "selectors": [ + "check_bundle_mode::check_bundle_mode_matches_verify" + ], + "traces_to": [ + "7df3b426-c9ea-426b-8fb9-64ce247312a0" + ] + }, + { + "id": "TEST-025", + "uid": "3dc79eb5-3ffc-4b04-ba4c-e2a2e458feeb", + "name": "check dispatches source mode on a workspace directory", + "selectors": [ + "check_source_tree::check_source_mode_on_clean_workspace" + ], + "traces_to": [ + "7df3b426-c9ea-426b-8fb9-64ce247312a0" + ] + }, + { + "id": "TEST-089", + "uid": "27e2ee62-9591-4353-9ced-2f009ba7de97", + "name": "context::error variants map to documented CONTEXT_* codes", + "selectors": [ + "context::tests::missing_trace_root_returns_trace_not_configured", + "context::tests::selector_out_of_scope_preserves_raw_input" + ], + "traces_to": [ + "ca6f202f-8957-4ba3-adb5-fbc7054e9197" + ] + }, + { + "id": "TEST-059", + "uid": "a1d3ac91-35ed-4171-8646-1e3cce4c3e96", + "name": "evaluate_thresholds normal + robustness + BVA (strict <, per-level dispatch)", + "selectors": [ + "evidence_core::coverage::thresholds::tests::threshold_bva_exact_equality_passes", + "evidence_core::coverage::thresholds::tests::threshold_bva_just_below_fires", + "evidence_core::coverage::thresholds::tests::threshold_integration_high_lines_low_branches_fires_branch_only", + "evidence_core::coverage::thresholds::tests::threshold_normal_branch_below_dalb_statement_above", + "evidence_core::coverage::thresholds::tests::threshold_robustness_measurement_absent_no_violation", + "evidence_core::coverage::thresholds::tests::threshold_robustness_none_threshold_skips_check" + ], + "traces_to": [ + "b1377fb5-f879-4edc-9c50-46c5e67c71ad" + ] + }, + { + "id": "TEST-015", + "uid": "c818cd84-8d25-457e-b18e-a613c691964d", + "name": "init-scaffolded template does not trip the policy-implementable gate", + "selectors": [ + "cli::test_init_template_does_not_trip_policy_gate" + ], + "traces_to": [ + "bb176fd9-7859-4ec1-bbc0-088d2518dc54" + ] + }, + { + "id": "TEST-084", + "uid": "92ca41f0-30ba-4c22-8dd0-0aa0b7d96111", + "name": "keygen lifecycle: create / refuse-overwrite / rotate / log", + "selectors": [ + "cli::keygen::tests::first_time_create_writes_both_files", + "cli::keygen::tests::refuses_overwrite_without_rotate", + "cli::keygen::tests::rotate_appends_each_log_line", + "cli::keygen::tests::rotate_overwrites_and_logs", + "cli::keygen::tests::rotate_requires_existing_pair", + "cli::keygen::tests::rotate_without_reason_is_an_error", + "cli::keygen::tests::signing_key_chmod_600_on_unix" + ], + "traces_to": [ + "2edcdfa5-d7e7-4115-a3fa-8ed36354aa1b" + ] + }, + { + "id": "TEST-060", + "uid": "64b360d1-a9be-4097-a011-ed6f0625fe1c", + "name": "load_max_dal derives trace DAL as max over dal_map; normal + robustness + BVA", + "selectors": [ + "doctor_max_dal::empty_in_scope_falls_back_to_default_dal_not_hard_d", + "doctor_max_dal::mixed_overrides_take_highest", + "doctor_max_dal::no_override_at_default_d_leaves_gate_skipped", + "doctor_max_dal::override_raises_dal_past_qualification_gate", + "doctor_max_dal::single_crate_at_dal_c_fires_gate_inclusively" + ], + "traces_to": [ + "a88f2efa-7aa7-4bfc-9554-610a906f2e2e" + ] + }, + { + "id": "TEST-029", + "uid": "ad68d844-d00a-4c4d-8963-0bfcb20f4e3b", + "name": "rules --json emits stable JSON and --format=jsonl is rejected", + "selectors": [ + "rules_cmd::rules_json_matches_rules_json_helper" + ], + "traces_to": [ + "0835d2e8-3a84-4a4a-97e0-46985be02892" + ] + }, + { + "id": "TEST-018", + "uid": "f7cd1dd3-8a82-4442-9c19-93f6729329af", + "name": "schema show index prints the embedded schema", + "selectors": [ + "cli::test_schema_show_index" + ], + "traces_to": [ + "dfc19912-4b1f-4a0c-8e18-a66d6e0738c3" + ] + }, + { + "id": "TEST-002", + "uid": "3da79a9f-157a-47e5-a6b6-a160c3854b43", + "name": "stdout under --format=jsonl is strict JSONL", + "selectors": [ + "verify_jsonl::verify_jsonl_stdout_is_strict_jsonl_only" + ], + "traces_to": [ + "36491f32-293f-4f39-a2f5-cdb7e746330c" + ] + }, + { + "id": "TEST-042", + "uid": "453db165-a0b4-45d3-9c12-895537b2f067", + "name": "trace --validate --format=jsonl emits one event per LinkError", + "selectors": [ + "trace_cmd_jsonl::trace_validate_jsonl_emits_per_variant" + ], + "traces_to": [ + "61f2c36a-0d47-4485-acca-f2d4403ec339" + ] + }, + { + "id": "TEST-061", + "uid": "cff40a9c-4036-4918-99e3-0611bee227c8", + "name": "trace_validation_passed honesty — A3-6 / A4-6 Met/Partial/NotMet per (has_trace, passed) matrix", + "selectors": [ + "compliance_trace_honesty::a3_6_bva_no_trace_data_is_notmet_even_when_passed_false", + "compliance_trace_honesty::a3_6_bva_no_trace_data_is_notmet_even_when_passed_true", + "compliance_trace_honesty::a3_6_normal_passed_and_present_is_met", + "compliance_trace_honesty::a3_6_notmet_reason_is_no_trace_data_not_validation_failure", + "compliance_trace_honesty::a3_6_partial_carries_reason_naming_the_gap", + "compliance_trace_honesty::a3_6_robustness_warned_continuing_is_partial", + "compliance_trace_honesty::a4_6_normal_passed_and_present_is_met", + "compliance_trace_honesty::a4_6_robustness_warned_continuing_is_partial" + ], + "traces_to": [ + "d67f45bf-c9d0-4f2a-87d1-0dfbf14d9262" + ] + }, + { + "id": "TEST-001", + "uid": "c688bd6f-7514-43c4-97c5-d22cadba8e26", + "name": "verify --format=jsonl happy path ends with VERIFY_OK", + "selectors": [ + "verify_jsonl::verify_ok_terminates_with_verify_ok_and_exit_zero" + ], + "traces_to": [ + "ef688fd5-21e0-4cf7-9ed5-b1819b5ae50d" + ] + }, + { + "id": "TEST-072", + "uid": "33ed3802-036f-443a-b465-b56a500c6428", + "name": "verify --verify-key I/O failure emits structured diag + VERIFY_ERROR terminal", + "selectors": [ + "evidence_core::verify::runtime_error::tests::read_verify_key_code_and_location", + "verify_e2e::human_prints_error_on_unreadable_verify_key", + "verify_e2e::json_wraps_verify_key_io_failure", + "verify_e2e::jsonl_emits_terminal_on_unreadable_verify_key" + ], + "traces_to": [ + "4abf426c-da46-4307-ba7d-9d7dbfd86f63" + ] + } + ], + "diagnostic_codes": [ + "BOUNDARY_DAL_A_MISSING_AUXILIARY_MCDC", + "CHECK_TEST_RUNTIME_FAILURE", + "CLI_INVALID_ARGUMENT", + "CLI_UNSUPPORTED_FORMAT", + "CONTEXT_AMBIGUOUS_SELECTOR", + "CONTEXT_ERROR", + "CONTEXT_FAIL", + "CONTEXT_NO_REQUIREMENTS_FOR_SELECTOR", + "CONTEXT_NO_TRACE_CONFIGURED", + "CONTEXT_OK", + "CONTEXT_SELECTOR_OUT_OF_SCOPE", + "COVERAGE_BELOW_THRESHOLD", + "COVERAGE_LLVMCOV_MISSING", + "COVERAGE_OK", + "COVERAGE_PARSE_FAILED", + "DOCTOR_BOUNDARY_MISSING", + "DOCTOR_CHECK_PASSED", + "DOCTOR_CI_INTEGRATION_MISSING", + "DOCTOR_FAIL", + "DOCTOR_FLOORS_BOUNDARY_MISMATCH", + "DOCTOR_FLOORS_MISSING", + "DOCTOR_FLOORS_SLACK", + "DOCTOR_FLOORS_VIOLATED", + "DOCTOR_MERGE_STYLE_RISK", + "DOCTOR_MERGE_STYLE_UNKNOWN", + "DOCTOR_OK", + "DOCTOR_OVERRIDE_PROTOCOL_UNDOCUMENTED", + "DOCTOR_QUALIFICATION_MISSING", + "DOCTOR_TRACE_EMPTY", + "DOCTOR_TRACE_INVALID", + "ENV_ENGINE_RELEASE_PROVENANCE", + "FLOORS_BELOW_MIN", + "FLOORS_DIMENSION_OK", + "FLOORS_FAIL", + "FLOORS_OK", + "KEYGEN_FAIL", + "KEYGEN_KEY_EXISTS", + "KEYGEN_OK", + "SCHEMA_COMPILE_FAILED", + "SCHEMA_INSTANCE_INVALID", + "SCHEMA_PARSE_FAILED", + "SIGN_INVALID_KEY", + "SIGN_INVALID_SIGNATURE_HEX", + "SIGN_PUBKEY_ANCHOR_MISMATCH", + "SIGN_READ_FAILED", + "SIGN_WRITE_FAILED", + "TRACE_REGISTER_FAILED", + "TRACE_SELECTOR_UNRESOLVED", + "VERIFY_ERROR", + "VERIFY_FAIL", + "VERIFY_INVALID_FORMAT", + "VERIFY_LLR_CHECK_SKIPPED_NO_OUTCOMES", + "VERIFY_LLR_TEST_SELECTOR_UNRESOLVED", + "VERIFY_OK", + "VERIFY_PRERELEASE_TOOL", + "VERIFY_RUNTIME_READ_VERIFY_KEY", + "VERIFY_SIGNATURE_INVALID" + ], + "floors": [ + { + "dimension": "library_panics", + "kind": "per_crate_ceiling", + "current": 0, + "floor": 0 + }, + { + "dimension": "test_count", + "kind": "per_crate_floor", + "current": 158, + "floor": 158 + } + ], + "boundary": { + "in_scope": true, + "forbidden_deps": [] + }, + "conventions": { + "nearest_claude_md": "crates/cargo-evidence/CLAUDE.md" + }, + "warnings": [] +} diff --git a/crates/cargo-evidence/tests/fixtures/golden_rules.json b/crates/cargo-evidence/tests/fixtures/golden_rules.json index 8fe2914..afdda7c 100644 --- a/crates/cargo-evidence/tests/fixtures/golden_rules.json +++ b/crates/cargo-evidence/tests/fixtures/golden_rules.json @@ -195,6 +195,55 @@ "has_fix_hint": false, "terminal": false }, + { + "code": "CONTEXT_AMBIGUOUS_SELECTOR", + "severity": "warning", + "domain": "context", + "has_fix_hint": false, + "terminal": false + }, + { + "code": "CONTEXT_ERROR", + "severity": "error", + "domain": "context", + "has_fix_hint": false, + "terminal": true + }, + { + "code": "CONTEXT_FAIL", + "severity": "error", + "domain": "context", + "has_fix_hint": false, + "terminal": true + }, + { + "code": "CONTEXT_NO_REQUIREMENTS_FOR_SELECTOR", + "severity": "warning", + "domain": "context", + "has_fix_hint": false, + "terminal": false + }, + { + "code": "CONTEXT_NO_TRACE_CONFIGURED", + "severity": "info", + "domain": "context", + "has_fix_hint": false, + "terminal": false + }, + { + "code": "CONTEXT_OK", + "severity": "info", + "domain": "context", + "has_fix_hint": false, + "terminal": true + }, + { + "code": "CONTEXT_SELECTOR_OUT_OF_SCOPE", + "severity": "error", + "domain": "context", + "has_fix_hint": false, + "terminal": false + }, { "code": "COVERAGE_BELOW_THRESHOLD", "severity": "error", diff --git a/crates/cargo-evidence/tests/rules_cmd.rs b/crates/cargo-evidence/tests/rules_cmd.rs index 10d2e87..b8d12da 100644 --- a/crates/cargo-evidence/tests/rules_cmd.rs +++ b/crates/cargo-evidence/tests/rules_cmd.rs @@ -144,6 +144,7 @@ fn rules_json_every_entry_has_known_severity_and_domain() { | "check" | "cli" | "cmd" + | "context" | "coverage" | "doctor" | "env" diff --git a/crates/evidence-core/src/context.rs b/crates/evidence-core/src/context.rs new file mode 100644 index 0000000..43eac9b --- /dev/null +++ b/crates/evidence-core/src/context.rs @@ -0,0 +1,37 @@ +//! Per-module agent-context surface. +//! +//! Builds a single [`ContextReport`] for any selector — file path, +//! workspace crate name, Rust module path, or the workspace overview +//! when no selector is supplied. The report carries the LLR-level +//! requirements governing the selector, their parent HLR / SYS +//! roll-up, the verifying tests, the diagnostic codes they own, the +//! per-crate floors slice, the boundary policy, and a pointer to +//! the nearest layered `CLAUDE.md`. +//! +//! Two entry points: +//! +//! - [`resolve_selector`] classifies the raw input (priority on +//! ambiguity: File > Crate > Module). +//! - [`context_for`] composes the report from the resolved +//! selector. +//! +//! The implementation is split across private sibling files to stay +//! under the workspace 500-line file cap. Sub-modules are private +//! because the only stable public surface is the two entry points +//! plus the [`ContextReport`] / [`ContextError`] types. + +mod error; +mod lookup; +mod report; +mod resolver; + +#[cfg(test)] +mod tests; + +pub use error::ContextError; +pub use lookup::context_for; +pub use report::{ + BoundarySlice, ContextReport, ContextWarning, Conventions, FloorRow, ParentRef, RequirementRef, + SelectorView, TestRef, +}; +pub use resolver::{ResolvedSelector, resolve_selector}; diff --git a/crates/evidence-core/src/context/error.rs b/crates/evidence-core/src/context/error.rs new file mode 100644 index 0000000..8876f0c --- /dev/null +++ b/crates/evidence-core/src/context/error.rs @@ -0,0 +1,81 @@ +//! Typed error enum for the [`context`](super) module. +//! +//! Variants carry the upstream cause via `#[source]` / `#[from]` so +//! context is never lost. The CLI layer (`cargo_evidence::cli::context`) +//! picks the right `CONTEXT_*` diagnostic code per variant — keeping +//! the mapping at the emit-site avoids a `DiagnosticCode` impl whose +//! match arms would collide on Schema Rule 3 (one code per arm) for +//! the IO / manifest variants that share a single content code. + +use std::path::PathBuf; + +use thiserror::Error; + +use crate::trace::TraceReadError; + +/// Errors returned by [`resolve_selector`](super::resolver::resolve_selector) +/// and [`context_for`](super::context_for). +#[derive(Debug, Error)] +pub enum ContextError { + /// The selector did not resolve as a file under + /// `crates//`, a known workspace crate name, or a + /// reasonably-shaped module path. Caller wrote the input + /// `selector` in the message verbatim so the agent can fix the + /// typo. + #[error("selector '{0}' resolves outside the workspace")] + SelectorOutOfScope(String), + /// `cert/trace/` is missing from the workspace — the + /// non-adopter graceful path. The CLI maps this to + /// `CONTEXT_NO_TRACE_CONFIGURED` (info) + `CONTEXT_OK` (exit 0) + /// rather than treating it as an error. + #[error("no trace configured at {0}")] + TraceNotConfigured(PathBuf), + /// Underlying trace TOML read or parse failure. Carries the + /// upstream `TraceReadError` for path / span context. + #[error("trace read failed")] + TraceRead(#[from] TraceReadError), + /// I/O failure while reading a non-trace file (`Cargo.toml` lookup, + /// `CLAUDE.md` discovery, etc.). + #[error("I/O failed")] + Io(#[from] std::io::Error), + /// Failed to read a workspace crate's `Cargo.toml`. Carries the + /// path so the operator knows which manifest tripped the lookup. + #[error("reading {path}")] + CargoManifestRead { + /// Manifest path that failed to read. + path: PathBuf, + /// Underlying OS error. + #[source] + err: std::io::Error, + }, + /// Failed to parse a workspace crate's `Cargo.toml`. Carries the + /// path + the underlying TOML error. + #[error("parsing {path}")] + CargoManifestParse { + /// Manifest path that failed to parse. + path: PathBuf, + /// Underlying TOML error. + #[source] + err: toml::de::Error, + }, +} + +impl ContextError { + /// Pick the `CONTEXT_*` content code the CLI emits for this + /// variant. Kept outside the [`DiagnosticCode`] trait because the + /// IO / manifest variants share a code (`CONTEXT_SELECTOR_OUT_OF_SCOPE`) + /// and a trait impl with multiple match arms returning the same + /// string would trip the Schema Rule 3 uniqueness check. + /// + /// [`DiagnosticCode`]: crate::diagnostic::DiagnosticCode + pub fn content_code(&self) -> &'static str { + match self { + ContextError::SelectorOutOfScope(_) => "CONTEXT_SELECTOR_OUT_OF_SCOPE", + ContextError::TraceNotConfigured(_) => "CONTEXT_NO_TRACE_CONFIGURED", + ContextError::TraceRead(_) => "CONTEXT_SELECTOR_OUT_OF_SCOPE", + ContextError::Io(_) + | ContextError::CargoManifestRead { .. } + | ContextError::CargoManifestParse { .. } => "CONTEXT_SELECTOR_OUT_OF_SCOPE", + } + } +} diff --git a/crates/evidence-core/src/context/lookup.rs b/crates/evidence-core/src/context/lookup.rs new file mode 100644 index 0000000..1f4e1bd --- /dev/null +++ b/crates/evidence-core/src/context/lookup.rs @@ -0,0 +1,421 @@ +//! Compose a [`ContextReport`] from the trace graph, boundary +//! policy, floors config, and the layered `CLAUDE.md` set. +//! +//! The lookup pass is deliberately read-only: it ingests data via +//! the existing `read_all_trace_files`, `BoundaryConfig::load`, and +//! `FloorsConfig::load_or_missing` helpers and never touches disk +//! beyond those calls + the `CLAUDE.md` existence probe. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::Path; + +use super::error::ContextError; +use super::report::{ + BoundarySlice, ContextReport, ContextWarning, Conventions, FloorRow, ParentRef, RequirementRef, + SelectorView, TestRef, +}; +use super::resolver::{ResolvedSelector, discover_workspace_crates}; +use crate::floors::{FloorsConfig, LoadOutcome, current_measurements, per_crate_measurements}; +use crate::policy::BoundaryConfig; +use crate::trace::{HlrEntry, LlrEntry, TestEntry, TraceFiles, read_all_trace_files}; + +/// Top-level entry point — compose the report for `selector`. +pub fn context_for( + workspace_root: &Path, + selector: &ResolvedSelector, +) -> Result { + let trace_root = workspace_root.join("cert").join("trace"); + if !trace_root.is_dir() { + return Err(ContextError::TraceNotConfigured(trace_root)); + } + let trace = read_all_trace_files(trace_root.to_string_lossy().as_ref())?; + let boundary = BoundaryConfig::load_or_default(&workspace_root.join("cert/boundary.toml")); + let floors_cfg = match FloorsConfig::load_or_missing(&workspace_root.join("cert/floors.toml")) { + LoadOutcome::Loaded(cfg) => Some(cfg), + LoadOutcome::Missing | LoadOutcome::Error(_) => None, + }; + + let report = match selector { + ResolvedSelector::Workspace => { + build_workspace_overview(workspace_root, &boundary, floors_cfg.as_ref()) + } + _ => build_scoped_report( + workspace_root, + selector, + &trace, + &boundary, + floors_cfg.as_ref(), + )?, + }; + Ok(report) +} + +/// Workspace-overview path — empty selector returns the high-level +/// crate map + workspace-wide floors + the root `CLAUDE.md` pointer. +fn build_workspace_overview( + workspace_root: &Path, + boundary: &BoundaryConfig, + floors: Option<&FloorsConfig>, +) -> ContextReport { + let mut report = ContextReport::workspace_default(); + report.boundary = BoundarySlice { + in_scope: true, + forbidden_deps: boundary.scope.explicit_forbidden.clone(), + }; + if let Some(cfg) = floors { + let measured = current_measurements(workspace_root); + let mut rows: Vec = cfg + .floors + .iter() + .map(|(dim, &floor)| FloorRow { + dimension: dim.clone(), + kind: "floor".to_string(), + current: measured.get(dim).copied().unwrap_or(0), + floor, + }) + .collect(); + rows.sort_by(|a, b| a.dimension.cmp(&b.dimension)); + report.floors = rows; + } + report.conventions = Conventions { + nearest_claude_md: workspace_claude_md(workspace_root), + }; + report +} + +/// Per-selector report. Re-uses the trace graph for requirement / +/// parent / test rollup, the boundary `dal_map` for the effective +/// DAL, and the per-crate floors slice for the floors block. +fn build_scoped_report( + workspace_root: &Path, + selector: &ResolvedSelector, + trace: &TraceFiles, + boundary: &BoundaryConfig, + floors: Option<&FloorsConfig>, +) -> Result { + let (crate_name, selector_path, selector_input) = describe_selector(selector); + let llrs = filter_llrs_for_selector(selector, &trace.llr.requirements); + let parents = roll_up_parents(&llrs, &trace.hlr.requirements, &trace.sys.requirements); + let tests = filter_tests_for_llrs(&llrs, &trace.tests.tests); + let diagnostic_codes = collect_emits(&llrs); + + let dal = dal_for_crate(boundary, crate_name.as_deref()); + let floors_rows = floors + .map(|cfg| per_crate_floor_rows(workspace_root, cfg, crate_name.as_deref())) + .unwrap_or_default(); + + let in_scope = crate_name + .as_ref() + .map(|name| boundary.scope.in_scope.iter().any(|c| c == name)) + .unwrap_or(false); + + let conventions = Conventions { + nearest_claude_md: nearest_claude_md(workspace_root, crate_name.as_deref()), + }; + + let warnings = collect_warnings(selector, &llrs); + + Ok(ContextReport { + selector: SelectorView { + kind: selector.kind().to_string(), + input: selector_input, + resolved: selector_path, + }, + crate_name: crate_name.clone().unwrap_or_default(), + dal, + requirements: llrs.iter().map(llr_to_ref).collect(), + parents, + tests, + diagnostic_codes, + floors: floors_rows, + boundary: BoundarySlice { + in_scope, + forbidden_deps: boundary.scope.explicit_forbidden.clone(), + }, + conventions, + warnings, + }) +} + +fn describe_selector(selector: &ResolvedSelector) -> (Option, String, String) { + match selector { + ResolvedSelector::Workspace => (None, String::new(), String::new()), + ResolvedSelector::File { + raw, + path, + crate_name, + .. + } => (Some(crate_name.clone()), path.clone(), raw.clone()), + ResolvedSelector::Crate { + raw, crate_name, .. + } => (Some(crate_name.clone()), crate_name.clone(), raw.clone()), + ResolvedSelector::Module { raw, path, .. } => { + let crate_name = path + .split("::") + .next() + .map(|s| s.replace('_', "-")) + .filter(|s| !s.is_empty()); + (crate_name, path.clone(), raw.clone()) + } + } +} + +/// Filter LLRs whose `modules` field overlaps the selector's +/// module space. For file / crate / module selectors we match against +/// a derived module-prefix; the workspace path uses every LLR. +fn filter_llrs_for_selector(selector: &ResolvedSelector, llrs: &[LlrEntry]) -> Vec { + let prefixes = selector_prefixes(selector); + if prefixes.is_empty() { + return llrs.to_vec(); + } + llrs.iter() + .filter(|llr| llr.modules.iter().any(|m| matches_any_prefix(m, &prefixes))) + .cloned() + .collect() +} + +/// Compute the set of acceptable `modules`-field prefixes for the +/// selector. File and module selectors share the same prefix; crate +/// selectors match by `::*` (and the bare ``). +fn selector_prefixes(selector: &ResolvedSelector) -> Vec { + match selector { + ResolvedSelector::Workspace => Vec::new(), + ResolvedSelector::File { crate_name, .. } | ResolvedSelector::Crate { crate_name, .. } => { + vec![crate_name.replace('-', "_")] + } + ResolvedSelector::Module { path, .. } => vec![path.clone()], + } +} + +/// `module` matches `prefix` iff `module == prefix` or +/// `module` starts with `prefix::`. We also accept the +/// reverse (e.g. selector "evidence_core::trace" matches LLR module +/// "evidence_core::trace::validation") since the spec asks for +/// prefix-match on either side. +fn matches_any_prefix(module: &str, prefixes: &[String]) -> bool { + prefixes.iter().any(|p| { + module == p + || module.starts_with(&format!("{}::", p)) + || p.starts_with(&format!("{}::", module)) + }) +} + +/// Collect the HLR / SYS rows reached from each LLR's `traces_to`. +/// HLRs go one level up to SYS; the result is a flat list sorted by +/// `id` for deterministic serialization. +fn roll_up_parents( + llrs: &[LlrEntry], + hlr_pool: &[HlrEntry], + sys_pool: &[HlrEntry], +) -> Vec { + let hlr_by_uid: BTreeMap<&str, &HlrEntry> = hlr_pool + .iter() + .filter_map(|h| h.uid.as_deref().map(|u| (u, h))) + .collect(); + let sys_by_uid: BTreeMap<&str, &HlrEntry> = sys_pool + .iter() + .filter_map(|s| s.uid.as_deref().map(|u| (u, s))) + .collect(); + + let mut seen: BTreeSet = BTreeSet::new(); + let mut rows: Vec = Vec::new(); + + for llr in llrs { + for parent_uid in &llr.traces_to { + if !seen.insert(parent_uid.clone()) { + continue; + } + if let Some(hlr) = hlr_by_uid.get(parent_uid.as_str()) { + rows.push(hlr_to_parent_ref(hlr)); + for grandparent_uid in &hlr.traces_to { + if !seen.insert(grandparent_uid.clone()) { + continue; + } + if let Some(sys) = sys_by_uid.get(grandparent_uid.as_str()) { + rows.push(sys_to_parent_ref(sys)); + } + } + } + } + } + rows.sort_by(|a, b| a.id.cmp(&b.id)); + rows +} + +/// Filter test entries that trace into any of the listed LLRs (by +/// uid) and sort by `name`. +fn filter_tests_for_llrs(llrs: &[LlrEntry], tests: &[TestEntry]) -> Vec { + let llr_uids: BTreeSet<&str> = llrs.iter().filter_map(|l| l.uid.as_deref()).collect(); + let mut rows: Vec = tests + .iter() + .filter(|t| { + t.traces_to + .iter() + .any(|uid| llr_uids.contains(uid.as_str())) + }) + .map(test_to_ref) + .collect(); + rows.sort_by(|a, b| a.name.cmp(&b.name)); + rows +} + +/// Collect every diagnostic code claimed by the listed LLRs' +/// `emits`. Deduped + alphabetically sorted. +fn collect_emits(llrs: &[LlrEntry]) -> Vec { + let mut set: BTreeSet = BTreeSet::new(); + for llr in llrs { + for code in &llr.emits { + set.insert(code.clone()); + } + } + set.into_iter().collect() +} + +/// Resolve the effective DAL for `crate_name`. Falls back to the +/// `default_dal` when the crate isn't in `dal.crate_overrides`. +fn dal_for_crate(boundary: &BoundaryConfig, crate_name: Option<&str>) -> String { + let dal = crate_name + .and_then(|name| boundary.dal.crate_overrides.get(name).copied()) + .unwrap_or(boundary.dal.default_dal); + format!("{:?}", dal) +} + +/// Per-crate floor rows applicable to the selector. Workspace-wide +/// floors (`[floors]`) are omitted from per-crate reports — the +/// workspace overview owns those. +fn per_crate_floor_rows( + workspace_root: &Path, + cfg: &FloorsConfig, + crate_name: Option<&str>, +) -> Vec { + let Some(crate_name) = crate_name else { + return Vec::new(); + }; + let measured = per_crate_measurements(workspace_root); + let per_crate_floors = cfg.per_crate.get(crate_name); + let per_crate_ceilings = cfg.per_crate_ceilings.get(crate_name); + let measured_for_crate = measured.get(crate_name); + + let mut rows: Vec = Vec::new(); + if let Some(map) = per_crate_floors { + for (dim, &floor) in map { + let current = measured_for_crate + .and_then(|m| m.get(dim).copied()) + .unwrap_or(0); + rows.push(FloorRow { + dimension: dim.clone(), + kind: "per_crate_floor".to_string(), + current, + floor, + }); + } + } + if let Some(map) = per_crate_ceilings { + for (dim, &ceiling) in map { + let current = measured_for_crate + .and_then(|m| m.get(dim).copied()) + .unwrap_or(0); + rows.push(FloorRow { + dimension: dim.clone(), + kind: "per_crate_ceiling".to_string(), + current, + floor: ceiling, + }); + } + } + rows.sort_by(|a, b| a.dimension.cmp(&b.dimension).then(a.kind.cmp(&b.kind))); + rows +} + +/// Find the nearest `CLAUDE.md`: per-crate when `crate_name` is set +/// and reachable, the workspace root otherwise, or `None` if neither +/// exists. +fn nearest_claude_md(workspace_root: &Path, crate_name: Option<&str>) -> Option { + if let Some(name) = crate_name { + if let Ok(crates) = discover_workspace_crates(workspace_root) { + if let Some(entry) = crates.get(name) { + let path = format!("{}/CLAUDE.md", entry.dir); + if workspace_root.join(&path).is_file() { + return Some(path); + } + } + } + } + workspace_claude_md(workspace_root) +} + +/// Convenience wrapper for the root-level `CLAUDE.md` lookup. +fn workspace_claude_md(workspace_root: &Path) -> Option { + let root = workspace_root.join("CLAUDE.md"); + if root.is_file() { + Some("CLAUDE.md".to_string()) + } else { + None + } +} + +fn collect_warnings(selector: &ResolvedSelector, llrs: &[LlrEntry]) -> Vec { + let mut out: Vec = Vec::new(); + let amb = selector.ambiguities(); + if !amb.is_empty() { + out.push(ContextWarning { + code: "CONTEXT_AMBIGUOUS_SELECTOR".to_string(), + message: format!( + "selector also matched: {} — resolver picked '{}'", + amb.join(", "), + selector.kind() + ), + }); + } + if llrs.is_empty() && !matches!(selector, ResolvedSelector::Workspace) { + out.push(ContextWarning { + code: "CONTEXT_NO_REQUIREMENTS_FOR_SELECTOR".to_string(), + message: "selector matched no LLR — module not yet covered by trace".to_string(), + }); + } + out +} + +fn llr_to_ref(llr: &LlrEntry) -> RequirementRef { + RequirementRef { + id: llr.id.clone(), + uid: llr.uid.clone().unwrap_or_default(), + layer: "llr".to_string(), + title: llr.title.clone(), + description: llr.description.clone().unwrap_or_default(), + modules: llr.modules.clone(), + emits: llr.emits.clone(), + traces_to: llr.traces_to.clone(), + verification_methods: llr.verification_methods.clone(), + } +} + +fn hlr_to_parent_ref(hlr: &HlrEntry) -> ParentRef { + ParentRef { + id: hlr.id.clone(), + uid: hlr.uid.clone().unwrap_or_default(), + layer: "hlr".to_string(), + title: hlr.title.clone(), + traces_to: hlr.traces_to.clone(), + } +} + +fn sys_to_parent_ref(sys: &HlrEntry) -> ParentRef { + ParentRef { + id: sys.id.clone(), + uid: sys.uid.clone().unwrap_or_default(), + layer: "sys".to_string(), + title: sys.title.clone(), + traces_to: sys.traces_to.clone(), + } +} + +fn test_to_ref(t: &TestEntry) -> TestRef { + TestRef { + id: t.id.clone(), + uid: t.uid.clone().unwrap_or_default(), + name: t.title.clone(), + selectors: t.all_selectors(), + traces_to: t.traces_to.clone(), + } +} diff --git a/crates/evidence-core/src/context/report.rs b/crates/evidence-core/src/context/report.rs new file mode 100644 index 0000000..b7431c9 --- /dev/null +++ b/crates/evidence-core/src/context/report.rs @@ -0,0 +1,219 @@ +//! Data types serialized into the `ContextReport` JSON blob. +//! +//! The wire shape mirrors design spec §3.1 exactly. Field order is +//! load-bearing — the report is byte-locked against +//! `crates/cargo-evidence/tests/fixtures/golden_context.json` so any +//! accidental rename or reorder fires the golden test. The supporting +//! structs (`RequirementRef`, `ParentRef`, `TestRef`, `FloorRow`, +//! `BoundarySlice`, `Conventions`) live in dedicated sub-types here so +//! `serde` produces stable nested keys. +//! +//! All map-typed fields use `BTreeMap` and every array of structs is +//! sorted by a stable key (`id` for requirements, `name` for tests) +//! before serialization — see `lookup::build_report`. + +use serde::{Deserialize, Serialize}; + +/// Resolved selector classification — what kind of input the resolver +/// matched. Carries both the raw `input` (what the caller wrote) and +/// the canonical `resolved` form (workspace-relative file path, +/// `[package].name`, or fully-qualified module path). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SelectorView { + /// Classification: `"workspace"`, `"file"`, `"crate"`, or + /// `"module"`. + pub kind: String, + /// The raw input the caller passed, verbatim. Empty for the + /// workspace overview path. + pub input: String, + /// Canonical resolved form. For files: workspace-relative path + /// with forward slashes. For crates: the `[package].name` from + /// `Cargo.toml`. For modules: the dotted path + /// (`evidence_core::trace`). Empty for the workspace overview. + pub resolved: String, +} + +/// One LLR or HLR reference in the report. `layer` distinguishes the +/// origin file (`"llr"` for entries from `llr.toml`, `"hlr"` for +/// entries from `hlr.toml`). The wire shape stays the same so a +/// consumer can render any layer uniformly. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RequirementRef { + /// Human-readable ID (e.g. `"LLR-001"`). + pub id: String, + /// Machine-stable UUID. + pub uid: String, + /// Origin layer (`"hlr"` or `"llr"`). + pub layer: String, + /// Title carried by the entry. + pub title: String, + /// Description (may be empty for entries that omit it). + pub description: String, + /// Implementation modules. Empty for HLRs. + pub modules: Vec, + /// Diagnostic codes the entry owns. Empty for HLRs. + pub emits: Vec, + /// Parent UIDs this entry traces up to. + pub traces_to: Vec, + /// Verification methods declared on the entry. + pub verification_methods: Vec, +} + +/// Parent HLR or SYS reference rolled up from a `RequirementRef`'s +/// `traces_to` list. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ParentRef { + /// Human-readable ID. + pub id: String, + /// Machine-stable UUID. + pub uid: String, + /// Origin layer (`"sys"` or `"hlr"`). + pub layer: String, + /// Title carried by the entry. + pub title: String, + /// Parent UIDs (empty for SYS rows). + pub traces_to: Vec, +} + +/// Test entry reference attached to one or more LLRs in the report's +/// `requirements` list. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct TestRef { + /// Human-readable ID. + pub id: String, + /// Machine-stable UUID. + pub uid: String, + /// Test title. + pub name: String, + /// All resolved selectors (merged from `test_selector` + + /// `test_selectors`). + pub selectors: Vec, + /// Parent LLR UIDs. + pub traces_to: Vec, +} + +/// One row in the floors slice for the selector's crate. Mirrors the +/// `[per_crate.]` / `[per_crate_ceilings.]` / +/// `[floors]` shape from `cert/floors.toml`. Workspace-wide rows +/// (`kind = "floor"`) are emitted only for the workspace-overview +/// path; per-crate rows are emitted for file/crate/module selectors. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct FloorRow { + /// Dimension name (e.g. `"test_count"`, `"library_panics"`). + pub dimension: String, + /// Kind discriminator: `"floor"`, `"per_crate_floor"`, or + /// `"per_crate_ceiling"`. + pub kind: String, + /// Current measured value. + pub current: u64, + /// Committed floor or ceiling. + pub floor: u64, +} + +/// Boundary policy slice — the per-crate facts an agent editing the +/// crate cares about. Workspace-overview reports populate `in_scope` +/// from the union; per-crate reports populate it from the lookup. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct BoundarySlice { + /// Whether the resolved crate is in `boundary.toml`'s + /// `scope.in_scope`. Always `true` for the workspace-overview + /// path (no specific crate to disqualify). + pub in_scope: bool, + /// Workspace crates explicitly forbidden as deps for in-scope + /// crates. Carried straight through from + /// `BoundaryConfig.scope.explicit_forbidden`. + pub forbidden_deps: Vec, +} + +/// Conventions block — the layered `CLAUDE.md` pointer. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct Conventions { + /// Workspace-relative path to the nearest `CLAUDE.md` (per-crate + /// when the selector resolves into a crate, root otherwise). + /// `None` when no `CLAUDE.md` is reachable from the workspace + /// root. + pub nearest_claude_md: Option, +} + +/// One non-fatal warning attached to the report. The same code names +/// the CLI's hand-emitted JSONL diagnostic so MCP consumers and CLI +/// consumers see the same vocabulary. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ContextWarning { + /// `CONTEXT_*` diagnostic code. + pub code: String, + /// Human one-liner explaining the warning. + pub message: String, +} + +/// Single-blob response — the canonical wire shape of `evidence +/// context`. Field order matches design spec §3.1; the golden fixture +/// pins the byte layout. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ContextReport { + /// What the resolver classified the selector as. + pub selector: SelectorView, + /// Crate this report is scoped to. Empty string for the + /// workspace-overview path. + #[serde(rename = "crate")] + pub crate_name: String, + /// Effective DAL for the resolved crate (`"A"` / `"B"` / `"C"` / + /// `"D"`). `"D"` for the workspace overview (the lowest-rigor + /// safe default). + pub dal: String, + /// LLR-level requirements governing the selector. Sorted by + /// `id`. + pub requirements: Vec, + /// Parent HLR / SYS roll-up — every distinct UID reached from + /// `requirements[*].traces_to` plus their parents. + pub parents: Vec, + /// Tests that verify any of the listed requirements. Sorted by + /// `name`. + pub tests: Vec, + /// Diagnostic codes the listed requirements collectively own. + /// Sorted alphabetically. + pub diagnostic_codes: Vec, + /// Floors / ceilings applicable to the resolved crate. + pub floors: Vec, + /// Boundary slice. + pub boundary: BoundarySlice, + /// Conventions block. + pub conventions: Conventions, + /// Warnings the resolver / lookup attached. Ordered by emission. + pub warnings: Vec, +} + +/// Convenience constructor for the workspace-overview shape — every +/// per-selector field empty / default. Callers populate the +/// `selector` view themselves so they own the `kind` discriminator. +impl ContextReport { + /// Build a `ContextReport` with `kind="workspace"` and the + /// selector-specific fields at their lowest-information defaults + /// (`crate_name=""`, `dal="D"`, empty arrays, no nearest + /// `CLAUDE.md`). Callers can fill in the workspace-level fields + /// after construction. + pub fn workspace_default() -> Self { + Self { + selector: SelectorView { + kind: "workspace".to_string(), + input: String::new(), + resolved: String::new(), + }, + crate_name: String::new(), + dal: "D".to_string(), + requirements: Vec::new(), + parents: Vec::new(), + tests: Vec::new(), + diagnostic_codes: Vec::new(), + floors: Vec::new(), + boundary: BoundarySlice { + in_scope: true, + forbidden_deps: Vec::new(), + }, + conventions: Conventions { + nearest_claude_md: None, + }, + warnings: Vec::new(), + } + } +} diff --git a/crates/evidence-core/src/context/resolver.rs b/crates/evidence-core/src/context/resolver.rs new file mode 100644 index 0000000..505ed72 --- /dev/null +++ b/crates/evidence-core/src/context/resolver.rs @@ -0,0 +1,298 @@ +//! Selector classification — turn the caller's raw string into a +//! [`ResolvedSelector`]. +//! +//! Priority on ambiguity: File > Crate > Module. When the same input +//! matches more than one kind, the higher-priority match wins and +//! the resolver records the alternates so the lookup phase can attach +//! a `CONTEXT_AMBIGUOUS_SELECTOR` warning naming what was skipped. +//! +//! Workspace-overview path: `raw = None` short-circuits to +//! [`ResolvedSelector::Workspace`] without touching the filesystem. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use serde::Deserialize; +use walkdir::WalkDir; + +use super::error::ContextError; + +/// Resolved selector — what the resolver decided the caller meant. +/// +/// `ambiguities` is the list of *other* kinds that also matched the +/// input. The chosen variant always wins; the ambiguous-warning +/// surface uses `ambiguities` to name the alternates. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ResolvedSelector { + /// No selector supplied — return the workspace overview. + Workspace, + /// File path under `crates//...`. Carried as workspace- + /// relative with forward slashes; never absolute. + File { + /// Raw input the caller passed. + raw: String, + /// Workspace-relative path (e.g. + /// `crates/evidence-core/src/trace.rs`). + path: String, + /// Crate the file lives in. + crate_name: String, + /// Other selector kinds the same input matched. + ambiguities: Vec, + }, + /// Workspace crate name (matches `[package].name` in + /// `crates/*/Cargo.toml`). + Crate { + /// Raw input the caller passed. + raw: String, + /// Resolved `[package].name`. + crate_name: String, + /// Other selector kinds the same input matched. + ambiguities: Vec, + }, + /// Rust module path (`evidence_core::trace`). Matched as a prefix + /// against any LLR's `modules` field — the actual lookup happens + /// in the lookup phase, so we only carry the dotted string here. + Module { + /// Raw input the caller passed. + raw: String, + /// Module path (already normalized to `::`-separated form). + path: String, + /// Other selector kinds the same input matched. + ambiguities: Vec, + }, +} + +impl ResolvedSelector { + /// Stable kind label used in the `selector.kind` JSON field. + pub fn kind(&self) -> &'static str { + match self { + ResolvedSelector::Workspace => "workspace", + ResolvedSelector::File { .. } => "file", + ResolvedSelector::Crate { .. } => "crate", + ResolvedSelector::Module { .. } => "module", + } + } + + /// Other kinds the same input matched; informational only. + pub fn ambiguities(&self) -> &[String] { + match self { + ResolvedSelector::Workspace => &[], + ResolvedSelector::File { ambiguities, .. } + | ResolvedSelector::Crate { ambiguities, .. } + | ResolvedSelector::Module { ambiguities, .. } => ambiguities, + } + } +} + +/// One workspace crate as found under `crates/*/`. +#[derive(Debug, Clone)] +pub struct WorkspaceCrate { + /// `[package].name` from the manifest. + pub name: String, + /// Workspace-relative directory (`crates/` form). Forward + /// slashes only. + pub dir: String, +} + +/// Discover every workspace crate by walking `crates/*/Cargo.toml`. +/// Empty map if the `crates/` directory is missing — downstream +/// projects that haven't adopted the layout pattern see no crates. +pub fn discover_workspace_crates( + workspace_root: &Path, +) -> Result, ContextError> { + let crates_dir = workspace_root.join("crates"); + let mut out: BTreeMap = BTreeMap::new(); + if !crates_dir.is_dir() { + return Ok(out); + } + let entries = WalkDir::new(&crates_dir) + .follow_links(false) + .min_depth(1) + .max_depth(1) + .into_iter() + .filter_map(Result::ok) + .filter(|e| e.file_type().is_dir()); + for entry in entries { + let dir_path = entry.into_path(); + let manifest_path = dir_path.join("Cargo.toml"); + if !manifest_path.is_file() { + continue; + } + let name = read_package_name(&manifest_path)?; + let dir_rel = workspace_relative(workspace_root, &dir_path); + out.insert(name.clone(), WorkspaceCrate { name, dir: dir_rel }); + } + Ok(out) +} + +/// Classify `raw` (or `None` for the workspace overview) into a +/// [`ResolvedSelector`]. Priority on ambiguity: File > Crate > +/// Module. +pub fn resolve_selector( + workspace_root: &Path, + raw: Option<&str>, +) -> Result { + let Some(input) = raw else { + return Ok(ResolvedSelector::Workspace); + }; + let trimmed = input.trim(); + if trimmed.is_empty() { + return Ok(ResolvedSelector::Workspace); + } + + let crates = discover_workspace_crates(workspace_root)?; + let mut ambiguities: Vec = Vec::new(); + + let file_match = match_file(workspace_root, trimmed, &crates); + let crate_match = match_crate(trimmed, &crates); + let module_match = match_module(trimmed); + + if let Some((rel_path, crate_name)) = file_match { + if crate_match.is_some() { + ambiguities.push("crate".to_string()); + } + if module_match.is_some() { + ambiguities.push("module".to_string()); + } + return Ok(ResolvedSelector::File { + raw: input.to_string(), + path: rel_path, + crate_name, + ambiguities, + }); + } + if let Some(crate_name) = crate_match { + if module_match.is_some() { + ambiguities.push("module".to_string()); + } + return Ok(ResolvedSelector::Crate { + raw: input.to_string(), + crate_name, + ambiguities, + }); + } + if let Some(path) = module_match { + return Ok(ResolvedSelector::Module { + raw: input.to_string(), + path, + ambiguities, + }); + } + Err(ContextError::SelectorOutOfScope(input.to_string())) +} + +/// Resolve a candidate file path under the workspace. +/// +/// Accepts absolute paths (must live under `workspace_root`) and +/// workspace-relative paths. Returns `(rel_path, crate_name)` if the +/// path resolves to a file under `crates//...`; `None` +/// otherwise. +fn match_file( + workspace_root: &Path, + input: &str, + crates: &BTreeMap, +) -> Option<(String, String)> { + let candidate = PathBuf::from(input); + let abs = if candidate.is_absolute() { + candidate + } else { + workspace_root.join(&candidate) + }; + if !abs.is_file() { + return None; + } + let canon_root = workspace_root.canonicalize().ok()?; + let canon_abs = abs.canonicalize().ok()?; + let rel = canon_abs.strip_prefix(&canon_root).ok()?; + let rel_str = rel.to_string_lossy().replace('\\', "/"); + let crate_name = crate_for_relative_path(&rel_str, crates)?; + Some((rel_str, crate_name)) +} + +/// Match a crate-name selector against the discovered crate set. +fn match_crate(input: &str, crates: &BTreeMap) -> Option { + crates.contains_key(input).then(|| input.to_string()) +} + +/// A bare-string selector is a module candidate iff it contains +/// `::` and parses as a sequence of valid Rust identifiers separated +/// by `::`. Empty / file-shaped / single-token / boundary-violating +/// inputs don't match — those are either crate-name candidates (the +/// crate matcher handles them) or genuinely out of scope. +/// +/// The `::` requirement is load-bearing for `CONTEXT_SELECTOR_OUT_OF_SCOPE`: +/// without it, every typo'd word ("not-a-crate") classifies as a +/// Module with an empty trace slice, hiding the user's mistake. +fn match_module(input: &str) -> Option { + if input.is_empty() { + return None; + } + if input.contains('/') || input.contains('\\') || input.contains('.') { + return None; + } + let normalized = input.replace('-', "_"); + if !normalized.contains("::") { + return None; + } + let segments: Vec<&str> = normalized.split("::").collect(); + let all_idents = segments.iter().all(|seg| is_valid_rust_ident(seg)); + all_idents.then_some(normalized) +} + +fn is_valid_rust_ident(s: &str) -> bool { + let mut chars = s.chars(); + match chars.next() { + Some(c) if c.is_ascii_alphabetic() || c == '_' => {} + _ => return false, + } + chars.all(|c| c.is_ascii_alphanumeric() || c == '_') +} + +/// Given a workspace-relative path of the form +/// `crates//...`, return the `[package].name` registered +/// for that crate. +fn crate_for_relative_path(rel: &str, crates: &BTreeMap) -> Option { + let stripped = rel.strip_prefix("crates/")?; + let dir = stripped.split('/').next()?; + let needle = format!("crates/{dir}"); + crates + .values() + .find(|c| c.dir == needle) + .map(|c| c.name.clone()) +} + +/// Read `[package].name` from a `Cargo.toml`. +fn read_package_name(manifest_path: &Path) -> Result { + #[derive(Deserialize)] + struct Manifest { + package: Package, + } + #[derive(Deserialize)] + struct Package { + name: String, + } + let text = + std::fs::read_to_string(manifest_path).map_err(|err| ContextError::CargoManifestRead { + path: manifest_path.to_path_buf(), + err, + })?; + let manifest: Manifest = + toml::from_str(&text).map_err(|err| ContextError::CargoManifestParse { + path: manifest_path.to_path_buf(), + err, + })?; + Ok(manifest.package.name) +} + +/// Workspace-relative path representation. Falls back to a +/// best-effort lossy form if `strip_prefix` fails (which would only +/// happen if the discovered crate dir wasn't under `workspace_root`, +/// e.g. a symlinked path — already a no-follow violation per the +/// walker contract). +fn workspace_relative(workspace_root: &Path, candidate: &Path) -> String { + candidate + .strip_prefix(workspace_root) + .unwrap_or(candidate) + .to_string_lossy() + .replace('\\', "/") +} diff --git a/crates/evidence-core/src/context/tests.rs b/crates/evidence-core/src/context/tests.rs new file mode 100644 index 0000000..0d43d69 --- /dev/null +++ b/crates/evidence-core/src/context/tests.rs @@ -0,0 +1,272 @@ +//! Unit tests for the [`context`](super) module. +//! +//! Three slices, each `#[test]`-fn-scoped so a regression names the +//! exact case that broke: selector classification, lookup composition, +//! and error variants. The repo's own `cert/trace/` is the canonical +//! fixture — building against a synthetic mini-workspace would only +//! prove the test plumbing parses TOML. + +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + reason = "test setup failures should panic immediately" +)] + +use std::path::PathBuf; + +use super::error::ContextError; +use super::resolver::{ResolvedSelector, resolve_selector}; +use super::{ContextReport, context_for}; + +fn workspace_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("crates/") + .parent() + .expect("workspace root") + .to_path_buf() +} + +// ============================================================================ +// resolver tests (selector classification) +// ============================================================================ + +/// `raw = None` short-circuits to `Workspace` without touching the +/// filesystem. +#[test] +fn resolve_none_returns_workspace() { + let resolved = resolve_selector(&workspace_root(), None).expect("resolve"); + assert!(matches!(resolved, ResolvedSelector::Workspace)); +} + +/// An empty / whitespace-only selector is the same as `None` — treat +/// as workspace overview rather than a typo error. +#[test] +fn resolve_empty_string_returns_workspace() { + let resolved = resolve_selector(&workspace_root(), Some("")).expect("resolve"); + assert!(matches!(resolved, ResolvedSelector::Workspace)); + let resolved_ws = resolve_selector(&workspace_root(), Some(" ")).expect("resolve"); + assert!(matches!(resolved_ws, ResolvedSelector::Workspace)); +} + +/// A workspace-relative file under `crates/evidence-core/src/...` +/// classifies as `File` and carries the crate name. +#[test] +fn resolve_file_under_crates_dir() { + let resolved = resolve_selector(&workspace_root(), Some("crates/evidence-core/src/trace.rs")) + .expect("resolve"); + match resolved { + ResolvedSelector::File { + path, crate_name, .. + } => { + assert_eq!(path, "crates/evidence-core/src/trace.rs"); + assert_eq!(crate_name, "evidence-core"); + } + other => panic!("expected File, got {:?}", other), + } +} + +/// A bare workspace crate name resolves as `Crate`. +#[test] +fn resolve_crate_by_package_name() { + let resolved = resolve_selector(&workspace_root(), Some("evidence-core")).expect("resolve"); + match resolved { + ResolvedSelector::Crate { crate_name, .. } => { + assert_eq!(crate_name, "evidence-core"); + } + other => panic!("expected Crate, got {:?}", other), + } +} + +/// A `::`-shaped string with no matching file resolves as `Module`. +#[test] +fn resolve_module_by_dotted_path() { + let resolved = + resolve_selector(&workspace_root(), Some("evidence_core::trace")).expect("resolve"); + match resolved { + ResolvedSelector::Module { path, .. } => { + assert_eq!(path, "evidence_core::trace"); + } + other => panic!("expected Module, got {:?}", other), + } +} + +/// A typo / unrecognized selector surfaces +/// `SelectorOutOfScope` — caller distinguishes from the legitimate +/// `Workspace` shortcut. +#[test] +fn resolve_unknown_returns_out_of_scope() { + let err = resolve_selector(&workspace_root(), Some("not-a-crate")) + .expect_err("must reject unknown selectors"); + match err { + ContextError::SelectorOutOfScope(input) => assert_eq!(input, "not-a-crate"), + other => panic!("expected SelectorOutOfScope, got {:?}", other), + } +} + +// ============================================================================ +// lookup tests (context composition) +// ============================================================================ + +/// Workspace overview returns the global crate slice + the root +/// `CLAUDE.md` pointer. +#[test] +fn workspace_overview_carries_root_claude_md() { + let report = context_for(&workspace_root(), &ResolvedSelector::Workspace).expect("ctx"); + assert_eq!(report.selector.kind, "workspace"); + assert_eq!(report.crate_name, ""); + assert!(report.conventions.nearest_claude_md.is_some()); + assert_eq!( + report.conventions.nearest_claude_md.as_deref(), + Some("CLAUDE.md") + ); +} + +/// File-selector report carries the per-crate `CLAUDE.md` pointer. +#[test] +fn file_selector_carries_per_crate_claude_md() { + let selector = resolve_selector(&workspace_root(), Some("crates/evidence-core/src/trace.rs")) + .expect("resolve"); + let report = context_for(&workspace_root(), &selector).expect("ctx"); + assert_eq!(report.selector.kind, "file"); + assert_eq!(report.crate_name, "evidence-core"); + assert_eq!( + report.conventions.nearest_claude_md.as_deref(), + Some("crates/evidence-core/CLAUDE.md") + ); +} + +/// File selector under an LLR-claimed module returns at least one +/// requirement and its rolled-up parents — guards against the +/// trace-empty regression that would silently strip the report. +#[test] +fn file_selector_pulls_requirements_and_parents() { + let selector = resolve_selector( + &workspace_root(), + Some("crates/cargo-evidence/src/cli/rules.rs"), + ) + .expect("resolve"); + let report = context_for(&workspace_root(), &selector).expect("ctx"); + assert!( + !report.requirements.is_empty(), + "expected at least one LLR for cargo-evidence's cli/rules.rs" + ); + let req_layers: Vec<&str> = report + .requirements + .iter() + .map(|r| r.layer.as_str()) + .collect(); + for layer in &req_layers { + assert_eq!(*layer, "llr", "every requirement row must be an LLR"); + } + // Parents are rolled-up — at least one HLR or SYS should be + // present for any non-empty requirements list. + assert!( + !report.parents.is_empty(), + "non-empty requirements must produce at least one parent row" + ); +} + +/// Crate selector populates per-crate floor rows. +#[test] +fn crate_selector_carries_per_crate_floor_rows() { + let selector = resolve_selector(&workspace_root(), Some("evidence-core")).expect("resolve"); + let report = context_for(&workspace_root(), &selector).expect("ctx"); + assert_eq!(report.crate_name, "evidence-core"); + let has_test_count = report + .floors + .iter() + .any(|f| f.dimension == "test_count" && f.kind == "per_crate_floor"); + assert!( + has_test_count, + "evidence-core should carry a per_crate_floor row for test_count, got {:?}", + report.floors + ); + let has_panics_ceiling = report + .floors + .iter() + .any(|f| f.dimension == "library_panics" && f.kind == "per_crate_ceiling"); + assert!( + has_panics_ceiling, + "evidence-core should carry a per_crate_ceiling row for library_panics, got {:?}", + report.floors + ); +} + +/// Diagnostic codes block aggregates every `emits` from matched LLRs. +#[test] +fn requirements_emit_set_aggregates_diagnostic_codes() { + let selector = resolve_selector(&workspace_root(), Some("cargo-evidence")).expect("resolve"); + let report = context_for(&workspace_root(), &selector).expect("ctx"); + // Empty cargo-evidence diagnostic_codes would mean the per-crate + // selector matched nothing — the cargo-evidence crate owns many + // CLI emits. + assert!( + !report.diagnostic_codes.is_empty(), + "cargo-evidence selector should aggregate at least one emit" + ); + // Codes are sorted alphabetically; the first must be <= the last. + let codes = &report.diagnostic_codes; + let mut sorted = codes.clone(); + sorted.sort(); + assert_eq!( + codes, &sorted, + "diagnostic_codes must be alphabetically sorted" + ); +} + +/// Boundary slice reports `in_scope = true` for the workspace's own +/// crates (every member is in `boundary.toml`). +#[test] +fn boundary_slice_reports_in_scope_for_workspace_crate() { + let selector = resolve_selector(&workspace_root(), Some("evidence-core")).expect("resolve"); + let report = context_for(&workspace_root(), &selector).expect("ctx"); + assert!(report.boundary.in_scope, "evidence-core must be in scope"); +} + +/// Round-trip serialize / deserialize keeps the wire shape stable. +#[test] +fn context_report_round_trips_via_serde_json() { + let selector = resolve_selector(&workspace_root(), Some("evidence-core")).expect("resolve"); + let report = context_for(&workspace_root(), &selector).expect("ctx"); + let s = serde_json::to_string(&report).expect("serialize"); + let back: ContextReport = serde_json::from_str(&s).expect("deserialize"); + assert_eq!(report, back); +} + +// ============================================================================ +// error tests +// ============================================================================ + +/// Workspace without `cert/trace/` returns `TraceNotConfigured` +/// rather than blowing up — that's the non-adopter graceful path +/// the CLI converts to `CONTEXT_NO_TRACE_CONFIGURED` (exit 0). +#[test] +fn missing_trace_root_returns_trace_not_configured() { + let tmp = tempfile::tempdir().expect("tempdir"); + let err = context_for(tmp.path(), &ResolvedSelector::Workspace) + .expect_err("missing cert/trace must error"); + match err { + ContextError::TraceNotConfigured(path) => { + assert!( + path.ends_with("cert/trace"), + "TraceNotConfigured must carry cert/trace path, got {:?}", + path + ); + } + other => panic!("expected TraceNotConfigured, got {:?}", other), + } +} + +/// `SelectorOutOfScope` carries the raw input verbatim so the +/// caller can echo it back in a fix-hint. +#[test] +fn selector_out_of_scope_preserves_raw_input() { + let err = + resolve_selector(&workspace_root(), Some("does/not/exist.rs")).expect_err("must reject"); + match err { + ContextError::SelectorOutOfScope(input) => assert_eq!(input, "does/not/exist.rs"), + other => panic!("expected SelectorOutOfScope, got {:?}", other), + } +} diff --git a/crates/evidence-core/src/diagnostic.rs b/crates/evidence-core/src/diagnostic.rs index c955983..4fc3018 100644 --- a/crates/evidence-core/src/diagnostic.rs +++ b/crates/evidence-core/src/diagnostic.rs @@ -93,6 +93,9 @@ pub const TERMINAL_CODES: &[&str] = &[ "FLOORS_FAIL", "GENERATE_OK", "GENERATE_FAIL", + "CONTEXT_OK", + "CONTEXT_FAIL", + "CONTEXT_ERROR", ]; /// One observation in the diagnostic stream. diff --git a/crates/evidence-core/src/lib.rs b/crates/evidence-core/src/lib.rs index 42d9e8a..1302174 100644 --- a/crates/evidence-core/src/lib.rs +++ b/crates/evidence-core/src/lib.rs @@ -45,6 +45,7 @@ pub mod boundary_check; pub mod bundle; pub mod cargo_metadata; pub mod compliance; +pub mod context; pub mod coverage; pub mod diagnostic; pub mod env; diff --git a/crates/evidence-core/src/rules.rs b/crates/evidence-core/src/rules.rs index d1730c5..edc0f0a 100644 --- a/crates/evidence-core/src/rules.rs +++ b/crates/evidence-core/src/rules.rs @@ -21,6 +21,7 @@ pub enum Domain { Check, Cli, Cmd, + Context, Coverage, Doctor, Env, @@ -144,6 +145,13 @@ pub const RULES: &[RuleEntry] = &[ r("CMD_LAUNCH_FAILED", Severity::Error, Domain::Cmd), r("CMD_NON_UTF8_OUTPUT", Severity::Error, Domain::Cmd), r("CMD_NON_ZERO_EXIT", Severity::Error, Domain::Cmd), + context("CONTEXT_AMBIGUOUS_SELECTOR", Severity::Warning), + terminal("CONTEXT_ERROR", Severity::Error), + terminal("CONTEXT_FAIL", Severity::Error), + context("CONTEXT_NO_REQUIREMENTS_FOR_SELECTOR", Severity::Warning), + context("CONTEXT_NO_TRACE_CONFIGURED", Severity::Info), + terminal("CONTEXT_OK", Severity::Info), + context("CONTEXT_SELECTOR_OUT_OF_SCOPE", Severity::Error), r( "COVERAGE_BELOW_THRESHOLD", Severity::Error, @@ -408,73 +416,11 @@ pub const RULES: &[RuleEntry] = &[ r("VERIFY_UNSAFE_PATH", Severity::Error, Domain::Verify), ]; -// Constructor helpers — kept `const fn` so RULES stays a true const. - -const fn r(code: &'static str, severity: Severity, domain: Domain) -> RuleEntry { - RuleEntry { - code, - severity, - domain, - has_fix_hint: false, - terminal: false, - } -} - -const fn req(code: &'static str, severity: Severity) -> RuleEntry { - RuleEntry { - code, - severity, - domain: Domain::Req, - has_fix_hint: false, - terminal: false, - } -} - -const fn req_gap(code: &'static str) -> RuleEntry { - RuleEntry { - code, - severity: Severity::Error, - domain: Domain::Req, - has_fix_hint: true, - terminal: false, - } -} - -const fn cli(code: &'static str, severity: Severity) -> RuleEntry { - RuleEntry { - code, - severity, - domain: Domain::Cli, - has_fix_hint: false, - terminal: false, - } -} - -const fn floors(code: &'static str, severity: Severity) -> RuleEntry { - RuleEntry { - code, - severity, - domain: Domain::Floors, - has_fix_hint: false, - terminal: false, - } -} - -const fn terminal(code: &'static str, severity: Severity) -> RuleEntry { - RuleEntry { - code, - severity, - domain: match Domain::from_code_const(code) { - Some(d) => d, - None => Domain::Cli, - }, - has_fix_hint: false, - terminal: true, - } -} - +mod constructors; mod domain_map; +use constructors::{cli, context, floors, r, req, req_gap, terminal}; + /// Serialize [`RULES`] as a JSON array for `cargo evidence rules /// --json`. Deterministic (alphabetical by `code`). pub fn rules_json() -> String { diff --git a/crates/evidence-core/src/rules/constructors.rs b/crates/evidence-core/src/rules/constructors.rs new file mode 100644 index 0000000..cb5871c --- /dev/null +++ b/crates/evidence-core/src/rules/constructors.rs @@ -0,0 +1,86 @@ +//! `const fn` constructor helpers used to populate the +//! [`RULES`](super::RULES) array. Split out of the parent `rules.rs` +//! facade to keep that file under the 500-line workspace limit. +//! +//! Each helper is a thin convenience: it sets the domain to the +//! right [`super::Domain`] variant and pre-fills the boolean fields +//! (`has_fix_hint`, `terminal`) so the call-site stays short. Adding +//! a new helper means (a) a new `pub(super) const fn` here and (b) a +//! call from `RULES`'s definition. + +use crate::diagnostic::Severity; + +use super::{Domain, RuleEntry}; + +pub(super) const fn r(code: &'static str, severity: Severity, domain: Domain) -> RuleEntry { + RuleEntry { + code, + severity, + domain, + has_fix_hint: false, + terminal: false, + } +} + +pub(super) const fn req(code: &'static str, severity: Severity) -> RuleEntry { + RuleEntry { + code, + severity, + domain: Domain::Req, + has_fix_hint: false, + terminal: false, + } +} + +pub(super) const fn req_gap(code: &'static str) -> RuleEntry { + RuleEntry { + code, + severity: Severity::Error, + domain: Domain::Req, + has_fix_hint: true, + terminal: false, + } +} + +pub(super) const fn cli(code: &'static str, severity: Severity) -> RuleEntry { + RuleEntry { + code, + severity, + domain: Domain::Cli, + has_fix_hint: false, + terminal: false, + } +} + +pub(super) const fn context(code: &'static str, severity: Severity) -> RuleEntry { + RuleEntry { + code, + severity, + domain: Domain::Context, + has_fix_hint: false, + terminal: false, + } +} + +pub(super) const fn floors(code: &'static str, severity: Severity) -> RuleEntry { + RuleEntry { + code, + severity, + domain: Domain::Floors, + has_fix_hint: false, + terminal: false, + } +} + +pub(super) const fn terminal(code: &'static str, severity: Severity) -> RuleEntry { + RuleEntry { + code, + severity, + domain: match Domain::from_code_const(code) { + Some(d) => d, + None => Domain::Cli, + }, + has_fix_hint: false, + terminal: true, + } +} diff --git a/crates/evidence-core/src/rules/domain_map.rs b/crates/evidence-core/src/rules/domain_map.rs index 859df15..015fa0d 100644 --- a/crates/evidence-core/src/rules/domain_map.rs +++ b/crates/evidence-core/src/rules/domain_map.rs @@ -30,6 +30,7 @@ impl Domain { b"CHECK" => Some(Self::Check), b"CLI" => Some(Self::Cli), b"CMD" => Some(Self::Cmd), + b"CONTEXT" => Some(Self::Context), b"COVERAGE" => Some(Self::Coverage), b"DOCTOR" => Some(Self::Doctor), b"ENV" => Some(Self::Env), diff --git a/crates/evidence-core/src/rules/hand_emitted.rs b/crates/evidence-core/src/rules/hand_emitted.rs index 2880b0f..ea1c11f 100644 --- a/crates/evidence-core/src/rules/hand_emitted.rs +++ b/crates/evidence-core/src/rules/hand_emitted.rs @@ -29,6 +29,10 @@ pub const HAND_EMITTED_CLI_CODES: &[&str] = &[ "CHECK_TEST_RUNTIME_FAILURE", "CLI_INVALID_ARGUMENT", "CLI_UNSUPPORTED_FORMAT", + "CONTEXT_AMBIGUOUS_SELECTOR", + "CONTEXT_NO_REQUIREMENTS_FOR_SELECTOR", + "CONTEXT_NO_TRACE_CONFIGURED", + "CONTEXT_SELECTOR_OUT_OF_SCOPE", "COVERAGE_BELOW_THRESHOLD", "COVERAGE_LLVMCOV_MISSING", "COVERAGE_OK", diff --git a/crates/evidence-core/src/trace.rs b/crates/evidence-core/src/trace.rs index f66632f..28850e1 100644 --- a/crates/evidence-core/src/trace.rs +++ b/crates/evidence-core/src/trace.rs @@ -33,7 +33,7 @@ pub use entries::{ TraceMeta, }; pub use matrix::generate_traceability_matrix; -pub use read::{TraceFiles, read_all_trace_files, read_toml}; +pub use read::{TraceFiles, TraceReadError, read_all_trace_files, read_toml}; pub use requirement_report::{RequirementStatus, build_requirement_report}; pub use selector_check::{UnresolvedSelector, resolve_test_selectors}; pub use surfaces::KNOWN_SURFACES; diff --git a/crates/evidence-core/src/trace/surfaces.rs b/crates/evidence-core/src/trace/surfaces.rs index 82c819c..f81aecc 100644 --- a/crates/evidence-core/src/trace/surfaces.rs +++ b/crates/evidence-core/src/trace/surfaces.rs @@ -36,6 +36,7 @@ pub const KNOWN_SURFACES: &[&str] = &[ // Group 1 — CLI verb names (lowercase; match the `Commands::*` // variants exactly). "check", + "context", "doctor", "floors", "generate", @@ -53,6 +54,7 @@ pub const KNOWN_SURFACES: &[&str] = &[ "diagnostic code namespace (regex + reserved suffixes)", "editor-duplicate gate", "jsonl stream per Schema Rule 2", + "layered CLAUDE.md (root + crates/*/CLAUDE.md)", "per-test outcome capture", "per-test ↔ LLR back-link", "pre-release safety gate", @@ -68,7 +70,7 @@ pub const KNOWN_SURFACES: &[&str] = &[ /// Used by the group-scoped sort test to validate within-group order /// without imposing cross-group ordering. #[cfg(test)] -const CONTRACTS_START: usize = 8; +const CONTRACTS_START: usize = 9; #[cfg(test)] mod tests { diff --git a/crates/evidence-core/tests/layered_claude_md_doctrine.rs b/crates/evidence-core/tests/layered_claude_md_doctrine.rs index df37391..a6c1cde 100644 --- a/crates/evidence-core/tests/layered_claude_md_doctrine.rs +++ b/crates/evidence-core/tests/layered_claude_md_doctrine.rs @@ -23,7 +23,7 @@ )] use std::fs; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use walkdir::WalkDir; @@ -36,7 +36,7 @@ fn workspace_root() -> PathBuf { .to_path_buf() } -fn crate_dirs(root: &PathBuf) -> Vec<(String, PathBuf)> { +fn crate_dirs(root: &Path) -> Vec<(String, PathBuf)> { let crates_root = root.join("crates"); WalkDir::new(&crates_root) .follow_links(false) diff --git a/tools/regen-golden-fixtures.sh b/tools/regen-golden-fixtures.sh index 8c628a9..5560dc6 100755 --- a/tools/regen-golden-fixtures.sh +++ b/tools/regen-golden-fixtures.sh @@ -15,6 +15,12 @@ cd "$(git rev-parse --show-toplevel)" cargo build --release -p cargo-evidence --quiet ./target/release/cargo-evidence evidence rules --json \ > crates/cargo-evidence/tests/fixtures/golden_rules.json +./target/release/cargo-evidence evidence context --crate cargo-evidence --json \ + > crates/cargo-evidence/tests/fixtures/golden_context.json echo "regenerated: crates/cargo-evidence/tests/fixtures/golden_rules.json" +echo "regenerated: crates/cargo-evidence/tests/fixtures/golden_context.json" echo "diff (if any):" -git --no-pager diff -- crates/cargo-evidence/tests/fixtures/golden_rules.json | head -80 || true +git --no-pager diff -- \ + crates/cargo-evidence/tests/fixtures/golden_rules.json \ + crates/cargo-evidence/tests/fixtures/golden_context.json \ + | head -120 || true From 765b27506d24d5138dcf8df3c32c573615df298e Mon Sep 17 00:00:00 2001 From: sokoly Date: Tue, 19 May 2026 21:13:53 -0400 Subject: [PATCH 3/8] feat(init): --with-agent-context scaffolds downstream CLAUDE.md + .claude/settings.json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR 4 of the agent-context-from-evidence design at docs/superpowers/specs/2026-05-19-agent-context-from-evidence-design.md: make `cargo evidence init` write a starter root `CLAUDE.md` + a `.claude/settings.json` snippet that registers `evidence-mcp` as an MCP server alongside the existing `cert/` tree, so downstream adopters land on the same lean-layered convention this repo uses. Defaults to on; `--no-agent-context` opts out (mutually exclusive with `--with-agent-context` via clap's `conflicts_with`). Existing files are never overwritten — pre-existing `CLAUDE.md` is preserved unchanged, and a pre-existing `.claude/settings.json` triggers a single stderr advisory describing the merge the user would do by hand. Starter `CLAUDE.md` is intentionally lean (≤30 lines): title + project-description placeholder + one section pointing agents at `evidence_context` (MCP) / `cargo evidence context` for per-module trace + boundary + floors context + one section telling future humans to add `crates//CLAUDE.md` per workspace crate. No project rules — those belong to the downstream user, not this scaffold. `init.rs` stays under the 500-line cap by carving the new emitter into `cli/init/agent_context.rs` (172 lines); `init.rs` grows by 13 lines (414 → 427) to wire the call through. Trace chain seeded for this PR: - HLR-075 — cargo evidence init --with-agent-context scaffolds downstream CLAUDE.md - LLR-090 — write_agent_context_files emits root CLAUDE.md + .claude/settings.json - TEST-097 — init --with-agent-context scaffolds CLAUDE.md + .claude/settings.json (5 integration tests in crates/cargo-evidence/tests/init_agent_context.rs) Side fixes carried in this PR to keep CI green on the branch: - Add `"init"` and `"layered CLAUDE.md (root + crates/*/CLAUDE.md)"` to `KNOWN_SURFACES`. HLR-072 (PR 1) and HLR-075 (this PR) now resolve under `--require-hlr-surface-bijection`, which the trace_cmd_jsonl::trace_validate_jsonl_happy_path test exercises. - Bump known_surfaces floor 23 → 25 to match. - Replace `&PathBuf` with `&Path` in layered_claude_md_doctrine.rs so clippy's `ptr_arg` stays quiet under -D warnings. Co-Authored-By: Claude Opus 4.7 (1M context) --- cert/floors.toml | 2 +- cert/trace/hlr.toml | 26 ++ cert/trace/llr.toml | 33 +++ cert/trace/tests.toml | 33 +++ crates/cargo-evidence/src/cli/args.rs | 13 + crates/cargo-evidence/src/cli/init.rs | 19 +- .../src/cli/init/agent_context.rs | 172 +++++++++++++ crates/cargo-evidence/src/main.rs | 12 +- .../tests/init_agent_context.rs | 232 ++++++++++++++++++ crates/evidence-core/src/trace/surfaces.rs | 8 +- .../tests/layered_claude_md_doctrine.rs | 4 +- 11 files changed, 544 insertions(+), 10 deletions(-) create mode 100644 crates/cargo-evidence/src/cli/init/agent_context.rs create mode 100644 crates/cargo-evidence/tests/init_agent_context.rs diff --git a/cert/floors.toml b/cert/floors.toml index 3823fa2..d421636 100644 --- a/cert/floors.toml +++ b/cert/floors.toml @@ -63,7 +63,7 @@ trace_test = 83 # coverage is enforced by `require_hlr_surface_bijection`; this floor # guards against silently shrinking the catalog itself (which would # relax the bijection without firing the check). -known_surfaces = 23 +known_surfaces = 25 # -------------------------------------------------------------------- # Per-crate-true dimensions: one table per in-scope crate. diff --git a/cert/trace/hlr.toml b/cert/trace/hlr.toml index 1336079..ae8df33 100644 --- a/cert/trace/hlr.toml +++ b/cert/trace/hlr.toml @@ -1822,3 +1822,29 @@ workspace crate without a `CLAUDE.md` fails CI. verification_methods = ["test"] traces_to = ["d8d1f204-3909-418d-b62a-1e28edd088ed"] surfaces = ["layered CLAUDE.md (root + crates/*/CLAUDE.md)"] + +[[requirements]] +uid = "e93c01e2-b5e8-48f4-94ee-e08c139245ae" +id = "HLR-075" +title = "cargo evidence init --with-agent-context scaffolds downstream CLAUDE.md" +owner = "tool" +scope = "component" +description = """ +`cargo evidence init` accepts `--with-agent-context` (default on) +and `--no-agent-context` (mutually exclusive). When on, the +command emits a starter root `CLAUDE.md` (≤30 lines, lean: title ++ project-description placeholder + pointer to `evidence_context` +/ `cargo evidence context` + per-crate-CLAUDE.md guidance — no +project rules) and a `.claude/settings.json` snippet registering +`evidence-mcp` as an MCP server with a `permissions.deny` entry +for the default `evidence/**` output dir. Existing files are +preserved unconditionally (matches `init`'s pre-existing +no-overwrite behavior); when `.claude/settings.json` already +exists, the command prints one stderr info line explaining what +the user would merge in by hand and continues. New emissions log +via the same JSONL `INIT_TEMPLATE_WRITTEN` channel as the +`cert/` tree so machine consumers see one stream. +""" +verification_methods = ["test"] +traces_to = ["d8d1f204-3909-418d-b62a-1e28edd088ed"] +surfaces = ["init"] diff --git a/cert/trace/llr.toml b/cert/trace/llr.toml index c599eff..b72f011 100644 --- a/cert/trace/llr.toml +++ b/cert/trace/llr.toml @@ -2451,3 +2451,36 @@ that crate. Failures are accumulated and reported together so one run shows every crate that needs fixing, not just the first. """ verification_methods = ["test"] + +[[requirements]] +uid = "b402bcc1-f3bb-4fb5-a616-c3e18ffd321a" +id = "LLR-090" +title = "write_agent_context_files emits root CLAUDE.md + .claude/settings.json" +owner = "tool" +traces_to = ["e93c01e2-b5e8-48f4-94ee-e08c139245ae"] +modules = [ + "cargo_evidence::cli::init::agent_context::write_agent_context_files", + "cargo_evidence::cli::init::cmd_init", +] +description = """ +`cmd_init` accepts an `agent_context: bool` parameter derived from +the clap flag pair `--with-agent-context` / `--no-agent-context` +(mutually exclusive via `conflicts_with`; default on). When true, +the parent dispatches to `write_agent_context_files(root, jsonl)` +which: + - writes a starter `CLAUDE.md` at the root (lean ≤30 lines, + rendered by `render_root_claude_md`) — title naming the + project + `evidence_context` / `cargo evidence context` + pointer + per-crate-CLAUDE.md guidance, no project rules; + - writes `.claude/settings.json` registering `evidence-mcp` as + an MCP server and adding a `permissions.deny` entry for + `evidence/**` (the default output dir). +Both writes are skipped when the target file already exists; the +existing file is never clobbered. When `.claude/settings.json` +already exists, a single info line on stderr explains the merge +the user would do by hand. Each successful write goes through +`emit_template_written` so JSONL consumers see one +`INIT_TEMPLATE_WRITTEN` per file in the same stream as the +`cert/` emissions, terminating with the standard `INIT_OK`. +""" +verification_methods = ["test"] diff --git a/cert/trace/tests.toml b/cert/trace/tests.toml index 258ac68..e09d097 100644 --- a/cert/trace/tests.toml +++ b/cert/trace/tests.toml @@ -1326,3 +1326,36 @@ missing the literal `cargo test -p ` scoped command. Asserts the accumulated failure list is empty. Failure message joins every offending crate so a single run names them all. """ + +[[tests]] +uid = "f563389d-b209-4258-a482-7508c11f87aa" +id = "TEST-097" +title = "init --with-agent-context scaffolds CLAUDE.md + .claude/settings.json" +owner = "tool" +traces_to = ["b402bcc1-f3bb-4fb5-a616-c3e18ffd321a"] +description = """ +Integration tests under `crates/cargo-evidence/tests/init_agent_context.rs` +that spawn the `cargo-evidence` binary against fresh tempdirs: + - init_with_agent_context_writes_root_files: the happy path; + asserts root CLAUDE.md + .claude/settings.json appear with + the lean-template content (title, MCP/CLI pointers, per-crate + section) and that settings.json parses as JSON registering + `evidence-mcp` + denying `evidence/**`. + - init_no_agent_context_skips_scaffold: opt-out leaves no + CLAUDE.md and no .claude/ behind while still writing cert/. + - init_with_agent_context_preserves_existing_files: pre-existing + CLAUDE.md or .claude/settings.json are read back byte-for-byte + after `init`. + - init_with_agent_context_jsonl_lists_new_files: --format=jsonl + emits INIT_TEMPLATE_WRITTEN entries for both new files plus + the INIT_OK terminal as last line. + - init_rejects_both_flags: clap's conflicts_with rejects the + combination of --with-agent-context and --no-agent-context. +""" +test_selectors = [ + "init_agent_context::init_with_agent_context_writes_root_files", + "init_agent_context::init_no_agent_context_skips_scaffold", + "init_agent_context::init_with_agent_context_preserves_existing_files", + "init_agent_context::init_with_agent_context_jsonl_lists_new_files", + "init_agent_context::init_rejects_both_flags", +] diff --git a/crates/cargo-evidence/src/cli/args.rs b/crates/cargo-evidence/src/cli/args.rs index d94375a..38932f4 100644 --- a/crates/cargo-evidence/src/cli/args.rs +++ b/crates/cargo-evidence/src/cli/args.rs @@ -260,6 +260,19 @@ pub enum Commands { /// Overwrite existing files #[arg(long)] force: bool, + + /// Emit agent-context scaffold (root `CLAUDE.md` + + /// `.claude/settings.json`) alongside the `cert/` tree. + /// Defaults to enabled; pass `--no-agent-context` to skip. + /// Existing files are preserved either way. + #[arg(long, conflicts_with = "no_agent_context")] + with_agent_context: bool, + + /// Skip the agent-context scaffold; only the `cert/` + /// tree is written. Mutually exclusive with + /// `--with-agent-context`. + #[arg(long, conflicts_with = "with_agent_context")] + no_agent_context: bool, }, /// Manage the project's ed25519 signing keypair (lifecycle: create / rotate). diff --git a/crates/cargo-evidence/src/cli/init.rs b/crates/cargo-evidence/src/cli/init.rs index 505224d..3f494f0 100644 --- a/crates/cargo-evidence/src/cli/init.rs +++ b/crates/cargo-evidence/src/cli/init.rs @@ -1,5 +1,7 @@ //! `cargo evidence init`. +mod agent_context; + use std::fs; use std::path::{Path, PathBuf}; @@ -8,6 +10,7 @@ use anyhow::Result; use evidence_core::diagnostic::{Diagnostic, Severity}; use evidence_core::schema_versions::{BOUNDARY, TRACE}; +use self::agent_context::write_agent_context_files; use super::args::{EXIT_ERROR, EXIT_SUCCESS, OutputFormat}; use super::output::emit_jsonl; @@ -132,7 +135,13 @@ fail_on_dirty = true /// `cargo evidence init` handler: scaffold a `cert/` layout /// (boundary.toml + per-profile stubs) for a fresh project. Refuses /// to overwrite an existing `cert/` tree unless `force` is set. -pub fn cmd_init(force: bool, format: OutputFormat) -> Result { +/// +/// When `agent_context` is true, also writes a starter root +/// `CLAUDE.md` and `.claude/settings.json` snippet (see +/// `write_agent_context_files`). Existing files are preserved +/// either way — the agent-context emitter never clobbers +/// downstream-authored conventions. +pub fn cmd_init(force: bool, agent_context: bool, format: OutputFormat) -> Result { let jsonl = format == OutputFormat::Jsonl; let cert_dir = PathBuf::from("cert"); let profiles_dir = cert_dir.join("profiles"); @@ -364,11 +373,15 @@ verification_methods = ["review"] } } + if agent_context { + written += write_agent_context_files(Path::new("."), jsonl)?; + } + if jsonl { emit_jsonl(&init_terminal( "INIT_OK", Severity::Info, - &format!("init wrote {} template file(s) under cert/", written), + &format!("init wrote {} template file(s)", written), ))?; } else { println!("\nInitialized evidence tracking in cert/"); @@ -381,7 +394,7 @@ verification_methods = ["review"] Ok(EXIT_SUCCESS) } -fn emit_template_written(jsonl: bool, path: &Path) -> Result<()> { +pub(crate) fn emit_template_written(jsonl: bool, path: &Path) -> Result<()> { if jsonl { emit_jsonl(&Diagnostic { code: "INIT_TEMPLATE_WRITTEN".to_string(), diff --git a/crates/cargo-evidence/src/cli/init/agent_context.rs b/crates/cargo-evidence/src/cli/init/agent_context.rs new file mode 100644 index 0000000..a4c861d --- /dev/null +++ b/crates/cargo-evidence/src/cli/init/agent_context.rs @@ -0,0 +1,172 @@ +//! Agent-context scaffolding emitted by `cargo evidence init`. +//! +//! When `--with-agent-context` (the default) is on, `cmd_init` +//! writes a starter root `CLAUDE.md` and `.claude/settings.json` +//! pointing the agent harness at `evidence-mcp`. Existing files +//! are preserved unconditionally — the user owns those files once +//! they exist. See HLR-075 / LLR-090. +//! +//! Lives in its own submodule so the parent `init.rs` stays under +//! the workspace 500-line file cap once trace template literals +//! and the agent-context emitter coexist. + +use std::fs; +use std::path::Path; + +use anyhow::Result; + +use crate::cli::init::emit_template_written; + +/// Emit the agent-context scaffold under `root`. Returns the +/// number of files actually written (zero if both already exist). +/// Never overwrites: pre-existing `CLAUDE.md` or +/// `.claude/settings.json` are preserved unconditionally — the +/// user owns those files once they exist. When +/// `.claude/settings.json` is present, a single info line is +/// logged on stderr explaining what the user would merge in by +/// hand; the rest of the scaffold continues regardless. +pub fn write_agent_context_files(root: &Path, jsonl: bool) -> Result { + let mut written = 0u64; + + let claude_md = root.join("CLAUDE.md"); + if !claude_md.exists() { + let project_name = detect_project_name(root); + fs::write(&claude_md, render_root_claude_md(&project_name))?; + emit_template_written(jsonl, &claude_md)?; + written += 1; + } + + let dot_claude = root.join(".claude"); + let settings_path = dot_claude.join("settings.json"); + if !settings_path.exists() { + fs::create_dir_all(&dot_claude)?; + fs::write(&settings_path, AGENT_SETTINGS_JSON)?; + emit_template_written(jsonl, &settings_path)?; + written += 1; + } else { + // Stderr-only advisory: the user already has a settings + // file. We do not touch it; we tell them what merging in + // by hand would mean so the upgrade path is discoverable + // without surprising them. + eprintln!( + "info: {} already exists; leaving it untouched. To wire up the \ + agent-context surface, merge an entry for `evidence-mcp` into \ + `mcpServers` and a `evidence/**` rule into `permissions.deny`.", + settings_path.display() + ); + } + + Ok(written) +} + +/// Project-name heuristic for the starter `CLAUDE.md` title. Uses +/// the canonicalized basename of `root`; falls back to `"project"` +/// if the path is empty (e.g. `.` at a filesystem root) or +/// non-UTF-8. The value lands in a single Markdown title line — +/// not a load-bearing identifier — so a sensible default is fine. +fn detect_project_name(root: &Path) -> String { + let absolute = fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf()); + absolute + .file_name() + .and_then(|n| n.to_str()) + .map(|s| s.to_string()) + .unwrap_or_else(|| "project".to_string()) +} + +/// Build the starter root `CLAUDE.md` body. Kept lean by design +/// (≤30 lines): one title, one project-description placeholder, +/// one section pointing agents at the queryable per-module +/// context surface, one section instructing future humans to add +/// per-crate `CLAUDE.md` files. No project rules — those belong +/// to the downstream user, not this scaffold. +fn render_root_claude_md(project_name: &str) -> String { + format!( + r#"# {project_name} — agent context + + + +## Module-level context for agents + +For per-module trace + boundary + floors context on any source file, +call `evidence_context` (MCP) or `cargo evidence context `. +Don't grep `cert/trace/*.toml` manually — the query returns the +requirements governing the file, their parents, the tests that +verify them, the diagnostic codes the module owns, and the floors +it must respect. See `cert/trace/` for the underlying data. + +## Per-crate conventions + +Add a `crates//CLAUDE.md` per workspace crate carrying local +conventions and the scoped test command for that crate (e.g. +`cargo test -p `). Keep each file focused on what is *local* +to that crate — workspace-wide rules belong here in the root. +"# + ) +} + +/// Starter `.claude/settings.json`. Registers `evidence-mcp` as +/// an MCP server (the binary must be on PATH for the agent +/// harness to spawn it) and denies writes under `evidence/`, the +/// default bundle output dir, so a careless edit can't dirty +/// generated artifacts mid-session. +const AGENT_SETTINGS_JSON: &str = r#"{ + "mcpServers": { + "evidence-mcp": { + "command": "evidence-mcp", + "args": [] + } + }, + "permissions": { + "deny": [ + "evidence/**" + ] + } +} +"#; + +#[cfg(test)] +#[allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + reason = "test setup failures should panic immediately" +)] +mod tests { + use super::*; + + #[test] + fn detect_project_name_uses_dir_basename() { + let tmp = tempfile::TempDir::new().expect("tempdir"); + let sub = tmp.path().join("MyProject"); + fs::create_dir(&sub).expect("mkdir"); + assert_eq!(detect_project_name(&sub), "MyProject"); + } + + #[test] + fn render_root_claude_md_under_thirty_lines() { + let body = render_root_claude_md("demo"); + let lines = body.lines().count(); + assert!( + lines <= 30, + "starter CLAUDE.md must stay lean (<= 30 lines); got {lines}" + ); + assert!(body.contains("evidence_context")); + assert!(body.contains("cargo evidence context")); + assert!(body.contains("crates//CLAUDE.md")); + } + + #[test] + fn settings_json_registers_evidence_mcp_and_deny_rule() { + let v: serde_json::Value = + serde_json::from_str(AGENT_SETTINGS_JSON).expect("settings.json must be valid JSON"); + assert_eq!(v["mcpServers"]["evidence-mcp"]["command"], "evidence-mcp"); + assert!( + v["permissions"]["deny"] + .as_array() + .expect("deny array") + .iter() + .any(|s| s == "evidence/**"), + "settings.json must deny `evidence/**`" + ); + } +} diff --git a/crates/cargo-evidence/src/main.rs b/crates/cargo-evidence/src/main.rs index 691b54c..879e632 100644 --- a/crates/cargo-evidence/src/main.rs +++ b/crates/cargo-evidence/src/main.rs @@ -228,7 +228,17 @@ fn dispatch(args: EvidenceArgs) -> anyhow::Result { bundle_b, json, }) => cmd_diff(bundle_a, bundle_b, json), - Some(Commands::Init { force }) => cmd_init(force, args.format), + Some(Commands::Init { + force, + with_agent_context, + no_agent_context, + }) => { + // Default-on: the user gets the agent-context scaffold + // unless they opt out via --no-agent-context. clap's + // `conflicts_with` already rejects passing both at once. + let agent_context = !no_agent_context || with_agent_context; + cmd_init(force, agent_context, args.format) + } Some(Commands::Keygen { rotate, reason, diff --git a/crates/cargo-evidence/tests/init_agent_context.rs b/crates/cargo-evidence/tests/init_agent_context.rs new file mode 100644 index 0000000..4e21055 --- /dev/null +++ b/crates/cargo-evidence/tests/init_agent_context.rs @@ -0,0 +1,232 @@ +//! Integration tests for `cargo evidence init --with-agent-context` +//! (TEST-097, governing LLR-090). Spawns the binary against fresh +//! tempdirs and asserts the agent-context scaffold (root +//! `CLAUDE.md` + `.claude/settings.json`) appears, opts out +//! cleanly, preserves existing files, and shows up in the +//! `--json`/`--format=jsonl` written-files stream. + +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + reason = "test setup failures should panic immediately" +)] + +use std::fs; +use std::path::Path; + +use assert_cmd::Command as AssertCommand; +use tempfile::TempDir; + +fn cargo_evidence(cwd: &Path) -> AssertCommand { + #[allow(deprecated)] + let mut cmd = AssertCommand::cargo_bin("cargo-evidence").unwrap(); + cmd.current_dir(cwd); + cmd +} + +/// Happy path: fresh tempdir + `init` (default-on agent-context) +/// writes both root `CLAUDE.md` and `.claude/settings.json`. The +/// `CLAUDE.md` carries the pointer to `evidence_context` / +/// `cargo evidence context`; `settings.json` parses as JSON and +/// registers `evidence-mcp` + the `evidence/**` deny rule. Pins +/// the default-on contract: a bare `init` produces the scaffold +/// without the user needing to pass `--with-agent-context`. +#[test] +fn init_with_agent_context_writes_root_files() { + let tmp = TempDir::new().expect("tempdir"); + + cargo_evidence(tmp.path()) + .args(["evidence", "init"]) + .assert() + .success(); + + let claude_md = tmp.path().join("CLAUDE.md"); + assert!( + claude_md.exists(), + "expected {} to be written", + claude_md.display() + ); + let body = fs::read_to_string(&claude_md).expect("read CLAUDE.md"); + assert!( + body.contains("agent context"), + "expected starter title in CLAUDE.md:\n{body}" + ); + assert!( + body.contains("evidence_context"), + "expected MCP-tool pointer in CLAUDE.md:\n{body}" + ); + assert!( + body.contains("cargo evidence context"), + "expected CLI-verb pointer in CLAUDE.md:\n{body}" + ); + assert!( + body.contains("Per-crate conventions"), + "expected per-crate guidance section in CLAUDE.md:\n{body}" + ); + assert!( + body.lines().count() <= 30, + "starter CLAUDE.md must stay lean (<= 30 lines); got {}", + body.lines().count() + ); + + let settings = tmp.path().join(".claude").join("settings.json"); + assert!( + settings.exists(), + "expected {} to be written", + settings.display() + ); + let settings_body = fs::read_to_string(&settings).expect("read settings.json"); + let parsed: serde_json::Value = + serde_json::from_str(&settings_body).expect("settings.json must be valid JSON"); + assert_eq!( + parsed["mcpServers"]["evidence-mcp"]["command"], + "evidence-mcp" + ); + assert!( + parsed["permissions"]["deny"] + .as_array() + .expect("deny is an array") + .iter() + .any(|s| s == "evidence/**"), + "expected evidence/** in permissions.deny:\n{settings_body}" + ); +} + +/// Opt-out: `init --no-agent-context` writes the `cert/` tree but +/// skips the agent-context scaffold entirely. Neither root +/// `CLAUDE.md` nor `.claude/settings.json` appears. +#[test] +fn init_no_agent_context_skips_scaffold() { + let tmp = TempDir::new().expect("tempdir"); + + cargo_evidence(tmp.path()) + .args(["evidence", "init", "--no-agent-context"]) + .assert() + .success(); + + // The cert tree is still written. + assert!( + tmp.path().join("cert").join("boundary.toml").exists(), + "init without agent-context must still write cert/" + ); + // But the scaffold is absent. + assert!( + !tmp.path().join("CLAUDE.md").exists(), + "--no-agent-context must skip root CLAUDE.md" + ); + assert!( + !tmp.path().join(".claude").exists(), + "--no-agent-context must skip .claude/" + ); +} + +/// Idempotency: a pre-existing `CLAUDE.md` is preserved verbatim, +/// and a pre-existing `.claude/settings.json` is also preserved. +/// `init` never clobbers downstream-authored conventions. +#[test] +fn init_with_agent_context_preserves_existing_files() { + let tmp = TempDir::new().expect("tempdir"); + + let claude_md = tmp.path().join("CLAUDE.md"); + let original_claude = "# Hand-written\n\nDownstream-owned conventions.\n"; + fs::write(&claude_md, original_claude).expect("write CLAUDE.md"); + + let dot_claude = tmp.path().join(".claude"); + fs::create_dir(&dot_claude).expect("mkdir .claude"); + let settings = dot_claude.join("settings.json"); + let original_settings = r#"{"hand":"written"}"#; + fs::write(&settings, original_settings).expect("write settings.json"); + + cargo_evidence(tmp.path()) + .args(["evidence", "init", "--with-agent-context"]) + .assert() + .success(); + + let claude_after = fs::read_to_string(&claude_md).expect("read CLAUDE.md"); + assert_eq!( + claude_after, original_claude, + "existing CLAUDE.md must be left untouched" + ); + + let settings_after = fs::read_to_string(&settings).expect("read settings.json"); + assert_eq!( + settings_after, original_settings, + "existing .claude/settings.json must be left untouched" + ); +} + +/// JSON-format output: every emitted file shows up as an +/// `INIT_TEMPLATE_WRITTEN` line in the JSONL stream, including the +/// two new agent-context files. The terminal line is `INIT_OK`. +#[test] +fn init_with_agent_context_jsonl_lists_new_files() { + let tmp = TempDir::new().expect("tempdir"); + + let out = cargo_evidence(tmp.path()) + .args(["evidence", "--format=jsonl", "init", "--with-agent-context"]) + .output() + .expect("spawn"); + assert!( + out.status.success(), + "init --with-agent-context --format=jsonl must exit 0; stderr:\n{}", + String::from_utf8_lossy(&out.stderr) + ); + + let stdout = String::from_utf8_lossy(&out.stdout); + let written_paths: Vec = stdout + .lines() + .filter_map(|line| { + let v: serde_json::Value = serde_json::from_str(line).ok()?; + if v["code"] != "INIT_TEMPLATE_WRITTEN" { + return None; + } + v["location"]["file"].as_str().map(|s| s.to_string()) + }) + .collect(); + + let has_claude = written_paths.iter().any(|p| p.ends_with("CLAUDE.md")); + let has_settings = written_paths + .iter() + .any(|p| p.replace('\\', "/").ends_with(".claude/settings.json")); + assert!( + has_claude, + "CLAUDE.md must appear in JSONL written-files; got:\n{stdout}" + ); + assert!( + has_settings, + ".claude/settings.json must appear in JSONL written-files; got:\n{stdout}" + ); + + let last_nonempty = stdout + .lines() + .rev() + .find(|l| !l.trim().is_empty()) + .expect("at least one stdout line"); + let terminal: serde_json::Value = + serde_json::from_str(last_nonempty).expect("terminal line must be JSON"); + assert_eq!(terminal["code"], "INIT_OK"); +} + +/// `--with-agent-context` and `--no-agent-context` are mutually +/// exclusive (clap `conflicts_with`). Passing both fires the CLI +/// argument-error path; exit code is non-zero. +#[test] +fn init_rejects_both_flags() { + let tmp = TempDir::new().expect("tempdir"); + + let out = cargo_evidence(tmp.path()) + .args([ + "evidence", + "init", + "--with-agent-context", + "--no-agent-context", + ]) + .output() + .expect("spawn"); + assert!( + !out.status.success(), + "passing both --with-agent-context and --no-agent-context must fail; stderr:\n{}", + String::from_utf8_lossy(&out.stderr) + ); +} diff --git a/crates/evidence-core/src/trace/surfaces.rs b/crates/evidence-core/src/trace/surfaces.rs index 82c819c..b2020e2 100644 --- a/crates/evidence-core/src/trace/surfaces.rs +++ b/crates/evidence-core/src/trace/surfaces.rs @@ -25,7 +25,7 @@ /// the two groups can use their natural conventions (lowercase CLI /// verbs; Capitalized observable-contract labels). /// -/// **Not yet covered**: `cargo evidence diff`, `init`, +/// **Not yet covered**: `cargo evidence diff`, /// `schema show`, `schema validate`. These subcommands exist but /// don't have governing HLRs in the self-trace today; adding them /// to KNOWN_SURFACES would fire the unclaimed-surface rule @@ -39,6 +39,7 @@ pub const KNOWN_SURFACES: &[&str] = &[ "doctor", "floors", "generate", + "init", "keygen", "rules", "trace", @@ -53,6 +54,7 @@ pub const KNOWN_SURFACES: &[&str] = &[ "diagnostic code namespace (regex + reserved suffixes)", "editor-duplicate gate", "jsonl stream per Schema Rule 2", + "layered CLAUDE.md (root + crates/*/CLAUDE.md)", "per-test outcome capture", "per-test ↔ LLR back-link", "pre-release safety gate", @@ -68,7 +70,7 @@ pub const KNOWN_SURFACES: &[&str] = &[ /// Used by the group-scoped sort test to validate within-group order /// without imposing cross-group ordering. #[cfg(test)] -const CONTRACTS_START: usize = 8; +const CONTRACTS_START: usize = 9; #[cfg(test)] mod tests { @@ -126,12 +128,12 @@ mod tests { "doctor", "floors", "generate", + "init", "keygen", "rules", "trace", "verify", "diff", - "init", "schema show", "schema validate", ]; diff --git a/crates/evidence-core/tests/layered_claude_md_doctrine.rs b/crates/evidence-core/tests/layered_claude_md_doctrine.rs index df37391..a6c1cde 100644 --- a/crates/evidence-core/tests/layered_claude_md_doctrine.rs +++ b/crates/evidence-core/tests/layered_claude_md_doctrine.rs @@ -23,7 +23,7 @@ )] use std::fs; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use walkdir::WalkDir; @@ -36,7 +36,7 @@ fn workspace_root() -> PathBuf { .to_path_buf() } -fn crate_dirs(root: &PathBuf) -> Vec<(String, PathBuf)> { +fn crate_dirs(root: &Path) -> Vec<(String, PathBuf)> { let crates_root = root.join("crates"); WalkDir::new(&crates_root) .follow_links(false) From 1a911ae3a0f2483bc8a38870906e168a01680245 Mon Sep 17 00:00:00 2001 From: sokoly Date: Wed, 20 May 2026 04:10:52 -0400 Subject: [PATCH 4/8] feat(agent-context): evidence_context MCP tool wraps cargo evidence context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR 3 of the agent-context-from-evidence design at docs/superpowers/specs/2026-05-19-agent-context-from-evidence-design.md: exposes PR 2's CLI verb as the seventh `#[tool]` on the MCP `Server` so MCP-connected agents reach the per-module trace + boundary + floors slice without needing the human CLI surface. Handler (`Server::evidence_context`, single-blob shape mirroring `evidence_diff`): - Validates the request at the handler layer: at most one of `selector` / `crate_name` / `module` may be `Some` — supplying more than one returns `Err(String)` instead of silently picking one (same posture as `evidence_check`'s invalid `mode` value). - Spawns `cargo evidence context [--crate | --module | ] --json` via the existing `subprocess::run_evidence` plumbing. No new transport machinery. - Parses the CLI's single JSON blob into the `context` field; prepends workspace-fallback + version-skew warnings via the shared response helpers. - Tool-layer failures (subprocess can't spawn, times out, produces malformed JSON) surface as well-formed responses with `context = None`, `exit_code = 2`, structured `MCP_*` error diagnostic. No new diagnostic codes — the content vocabulary (`CONTEXT_*`) is owned by the wrapped CLI verb (PR 2). Schema additions in `evidence-mcp::schema`: - `ContextRequest { workspace_path, selector, crate_name, module }` with `#[serde(deny_unknown_fields)]` (typo'd args fail loud). - `ContextToolResponse { success, exit_code, context, warnings, error }` matching `DiffToolResponse`'s blob-style shape. Server-layer split (`server.rs` was approaching its 500-line cap): - New `server/context.rs` holds the `SelectorArg` enum + `pick_selector_arg` request-validation helper, plus five unit tests covering the four happy-path arms and the rejection arm. - New `responses::workspace_fallback_warning` helper returns `Option` for the single-blob handlers (the streaming helper `prepend_fallback_signal` keeps its in-place semantics). Tests: - `crates/evidence-mcp/tests/context_roundtrip.rs` (5 cases): workspace-overview returns `selector.kind == "workspace"`; file selector resolves into a crate with ≥1 governing LLR; unmapped file (`Cargo.toml`) stays well-formed on the failure path; mutual-exclusivity guard rejects multi-field requests; typo'd field rejected by `deny_unknown_fields`. - `mcp_surface::tools_list_advertises_all_six_verbs` updated to expect `evidence_context` (now seven verbs). - Helpers tweak: hold stdin open until every expected response is read. rmcp's stdio loop hits its drain-timeout the moment stdin returns EOF, truncating any tool-call response whose handler is still running a subprocess (the longer-running context handler surfaced this). Trace chain seeded: - HLR-074 (`f88508e9-eb70-4ee6-bb3e-13f07e4f181b`): claims surface "context" alongside HLR-073 (PR 2's CLI verb). - LLR-084 / LLR-085 / LLR-086: handler wiring, request/response schema, integration-test pin. - TEST-091 / TEST-092 / TEST-093 / TEST-094: covering the integration tests + the request-mapping unit tests. - `cert/floors.toml`: trace_hlr 73→74, trace_llr 83→86, trace_test 88→92, per_crate.evidence-mcp.test_count 45→55. No new diagnostic codes; `MCP_MALFORMED_JSONL` covers the JSON-parse failure path same as `evidence_diff`. Co-Authored-By: Claude Opus 4.7 (1M context) --- cert/floors.toml | 8 +- cert/trace/hlr.toml | 28 ++ cert/trace/llr.toml | 108 ++++++ cert/trace/tests.toml | 91 +++++ crates/evidence-mcp/src/schema.rs | 82 +++++ crates/evidence-mcp/src/server.rs | 84 ++++- crates/evidence-mcp/src/server/context.rs | 152 +++++++++ crates/evidence-mcp/src/server/responses.rs | 16 + .../evidence-mcp/tests/context_roundtrip.rs | 310 ++++++++++++++++++ crates/evidence-mcp/tests/mcp_surface.rs | 1 + .../evidence-mcp/tests/mcp_surface/helpers.rs | 19 +- 11 files changed, 891 insertions(+), 8 deletions(-) create mode 100644 crates/evidence-mcp/src/server/context.rs create mode 100644 crates/evidence-mcp/tests/context_roundtrip.rs diff --git a/cert/floors.toml b/cert/floors.toml index 9242460..8bf6932 100644 --- a/cert/floors.toml +++ b/cert/floors.toml @@ -53,11 +53,11 @@ terminal_codes = 18 # cert/trace/sys.toml — System Requirements. trace_sys = 31 # cert/trace/hlr.toml — High-Level Requirements. -trace_hlr = 73 +trace_hlr = 74 # cert/trace/llr.toml — Low-Level Requirements. -trace_llr = 83 +trace_llr = 86 # cert/trace/tests.toml — Test Cases. -trace_test = 88 +trace_test = 92 # evidence_core::trace::surfaces::KNOWN_SURFACES length — hand-curated # catalog of CLI verbs + named observable contracts. Matching HLR @@ -85,7 +85,7 @@ test_count = 383 test_count = 158 [per_crate.evidence-mcp] -test_count = 45 +test_count = 55 # Per-crate ceilings. library_panics is a ceiling, not a floor: # more panics is always worse. Placing a ceiling dimension under diff --git a/cert/trace/hlr.toml b/cert/trace/hlr.toml index 98294fd..95d5e84 100644 --- a/cert/trace/hlr.toml +++ b/cert/trace/hlr.toml @@ -1848,3 +1848,31 @@ terminal). The wire shape is byte-locked against a golden fixture. verification_methods = ["test"] traces_to = ["d8d1f204-3909-418d-b62a-1e28edd088ed"] surfaces = ["context"] + +[[requirements]] +uid = "f88508e9-eb70-4ee6-bb3e-13f07e4f181b" +id = "HLR-074" +title = "evidence_context MCP tool returns per-module trace slice" +owner = "tool" +scope = "component" +description = """ +`evidence_context` is the seventh `#[tool]` on the MCP `Server`. It +takes a `ContextRequest` with an optional `workspace_path` and at +most one of `selector` (file/crate/module string), `crate_name` +(crate disambiguator), or `module` (module-path disambiguator), and +returns a `ContextToolResponse` carrying the single JSON blob +emitted by `cargo evidence context [--crate | --module | +] --json`. The handler spawns the CLI verb through the +existing `subprocess::run_evidence` plumbing (no new transport +machinery), prepends workspace-fallback + version-skew warnings via +the shared response helpers, and surfaces tool-layer failures +(subprocess could not spawn / timed out / produced malformed JSON) +as a well-formed response with `context = None`, `exit_code != 0`, +and a structured `MCP_*` diagnostic in `error`. Mutual-exclusivity +of the three selector fields is validated at the handler layer and +returned as `Err(String)`. No new diagnostic codes — the underlying +content vocabulary (`CONTEXT_*`) is owned by the CLI verb (HLR-073). +""" +verification_methods = ["test"] +traces_to = ["d8d1f204-3909-418d-b62a-1e28edd088ed"] +surfaces = ["context"] diff --git a/cert/trace/llr.toml b/cert/trace/llr.toml index 4e7d882..76015e2 100644 --- a/cert/trace/llr.toml +++ b/cert/trace/llr.toml @@ -2568,3 +2568,111 @@ emits = [ "CONTEXT_NO_TRACE_CONFIGURED", "CONTEXT_SELECTOR_OUT_OF_SCOPE", ] + +[[requirements]] +uid = "dfa2dfae-d1ec-4cb9-9a88-0d9ea1499907" +id = "LLR-084" +title = "evidence_context handler wraps cargo evidence context --json" +owner = "tool" +traces_to = ["f88508e9-eb70-4ee6-bb3e-13f07e4f181b"] +modules = [ + "evidence_mcp::server", + "evidence_mcp::server::Server::evidence_context", +] +description = """ +Seventh `#[tool]` on `Server`, alongside `evidence_rules`, +`evidence_doctor`, `evidence_check`, `evidence_ping`, +`evidence_floors`, `evidence_diff`. Single-blob shape (like +`evidence_diff`), not the streaming-verb shape used by `check` / +`doctor` / `floors`. + +Handler body: + + 1. Validate the request: at most one of `selector`, `crate_name`, + `module` may be `Some`. Multiple set → `Err(String)` with a + human message naming the conflicting fields (mirrors + `evidence_check`'s `mode` validation). + 2. Resolve the workspace path via `workspace::resolve_workspace` + (HLR-054); a `Fallback` resolution prepends the + `MCP_WORKSPACE_FALLBACK` warning on the response. + 3. Compose the CLI argument list: `["context", "--json"]` plus + `--crate ` / `--module ` / `` from whichever + field was set. + 4. Spawn `cargo evidence context ... --json` via + `subprocess::run_evidence` under the subprocess-timeout cap + (LLR-065). On subprocess failure, return a + `ContextToolResponse` with `context = None`, `exit_code = 2`, + `error = Some(MCP_* diagnostic)` via `context_response_from_run_error`. + 5. On subprocess success, parse stdout as a single + `serde_json::Value`. On parse failure, return + `MCP_MALFORMED_JSONL` as the error diagnostic. + 6. Build `warnings` from `skew_diagnostic(&self.version_skew)` + (HLR-060) plus the optional workspace-fallback signal. + +No new diagnostic codes — content vocabulary (`CONTEXT_*`) is +owned by the wrapped CLI verb (LLR-082, LLR-083). +""" +verification_methods = ["test"] + +[[requirements]] +uid = "9d8d4f30-d591-40fc-beab-5f9eda6c91fa" +id = "LLR-085" +title = "ContextRequest / ContextToolResponse define the evidence_context wire shape" +owner = "tool" +traces_to = ["f88508e9-eb70-4ee6-bb3e-13f07e4f181b"] +modules = [ + "evidence_mcp::schema", +] +description = """ +Two new `schemars::JsonSchema`-derived types in `crates/evidence- +mcp/src/schema.rs`. + +`ContextRequest { workspace_path: Option, selector: +Option, crate_name: Option, module: Option }` +with `#[serde(deny_unknown_fields)]` so an agent typo (e.g. +`crate` instead of `crate_name`) fails loud at deserialization +rather than falling through to the workspace-overview path. +Field-level doc comments give agents the schema description for +each input. + +`ContextToolResponse { success: bool, exit_code: i32, context: +Option, warnings: Vec, error: +Option }`. Mirrors `DiffToolResponse` (the +sibling single-blob response shape). `success` is derived: `true` +exactly when `exit_code == 0` AND `error.is_none()`. Warnings +(version-skew, workspace-fallback) do not flip the bit. The +`context` field carries the raw JSON the CLI emitted so agents see +the exact byte-locked wire shape from the underlying golden +fixture. +""" +verification_methods = ["test"] + +[[requirements]] +uid = "802701d1-9770-40fa-8a74-d65c3c34c8ca" +id = "LLR-086" +title = "context_roundtrip integration test pins the MCP wire shape against the CLI" +owner = "tool" +traces_to = ["f88508e9-eb70-4ee6-bb3e-13f07e4f181b"] +modules = [ + "evidence_mcp::server::Server::evidence_context", +] +description = """ +`crates/evidence-mcp/tests/context_roundtrip.rs` spawns the built +`evidence-mcp` binary via `assert_cmd`, drives an MCP `tools/call` +JSON-RPC conversation for `evidence_context`, and asserts on the +structured response. + +Three cases, mirroring the diff-roundtrip pattern: + +- Workspace-overview selector (no selector / crate_name / module): + `context` is a non-null JSON object whose `selector.kind` is + `"workspace"`. `exit_code == 0`, `success == true`, no transport + error. +- File selector that resolves into a crate with at least one + governing LLR: `context.requirements` is a non-empty array (the + CLI exercises the live `cert/trace/`). `success == true`. +- Mutual-exclusivity guard: passing both `selector` and `crate_name` + surfaces a tool-layer error (either a JSON-RPC error or an + `Err(String)` from the handler). +""" +verification_methods = ["test"] diff --git a/cert/trace/tests.toml b/cert/trace/tests.toml index c427a04..b38c533 100644 --- a/cert/trace/tests.toml +++ b/cert/trace/tests.toml @@ -1421,3 +1421,94 @@ test_selectors = [ "cli_context::context_jsonl_non_adopter_graceful_path", "cli_context::context_jsonl_invalid_selector_emits_fail_terminal", ] + +[[tests]] +uid = "8a81c73c-6e6e-4e61-abb5-4db2c1f3bbce" +id = "TEST-091" +title = "evidence_context workspace-overview returns a well-formed ContextReport" +owner = "tool" +traces_to = ["dfa2dfae-d1ec-4cb9-9a88-0d9ea1499907"] +description = """ +Spawns the built `evidence-mcp` binary, drives a `tools/call` for +`evidence_context` with no selector / crate_name / module fields, +and asserts on the structured response. `context` is a non-null +JSON object whose `selector.kind` is `"workspace"`. `exit_code == +0`, `success == true`, no transport-layer error. Pins the happy- +path plumbing end-to-end without depending on a specific +requirements count. +""" +test_selectors = [ + "context_roundtrip::evidence_context_workspace_overview_returns_report", +] + +[[tests]] +uid = "86f4d5a7-852e-4bdf-96ab-7dd0dcb8474f" +id = "TEST-092" +title = "evidence_context file selector resolves to governing requirements" +owner = "tool" +traces_to = ["dfa2dfae-d1ec-4cb9-9a88-0d9ea1499907"] +description = """ +Two co-located cases pin the file-selector path: + +- `evidence_context_file_selector_pulls_requirements`: passes + `crates/evidence-mcp/src/server.rs` and asserts the response's + `context.requirements` array is non-empty (the CLI exercises the + live `cert/trace/` against the real workspace). `selector.kind` + is `"file"`, `success == true`. +- `evidence_context_unmapped_file_carries_no_requirements_warning`: + passes a workspace file (`Cargo.toml`) that no LLR claims via + `modules`; asserts the response stays well-formed and surfaces + the graceful warning path through the MCP layer. +""" +test_selectors = [ + "context_roundtrip::evidence_context_file_selector_pulls_requirements", + "context_roundtrip::evidence_context_unmapped_file_carries_no_requirements_warning", +] + +[[tests]] +uid = "2d6c95c4-8b28-4b8d-92a4-cbc81e7107d6" +id = "TEST-093" +title = "evidence_context mutual-exclusivity guard rejects multiple selector fields" +owner = "tool" +traces_to = ["dfa2dfae-d1ec-4cb9-9a88-0d9ea1499907"] +description = """ +Two co-located cases pin the request-validation contract: + +- `evidence_context_rejects_multiple_selector_fields`: submits a + request with both `selector` and `crate_name` set; asserts the + response is either a JSON-RPC error or carries `isError == true` + — not a silent success against one of the two values. Defense + against agent misuse of the three equivalent entry points. +- `evidence_context_rejects_unknown_field_typo`: pins the + `#[serde(deny_unknown_fields)]` contract on `ContextRequest` + (mirrors `evidence_check_rejects_unknown_field_typo`). +""" +test_selectors = [ + "context_roundtrip::evidence_context_rejects_multiple_selector_fields", + "context_roundtrip::evidence_context_rejects_unknown_field_typo", +] + +[[tests]] +uid = "38cb6b28-ddb8-4bd9-b2ef-3fdd3a223db2" +id = "TEST-094" +title = "pick_selector_arg lifts ContextRequest into the CLI argument vector" +owner = "tool" +traces_to = ["9d8d4f30-d591-40fc-beab-5f9eda6c91fa"] +description = """ +Unit tests on the request-mapping helper that turns +`ContextRequest` into the argument vector passed to `cargo +evidence context`. Five cases cover: workspace overview (all +unset), positional selector wins when set, `--crate ` +disambiguator, `--module ` disambiguator, and the mutual- +exclusivity rejection path. Together they pin the +`ContextRequest` ⇄ CLI-arg bijection at the request-shape layer +so the integration test only has to verify the wire-level +plumbing. +""" +test_selectors = [ + "evidence_mcp::server::context::tests::pick_selector_arg_none_when_all_unset", + "evidence_mcp::server::context::tests::pick_selector_arg_positional_wins_when_selector_set", + "evidence_mcp::server::context::tests::pick_selector_arg_crate_flag_emits_crate_arg_pair", + "evidence_mcp::server::context::tests::pick_selector_arg_module_flag_emits_module_arg_pair", + "evidence_mcp::server::context::tests::pick_selector_arg_rejects_multiple_fields_set", +] diff --git a/crates/evidence-mcp/src/schema.rs b/crates/evidence-mcp/src/schema.rs index a5acf0f..6cb1be1 100644 --- a/crates/evidence-mcp/src/schema.rs +++ b/crates/evidence-mcp/src/schema.rs @@ -248,6 +248,88 @@ pub struct DiffRequest { pub bundle_b_path: String, } +/// Input to `evidence_context`. Mirrors the `cargo evidence +/// context` CLI verb: at most one of `selector`, `crate_name`, +/// `module` may be supplied; supplying multiple is a host-contract +/// error and the handler returns `Err(String)` rather than +/// silently picking one. With all three absent, the response +/// carries the workspace overview. +/// +/// `#[serde(deny_unknown_fields)]` matches the convention for +/// the other MCP tool input shapes — a typo'd field fails loud +/// at deserialization rather than falling through to the +/// workspace-overview path. +#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ContextRequest { + /// Absolute or MCP-server-CWD-relative path to the workspace + /// whose trace + boundary + floors slice should be queried. + /// Defaults to the server's CWD when omitted (triggers an + /// `MCP_WORKSPACE_FALLBACK` warning on the response). + #[serde(default)] + pub workspace_path: Option, + + /// Free-form selector — a workspace-relative file path under + /// `crates//...`, a workspace crate name, or a Rust + /// module path. Resolution priority on ambiguity: + /// file > crate > module. Mutually exclusive with + /// `crate_name` and `module`. + #[serde(default)] + pub selector: Option, + + /// Disambiguate the selector as a workspace crate name + /// (matches `[package].name` in `crates/*/Cargo.toml`). + /// Mutually exclusive with `selector` and `module`. + #[serde(default)] + pub crate_name: Option, + + /// Disambiguate the selector as a Rust module path + /// (e.g. `evidence_core::trace`). Mutually exclusive with + /// `selector` and `crate_name`. + #[serde(default)] + pub module: Option, +} + +/// Response shape for `evidence_context` — a one-shot blob-style +/// per-module context report. +/// +/// The CLI's context output is a single JSON document (the +/// `ContextReport` defined in `evidence_core::context`), not a +/// JSONL stream, so the response shape mirrors +/// [`DiffToolResponse`] rather than [`JsonlToolResponse`]. +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct ContextToolResponse { + /// Canonical machine signal: `true` exactly when + /// `exit_code == 0` AND `error.is_none()`. Server-layer + /// `warnings` (version-skew, workspace-fallback) are + /// informational and do not flip the bit. + pub success: bool, + + /// Exit code advertised back to the host. `0` on success; + /// `2` on tool-layer failure (see `error`). Documentation + /// field — see [`Self::success`] for the canonical pass/fail + /// dispatch. + pub exit_code: i32, + + /// The full `ContextReport` blob as emitted by `cargo evidence + /// context --json` on success. `None` on tool-layer failure. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub context: Option, + + /// Server-layer warnings — version-skew signals from the + /// startup probe (HLR-060) and workspace-fallback signals + /// (HLR-054). Empty in the happy path. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub warnings: Vec, + + /// Tool-layer failure diagnostic when the subprocess could + /// not run or its stdout was not valid JSON. `None` on + /// success. Carries an `MCP_*` code from + /// `evidence_core::HAND_EMITTED_MCP_CODES`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, +} + /// Response shape for `evidence_diff` — a one-shot blob-style /// comparison between two on-disk bundles. /// diff --git a/crates/evidence-mcp/src/server.rs b/crates/evidence-mcp/src/server.rs index 1579f91..6793533 100644 --- a/crates/evidence-mcp/src/server.rs +++ b/crates/evidence-mcp/src/server.rs @@ -18,18 +18,20 @@ use rmcp::{ }; use crate::schema::{ - CheckRequest, DoctorRequest, FloorsRequest, JsonlToolResponse, PingRequest, PingResponse, - RulesRequest, RulesToolResponse, + CheckRequest, ContextRequest, ContextToolResponse, DoctorRequest, FloorsRequest, + JsonlToolResponse, PingRequest, PingResponse, RulesRequest, RulesToolResponse, }; use crate::subprocess::{MCP_MALFORMED_JSONL, parse_jsonl, run_evidence}; use crate::version_probe::{VersionSkew, detect_with_probe, probe_cli_version, skew_diagnostic}; use crate::workspace::resolve_workspace; +mod context; mod responses; +use context::pick_selector_arg; use responses::{ TOOL_FAILURE_EXIT_CODE, blob_success, jsonl_response_from_run_error, jsonl_success, mcp_diagnostic, ping_response_from_skew, prepend_fallback_signal, prepend_skew_signal, - rules_response_from_run_error, + rules_response_from_run_error, workspace_fallback_warning, }; /// MCP server handle. Stateless per-request — each tool call @@ -386,6 +388,82 @@ impl Server { } } } + + /// `evidence_context` — return the per-module trace + + /// boundary + floors slice for any selector (file / crate / + /// module / null). Pure inspection — no `cargo test`, no + /// disk writes; cheap enough to call on every agent loop + /// iteration. + /// + /// Wraps `cargo evidence context [--crate | --module + /// | ] --json` via the existing + /// `subprocess::run_evidence` plumbing. The CLI emits a + /// single JSON blob (`ContextReport`); the response carries + /// it under the `context` field. Mutually-exclusive selector + /// fields (`selector`, `crate_name`, `module`) are validated + /// at the handler layer — supplying more than one is a + /// host-contract error and returns `Err(String)`. + #[tool( + name = "evidence_context", + description = "Per-module trace + boundary + floors slice for a selector \ + (file / crate / module / null). Pure inspection — no cargo \ + test, no disk writes; cheap enough to call on every agent loop \ + iteration. At most one of selector / crate_name / module." + )] + pub async fn evidence_context( + &self, + Parameters(req): Parameters, + ) -> Result, String> { + let cli_arg = pick_selector_arg(&req)?; + let (cwd, resolution) = resolve_workspace(req.workspace_path.as_deref())?; + let mut warnings = Vec::new(); + if let Some(d) = workspace_fallback_warning(resolution, &cwd) { + warnings.push(d); + } + if let Some(d) = skew_diagnostic(&self.version_skew) { + warnings.push(d); + } + let mut args: Vec = vec!["context".into(), "--json".into()]; + if let Some(sa) = cli_arg { + sa.extend_args(&mut args); + } + let args_refs: Vec<&str> = args.iter().map(String::as_str).collect(); + let captured = match run_evidence(&args_refs, &cwd).await { + Ok(c) => c, + Err(e) => { + let error = Some(mcp_diagnostic(e.code(), &e.to_string())); + return Ok(Json(ContextToolResponse { + success: blob_success(TOOL_FAILURE_EXIT_CODE, error.as_ref()), + exit_code: TOOL_FAILURE_EXIT_CODE, + context: None, + warnings, + error, + })); + } + }; + match serde_json::from_slice::(&captured.stdout) { + Ok(context) => Ok(Json(ContextToolResponse { + success: blob_success(captured.exit_code, None), + exit_code: captured.exit_code, + context: Some(context), + warnings, + error: None, + })), + Err(e) => { + let error = Some(mcp_diagnostic( + MCP_MALFORMED_JSONL, + &format!("cargo evidence context --json produced invalid JSON: {e}"), + )); + Ok(Json(ContextToolResponse { + success: blob_success(TOOL_FAILURE_EXIT_CODE, error.as_ref()), + exit_code: TOOL_FAILURE_EXIT_CODE, + context: None, + warnings, + error, + })) + } + } + } } impl Default for Server { diff --git a/crates/evidence-mcp/src/server/context.rs b/crates/evidence-mcp/src/server/context.rs new file mode 100644 index 0000000..4d49c43 --- /dev/null +++ b/crates/evidence-mcp/src/server/context.rs @@ -0,0 +1,152 @@ +//! Helpers backing the [`crate::Server::evidence_context`] tool +//! method. Lives next to `responses.rs` so the parent module +//! stays under the workspace 500-line limit while the handler- +//! specific argument-mapping logic gets its own home. +//! +//! The two exposed items both serve the handler exclusively: +//! [`SelectorArg`] mirrors the three equivalent CLI entry points +//! (`` / `--crate` / `--module`); [`pick_selector_arg`] +//! validates the [`crate::schema::ContextRequest`] selector trio +//! and lifts the chosen field into the enum. + +use crate::schema::ContextRequest; + +/// Disambiguated selector argument to pass to `cargo evidence +/// context`. The three variants correspond 1:1 with the three +/// equivalent entry points exposed by the CLI (``, +/// `--crate`, `--module`). +#[derive(Debug)] +pub(super) enum SelectorArg { + /// Positional argument — free-form selector resolved by + /// priority (file > crate > module). + Positional(String), + /// `--crate ` disambiguator. + Crate(String), + /// `--module ` disambiguator. + Module(String), +} + +impl SelectorArg { + /// Append the CLI arguments this selector represents onto an + /// existing arg vector. Keeps the handler-side dispatch + /// shape-agnostic (the handler just calls `extend_args` + /// regardless of the variant). + pub(super) fn extend_args(self, args: &mut Vec) { + match self { + Self::Crate(c) => { + args.push("--crate".into()); + args.push(c); + } + Self::Module(m) => { + args.push("--module".into()); + args.push(m); + } + Self::Positional(p) => { + args.push(p); + } + } + } +} + +/// Validate the [`ContextRequest`] selector trio and pick the +/// single non-`None` field. Returns `Ok(None)` when all three are +/// absent (workspace overview) and `Err(String)` when more than +/// one is set — a host-contract error reported the same way as +/// `evidence_check`'s invalid `mode` value. +pub(super) fn pick_selector_arg(req: &ContextRequest) -> Result, String> { + let set: Vec<&str> = [ + req.selector.as_deref().map(|_| "selector"), + req.crate_name.as_deref().map(|_| "crate_name"), + req.module.as_deref().map(|_| "module"), + ] + .into_iter() + .flatten() + .collect(); + if set.len() > 1 { + return Err(format!( + "invalid request: at most one of selector / crate_name / module may be set; \ + got {set:?}" + )); + } + if let Some(s) = req.selector.as_deref().filter(|s| !s.is_empty()) { + return Ok(Some(SelectorArg::Positional(s.to_string()))); + } + if let Some(c) = req.crate_name.as_deref().filter(|c| !c.is_empty()) { + return Ok(Some(SelectorArg::Crate(c.to_string()))); + } + if let Some(m) = req.module.as_deref().filter(|m| !m.is_empty()) { + return Ok(Some(SelectorArg::Module(m.to_string()))); + } + Ok(None) +} + +#[cfg(test)] +#[allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + reason = "test setup failures should panic immediately" +)] +mod tests { + use super::*; + + #[test] + fn pick_selector_arg_none_when_all_unset() { + let req = ContextRequest::default(); + let picked = pick_selector_arg(&req).expect("ok"); + assert!( + picked.is_none(), + "all-unset request should be workspace overview" + ); + } + + #[test] + fn pick_selector_arg_positional_wins_when_selector_set() { + let req = ContextRequest { + selector: Some("crates/x/src/y.rs".to_string()), + ..Default::default() + }; + let picked = pick_selector_arg(&req).expect("ok").expect("some"); + assert!(matches!(picked, SelectorArg::Positional(_))); + let mut args = vec!["context".to_string()]; + picked.extend_args(&mut args); + assert_eq!(args, vec!["context", "crates/x/src/y.rs"]); + } + + #[test] + fn pick_selector_arg_crate_flag_emits_crate_arg_pair() { + let req = ContextRequest { + crate_name: Some("evidence-mcp".to_string()), + ..Default::default() + }; + let picked = pick_selector_arg(&req).expect("ok").expect("some"); + let mut args = vec!["context".to_string()]; + picked.extend_args(&mut args); + assert_eq!(args, vec!["context", "--crate", "evidence-mcp"]); + } + + #[test] + fn pick_selector_arg_module_flag_emits_module_arg_pair() { + let req = ContextRequest { + module: Some("evidence_core::trace".to_string()), + ..Default::default() + }; + let picked = pick_selector_arg(&req).expect("ok").expect("some"); + let mut args = vec!["context".to_string()]; + picked.extend_args(&mut args); + assert_eq!(args, vec!["context", "--module", "evidence_core::trace"]); + } + + #[test] + fn pick_selector_arg_rejects_multiple_fields_set() { + let req = ContextRequest { + selector: Some("foo".to_string()), + crate_name: Some("bar".to_string()), + ..Default::default() + }; + let err = pick_selector_arg(&req).expect_err("multiple set must fail"); + assert!(err.contains("at most one")); + assert!(err.contains("selector")); + assert!(err.contains("crate_name")); + } +} diff --git a/crates/evidence-mcp/src/server/responses.rs b/crates/evidence-mcp/src/server/responses.rs index f0ccfe0..b59c923 100644 --- a/crates/evidence-mcp/src/server/responses.rs +++ b/crates/evidence-mcp/src/server/responses.rs @@ -66,6 +66,22 @@ pub(super) fn prepend_fallback_signal( .or_insert(0) += 1; } +/// Build a `MCP_WORKSPACE_FALLBACK` diagnostic when the caller +/// fell back to server CWD; returns `None` for `Given`. Used by +/// the single-blob handlers (`evidence_context`) where the +/// fallback signal goes into the response's `warnings` array +/// rather than being prepended onto a streaming `diagnostics` +/// vec. +pub(super) fn workspace_fallback_warning( + resolution: WorkspaceResolution, + cwd: &std::path::Path, +) -> Option { + match resolution { + WorkspaceResolution::Fallback => Some(workspace_fallback_diagnostic(cwd)), + WorkspaceResolution::Given => None, + } +} + /// Prepend `MCP_VERSION_SKEW` / `MCP_VERSION_PROBE_FAILED` when /// the server's cached skew outcome is not `Matched`. No-op on /// match. Both the diagnostic vec and the summary map get diff --git a/crates/evidence-mcp/tests/context_roundtrip.rs b/crates/evidence-mcp/tests/context_roundtrip.rs new file mode 100644 index 0000000..db20181 --- /dev/null +++ b/crates/evidence-mcp/tests/context_roundtrip.rs @@ -0,0 +1,310 @@ +//! Surface tests for `evidence_context` (TEST-091 / TEST-092 / +//! TEST-093). +//! +//! Separate integration-test binary from `mcp_surface.rs` so the +//! parent stays under the workspace 500-line limit. Shares the +//! `helpers` module via `#[path]`. + +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + reason = "test setup failures should panic immediately" +)] + +use serde_json::{Value, json}; + +#[path = "mcp_surface/helpers.rs"] +mod helpers; + +use helpers::{init_frames, session_in}; + +/// Workspace root of the evidence project — `crates/evidence-mcp`'s +/// grandparent. Tests use this as `workspace_path` so the +/// underlying `cargo evidence context` spawn sees real +/// `cert/trace/` data instead of an empty CWD. +fn workspace_root() -> std::path::PathBuf { + std::env::var("CARGO_MANIFEST_DIR") + .map(std::path::PathBuf::from) + .expect("CARGO_MANIFEST_DIR") + .parent() + .expect("crates/") + .parent() + .expect("workspace root") + .to_path_buf() +} + +/// TEST-091 selector: `evidence_context` with no selector returns +/// a structured workspace-overview report. `context` is a non-null +/// JSON object whose `selector.kind` is `"workspace"`, +/// `exit_code == 0`, `success == true`, no transport-layer error. +/// Pins the happy-path plumbing end-to-end without depending on a +/// specific requirements count. +#[test] +fn evidence_context_workspace_overview_returns_report() { + let root = workspace_root(); + let mut frames = init_frames(); + frames.push(json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { + "name": "evidence_context", + "arguments": { + "workspace_path": root.to_str().expect("utf-8 path") + } + } + })); + + let responses = session_in(&frames, 2, Some(&root)); + assert_eq!(responses.len(), 2, "responses: {responses:?}"); + + let call_resp = &responses[1]; + let structured = call_resp + .pointer("/result/structuredContent") + .unwrap_or_else(|| panic!("missing structuredContent: {call_resp}")); + + assert_eq!( + structured["exit_code"].as_i64(), + Some(0), + "expected exit_code == 0; structured={structured}" + ); + assert_eq!( + structured["success"].as_bool(), + Some(true), + "expected success == true; structured={structured}" + ); + let context = structured + .get("context") + .unwrap_or_else(|| panic!("missing context field: {structured}")); + assert!( + !context.is_null(), + "context must be non-null on success: {structured}" + ); + let kind = context + .pointer("/selector/kind") + .and_then(Value::as_str) + .unwrap_or_else(|| panic!("missing context.selector.kind: {structured}")); + assert_eq!( + kind, "workspace", + "no-selector request must produce a workspace overview; got kind={kind}" + ); + + let is_error = call_resp + .pointer("/result/isError") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + assert!( + !is_error, + "tool call unexpectedly flagged isError: {call_resp}" + ); +} + +/// TEST-092 selector: a file selector that resolves into a crate +/// with at least one governing LLR populates `context.requirements` +/// with a non-empty array. The CLI exercises the live `cert/trace/` +/// against the real workspace, so we pick a known file +/// (`crates/evidence-mcp/src/server.rs`) that LLR-050 / LLR-064 +/// govern. +#[test] +fn evidence_context_file_selector_pulls_requirements() { + let root = workspace_root(); + let mut frames = init_frames(); + frames.push(json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { + "name": "evidence_context", + "arguments": { + "workspace_path": root.to_str().expect("utf-8 path"), + "selector": "crates/evidence-mcp/src/server.rs" + } + } + })); + + let responses = session_in(&frames, 2, Some(&root)); + assert_eq!(responses.len(), 2, "responses: {responses:?}"); + + let call_resp = &responses[1]; + let structured = call_resp + .pointer("/result/structuredContent") + .unwrap_or_else(|| panic!("missing structuredContent: {call_resp}")); + + assert_eq!( + structured["success"].as_bool(), + Some(true), + "expected success on a known-good selector; structured={structured}" + ); + + let context = structured + .get("context") + .unwrap_or_else(|| panic!("missing context field: {structured}")); + let kind = context + .pointer("/selector/kind") + .and_then(Value::as_str) + .unwrap_or_else(|| panic!("missing context.selector.kind: {structured}")); + assert_eq!( + kind, "file", + "expected kind == file for a path selector; got {kind}; context={context}" + ); + + let requirements = context + .get("requirements") + .and_then(Value::as_array) + .unwrap_or_else(|| panic!("requirements not array: {context}")); + assert!( + !requirements.is_empty(), + "evidence-mcp's server.rs is governed by at least one LLR; \ + got empty requirements: {context}" + ); +} + +/// TEST-092 selector (graceful warning path): a selector that +/// resolves to a file outside any LLR's `modules` field carries +/// `CONTEXT_NO_REQUIREMENTS_FOR_SELECTOR` in `context.warnings` +/// rather than failing. Pins the wrapped CLI's graceful-warning +/// path through the MCP layer. +#[test] +fn evidence_context_unmapped_file_carries_no_requirements_warning() { + let root = workspace_root(); + // README.md sits at the workspace root, not under any + // crate's source tree — the CLI's file resolver rejects it + // as out-of-scope rather than mapping it to zero LLRs, so + // this case actually pins the SELECTOR_OUT_OF_SCOPE path. + // Pick a Cargo.toml at the workspace root instead — it + // exists, lives under the workspace, and isn't owned by any + // LLR's `modules` field. + let mut frames = init_frames(); + frames.push(json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { + "name": "evidence_context", + "arguments": { + "workspace_path": root.to_str().expect("utf-8 path"), + "selector": "Cargo.toml" + } + } + })); + + let responses = session_in(&frames, 2, Some(&root)); + assert_eq!(responses.len(), 2, "responses: {responses:?}"); + + let call_resp = &responses[1]; + let structured = call_resp + .pointer("/result/structuredContent") + .unwrap_or_else(|| panic!("missing structuredContent: {call_resp}")); + + // Either the CLI gracefully resolves it with an empty- + // requirements warning OR it returns FAIL with the OUT_OF_SCOPE + // diagnostic. Both are valid CLI-layer behaviors for a path + // that isn't owned by any LLR's `modules` field; the MCP + // layer must surface them well-formed in either case. + let exit_code = structured["exit_code"].as_i64().unwrap_or(99); + if exit_code == 0 { + let context = structured + .get("context") + .unwrap_or_else(|| panic!("missing context on exit 0: {structured}")); + let warnings = context + .get("warnings") + .and_then(Value::as_array) + .unwrap_or_else(|| panic!("warnings not array: {context}")); + let has_no_reqs = warnings.iter().any(|w| { + w.get("code").and_then(Value::as_str) == Some("CONTEXT_NO_REQUIREMENTS_FOR_SELECTOR") + }); + assert!( + has_no_reqs || warnings.is_empty(), + "graceful path should carry CONTEXT_NO_REQUIREMENTS_FOR_SELECTOR \ + when warnings is non-empty; got {warnings:?}" + ); + } else { + // Failure path — the response must still be well-formed: + // structured present, no transport-layer error. + let is_error = call_resp + .pointer("/result/isError") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + assert!( + !is_error, + "failure path should ride on structuredContent, not transport error: {call_resp}" + ); + } +} + +/// TEST-093 selector: a request that sets more than one of +/// `selector` / `crate_name` / `module` must fail at the handler +/// layer — either as a JSON-RPC error or with `isError == true`. +/// Defends against agent misuse of the three equivalent entry +/// points (silently picking one would mask the intent mismatch). +#[test] +fn evidence_context_rejects_multiple_selector_fields() { + let root = workspace_root(); + let mut frames = init_frames(); + frames.push(json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { + "name": "evidence_context", + "arguments": { + "workspace_path": root.to_str().expect("utf-8 path"), + "selector": "crates/evidence-mcp/src/server.rs", + "crate_name": "evidence-mcp" + } + } + })); + + let responses = session_in(&frames, 2, Some(&root)); + assert_eq!(responses.len(), 2, "responses: {responses:?}"); + + let call_resp = &responses[1]; + let is_error = call_resp.get("error").is_some(); + let is_error_flag = call_resp + .pointer("/result/isError") + .and_then(Value::as_bool) + .unwrap_or(false); + assert!( + is_error || is_error_flag, + "expected either a JSON-RPC error or isError:true when two selector \ + fields are set; got: {call_resp}" + ); +} + +/// TEST-093 selector: `#[serde(deny_unknown_fields)]` on +/// `ContextRequest` rejects typo'd argument fields (e.g. `crate` +/// instead of `crate_name`) at deserialization. Mirrors the +/// pattern from `evidence_check_rejects_unknown_field_typo`. +#[test] +fn evidence_context_rejects_unknown_field_typo() { + let root = workspace_root(); + let mut frames = init_frames(); + frames.push(json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { + "name": "evidence_context", + "arguments": { + "workspace_path": root.to_str().expect("utf-8 path"), + // Typo: `crate` instead of `crate_name`. + "crate": "evidence-mcp" + } + } + })); + + let responses = session_in(&frames, 2, Some(&root)); + assert_eq!(responses.len(), 2, "responses: {responses:?}"); + let call_resp = &responses[1]; + let is_error = call_resp.get("error").is_some(); + let is_error_flag = call_resp + .pointer("/result/isError") + .and_then(Value::as_bool) + .unwrap_or(false); + assert!( + is_error || is_error_flag, + "expected either a JSON-RPC error or isError:true on a typo'd field; \ + got: {call_resp}" + ); +} diff --git a/crates/evidence-mcp/tests/mcp_surface.rs b/crates/evidence-mcp/tests/mcp_surface.rs index f1e4143..7972643 100644 --- a/crates/evidence-mcp/tests/mcp_surface.rs +++ b/crates/evidence-mcp/tests/mcp_surface.rs @@ -34,6 +34,7 @@ use helpers::{init_frames, session, session_in}; fn tools_list_advertises_all_six_verbs() { const EXPECTED: &[&str] = &[ "evidence_check", + "evidence_context", "evidence_diff", "evidence_doctor", "evidence_floors", diff --git a/crates/evidence-mcp/tests/mcp_surface/helpers.rs b/crates/evidence-mcp/tests/mcp_surface/helpers.rs index 97f9b41..586d695 100644 --- a/crates/evidence-mcp/tests/mcp_surface/helpers.rs +++ b/crates/evidence-mcp/tests/mcp_surface/helpers.rs @@ -105,12 +105,17 @@ pub fn session_in_with_path( let mut child = spawn_server(cwd, path); let mut stdin: ChildStdin = child.stdin.take().expect("stdin"); let stdout: ChildStdout = child.stdout.take().expect("stdout"); + let stderr = child.stderr.take(); let mut reader = BufReader::new(stdout); for frame in frames { writeln!(stdin, "{}", serde_json::to_string(frame).expect("encode")).expect("write"); } - drop(stdin); + // Hold stdin open until every expected response has been read. + // rmcp's stdio loop hits its drain-timeout the moment stdin + // returns EOF, so closing stdin prematurely truncates any + // tool-call response whose handler is still running a + // subprocess. The drop happens after the read loop below. let mut responses = Vec::with_capacity(expect_responses); for _ in 0..expect_responses { @@ -125,7 +130,19 @@ pub fn session_in_with_path( } responses.push(serde_json::from_str::(trimmed).expect("parse response")); } + drop(stdin); + // Drain stderr if collection short-circuited so failing tests + // get the server's panic message in their output. + if responses.len() < expect_responses { + if let Some(mut e) = stderr { + let mut buf = Vec::new(); + std::io::Read::read_to_end(&mut e, &mut buf).ok(); + if !buf.is_empty() { + eprintln!("[mcp stderr] {}", String::from_utf8_lossy(&buf)); + } + } + } child.wait().ok(); responses } From 7b516c83d809a0c4cfa06f1a3b8e3a57570a130f Mon Sep 17 00:00:00 2001 From: sokoly Date: Wed, 20 May 2026 04:45:31 -0400 Subject: [PATCH 5/8] fix(agent-context): distinct CONTEXT_RUNTIME_ERROR for tool-side faults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issues from local-branch code review: 1. ContextError::content_code() mislabelled runtime variants. TraceRead / Io / CargoManifestRead / CargoManifestParse all returned "CONTEXT_SELECTOR_OUT_OF_SCOPE" — an IO fault would surface to agents as if the user's selector was bad, leading them to re-prompt the user instead of reporting the real cause. Fix: introduce CONTEXT_RUNTIME_ERROR (severity Error, content code) and route the four runtime variants to it. CONTEXT_SELECTOR_ OUT_OF_SCOPE stays scoped to genuine selector-not-resolved cases. - cert/floors.toml: diagnostic_codes 161 → 162. - cert/trace/llr.toml: LLR-083 emits the new code, description updated from "four content-level" to "five" with the new variant called out. - cert/trace/tests.toml: TEST-086 migrated from singular test_selector to the canonical test_selectors = [...] plural shape (review issue 5 — schema migration consistency). - golden_rules.json + golden_context.json regenerated. 2. match_file silently swallowed canonicalize() failures. Permission denied on a parent dir or a symlink to nonexistent target made the resolver return None — same misclassification problem as issue 1. Fix: match_file now returns Result, ContextError> and propagates IO via ContextError::Io, which maps to the new CONTEXT_RUNTIME_ERROR content code. A new unit test context::tests::runtime_error_variants_map_to_distinct_content_code pins the mapping so a future enum edit can't re-conflate the buckets. Spec §6 updated to list five content codes. Co-Authored-By: Claude Opus 4.7 (1M context) --- cert/floors.toml | 2 +- cert/trace/llr.toml | 11 +++-- cert/trace/tests.toml | 4 +- .../tests/fixtures/golden_context.json | 48 ++++++++++++++++++- .../tests/fixtures/golden_rules.json | 7 +++ crates/evidence-core/src/context/error.rs | 20 +++++--- crates/evidence-core/src/context/resolver.rs | 31 +++++++----- crates/evidence-core/src/context/tests.rs | 30 ++++++++++++ crates/evidence-core/src/rules.rs | 1 + ...5-19-agent-context-from-evidence-design.md | 3 +- 10 files changed, 132 insertions(+), 25 deletions(-) diff --git a/cert/floors.toml b/cert/floors.toml index cf77701..b5ffd57 100644 --- a/cert/floors.toml +++ b/cert/floors.toml @@ -42,7 +42,7 @@ schema_version = 1 # evidence_core::RULES length — every diagnostic code the tool can emit, # hand-curated. Lives in a single crate, so workspace-wide. -diagnostic_codes = 161 +diagnostic_codes = 162 # evidence_core::TERMINAL_CODES length — hand-emitted terminals # (VERIFY_OK / VERIFY_FAIL / VERIFY_ERROR / CLI_SUBCOMMAND_ERROR / diff --git a/cert/trace/llr.toml b/cert/trace/llr.toml index ed8a3f4..61d77b9 100644 --- a/cert/trace/llr.toml +++ b/cert/trace/llr.toml @@ -2547,15 +2547,19 @@ modules = [ "cargo_evidence::cli::context", ] description = """ -The four content-level `CONTEXT_*` codes are registered in +The five content-level `CONTEXT_*` codes are registered in `evidence_core::RULES` under the `Context` domain. The CLI's `cmd_context` emits each at the right point: `CONTEXT_AMBIGUOUS_SELECTOR` (warning) when the resolver records skipped alternates; `CONTEXT_NO_REQUIREMENTS_FOR_SELECTOR` (warning) when a non-workspace selector matches zero LLRs; `CONTEXT_SELECTOR_OUT_OF_SCOPE` (error) when the resolver rejects an unrecognized input; -`CONTEXT_NO_TRACE_CONFIGURED` (info) when `cert/trace/` is missing. -A golden fixture at +`CONTEXT_NO_TRACE_CONFIGURED` (info) when `cert/trace/` is missing; +`CONTEXT_RUNTIME_ERROR` (error) for the runtime / I/O variants of +`ContextError` (unreadable `Cargo.toml`, `canonicalize` failure on a +parent dir, underlying trace TOML read error) — distinct from +`CONTEXT_SELECTOR_OUT_OF_SCOPE` so an agent can tell user-typo from +tool-fault. A golden fixture at `crates/cargo-evidence/tests/fixtures/golden_context.json` is byte- diffed by the integration test, regenerated via `tools/regen-golden-fixtures.sh`. Drift in the wire shape fires the @@ -2566,6 +2570,7 @@ emits = [ "CONTEXT_AMBIGUOUS_SELECTOR", "CONTEXT_NO_REQUIREMENTS_FOR_SELECTOR", "CONTEXT_NO_TRACE_CONFIGURED", + "CONTEXT_RUNTIME_ERROR", "CONTEXT_SELECTOR_OUT_OF_SCOPE", ] diff --git a/cert/trace/tests.toml b/cert/trace/tests.toml index ed1d322..892b17b 100644 --- a/cert/trace/tests.toml +++ b/cert/trace/tests.toml @@ -1318,7 +1318,9 @@ id = "TEST-086" title = "every workspace crate ships a lean-layered CLAUDE.md" owner = "tool" traces_to = ["288c920a-c667-4f84-9d0b-f4e8419ac141"] -test_selector = "layered_claude_md_doctrine::every_workspace_crate_has_lean_layered_claude_md" +test_selectors = [ + "layered_claude_md_doctrine::every_workspace_crate_has_lean_layered_claude_md", +] description = """ Walks crates/* and accumulates per-crate violations: missing CLAUDE.md, fewer than 10 non-blank lines, more than 80 lines, or diff --git a/crates/cargo-evidence/tests/fixtures/golden_context.json b/crates/cargo-evidence/tests/fixtures/golden_context.json index dfea38e..089f273 100644 --- a/crates/cargo-evidence/tests/fixtures/golden_context.json +++ b/crates/cargo-evidence/tests/fixtures/golden_context.json @@ -587,7 +587,7 @@ "uid": "ca6f202f-8957-4ba3-adb5-fbc7054e9197", "layer": "llr", "title": "context content codes register in RULES and gate the golden wire shape", - "description": "The four content-level `CONTEXT_*` codes are registered in\n`evidence_core::RULES` under the `Context` domain. The CLI's\n`cmd_context` emits each at the right point: `CONTEXT_AMBIGUOUS_SELECTOR`\n(warning) when the resolver records skipped alternates;\n`CONTEXT_NO_REQUIREMENTS_FOR_SELECTOR` (warning) when a non-workspace\nselector matches zero LLRs; `CONTEXT_SELECTOR_OUT_OF_SCOPE` (error)\nwhen the resolver rejects an unrecognized input;\n`CONTEXT_NO_TRACE_CONFIGURED` (info) when `cert/trace/` is missing.\nA golden fixture at\n`crates/cargo-evidence/tests/fixtures/golden_context.json` is byte-\ndiffed by the integration test, regenerated via\n`tools/regen-golden-fixtures.sh`. Drift in the wire shape fires the\nfixture diff with a line-numbered error.\n", + "description": "The five content-level `CONTEXT_*` codes are registered in\n`evidence_core::RULES` under the `Context` domain. The CLI's\n`cmd_context` emits each at the right point: `CONTEXT_AMBIGUOUS_SELECTOR`\n(warning) when the resolver records skipped alternates;\n`CONTEXT_NO_REQUIREMENTS_FOR_SELECTOR` (warning) when a non-workspace\nselector matches zero LLRs; `CONTEXT_SELECTOR_OUT_OF_SCOPE` (error)\nwhen the resolver rejects an unrecognized input;\n`CONTEXT_NO_TRACE_CONFIGURED` (info) when `cert/trace/` is missing;\n`CONTEXT_RUNTIME_ERROR` (error) for the runtime / I/O variants of\n`ContextError` (unreadable `Cargo.toml`, `canonicalize` failure on a\nparent dir, underlying trace TOML read error) — distinct from\n`CONTEXT_SELECTOR_OUT_OF_SCOPE` so an agent can tell user-typo from\ntool-fault. A golden fixture at\n`crates/cargo-evidence/tests/fixtures/golden_context.json` is byte-\ndiffed by the integration test, regenerated via\n`tools/regen-golden-fixtures.sh`. Drift in the wire shape fires the\nfixture diff with a line-numbered error.\n", "modules": [ "evidence_core::context::lookup", "evidence_core::context::error", @@ -597,6 +597,7 @@ "CONTEXT_AMBIGUOUS_SELECTOR", "CONTEXT_NO_REQUIREMENTS_FOR_SELECTOR", "CONTEXT_NO_TRACE_CONFIGURED", + "CONTEXT_RUNTIME_ERROR", "CONTEXT_SELECTOR_OUT_OF_SCOPE" ], "traces_to": [ @@ -605,6 +606,24 @@ "verification_methods": [ "test" ] + }, + { + "id": "LLR-090", + "uid": "b402bcc1-f3bb-4fb5-a616-c3e18ffd321a", + "layer": "llr", + "title": "write_agent_context_files emits root CLAUDE.md + .claude/settings.json", + "description": "`cmd_init` accepts an `agent_context: bool` parameter derived from\nthe clap flag pair `--with-agent-context` / `--no-agent-context`\n(mutually exclusive via `conflicts_with`; default on). When true,\nthe parent dispatches to `write_agent_context_files(root, jsonl)`\nwhich:\n - writes a starter `CLAUDE.md` at the root (lean ≤30 lines,\n rendered by `render_root_claude_md`) — title naming the\n project + `evidence_context` / `cargo evidence context`\n pointer + per-crate-CLAUDE.md guidance, no project rules;\n - writes `.claude/settings.json` registering `evidence-mcp` as\n an MCP server and adding a `permissions.deny` entry for\n `evidence/**` (the default output dir).\nBoth writes are skipped when the target file already exists; the\nexisting file is never clobbered. When `.claude/settings.json`\nalready exists, a single info line on stderr explains the merge\nthe user would do by hand. Each successful write goes through\n`emit_template_written` so JSONL consumers see one\n`INIT_TEMPLATE_WRITTEN` per file in the same stream as the\n`cert/` emissions, terminating with the standard `INIT_OK`.\n", + "modules": [ + "cargo_evidence::cli::init::agent_context::write_agent_context_files", + "cargo_evidence::cli::init::cmd_init" + ], + "emits": [], + "traces_to": [ + "e93c01e2-b5e8-48f4-94ee-e08c139245ae" + ], + "verification_methods": [ + "test" + ] } ], "parents": [ @@ -833,6 +852,15 @@ "d8d1f204-3909-418d-b62a-1e28edd088ed" ] }, + { + "id": "HLR-075", + "uid": "e93c01e2-b5e8-48f4-94ee-e08c139245ae", + "layer": "hlr", + "title": "cargo evidence init --with-agent-context scaffolds downstream CLAUDE.md", + "traces_to": [ + "d8d1f204-3909-418d-b62a-1e28edd088ed" + ] + }, { "id": "SYS-002", "uid": "0e42b90f-88d7-408f-8843-0a3335279360", @@ -1230,6 +1258,21 @@ "b1377fb5-f879-4edc-9c50-46c5e67c71ad" ] }, + { + "id": "TEST-097", + "uid": "f563389d-b209-4258-a482-7508c11f87aa", + "name": "init --with-agent-context scaffolds CLAUDE.md + .claude/settings.json", + "selectors": [ + "init_agent_context::init_no_agent_context_skips_scaffold", + "init_agent_context::init_rejects_both_flags", + "init_agent_context::init_with_agent_context_jsonl_lists_new_files", + "init_agent_context::init_with_agent_context_preserves_existing_files", + "init_agent_context::init_with_agent_context_writes_root_files" + ], + "traces_to": [ + "b402bcc1-f3bb-4fb5-a616-c3e18ffd321a" + ] + }, { "id": "TEST-015", "uid": "c818cd84-8d25-457e-b18e-a613c691964d", @@ -1372,6 +1415,7 @@ "CONTEXT_NO_REQUIREMENTS_FOR_SELECTOR", "CONTEXT_NO_TRACE_CONFIGURED", "CONTEXT_OK", + "CONTEXT_RUNTIME_ERROR", "CONTEXT_SELECTOR_OUT_OF_SCOPE", "COVERAGE_BELOW_THRESHOLD", "COVERAGE_LLVMCOV_MISSING", @@ -1430,7 +1474,7 @@ { "dimension": "test_count", "kind": "per_crate_floor", - "current": 158, + "current": 166, "floor": 158 } ], diff --git a/crates/cargo-evidence/tests/fixtures/golden_rules.json b/crates/cargo-evidence/tests/fixtures/golden_rules.json index afdda7c..b0b4660 100644 --- a/crates/cargo-evidence/tests/fixtures/golden_rules.json +++ b/crates/cargo-evidence/tests/fixtures/golden_rules.json @@ -237,6 +237,13 @@ "has_fix_hint": false, "terminal": true }, + { + "code": "CONTEXT_RUNTIME_ERROR", + "severity": "error", + "domain": "context", + "has_fix_hint": false, + "terminal": false + }, { "code": "CONTEXT_SELECTOR_OUT_OF_SCOPE", "severity": "error", diff --git a/crates/evidence-core/src/context/error.rs b/crates/evidence-core/src/context/error.rs index 8876f0c..4518aaf 100644 --- a/crates/evidence-core/src/context/error.rs +++ b/crates/evidence-core/src/context/error.rs @@ -63,19 +63,27 @@ pub enum ContextError { impl ContextError { /// Pick the `CONTEXT_*` content code the CLI emits for this /// variant. Kept outside the [`DiagnosticCode`] trait because the - /// IO / manifest variants share a code (`CONTEXT_SELECTOR_OUT_OF_SCOPE`) - /// and a trait impl with multiple match arms returning the same - /// string would trip the Schema Rule 3 uniqueness check. + /// runtime variants (`TraceRead`, `Io`, `CargoManifestRead`, + /// `CargoManifestParse`) share a single content code + /// (`CONTEXT_RUNTIME_ERROR`); a trait impl with multiple match + /// arms returning the same string would trip the Schema Rule 3 + /// uniqueness check. + /// + /// Runtime / I/O faults are deliberately distinct from + /// `CONTEXT_SELECTOR_OUT_OF_SCOPE` so an agent parsing the JSONL + /// stream can tell the difference between a typo'd selector + /// (user fixable) and a tool-side failure (e.g. unreadable + /// `Cargo.toml`, missing permissions). /// /// [`DiagnosticCode`]: crate::diagnostic::DiagnosticCode pub fn content_code(&self) -> &'static str { match self { ContextError::SelectorOutOfScope(_) => "CONTEXT_SELECTOR_OUT_OF_SCOPE", ContextError::TraceNotConfigured(_) => "CONTEXT_NO_TRACE_CONFIGURED", - ContextError::TraceRead(_) => "CONTEXT_SELECTOR_OUT_OF_SCOPE", - ContextError::Io(_) + ContextError::TraceRead(_) + | ContextError::Io(_) | ContextError::CargoManifestRead { .. } - | ContextError::CargoManifestParse { .. } => "CONTEXT_SELECTOR_OUT_OF_SCOPE", + | ContextError::CargoManifestParse { .. } => "CONTEXT_RUNTIME_ERROR", } } } diff --git a/crates/evidence-core/src/context/resolver.rs b/crates/evidence-core/src/context/resolver.rs index 505ed72..2c5975b 100644 --- a/crates/evidence-core/src/context/resolver.rs +++ b/crates/evidence-core/src/context/resolver.rs @@ -143,7 +143,7 @@ pub fn resolve_selector( let crates = discover_workspace_crates(workspace_root)?; let mut ambiguities: Vec = Vec::new(); - let file_match = match_file(workspace_root, trimmed, &crates); + let file_match = match_file(workspace_root, trimmed, &crates)?; let crate_match = match_crate(trimmed, &crates); let module_match = match_module(trimmed); @@ -184,14 +184,20 @@ pub fn resolve_selector( /// Resolve a candidate file path under the workspace. /// /// Accepts absolute paths (must live under `workspace_root`) and -/// workspace-relative paths. Returns `(rel_path, crate_name)` if the -/// path resolves to a file under `crates//...`; `None` -/// otherwise. +/// workspace-relative paths. Returns `Ok(Some((rel_path, crate_name)))` +/// if the path resolves to a file under `crates//...`, +/// `Ok(None)` if the path simply isn't a file (selector didn't match +/// as a file — caller should try the next kind), or `Err(ContextError::Io)` +/// if `canonicalize` failed (a real runtime fault — permission denied +/// on a parent dir, symlink to nonexistent target). Surfacing the I/O +/// error as `CONTEXT_RUNTIME_ERROR` rather than silent +/// `CONTEXT_SELECTOR_OUT_OF_SCOPE` keeps the agent from mistaking a +/// tool-side fault for a user typo. fn match_file( workspace_root: &Path, input: &str, crates: &BTreeMap, -) -> Option<(String, String)> { +) -> Result, ContextError> { let candidate = PathBuf::from(input); let abs = if candidate.is_absolute() { candidate @@ -199,14 +205,17 @@ fn match_file( workspace_root.join(&candidate) }; if !abs.is_file() { - return None; + return Ok(None); } - let canon_root = workspace_root.canonicalize().ok()?; - let canon_abs = abs.canonicalize().ok()?; - let rel = canon_abs.strip_prefix(&canon_root).ok()?; + let canon_root = workspace_root.canonicalize()?; + let canon_abs = abs.canonicalize()?; + let Ok(rel) = canon_abs.strip_prefix(&canon_root) else { + // File exists but lives outside the workspace (e.g. absolute + // path pointing at /etc/passwd). Not a file-kind match. + return Ok(None); + }; let rel_str = rel.to_string_lossy().replace('\\', "/"); - let crate_name = crate_for_relative_path(&rel_str, crates)?; - Some((rel_str, crate_name)) + Ok(crate_for_relative_path(&rel_str, crates).map(|crate_name| (rel_str, crate_name))) } /// Match a crate-name selector against the discovered crate set. diff --git a/crates/evidence-core/src/context/tests.rs b/crates/evidence-core/src/context/tests.rs index 0d43d69..c5259b8 100644 --- a/crates/evidence-core/src/context/tests.rs +++ b/crates/evidence-core/src/context/tests.rs @@ -270,3 +270,33 @@ fn selector_out_of_scope_preserves_raw_input() { other => panic!("expected SelectorOutOfScope, got {:?}", other), } } + +/// Runtime / I/O variants of `ContextError` map to +/// `CONTEXT_RUNTIME_ERROR`, distinct from the user-facing +/// `CONTEXT_SELECTOR_OUT_OF_SCOPE`. Conflating the two would mislead +/// the agent: a missing manifest is a tool-side fault, not a typo'd +/// selector. Pinning the mapping here so a future enum edit can't +/// silently re-conflate them. +#[test] +fn runtime_error_variants_map_to_distinct_content_code() { + use std::io; + let io_err = ContextError::Io(io::Error::other("disk gone")); + assert_eq!(io_err.content_code(), "CONTEXT_RUNTIME_ERROR"); + + let manifest_read = ContextError::CargoManifestRead { + path: std::path::PathBuf::from("crates/foo/Cargo.toml"), + err: io::Error::other("read"), + }; + assert_eq!(manifest_read.content_code(), "CONTEXT_RUNTIME_ERROR"); + + let manifest_parse = ContextError::CargoManifestParse { + path: std::path::PathBuf::from("crates/foo/Cargo.toml"), + err: toml::from_str::("[unterminated").unwrap_err(), + }; + assert_eq!(manifest_parse.content_code(), "CONTEXT_RUNTIME_ERROR"); + + // User-fixable selector errors must not collapse into the runtime + // bucket. + let oos = ContextError::SelectorOutOfScope("bogus".into()); + assert_eq!(oos.content_code(), "CONTEXT_SELECTOR_OUT_OF_SCOPE"); +} diff --git a/crates/evidence-core/src/rules.rs b/crates/evidence-core/src/rules.rs index edc0f0a..a9f7711 100644 --- a/crates/evidence-core/src/rules.rs +++ b/crates/evidence-core/src/rules.rs @@ -151,6 +151,7 @@ pub const RULES: &[RuleEntry] = &[ context("CONTEXT_NO_REQUIREMENTS_FOR_SELECTOR", Severity::Warning), context("CONTEXT_NO_TRACE_CONFIGURED", Severity::Info), terminal("CONTEXT_OK", Severity::Info), + context("CONTEXT_RUNTIME_ERROR", Severity::Error), context("CONTEXT_SELECTOR_OUT_OF_SCOPE", Severity::Error), r( "COVERAGE_BELOW_THRESHOLD", diff --git a/docs/superpowers/specs/2026-05-19-agent-context-from-evidence-design.md b/docs/superpowers/specs/2026-05-19-agent-context-from-evidence-design.md index bbe1345..acdddae 100644 --- a/docs/superpowers/specs/2026-05-19-agent-context-from-evidence-design.md +++ b/docs/superpowers/specs/2026-05-19-agent-context-from-evidence-design.md @@ -315,9 +315,10 @@ least one LLR's `emits` list — per `diagnostic_codes_locked`. | `CONTEXT_FAIL` | Error | terminal | Selector invalid or no resolution possible. | | `CONTEXT_ERROR` | Error | terminal | Runtime failure (trace files unreadable, etc.). | | `CONTEXT_NO_REQUIREMENTS_FOR_SELECTOR` | Warning | content | Selector resolved but matches zero requirements (signal: untraced module). | -| `CONTEXT_SELECTOR_OUT_OF_SCOPE` | Error | content | Selector resolves outside the workspace. | +| `CONTEXT_SELECTOR_OUT_OF_SCOPE` | Error | content | Selector resolves outside the workspace (user-fixable typo). | | `CONTEXT_NO_TRACE_CONFIGURED` | Info | content | `cert/trace/` missing — non-adopter graceful path. | | `CONTEXT_AMBIGUOUS_SELECTOR` | Warning | content | Input matched multiple kinds; resolver picked the highest-priority one. | +| `CONTEXT_RUNTIME_ERROR` | Error | content | Tool-side I/O fault (unreadable `Cargo.toml`, `canonicalize` failure, underlying trace TOML read error). Distinct from `CONTEXT_SELECTOR_OUT_OF_SCOPE` so agents can tell user-typo from tool-fault. | ## 7. Tests From 60a7ee646b8445a3f139df1dded98cc90bad1570 Mon Sep 17 00:00:00 2001 From: sokoly Date: Wed, 20 May 2026 17:13:17 -0400 Subject: [PATCH 6/8] fix(floors): bump per-crate test_count to match post-merge counts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cert/floors.toml had slack on two dimensions after the four-PR merge: - per_crate.cargo-evidence test_count: 158 → 166 (PR 2's golden-fixture + cli_context tests, PR 4's init_agent_context tests). - per_crate.evidence-core test_count: 383 → 392 (PR 1's layered_claude_md_doctrine, PR 2's context::tests). The floors_equal_current_no_slack gate enforces floor == current so a later PR can't delete tests along a ratcheted dimension without firing the gate. Bumping both to the post-merge measurements closes the slack. Co-Authored-By: Claude Opus 4.7 (1M context) --- cert/floors.toml | 4 ++-- crates/cargo-evidence/tests/fixtures/golden_context.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cert/floors.toml b/cert/floors.toml index f9d5ec4..bff037f 100644 --- a/cert/floors.toml +++ b/cert/floors.toml @@ -79,10 +79,10 @@ known_surfaces = 26 [per_crate.evidence-core] # `#[test]` attribute count inside crates/evidence-core/**/*.rs. -test_count = 383 +test_count = 392 [per_crate.cargo-evidence] -test_count = 158 +test_count = 166 [per_crate.evidence-mcp] test_count = 55 diff --git a/crates/cargo-evidence/tests/fixtures/golden_context.json b/crates/cargo-evidence/tests/fixtures/golden_context.json index 089f273..3848059 100644 --- a/crates/cargo-evidence/tests/fixtures/golden_context.json +++ b/crates/cargo-evidence/tests/fixtures/golden_context.json @@ -1475,7 +1475,7 @@ "dimension": "test_count", "kind": "per_crate_floor", "current": 166, - "floor": 158 + "floor": 166 } ], "boundary": { From ada31ba812ee5c3e2ca638e2b6bf90c520b99bcc Mon Sep 17 00:00:00 2001 From: sokoly Date: Thu, 21 May 2026 08:18:57 -0400 Subject: [PATCH 7/8] =?UTF-8?q?fix(floors):=20evidence-core=20test=5Fcount?= =?UTF-8?q?=20392=20=E2=86=92=20384=20(match=20CI=20measurement)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI on three hosts reported: DOCTOR_FLOORS_VIOLATED: floors breached: evidence-core/test_count current=384 floor=392 The discrepancy is an 8-test untracked file (crates/evidence-core/src/policy/boundary/tests.rs, 227 lines) in the local working tree but not on the remote. The 392 floor I set in 60a7ee6 was based on the local measurement; CI sees 384 without the untracked file. Dropping the floor to 384 matches the real, mergeable state. The golden_context.json fixture is unchanged: it captures cargo-evidence's per-crate slice (test_count = 166), not evidence-core's, so the 392 → 384 change here has no fixture impact. (Supersedes da5118d, which mistakenly bundled an empty golden_context.json from a backgrounded regen redirect.) Co-Authored-By: Claude Opus 4.7 (1M context) --- cert/floors.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cert/floors.toml b/cert/floors.toml index bff037f..c8223b6 100644 --- a/cert/floors.toml +++ b/cert/floors.toml @@ -79,7 +79,7 @@ known_surfaces = 26 [per_crate.evidence-core] # `#[test]` attribute count inside crates/evidence-core/**/*.rs. -test_count = 392 +test_count = 384 [per_crate.cargo-evidence] test_count = 166 From 4ee5358167e00da0b91cb956bd2cc02bc4966b43 Mon Sep 17 00:00:00 2001 From: sokoly Date: Thu, 21 May 2026 08:31:12 -0400 Subject: [PATCH 8/8] fix(gitattributes): force LF on cert/**/*.toml so Windows CI doesn't embed CRLF MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows Check (windows-latest) failed: context --json diverged from golden at line 15: current: "description": "...\r\nstrict-signature-missing...\r\n", golden: "description": "...\nstrict-signature-missing...\n", Root cause: cert/trace/*.toml carries multi-line `description = """ ... """` blocks. The CLI parses them into strings and embeds those strings in the JSON response. On Windows with core.autocrlf=true (default), git checks the TOML files out with CRLF; the CLI then emits "\\r\\n" in the JSON, breaking the byte-diff against the LF golden_context.json fixture (which is already pinned `binary` in .gitattributes). Fix: pin every cert/**/*.toml to LF on checkout. Covers boundary.toml, floors.toml, profiles/*, and trace/*.toml — anything the tool reads into its serialized output. Matches the existing precedent for Cargo.lock + rust-toolchain.toml. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitattributes | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.gitattributes b/.gitattributes index dc28cc5..265f12d 100644 --- a/.gitattributes +++ b/.gitattributes @@ -26,3 +26,12 @@ rust-toolchain.toml text eol=lf # and trip the byte-diff tests. crates/cargo-evidence/tests/fixtures/golden_rules.json binary crates/cargo-evidence/tests/fixtures/golden_context.json binary + +# Certification config (boundary, profiles, floors, trace/*.toml) is +# read by `cargo evidence context --json` and its multi-line +# `description = """ ... """` blocks flow through into the JSON +# response. On Windows with `core.autocrlf=true`, git checks these +# out with CRLF and the CLI embeds `\r\n` in the JSON string values, +# breaking the byte-diff against the LF golden fixture. Force LF on +# checkout so cross-OS contributors see identical bytes. +cert/**/*.toml text eol=lf