From 6a2a474858a472de2a37211eceaf48cdad00e424 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sat, 8 Aug 2026 13:07:59 +0200 Subject: [PATCH 1/6] feat(intent): add the standalone checker crate and the CI that gates the corpus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lifts `flakes/axe/src/vrs.rs` out of schickling/dotfiles into a standalone package at `crates/intent/`, exposing both the `intent` binary and a library whose `run` entry point `axe vrs` calls directly. Wires the two checks that previously lived in dotfiles onto this repo's own CI, kept as separately-named jobs rather than collapsed behind a single gate, so a run shows which one concluded and how. Both checks are written against the way this class of gate fails silently. `intent check` exits 0 on an empty directory and on a directory holding no VRS artifacts, so "the check passed" cannot on its own distinguish a healthy corpus from a missing one. `corpus-strict` therefore asserts the corpus exists before checking, and afterwards asserts the derived graph is non-empty — the graph is what discriminates, being empty for both an empty directory and a wrong path. `semantic-review-fixtures` likewise fails when it finds zero fixtures, since a loop that is green over nothing is the same silent pass. `crates/intent` is a standalone package with no workspace root above it, so cargo writes to `crates/intent/target/` and not to `./target/`. The corpus check invokes the built binary by path from the checkout root, so it passes `--target-dir` explicitly instead of assuming either location. Enforcement assets now resolve under the corpus root rather than the enclosing repository, so a corpus without its own `16-enforcement/` fails instead of quietly borrowing the repository's copies and being graded against a rubric it never declared. The error names both the missing asset and the corpus root; `crates/intent/README.md` documents the behavior. Verified locally against this corpus from a clean checkout: strict check reports 0 diagnostics, the graph is 246 nodes / 215 edges, all 5 fixtures validate against the enforcement schema, and the crate's 8 tests pass. Co-Authored-By: Claude Opus 5 agent-tool: Claude Code agent-tool-version: 2.1.220 agent-runtime: Claude Code 2.1.220 agent-session-lookup: unavailable tooling-profile: dotfiles@unknown-dirty --- .github/workflows/ci.yml | 114 ++ .gitignore | 8 + crates/intent/Cargo.lock | 339 +++++ crates/intent/Cargo.toml | 22 + crates/intent/README.md | 64 + crates/intent/rust-toolchain.toml | 3 + crates/intent/src/lib.rs | 2322 +++++++++++++++++++++++++++++ crates/intent/src/main.rs | 9 + 8 files changed, 2881 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 crates/intent/Cargo.lock create mode 100644 crates/intent/Cargo.toml create mode 100644 crates/intent/README.md create mode 100644 crates/intent/rust-toolchain.toml create mode 100644 crates/intent/src/lib.rs create mode 100644 crates/intent/src/main.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ddec9ef --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,114 @@ +name: ci + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + # Answers one question only: does this repository materialise jobs at all? + # A run object can exist with zero jobs — that is how dotfiles' CI sat "queued" + # for a month unnoticed — so the cheapest possible job runs first and proves it. + materialises: + runs-on: ubuntu-latest + steps: + - run: echo "jobs materialise in this repository" + + crate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: cargo build --locked --manifest-path crates/intent/Cargo.toml + - run: cargo test --locked --manifest-path crates/intent/Cargo.toml + + # Port of dotfiles' `axe-vrs-context-strict`. + corpus-strict: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + # `crates/intent` is a standalone package with no workspace root, so cargo + # writes to `crates/intent/target/` and the steps below would not find the + # binary at `./target/`. `--target-dir` pins the output next to the checkout + # root regardless of whether a workspace root ever appears above the crate. + - run: cargo build --locked --release --manifest-path crates/intent/Cargo.toml --target-dir target + + - name: corpus is present + # `intent check` exits 0 on an empty directory and on a directory holding no + # VRS artifacts, so "the check passed" cannot by itself distinguish a healthy + # corpus from a missing one. Fail on absence explicitly, before checking. + run: | + set -euo pipefail + test -d intent || { echo "::error::corpus directory 'intent/' does not exist"; exit 1; } + + - name: strict check reports no diagnostics + run: | + set -euo pipefail + ./target/release/intent check intent --profile strict --json > report.json || { + cat report.json >&2; exit 1; + } + jq -e ' + .schema_version == "axe.vrs.check.v1" + and .profile == "strict" + and (.diagnostics | length) == 0 + ' report.json > /dev/null + + - name: check actually read the corpus + # The assertion above is satisfied by a run against a path containing nothing, + # so on its own it cannot tell "corpus is clean" from "corpus is not there". + # The graph is what discriminates: it is empty for both an empty directory and + # a wrong path, and non-empty only when artifacts were genuinely read. + run: | + set -euo pipefail + ./target/release/intent graph intent --json > graph.json + nodes="$(jq '.nodes | length' graph.json)" + echo "graph nodes: $nodes" + jq -e '(.nodes | length) > 0' graph.json > /dev/null \ + || { echo "::error::strict check examined 0 artifacts — wrong path or empty corpus"; exit 1; } + + # Port of dotfiles' `vrs-semantic-review-fixtures`. + semantic-review-fixtures: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: pipx install check-jsonschema + + - name: fixtures and the enforcement schema are present + run: | + set -euo pipefail + fixtures="intent/15-evaluation/semantic-review" + schema="intent/16-enforcement/review-result.schema.json" + test -d "$fixtures" || { echo "::error::missing fixtures root: $fixtures"; exit 1; } + test -f "$fixtures/fixture-format.md" || { echo "::error::missing fixture-format.md"; exit 1; } + test -f "$schema" || { echo "::error::missing review-result schema: $schema"; exit 1; } + + - name: every fixture validates against the enforcement schema + # Fully offline: every schema is a local file, so none is ever fetched. + run: | + set -euo pipefail + fixtures="intent/15-evaluation/semantic-review" + schema="intent/16-enforcement/review-result.schema.json" + found=0 + for fixture in "$fixtures"/*/; do + [ -d "$fixture" ] || continue + found=$((found + 1)) + name="$(basename "$fixture")" + test -f "$fixture/expected-review.json" \ + || { echo "::error::$name: missing expected-review.json"; exit 1; } + check-jsonschema --no-cache --schemafile "$schema" "$fixture/expected-review.json" \ + || { echo "::error::$name: expected-review.json does not satisfy the enforcement schema"; exit 1; } + # A fixture expecting no finding cannot protect any review behavior. + jq -e '.findings | length > 0' "$fixture/expected-review.json" > /dev/null \ + || { echo "::error::$name: expected-review.json must contain at least one expected finding"; exit 1; } + done + # Without this the loop is green over zero fixtures, which is the same + # silent-pass this whole file exists to prevent. + [ "$found" -gt 0 ] || { echo "::error::no semantic-review fixtures found under $fixtures"; exit 1; } + echo "fixtures validated: $found" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ddfd60a --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +# Cargo build output. Unanchored, so it matches both a repo-root `target/` and +# `crates/intent/target/` — which is where cargo writes unless `--target-dir` says +# otherwise, since the crate is a standalone package with no workspace root. +target/ + +# Artifacts the CI steps write into the checkout root while running. +/report.json +/graph.json diff --git a/crates/intent/Cargo.lock b/crates/intent/Cargo.lock new file mode 100644 index 0000000..33c4b17 --- /dev/null +++ b/crates/intent/Cargo.lock @@ -0,0 +1,339 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "intent" +version = "0.1.0" +dependencies = [ + "clap", + "serde", + "serde_json", + "tempfile", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/crates/intent/Cargo.toml b/crates/intent/Cargo.toml new file mode 100644 index 0000000..509c5c0 --- /dev/null +++ b/crates/intent/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "intent" +version = "0.1.0" +edition = "2021" +description = "Deterministic checks, graph extraction and semantic review for a VRS corpus" + +[[bin]] +name = "intent" +path = "src/main.rs" + +[lib] +name = "intent" +path = "src/lib.rs" + +[dependencies] +clap = { version = "4.5", features = ["derive", "env"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +# Runtime, not just tests: `review` stages the diagnostics packet and the CAIC +# workspace in scratch directories (lib.rs:366,417,560). Reached fully qualified, +# which is why it is absent from the `use` block at the top of the module. +tempfile = "3.10" diff --git a/crates/intent/README.md b/crates/intent/README.md new file mode 100644 index 0000000..d43ab23 --- /dev/null +++ b/crates/intent/README.md @@ -0,0 +1,64 @@ +# intent + +Deterministic checks, graph extraction, and semantic review for a VRS corpus. + +The crate ships both a binary (`intent`) and a library. `axe vrs` embeds the +library and calls `intent::run` directly, so the binary is a thin shell over the +same entry point — anything that lived only in `main.rs` would be behavior the +embedded caller silently does not get. + +## Commands + +| Command | What it does | +| ------------------------ | ---------------------------------------------------------------- | +| `intent check ` | Deterministic VRS checks. `--profile strict\|local`, `--json`. | +| `intent graph ` | Emits the derived VRS graph subset as JSON. | +| `intent review ` | Semantic review via the Coding Agent Invocation Contract. | +| `intent review-fixtures` | Grades semantic review against evaluation-fixture assertions. | + +## Enforcement assets resolve under the corpus, never the repository + +`review` reads two assets from the filesystem at runtime: + +``` +/16-enforcement/review-prompt.md +/16-enforcement/review-result.schema.json +``` + +Both are resolved **relative to the corpus root** given on the command line, not +relative to the enclosing repository. A corpus that moves takes its enforcement +assets with it, which is what keeps tool and corpus co-located. + +The consequence is deliberate and is a behavior change from earlier versions: a +corpus that has no `16-enforcement/` of its own **fails** rather than quietly +borrowing the enclosing repository's copies. Silently falling back produced a +review graded against a rubric the corpus never declared — passing for reasons +its own contents could not account for. + +The failure is exit code `2` on stderr, and it names both the asset that is +missing and the corpus it was missing from: + +```console +$ intent review ./some-corpus +axe vrs review: missing review asset 16-enforcement/review-prompt.md under corpus /abs/path/to/some-corpus +``` + +Naming both matters: the asset alone does not say which of several corpora was +searched, and the root alone does not say what it was expected to contain. + +`check` and `graph` do not read these assets and are unaffected. + +## Layout note + +`crates/intent` is a standalone package — it has its own `Cargo.lock` and there +is no workspace root above it. Cargo therefore writes build output to +`crates/intent/target/`, not to `./target/`. Anything invoking the built binary +by path from the repository root should pass `--target-dir` explicitly rather +than assume either location; `.github/workflows/ci.yml` does exactly that. + +## Development + +```console +cargo build --locked --manifest-path crates/intent/Cargo.toml +cargo test --locked --manifest-path crates/intent/Cargo.toml +``` diff --git a/crates/intent/rust-toolchain.toml b/crates/intent/rust-toolchain.toml new file mode 100644 index 0000000..d2c25ba --- /dev/null +++ b/crates/intent/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "stable" +components = ["rustc", "cargo", "rustfmt", "clippy", "rust-src"] diff --git a/crates/intent/src/lib.rs b/crates/intent/src/lib.rs new file mode 100644 index 0000000..db0d522 --- /dev/null +++ b/crates/intent/src/lib.rs @@ -0,0 +1,2322 @@ +use clap::{Parser, Subcommand, ValueEnum}; +use serde::Serialize; +use serde_json::Value; +use std::collections::{BTreeSet, HashSet}; +use std::ffi::OsStr; +use std::fs; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Command, ExitCode}; + +/* Positions INSIDE a corpus, so they hold for any repository that adopts the + layout. Resolving the review assets relative to the corpus root rather than the + repository root is what keeps tool and corpus co-located: `review` reads both + from the filesystem at runtime, so a corpus that moves takes them with it. */ +const SEMANTIC_REVIEW_SUBDIR: &str = "15-evaluation/semantic-review"; +const REVIEW_PROMPT_ASSET: &str = "16-enforcement/review-prompt.md"; +const REVIEW_SCHEMA_ASSET: &str = "16-enforcement/review-result.schema.json"; + +#[derive(Parser, Debug)] +pub struct VrsCli { + #[command(subcommand)] + pub cmd: VrsCmd, +} + +#[derive(Subcommand, Debug)] +pub enum VrsCmd { + /// Run deterministic VRS checks. + Check(CheckArgs), + /// Emit the derived VRS graph subset. + Graph(GraphArgs), + /// Run semantic VRS review through the Coding Agent Invocation Contract. + Review(ReviewArgs), + /// Grade semantic review against evaluation-fixture minimum assertions. + ReviewFixtures(ReviewFixturesArgs), +} + +#[derive(Parser, Debug)] +pub struct CheckArgs { + /// VRS root to check. + pub root: Option, + + /// Rule profile to run. + #[arg(long, value_enum, default_value_t = Profile::Local)] + pub profile: Profile, + + /// Emit machine-readable diagnostics JSON. + #[arg(long)] + pub json: bool, + + /// Treat warnings as errors. + #[arg(long)] + pub warnings_as_errors: bool, +} + +#[derive(Parser, Debug)] +pub struct GraphArgs { + /// VRS root to graph. + pub root: Option, + + /// Emit machine-readable graph JSON. + #[arg(long)] + pub json: bool, +} + +#[derive(Parser, Debug)] +pub struct ReviewArgs { + /// VRS root to review. + pub root: Option, + + /// Rule profile to use for the deterministic diagnostics packet. + #[arg(long, value_enum, default_value_t = Profile::Local)] + pub profile: Profile, + + /// Coding Agent Invocation Contract executable. + #[arg(long, env = "CODING_AGENT", default_value = "coding-agent")] + pub coding_agent: PathBuf, + + /// CAIC backend id. + #[arg(long)] + pub backend: Option, + + /// Whole-run timeout in seconds. + #[arg(long)] + pub timeout_seconds: Option, + + /// Write the review envelope to a file instead of stdout. + #[arg(long)] + pub report: Option, +} + +#[derive(Parser, Debug)] +pub struct ReviewFixturesArgs { + /// Semantic-review fixture root. + pub root: Option, + + /// Fixture id to grade. Repeatable; defaults to every fixture under the root. + #[arg(long = "fixture")] + pub fixtures: Vec, + + /// Rule profile to use for the deterministic diagnostics packet. + #[arg(long, value_enum, default_value_t = Profile::Local)] + pub profile: Profile, + + /// Coding Agent Invocation Contract executable. + #[arg(long, env = "CODING_AGENT", default_value = "coding-agent")] + pub coding_agent: PathBuf, + + /// CAIC backend id. + #[arg(long)] + pub backend: Option, + + /// Per-fixture review timeout in seconds. + #[arg(long)] + pub timeout_seconds: Option, + + /// Emit machine-readable grading JSON. + #[arg(long)] + pub json: bool, + + /// Write the grading report to a file instead of stdout. + #[arg(long)] + pub report: Option, +} + +#[derive(Clone, Copy, Debug, ValueEnum)] +pub enum Profile { + Local, + Strict, +} + +#[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum Severity { + Error, + Warning, + Info, +} + +#[derive(Clone, Debug, Serialize)] +pub struct Diagnostic { + pub schema_version: &'static str, + pub kind: &'static str, + pub severity: Severity, + pub gate: &'static str, + pub artifact: String, + pub owner: String, + pub rule: &'static str, + pub evidence: String, + pub suggested_fix: String, +} + +#[derive(Debug, Serialize)] +pub struct CheckReport { + pub schema_version: &'static str, + pub root: String, + pub profile: String, + pub diagnostics: Vec, +} + +#[derive(Debug, Serialize)] +pub struct GraphReport { + pub schema_version: &'static str, + pub root: String, + pub nodes: Vec, + pub edges: Vec, +} + +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)] +pub struct GraphNode { + pub id: String, + pub kind: String, + pub title: String, + pub path: String, + pub status: String, + pub refs: Vec, + pub refines: Vec, + pub evidence: Vec, +} + +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)] +pub struct GraphEdge { + pub source: String, + pub target: String, + pub kind: String, + pub path: String, + pub evidence: String, +} + +/// Where to look when the caller gave no path. +/// +/// Which directory holds a corpus is the embedding repository's layout policy, not +/// something a reusable checker can know: `intent` defaults to the directory it is +/// run in, while `axe vrs` supplies dotfiles' own `context/vrs`. Baking one repo's +/// layout in here is what made the tool unusable anywhere else, and a wrong default +/// is invisible — checking a path that holds no VRS artifacts exits 0. +pub struct Defaults { + corpus_root: PathBuf, +} + +impl Defaults { + pub fn corpus_root(root: impl Into) -> Self { + Self { + corpus_root: root.into(), + } + } + + fn root_or_default(&self, arg: Option) -> PathBuf { + arg.unwrap_or_else(|| self.corpus_root.clone()) + } + + /* Fixtures live at a fixed position INSIDE the corpus, so one caller-supplied + corpus root determines both defaults and they cannot drift apart. */ + fn fixtures_or_default(&self, arg: Option) -> PathBuf { + arg.unwrap_or_else(|| self.corpus_root.join(SEMANTIC_REVIEW_SUBDIR)) + } +} + +impl Default for Defaults { + fn default() -> Self { + Self::corpus_root(".") + } +} + +pub fn run(cli: VrsCli) -> ExitCode { + run_with(cli, &Defaults::default()) +} + +/// `run` with the caller's layout policy. `axe vrs` uses this to keep its own +/// `context/vrs` default, so extracting this crate did not change its behavior. +pub fn run_with(cli: VrsCli, defaults: &Defaults) -> ExitCode { + match cli.cmd { + VrsCmd::Check(args) => run_check(args, defaults), + VrsCmd::Graph(args) => run_graph(args, defaults), + VrsCmd::Review(args) => run_review(args, defaults), + VrsCmd::ReviewFixtures(args) => run_review_fixtures(args, defaults), + } +} + +fn run_check(args: CheckArgs, defaults: &Defaults) -> ExitCode { + let root = defaults.root_or_default(args.root); + let report = match check_root(&root, args.profile) { + Ok(report) => report, + Err(error) => { + eprintln!("axe vrs check: {error}"); + return ExitCode::from(2); + } + }; + let has_errors = report.diagnostics.iter().any(|diagnostic| { + diagnostic.severity == Severity::Error + || (args.warnings_as_errors && diagnostic.severity == Severity::Warning) + }); + + if args.json { + match serde_json::to_string_pretty(&report) { + Ok(json) => println!("{json}"), + Err(error) => { + eprintln!("axe vrs check: failed to render json: {error}"); + return ExitCode::from(2); + } + } + } else if report.diagnostics.is_empty() { + println!("axe vrs check: ok"); + } else { + for diagnostic in &report.diagnostics { + println!( + "{} {} {}: {}", + severity_label(&diagnostic.severity), + diagnostic.rule, + diagnostic.artifact, + diagnostic.evidence + ); + } + } + + if has_errors { + ExitCode::from(1) + } else { + ExitCode::SUCCESS + } +} + +fn run_graph(args: GraphArgs, defaults: &Defaults) -> ExitCode { + let root = defaults.root_or_default(args.root); + let report = match graph_root(&root) { + Ok(report) => report, + Err(error) => { + eprintln!("axe vrs graph: {error}"); + return ExitCode::from(2); + } + }; + + if args.json { + match serde_json::to_string_pretty(&report) { + Ok(json) => println!("{json}"), + Err(error) => { + eprintln!("axe vrs graph: failed to render json: {error}"); + return ExitCode::from(2); + } + } + } else { + println!( + "axe vrs graph: {} nodes, {} edges", + report.nodes.len(), + report.edges.len() + ); + } + + ExitCode::SUCCESS +} + +fn run_review(args: ReviewArgs, defaults: &Defaults) -> ExitCode { + let args_root = defaults.root_or_default(args.root); + if let Some(indicator) = automated_context_indicator() { + eprintln!("axe vrs review: refusing semantic review in automated context ({indicator})"); + return ExitCode::from(2); + } + + if let Err(error) = preflight_review_backend(&args.coding_agent, args.backend.as_deref()) { + eprintln!("axe vrs review: {error}"); + return ExitCode::from(2); + } + + let root = match fs::canonicalize(&args_root) { + Ok(root) => root, + Err(error) => { + eprintln!( + "axe vrs review: invalid root {}: {error}", + args_root.display() + ); + return ExitCode::from(2); + } + }; + if !root.is_dir() { + eprintln!( + "axe vrs review: root is not a directory: {}", + root.display() + ); + return ExitCode::from(2); + } + let workspace = review_workspace(&root); + let prompt = match corpus_asset(&root, REVIEW_PROMPT_ASSET) { + Ok(path) => path, + Err(error) => { + eprintln!("axe vrs review: {error}"); + return ExitCode::from(2); + } + }; + let schema = match corpus_asset(&root, REVIEW_SCHEMA_ASSET) { + Ok(path) => path, + Err(error) => { + eprintln!("axe vrs review: {error}"); + return ExitCode::from(2); + } + }; + + let invocation = ReviewInvocation { + coding_agent: &args.coding_agent, + backend: args.backend.as_deref(), + timeout_seconds: args.timeout_seconds, + root: &root, + workspace: &workspace, + prompt: &prompt, + schema: &schema, + profile: args.profile, + final_output: args.report.as_deref(), + }; + let mut plan = match prepare_review(&invocation) { + Ok(plan) => plan, + Err(error) => { + eprintln!("axe vrs review: {}", error.message); + return ExitCode::from(error.exit_code); + } + }; + + let output = match plan.command.output() { + Ok(output) => output, + Err(error) => { + eprintln!( + "axe vrs review: failed to start CAIC executable {}: {error}", + args.coding_agent.display() + ); + return ExitCode::from(2); + } + }; + let _ = io::stdout().write_all(&output.stdout); + let _ = io::stderr().write_all(&output.stderr); + match output.status.code() { + Some(code) => ExitCode::from(code as u8), + None => ExitCode::from(3), + } +} + +struct ReviewInvocation<'a> { + coding_agent: &'a Path, + backend: Option<&'a str>, + timeout_seconds: Option, + /// VRS tree to review. + root: &'a Path, + /// Directory the coding agent runs in. Finding artifact paths are reported + /// relative to it, so it decides what an artifact path in a review result means. + workspace: &'a Path, + prompt: &'a Path, + schema: &'a Path, + profile: Profile, + final_output: Option<&'a Path>, +} + +struct ReviewPlan { + command: Command, + /// Holds the generated diagnostics packet alive until the command has run. + _diagnostics: tempfile::TempDir, +} + +struct ReviewSetupError { + message: String, + exit_code: u8, +} + +impl ReviewSetupError { + fn tool(message: impl Into) -> Self { + Self { + message: message.into(), + exit_code: 2, + } + } +} + +/// Build the CAIC invocation for one semantic review, including the deterministic +/// diagnostics packet. Shared by `axe vrs review` and `axe vrs review-fixtures` so +/// fixture grading exercises the same invocation the operator command uses. +fn prepare_review(invocation: &ReviewInvocation<'_>) -> Result { + let target_files = match markdown_files(invocation.root) { + Ok(files) if !files.is_empty() => files, + Ok(_) => { + return Err(ReviewSetupError { + message: format!( + "no markdown VRS artifacts found under {}", + invocation.root.display() + ), + exit_code: 1, + }) + } + Err(error) => { + return Err(ReviewSetupError::tool(format!( + "failed to collect VRS artifacts: {error}" + ))) + } + }; + if let Some(outside) = target_files + .iter() + .find(|path| !path.starts_with(invocation.workspace)) + { + return Err(ReviewSetupError::tool(format!( + "target artifact escapes review workspace {}: {}", + invocation.workspace.display(), + outside.display() + ))); + } + + let report = check_root(invocation.root, invocation.profile) + .map_err(|error| ReviewSetupError::tool(format!("deterministic check failed: {error}")))?; + let tempdir = tempfile::tempdir().map_err(|error| { + ReviewSetupError::tool(format!("failed to create diagnostics packet: {error}")) + })?; + let diagnostics_path = tempdir.path().join("axe-vrs-check.json"); + let diagnostics_packet = serde_json::json!({ + "producer": "axe vrs check --json", + "schema_version": report.schema_version, + "root": report.root, + "profile": report.profile, + "diagnostics": report.diagnostics, + }); + let rendered = serde_json::to_vec_pretty(&diagnostics_packet).map_err(|error| { + ReviewSetupError::tool(format!("failed to render diagnostics packet: {error}")) + })?; + fs::write(&diagnostics_path, rendered).map_err(|error| { + ReviewSetupError::tool(format!("failed to write diagnostics packet: {error}")) + })?; + + let mut command = Command::new(invocation.coding_agent); + command + .arg("run") + .arg("--cwd") + .arg(invocation.workspace) + .arg("--prompt-file") + .arg(invocation.prompt) + .arg("--mode") + .arg("review") + .arg("--permission") + .arg("read-only") + .arg("--approval") + .arg("never") + .arg("--config-policy") + .arg("isolated") + .arg("--network-policy") + .arg("disabled") + .arg("--output-format") + .arg("json") + .arg("--output-schema") + .arg(invocation.schema) + .arg("--context-file") + .arg(format!( + "generated-diagnostics:{}", + diagnostics_path.display() + )); + if let Some(backend) = invocation.backend { + command.arg("--backend").arg(backend); + } + if let Some(timeout_seconds) = invocation.timeout_seconds { + command + .arg("--timeout-seconds") + .arg(timeout_seconds.to_string()); + } + if let Some(final_output) = invocation.final_output { + command.arg("--final-output").arg(final_output); + } + for path in target_files { + command + .arg("--context-file") + .arg(format!("normative:{}", path.display())); + } + + Ok(ReviewPlan { + command, + _diagnostics: tempdir, + }) +} + +#[derive(Debug, Serialize)] +pub struct FixtureGradingReport { + pub schema_version: &'static str, + pub fixtures_root: String, + pub backend: Option, + pub fixtures: Vec, + pub passed: usize, + pub failed: usize, + pub errored: usize, + pub skipped: usize, +} + +#[derive(Debug, Serialize)] +pub struct FixtureGrade { + pub id: String, + /// `passed`, `failed` (assertions unmet), `errored` (review did not run), or + /// `skipped` (fixture declares no minimum assertions). + pub status: &'static str, + pub assertion_mode: Option, + pub matched: Vec, + pub missing: Vec, + pub reason: Option, +} + +/// The stable comparison contract from decision 0027: a review result must contain +/// a finding with this `rule`, `severity`, `artifact`, and `owner`. Summary, +/// evidence, and suggested-fix wording are deliberately not compared. +#[derive(Clone, Debug, Serialize, PartialEq, Eq)] +pub struct MinimumFinding { + pub rule: String, + pub severity: String, + pub artifact: String, + pub owner: String, +} + +fn run_review_fixtures(args: ReviewFixturesArgs, defaults: &Defaults) -> ExitCode { + let args_root = defaults.fixtures_or_default(args.root.clone()); + if let Some(indicator) = automated_context_indicator() { + eprintln!( + "axe vrs review-fixtures: refusing fixture review in automated context ({indicator})" + ); + return ExitCode::from(2); + } + + if let Err(error) = preflight_review_backend(&args.coding_agent, args.backend.as_deref()) { + eprintln!("axe vrs review-fixtures: {error}"); + return ExitCode::from(2); + } + + let root = match fs::canonicalize(&args_root) { + Ok(root) if root.is_dir() => root, + Ok(root) => { + eprintln!( + "axe vrs review-fixtures: fixtures root is not a directory: {}", + root.display() + ); + return ExitCode::from(2); + } + Err(error) => { + eprintln!( + "axe vrs review-fixtures: invalid fixtures root {}: {error}", + args_root.display() + ); + return ExitCode::from(2); + } + }; + + let selected = match select_fixtures(&root, &args.fixtures) { + Ok(selected) => selected, + Err(error) => { + eprintln!("axe vrs review-fixtures: {error}"); + return ExitCode::from(2); + } + }; + + // Decision 0024: eval runs materialize tracked fixtures into an isolated + // temporary workspace instead of running against the tracked tree. + let workspaces = match tempfile::tempdir() { + Ok(workspaces) => workspaces, + Err(error) => { + eprintln!("axe vrs review-fixtures: failed to create eval workspace: {error}"); + return ExitCode::from(2); + } + }; + + let mut grades = Vec::new(); + for fixture in &selected { + let id = fixture + .file_name() + .and_then(OsStr::to_str) + .unwrap_or_default() + .to_string(); + grades.push(grade_fixture(&args, fixture, &id, workspaces.path())); + } + + let passed = grades.iter().filter(|g| g.status == "passed").count(); + let failed = grades.iter().filter(|g| g.status == "failed").count(); + let errored = grades.iter().filter(|g| g.status == "errored").count(); + let skipped = grades.iter().filter(|g| g.status == "skipped").count(); + let report = FixtureGradingReport { + schema_version: "axe.vrs.review-fixtures.v1", + fixtures_root: root.display().to_string(), + backend: args.backend.clone(), + fixtures: grades, + passed, + failed, + errored, + skipped, + }; + + let json = match serde_json::to_string_pretty(&report) { + Ok(json) => json, + Err(error) => { + eprintln!("axe vrs review-fixtures: failed to render grading report: {error}"); + return ExitCode::from(2); + } + }; + if let Some(path) = &args.report { + if let Err(error) = fs::write(path, format!("{json}\n")) { + eprintln!( + "axe vrs review-fixtures: failed to write grading report {}: {error}", + path.display() + ); + return ExitCode::from(2); + } + } else if args.json { + println!("{json}"); + } + + if args.report.is_some() || !args.json { + print_fixture_grades(&report); + } + + if report.errored > 0 { + ExitCode::from(2) + } else if report.failed > 0 { + ExitCode::from(1) + } else { + ExitCode::SUCCESS + } +} + +fn print_fixture_grades(report: &FixtureGradingReport) { + for grade in &report.fixtures { + if grade.status == "passed" { + continue; + } + println!("{} {}", grade.status, grade.id); + if let Some(reason) = &grade.reason { + println!(" {reason}"); + } + for missing in &grade.missing { + println!( + " missing {} {} {} ({})", + missing.rule, missing.severity, missing.artifact, missing.owner + ); + } + } + println!( + "axe vrs review-fixtures: {} passed, {} failed, {} errored, {} skipped", + report.passed, report.failed, report.errored, report.skipped + ); +} + +fn select_fixtures(root: &Path, requested: &[String]) -> Result, String> { + let mut available = Vec::new(); + for entry in fs::read_dir(root) + .map_err(|error| format!("failed to read fixtures root {}: {error}", root.display()))? + { + let entry = entry.map_err(|error| format!("failed to read fixture entry: {error}"))?; + let path = entry.path(); + if path.is_dir() && path.join("fixture.json").is_file() { + available.push(path); + } + } + available.sort(); + + if requested.is_empty() { + if available.is_empty() { + return Err(format!("no fixtures found under {}", root.display())); + } + return Ok(available); + } + + let mut selected = Vec::new(); + for id in requested { + let candidate = root.join(id); + if !available.contains(&candidate) { + return Err(format!("unknown fixture: {id}")); + } + selected.push(candidate); + } + Ok(selected) +} + +fn grade_fixture( + args: &ReviewFixturesArgs, + fixture: &Path, + id: &str, + workspaces: &Path, +) -> FixtureGrade { + let errored = |reason: String| FixtureGrade { + id: id.to_string(), + status: "errored", + assertion_mode: None, + matched: Vec::new(), + missing: Vec::new(), + reason: Some(reason), + }; + + let manifest = match read_json(&fixture.join("fixture.json")) { + Ok(manifest) => manifest, + Err(error) => return errored(error), + }; + let assertion_mode = manifest + .get("assertion_mode") + .and_then(Value::as_str) + .map(str::to_string); + + let assertions_path = fixture.join("assertions.json"); + if !assertions_path.is_file() { + return FixtureGrade { + id: id.to_string(), + status: "skipped", + assertion_mode, + matched: Vec::new(), + missing: Vec::new(), + reason: Some( + "fixture declares no assertions.json minimum findings to grade against".to_string(), + ), + }; + } + let assertions = match read_json(&assertions_path).and_then(|value| minimum_findings(&value)) { + Ok(assertions) => assertions, + Err(error) => return errored(error), + }; + + let prompt = match fixture_asset(fixture, &manifest, "prompt_ref") { + Ok(path) => path, + Err(error) => return errored(error), + }; + let schema = match fixture_asset(fixture, &manifest, "schema_ref") { + Ok(path) => path, + Err(error) => return errored(error), + }; + + // The workspace holds `input/` at its root, so the fixture-relative artifact + // paths in assertions.json are exactly the paths a review reports relative to + // the coding agent's cwd. + let workspace = workspaces.join(id); + let input = fixture.join("input"); + if !input.is_dir() { + return errored(format!("fixture has no input/ tree: {}", input.display())); + } + if let Err(error) = copy_dir_all(&input, &workspace.join("input")) { + return errored(format!("failed to materialize fixture input: {error}")); + } + let workspace = match fs::canonicalize(&workspace) { + Ok(workspace) => workspace, + Err(error) => return errored(format!("failed to resolve eval workspace: {error}")), + }; + let envelope_path = workspaces.join(format!("{id}.result.json")); + + let invocation = ReviewInvocation { + coding_agent: &args.coding_agent, + backend: args.backend.as_deref(), + timeout_seconds: args.timeout_seconds, + root: &workspace, + workspace: &workspace, + prompt: &prompt, + schema: &schema, + profile: args.profile, + final_output: Some(&envelope_path), + }; + let mut plan = match prepare_review(&invocation) { + Ok(plan) => plan, + Err(error) => return errored(error.message), + }; + let output = match plan.command.output() { + Ok(output) => output, + Err(error) => { + return errored(format!( + "failed to start CAIC executable {}: {error}", + args.coding_agent.display() + )) + } + }; + if !output.status.success() { + // CAIC reports failures as an error envelope on stdout, so a stderr-only + // message would hide the actual provider diagnosis. + return errored(format!( + "review invocation failed with {}{}{}", + output + .status + .code() + .map(|code| format!("exit code {code}")) + .unwrap_or_else(|| "terminated process".to_string()), + output_tail_suffix(&output.stderr), + output_tail_suffix(&output.stdout) + )); + } + + let envelope = match read_json(&envelope_path) { + Ok(envelope) => envelope, + Err(error) => return errored(error), + }; + let Some(result) = envelope.get("result") else { + return errored("review envelope has no result field".to_string()); + }; + let findings = match result.get("findings").and_then(Value::as_array) { + Some(findings) => findings, + None => return errored("review result has no findings array".to_string()), + }; + + let mut matched = Vec::new(); + let mut missing = Vec::new(); + for assertion in assertions { + if findings + .iter() + .any(|finding| finding_satisfies(finding, &assertion, &workspace)) + { + matched.push(assertion); + } else { + missing.push(assertion); + } + } + + FixtureGrade { + id: id.to_string(), + status: if missing.is_empty() { + "passed" + } else { + "failed" + }, + assertion_mode, + matched, + missing, + reason: None, + } +} + +fn finding_satisfies(finding: &Value, assertion: &MinimumFinding, workspace: &Path) -> bool { + let field = |name: &str| { + finding + .get(name) + .and_then(Value::as_str) + .unwrap_or_default() + }; + field("rule") == assertion.rule + && field("severity") == assertion.severity + && field("owner") == assertion.owner + && normalized_artifact(field("artifact"), workspace) + == normalized_artifact(&assertion.artifact, workspace) +} + +/// Make an artifact path comparable without weakening decision 0027's exact +/// `artifact` match: absolute paths inside the eval workspace become +/// workspace-relative, and a leading `./` is dropped. No suffix matching, so a +/// finding routed to the wrong artifact still fails. +fn normalized_artifact(artifact: &str, workspace: &Path) -> String { + let trimmed = artifact.trim(); + let path = Path::new(trimmed); + if let Ok(relative) = path.strip_prefix(workspace) { + return relative.display().to_string(); + } + trimmed.trim_start_matches("./").to_string() +} + +fn minimum_findings(value: &Value) -> Result, String> { + let entries = value + .get("minimum_findings") + .and_then(Value::as_array) + .ok_or_else(|| "assertions.json has no minimum_findings array".to_string())?; + let mut findings = Vec::new(); + for entry in entries { + let field = |name: &str| { + entry + .get(name) + .and_then(Value::as_str) + .map(str::to_string) + .ok_or_else(|| format!("minimum finding is missing `{name}`")) + }; + findings.push(MinimumFinding { + rule: field("rule")?, + severity: field("severity")?, + artifact: field("artifact")?, + owner: field("owner")?, + }); + } + if findings.is_empty() { + return Err("assertions.json declares no minimum findings".to_string()); + } + Ok(findings) +} + +fn fixture_asset(fixture: &Path, manifest: &Value, field: &str) -> Result { + let reference = manifest + .get(field) + .and_then(Value::as_str) + .ok_or_else(|| format!("fixture.json is missing `{field}`"))?; + fs::canonicalize(fixture.join(reference)) + .map_err(|error| format!("`{field}` does not resolve: {reference}: {error}")) +} + +fn read_json(path: &Path) -> Result { + let content = fs::read_to_string(path) + .map_err(|error| format!("failed to read {}: {error}", path.display()))?; + serde_json::from_str(&content) + .map_err(|error| format!("failed to parse {}: {error}", path.display())) +} + +fn copy_dir_all(source: &Path, destination: &Path) -> Result<(), std::io::Error> { + fs::create_dir_all(destination)?; + for entry in fs::read_dir(source)? { + let entry = entry?; + let path = entry.path(); + let target = destination.join(entry.file_name()); + if path.is_dir() { + copy_dir_all(&path, &target)?; + } else { + fs::copy(&path, &target)?; + } + } + Ok(()) +} + +fn preflight_review_backend( + coding_agent: &Path, + requested_backend: Option<&str>, +) -> Result<(), String> { + let output = Command::new(coding_agent) + .arg("capabilities") + .arg("--json") + .output() + .map_err(|error| { + format!( + "failed to start CAIC executable {} for capabilities preflight: {error}", + coding_agent.display() + ) + })?; + if !output.status.success() { + return Err(format!( + "CAIC capabilities preflight failed with {}{}", + output + .status + .code() + .map(|code| format!("exit code {code}")) + .unwrap_or_else(|| "terminated process".to_string()), + output_tail_suffix(&output.stderr) + )); + } + + let capabilities: Value = serde_json::from_slice(&output.stdout).map_err(|error| { + format!( + "CAIC capabilities preflight did not return JSON: {error}{}", + output_tail_suffix(&output.stderr) + ) + })?; + validate_review_capabilities(&capabilities, requested_backend) +} + +fn validate_review_capabilities( + capabilities: &Value, + requested_backend: Option<&str>, +) -> Result<(), String> { + if capabilities.get("schema_version").and_then(Value::as_str) + != Some("coding_agent.capabilities.v1") + { + return Err( + "CAIC capabilities preflight returned unsupported schema_version; expected coding_agent.capabilities.v1" + .to_string(), + ); + } + let backend_id = match requested_backend { + Some(backend) => backend.to_string(), + None => capabilities + .get("default_backend") + .and_then(Value::as_str) + .ok_or_else(|| { + "CAIC capabilities preflight omitted default_backend and no --backend was provided" + .to_string() + })? + .to_string(), + }; + let backend = capabilities + .get("backends") + .and_then(Value::as_array) + .and_then(|backends| { + backends + .iter() + .find(|backend| backend.get("id").and_then(Value::as_str) == Some(&backend_id)) + }) + .ok_or_else(|| format!("backend {backend_id} is not advertised by CAIC capabilities"))?; + + let mut missing = Vec::new(); + require_capability(backend, "modes", "review", &mut missing); + require_capability(backend, "permissions", "read-only", &mut missing); + require_capability(backend, "config_policies", "isolated", &mut missing); + require_capability(backend, "network_policies", "disabled", &mut missing); + require_capability(backend, "approval_modes", "never", &mut missing); + require_capability(backend, "output_formats", "json", &mut missing); + if backend.get("schema_output").and_then(Value::as_bool) != Some(true) { + missing.push("schema_output=true".to_string()); + } + if !capability_array_contains(backend, "schema_enforcement", "adapter-validated") + && !capability_array_contains(backend, "schema_enforcement", "provider-native") + { + missing + .push("schema_enforcement includes adapter-validated or provider-native".to_string()); + } + if missing.is_empty() { + Ok(()) + } else { + Err(format!( + "backend {backend_id} does not satisfy axe vrs review preflight: missing {}", + missing.join(", ") + )) + } +} + +fn require_capability(backend: &Value, field: &str, value: &str, missing: &mut Vec) { + if !capability_array_contains(backend, field, value) { + missing.push(format!("{field} includes {value}")); + } +} + +fn capability_array_contains(backend: &Value, field: &str, expected: &str) -> bool { + backend + .get(field) + .and_then(Value::as_array) + .is_some_and(|items| items.iter().any(|item| item.as_str() == Some(expected))) +} + +fn output_tail_suffix(output: &[u8]) -> String { + let text = String::from_utf8_lossy(output); + let trimmed = text.trim(); + if trimmed.is_empty() { + String::new() + } else { + format!(": {trimmed}") + } +} + +pub fn check_root( + root: &Path, + profile: Profile, +) -> Result> { + let root = fs::canonicalize(root)?; + let markdown_files = markdown_files(&root)?; + let mut diagnostics = Vec::new(); + + for path in &markdown_files { + check_markdown_links(&root, path, profile, &mut diagnostics)?; + } + + let decision_dir = meta_vrs_decision_dir(&root); + if decision_dir.is_dir() { + check_meta_decision_shape(&root, &decision_dir, profile, &mut diagnostics)?; + } + check_companion_directories(&root, profile, &mut diagnostics)?; + + Ok(CheckReport { + schema_version: "axe.vrs.check.v1", + root: root.display().to_string(), + profile: match profile { + Profile::Local => "local", + Profile::Strict => "strict", + } + .to_string(), + diagnostics, + }) +} + +pub fn graph_root(root: &Path) -> Result> { + let root = fs::canonicalize(root)?; + let markdown_files = markdown_files(&root)?; + let mut nodes = BTreeSet::new(); + let mut edges = BTreeSet::new(); + + for path in &markdown_files { + let relative = relative_display(&root, path); + let file_id = graph_file_id(&relative); + nodes.insert(GraphNode { + id: file_id.clone(), + kind: "file".to_string(), + title: path + .file_name() + .and_then(OsStr::to_str) + .unwrap_or(relative.as_str()) + .to_string(), + path: relative.clone(), + status: "active".to_string(), + refs: Vec::new(), + refines: Vec::new(), + evidence: Vec::new(), + }); + + let content = fs::read_to_string(path)?; + for id in structured_ids_outside_code(&content) { + nodes.insert(GraphNode { + id: id.id.clone(), + kind: graph_id_kind(&id.id).to_string(), + title: id.title, + path: relative.clone(), + status: "active".to_string(), + refs: id.refs, + refines: id.refines, + evidence: vec![id.evidence], + }); + edges.insert(GraphEdge { + source: file_id.clone(), + target: id.id, + kind: "contains".to_string(), + path: relative.clone(), + evidence: "structured-id".to_string(), + }); + } + + for link in markdown_links_outside_code(&content) { + let Some(target) = normalized_local_link_target(&link) else { + continue; + }; + let (file_part, _anchor_part) = split_anchor(&target); + if file_part.is_empty() { + continue; + } + let target_path = path.parent().unwrap_or(&root).join(file_part); + let Ok(target_path) = fs::canonicalize(target_path) else { + continue; + }; + if !target_path.starts_with(&root) || target_path.extension() != Some(OsStr::new("md")) + { + continue; + } + let target_relative = relative_display(&root, &target_path); + edges.insert(GraphEdge { + source: file_id.clone(), + target: graph_file_id(&target_relative), + kind: "markdown_link".to_string(), + path: relative.clone(), + evidence: target, + }); + } + + for wikilink in wikilinks_outside_code(&content) { + let target = format!("wiki:{wikilink}"); + nodes.insert(GraphNode { + id: target.clone(), + kind: "wikilink".to_string(), + title: wikilink.clone(), + path: String::new(), + status: "unresolved".to_string(), + refs: Vec::new(), + refines: Vec::new(), + evidence: Vec::new(), + }); + edges.insert(GraphEdge { + source: file_id.clone(), + target, + kind: "wikilink".to_string(), + path: relative.clone(), + evidence: format!("[[{wikilink}]]"), + }); + } + } + + Ok(GraphReport { + schema_version: "axe.vrs.graph.v0", + root: root.display().to_string(), + nodes: nodes.into_iter().collect(), + edges: edges.into_iter().collect(), + }) +} + +/* Corpus-relative only. The old second branch guessed `context/vrs/.decisions` to + cover being handed a repository root instead of a corpus root — a guess that was + silently wrong for any repository laid out differently, and that let a misaimed + invocation look like a clean one. Pointing this at a corpus is the caller's job. */ +fn meta_vrs_decision_dir(root: &Path) -> PathBuf { + root.join(".decisions") +} + +fn check_markdown_links( + root: &Path, + path: &Path, + profile: Profile, + diagnostics: &mut Vec, +) -> Result<(), Box> { + let content = fs::read_to_string(path)?; + let anchors = anchors_for(&content); + for link in markdown_links_outside_code(&content) { + let Some(target) = normalized_local_link_target(&link) else { + continue; + }; + let (file_part, anchor_part) = split_anchor(&target); + if file_part.is_empty() { + if !anchor_part.is_empty() && !anchors.contains(anchor_part) { + diagnostics.push(diagnostic( + root, + path, + "VRS.ENF.link.local-target", + format!("Local anchor `#{anchor_part}` does not resolve."), + "Update the anchor or heading in this file.", + link_severity(profile), + )); + } + continue; + } + + let target_path = path.parent().unwrap_or(root).join(file_part); + if !target_path.exists() { + diagnostics.push(diagnostic( + root, + path, + "VRS.ENF.link.local-target", + format!("Markdown link target `{target}` does not exist."), + "Update the link target or add the referenced artifact.", + link_severity(profile), + )); + continue; + } + + if !anchor_part.is_empty() && target_path.is_file() { + let target_content = fs::read_to_string(&target_path).unwrap_or_default(); + let target_anchors = anchors_for(&target_content); + if !target_anchors.contains(anchor_part) { + diagnostics.push(diagnostic( + root, + path, + "VRS.ENF.link.local-target", + format!("Markdown link anchor `{target}` does not resolve."), + "Update the anchor or add the referenced heading.", + link_severity(profile), + )); + } + } + } + Ok(()) +} + +fn check_meta_decision_shape( + root: &Path, + decision_dir: &Path, + profile: Profile, + diagnostics: &mut Vec, +) -> Result<(), Box> { + for path in markdown_files_direct(decision_dir)? { + let file_name = path.file_name().and_then(OsStr::to_str).unwrap_or_default(); + if !valid_decision_filename(file_name) { + diagnostics.push(decision_shape_diagnostic( + root, + &path, + format!("Decision filename `{file_name}` must match `000N-.md`."), + "Rename the decision record with the next durable numeric prefix.", + profile, + )); + } + + let content = fs::read_to_string(&path)?; + let sections = sections(&content); + + match status_line(&content) { + Some(status) if valid_status(status) => {} + Some(status) => diagnostics.push(decision_shape_diagnostic( + root, + &path, + format!( + "Decision status `{status}` is not accepted, deprecated, or superseded by ." + ), + "Use an accepted decision status.", + profile, + )), + None => diagnostics.push(decision_shape_diagnostic( + root, + &path, + "Decision record is missing `Status:`.".to_string(), + "Add `Status: accepted`, `Status: deprecated`, or `Status: superseded by `.", + profile, + )), + } + + for heading in ["Context", "Evidence and Argument", "Options", "Decision"] { + match sections.iter().find(|section| section.heading == heading) { + Some(section) if !section.body.trim().is_empty() => {} + Some(_) => diagnostics.push(decision_shape_diagnostic( + root, + &path, + format!("Decision section `## {heading}` is empty."), + "Add the required decision content or keep the record proposed.", + profile, + )), + None => diagnostics.push(decision_shape_diagnostic( + root, + &path, + format!("Decision record is missing `## {heading}`."), + "Add the required decision section or keep the record proposed.", + profile, + )), + } + } + + if let Some(options) = sections.iter().find(|section| section.heading == "Options") { + let option_rows = option_rows(&options.body); + if option_rows.len() < 2 { + diagnostics.push(decision_shape_diagnostic( + root, + &path, + "Options section must include at least two option rows.".to_string(), + "Use an `Option | Tradeoffs` table with the real alternatives considered.", + profile, + )); + } + for (option, tradeoffs) in option_rows { + if option.trim().is_empty() || tradeoffs.trim().is_empty() { + diagnostics.push(decision_shape_diagnostic( + root, + &path, + "Options table contains an empty option or tradeoff cell.".to_string(), + "Fill each option row with the option name and its tradeoffs.", + profile, + )); + } + } + } + } + Ok(()) +} + +fn check_companion_directories( + root: &Path, + profile: Profile, + diagnostics: &mut Vec, +) -> Result<(), Box> { + let companion_dirs = companion_dirs(root)?; + for dir in companion_dirs { + if is_semantic_review_fixture_input(root, &dir) { + continue; + } + match dir.file_name().and_then(OsStr::to_str) { + Some(".proposed") + if dir.parent().and_then(Path::file_name) == Some(OsStr::new(".decisions")) => + { + check_proposed_decisions(root, &dir, diagnostics)?; + } + Some(".delta") => check_delta_shape(root, &dir, diagnostics)?, + Some(".experiments") => check_experiment_shape(root, &dir, profile, diagnostics)?, + Some(".reference") => check_reference_shape(root, &dir, profile, diagnostics)?, + _ => {} + } + } + Ok(()) +} + +fn check_proposed_decisions( + root: &Path, + proposed_dir: &Path, + diagnostics: &mut Vec, +) -> Result<(), Box> { + for path in markdown_files_direct(proposed_dir)? { + diagnostics.push(diagnostic( + root, + &path, + "VRS.ENF.proposed-decision", + "Proposed decision records are PR-local and must not merge.".to_string(), + "Accept the decision, fold it into requirements/spec, move it to open questions, or delete it before merge.", + Severity::Error, + )); + } + Ok(()) +} + +fn check_delta_shape( + root: &Path, + delta_dir: &Path, + diagnostics: &mut Vec, +) -> Result<(), Box> { + for path in markdown_files_direct(delta_dir)? { + let file_name = path.file_name().and_then(OsStr::to_str).unwrap_or_default(); + if !valid_delta_filename(file_name) { + diagnostics.push(diagnostic( + root, + &path, + "VRS.ENF.delta-shape", + format!("Delta filename `{file_name}` must match `DELTA-001-.md`."), + "Rename the delta with a stable `DELTA-NNN-.md` identifier.", + Severity::Error, + )); + } + + let content = fs::read_to_string(&path)?; + match status_line(&content) { + Some("open") => {} + Some(status) => diagnostics.push(diagnostic( + root, + &path, + "VRS.ENF.delta-shape", + format!("Delta status `{status}` is not `open`."), + "Keep only open delta records; close resolved deltas by deleting the file.", + Severity::Error, + )), + None => diagnostics.push(diagnostic( + root, + &path, + "VRS.ENF.delta-shape", + "Delta record is missing `Status: open`.".to_string(), + "Add `Status: open` or delete the delta if it is resolved.", + Severity::Error, + )), + } + + let sections = sections(&content); + for heading in [ + "Divergence", + "VRS", + "Implementation", + "Direction", + "Resolution Signal", + ] { + require_section( + root, + &path, + §ions, + heading, + "VRS.ENF.delta-shape", + "Fill the required delta section or delete the stale delta.", + Severity::Error, + diagnostics, + ); + } + + if let Some(direction) = sections + .iter() + .find(|section| section.heading == "Direction") + { + let value = direction.body.trim(); + if !matches!(value, "update implementation" | "update VRS" | "decide") { + diagnostics.push(diagnostic( + root, + &path, + "VRS.ENF.delta-shape", + format!("Delta direction `{value}` must be `update implementation`, `update VRS`, or `decide`."), + "Set `## Direction` to one of the accepted delta direction values.", + Severity::Error, + )); + } + } + } + Ok(()) +} + +fn check_experiment_shape( + root: &Path, + experiment_dir: &Path, + profile: Profile, + diagnostics: &mut Vec, +) -> Result<(), Box> { + for path in markdown_files_direct(experiment_dir)? { + let content = fs::read_to_string(&path)?; + let sections = sections(&content); + for heading in ["Question", "Method", "Result", "Conclusion", "VRS Impact"] { + require_section( + root, + &path, + §ions, + heading, + "VRS.ENF.experiment-shape", + "Fill the required experiment evidence section or move speculative work out of `.experiments/`.", + companion_shape_severity(profile), + diagnostics, + ); + } + } + Ok(()) +} + +fn check_reference_shape( + root: &Path, + reference_dir: &Path, + profile: Profile, + diagnostics: &mut Vec, +) -> Result<(), Box> { + for path in markdown_files_direct(reference_dir)? { + let content = fs::read_to_string(&path)?; + if source_line(&content).is_none() { + diagnostics.push(diagnostic( + root, + &path, + "VRS.ENF.reference-shape", + "Reference record is missing `Source:`.".to_string(), + "Add the URL, file, command, or system that supplied the reference facts.", + companion_shape_severity(profile), + )); + } + + let sections = sections(&content); + for heading in ["Relevant Facts", "VRS Impact"] { + require_section( + root, + &path, + §ions, + heading, + "VRS.ENF.reference-shape", + "Fill the required reference section or delete source material that has no VRS impact.", + companion_shape_severity(profile), + diagnostics, + ); + } + } + Ok(()) +} + +fn require_section( + root: &Path, + path: &Path, + sections: &[Section], + heading: &str, + rule: &'static str, + suggested_fix: &str, + severity: Severity, + diagnostics: &mut Vec, +) { + match sections.iter().find(|section| section.heading == heading) { + Some(section) if !section.body.trim().is_empty() => {} + Some(section) => diagnostics.push(diagnostic( + root, + path, + rule, + format!("Section `## {}` is empty.", section.heading), + suggested_fix, + severity, + )), + None => diagnostics.push(diagnostic( + root, + path, + rule, + format!("Record is missing `## {heading}`."), + suggested_fix, + severity, + )), + } +} + +fn markdown_files(root: &Path) -> Result, Box> { + let mut out = Vec::new(); + visit_markdown(root, root, &mut out)?; + out.sort(); + Ok(out) +} + +fn companion_dirs(root: &Path) -> Result, Box> { + let mut out = Vec::new(); + visit_companion_dirs(root, &mut out)?; + out.sort(); + Ok(out) +} + +fn is_semantic_review_fixture_input(root: &Path, path: &Path) -> bool { + let Ok(relative) = path.strip_prefix(root) else { + return false; + }; + let components: Vec<_> = relative + .components() + .map(|component| component.as_os_str().to_string_lossy()) + .collect(); + components.len() >= 4 + && components[0] == "15-evaluation" + && components[1] == "semantic-review" + && components + .iter() + .any(|component| component.as_ref() == "input") +} + +fn markdown_files_direct(dir: &Path) -> Result, Box> { + let mut out = Vec::new(); + for entry in fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + if path.extension() == Some(OsStr::new("md")) { + out.push(path); + } + } + out.sort(); + Ok(out) +} + +/// The directory the review agent is given to work in, and the boundary its target +/// artifacts may not escape. +/// +/// This is still repository-scoped rather than corpus-scoped: a reviewer reasoning +/// about a corpus needs the repository around it. The `context/vrs` sentinel that +/// used to back this up is gone — it named one repository's layout, and it was only +/// ever reached when `.git` was absent. Falling back to the corpus root keeps that +/// no-`.git` case working without the tool having to know any repository's shape. +fn review_workspace(root: &Path) -> PathBuf { + for ancestor in root.ancestors() { + if ancestor.join(".git").exists() { + return ancestor.to_path_buf(); + } + } + root.to_path_buf() +} + +/// Resolve an enforcement asset that belongs to the corpus. +/// +/// Corpus-relative, not repository-relative: `review` reads these from the +/// filesystem at run time, so they must travel with the corpus they describe. +/// Nothing is compiled in, despite what the old error text claimed — there is no +/// `include_str!` here and never was. +fn corpus_asset(root: &Path, relative: &str) -> Result { + let candidate = root.join(relative); + if candidate.is_file() { + return Ok(candidate); + } + Err(format!( + "missing review asset {relative} under corpus {}", + root.display() + )) +} + +fn automated_context_indicator() -> Option<&'static str> { + for name in [ + "CI", + "GITHUB_ACTIONS", + "BUILDKITE", + "GITLAB_CI", + "CIRCLECI", + "JENKINS_URL", + "TEAMCITY_VERSION", + "TF_BUILD", + "CONTINUOUS_INTEGRATION", + "CODEBUILD_BUILD_ID", + "DRONE", + "PRE_COMMIT", + ] { + if std::env::var(name).is_ok_and(|value| !value.is_empty() && value != "false") { + return Some(name); + } + } + None +} + +fn visit_markdown( + root: &Path, + dir: &Path, + out: &mut Vec, +) -> Result<(), Box> { + for entry in fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + let name = path.file_name().and_then(OsStr::to_str).unwrap_or_default(); + if name == ".git" || name == "target" || name == "node_modules" { + continue; + } + if path.is_dir() { + // Semantic-review fixture `input/` trees are deliberately broken synthetic + // artifacts. They are neither real VRS artifacts for deterministic checks nor + // normative review context; collecting them would ship planted smells to the + // provider as genuine VRS. + if is_semantic_review_fixture_input(root, &path) { + continue; + } + visit_markdown(root, &path, out)?; + } else if path.extension() == Some(OsStr::new("md")) { + out.push(path); + } + } + Ok(()) +} + +fn visit_companion_dirs( + dir: &Path, + out: &mut Vec, +) -> Result<(), Box> { + for entry in fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + if !path.is_dir() { + continue; + } + + let name = path.file_name().and_then(OsStr::to_str).unwrap_or_default(); + if name == ".git" || name == "target" || name == "node_modules" { + continue; + } + if matches!(name, ".proposed" | ".delta" | ".experiments" | ".reference") { + out.push(path.clone()); + } + visit_companion_dirs(&path, out)?; + } + Ok(()) +} + +fn markdown_links_outside_code(content: &str) -> Vec { + let mut links = Vec::new(); + let mut in_fence = false; + for line in content.lines() { + if line.trim_start().starts_with("```") { + in_fence = !in_fence; + continue; + } + if in_fence { + continue; + } + links.extend(markdown_links_in_line(line)); + } + links +} + +fn wikilinks_outside_code(content: &str) -> Vec { + let mut links = Vec::new(); + let mut in_fence = false; + for line in content.lines() { + if line.trim_start().starts_with("```") { + in_fence = !in_fence; + continue; + } + if in_fence { + continue; + } + links.extend(wikilinks_in_line(line)); + } + links.sort(); + links.dedup(); + links +} + +fn wikilinks_in_line(line: &str) -> Vec { + let mut links = Vec::new(); + let mut index = 0; + while let Some(start) = line[index..].find("[[").map(|offset| index + offset) { + let link_start = start + 2; + let Some(end) = line[link_start..] + .find("]]") + .map(|offset| link_start + offset) + else { + break; + }; + let raw = line[link_start..end].trim(); + let without_alias = raw + .split_once('|') + .map(|(target, _)| target) + .unwrap_or(raw) + .trim(); + let target = without_alias + .split_once('#') + .map(|(target, _)| target) + .unwrap_or(without_alias) + .trim(); + if !target.is_empty() { + links.push(target.to_string()); + } + index = end + 2; + } + links +} + +#[derive(Debug)] +struct StructuredId { + id: String, + title: String, + refs: Vec, + refines: Vec, + evidence: String, +} + +fn structured_ids_outside_code(content: &str) -> Vec { + let mut ids = Vec::new(); + let mut in_fence = false; + for line in content.lines() { + if line.trim_start().starts_with("```") { + in_fence = !in_fence; + continue; + } + if in_fence { + continue; + } + if let Some(id) = structured_id_in_line(line) { + ids.push(id); + } + } + ids +} + +fn structured_id_in_line(line: &str) -> Option { + let start = line.find("**")? + 2; + let end = line[start..].find("**").map(|offset| start + offset)?; + let label = line[start..end].trim().trim_end_matches(':').trim(); + let mut parts = label.splitn(2, char::is_whitespace); + let id = parts.next()?.trim(); + if !looks_like_vrs_id(id) { + return None; + } + let title = parts.next().unwrap_or("").trim().to_string(); + let rest = &line[end + 2..]; + Some(StructuredId { + id: id.to_string(), + title: if title.is_empty() { + id.to_string() + } else { + title + }, + refs: refs_in_text(rest), + refines: refines_in_text(rest), + evidence: line.trim().to_string(), + }) +} + +fn looks_like_vrs_id(value: &str) -> bool { + value.len() >= 2 + && value.chars().any(|ch| ch.is_ascii_digit()) + && value + .chars() + .all(|ch| ch.is_ascii_uppercase() || ch.is_ascii_digit() || ch == '.' || ch == '-') +} + +fn refs_in_text(text: &str) -> Vec { + text.split(|ch: char| { + ch.is_whitespace() || matches!(ch, ',' | ';' | ':' | '(' | ')' | '[' | ']') + }) + .filter_map(|part| { + let candidate = part.trim_matches('.'); + looks_like_vrs_id(candidate).then(|| candidate.to_string()) + }) + .collect() +} + +fn refines_in_text(text: &str) -> Vec { + let Some((_, rest)) = text.split_once("refines:") else { + return Vec::new(); + }; + refs_in_text(rest) +} + +fn graph_id_kind(id: &str) -> &'static str { + if id.contains("-R") { + "requirement" + } else if id.contains("-A") { + "assumption" + } else if id.contains("-T") { + "tradeoff" + } else if id.starts_with("DQ") || id.contains("-DQ") { + "design_question" + } else { + "id" + } +} + +fn graph_file_id(relative: &str) -> String { + format!("file:{}", relative.replace('\\', "/")) +} + +fn markdown_links_in_line(line: &str) -> Vec { + let bytes = line.as_bytes(); + let mut links = Vec::new(); + let mut index = 0; + while index < bytes.len() { + let Some(open_bracket) = line[index..].find('[').map(|offset| index + offset) else { + break; + }; + if open_bracket > 0 && bytes[open_bracket - 1] == b'!' { + index = open_bracket + 1; + continue; + } + let Some(close_bracket) = line[open_bracket..] + .find(']') + .map(|offset| open_bracket + offset) + else { + break; + }; + let paren_start = close_bracket + 1; + if bytes.get(paren_start) != Some(&b'(') { + index = close_bracket + 1; + continue; + } + let link_start = paren_start + 1; + let Some(paren_end) = line[link_start..] + .find(')') + .map(|offset| link_start + offset) + else { + break; + }; + links.push(line[link_start..paren_end].trim().to_string()); + index = paren_end + 1; + } + links +} + +fn normalized_local_link_target(link: &str) -> Option { + let target = link.split_whitespace().next().unwrap_or("").trim(); + if target.is_empty() + || target.starts_with("http://") + || target.starts_with("https://") + || target.starts_with("mailto:") + || target.starts_with("tel:") + { + return None; + } + Some(percent_decode_minimal(target)) +} + +fn split_anchor(target: &str) -> (&str, &str) { + match target.split_once('#') { + Some((file, anchor)) => (file, anchor), + None => (target, ""), + } +} + +fn percent_decode_minimal(value: &str) -> String { + value.replace("%20", " ") +} + +fn anchors_for(content: &str) -> HashSet { + let mut anchors = HashSet::new(); + let mut seen = HashSet::new(); + for line in content.lines() { + let trimmed = line.trim_start(); + if !trimmed.starts_with('#') { + continue; + } + let heading = trimmed.trim_start_matches('#').trim(); + if heading.is_empty() { + continue; + } + let mut anchor = github_anchor(heading); + let base = anchor.clone(); + let mut index = 1; + while seen.contains(&anchor) { + anchor = format!("{base}-{index}"); + index += 1; + } + seen.insert(anchor.clone()); + anchors.insert(anchor); + } + anchors +} + +fn github_anchor(heading: &str) -> String { + let mut out = String::new(); + let mut last_dash = false; + for ch in heading.chars().flat_map(char::to_lowercase) { + if ch.is_ascii_alphanumeric() { + out.push(ch); + last_dash = false; + } else if (ch.is_whitespace() || ch == '-') && !last_dash && !out.is_empty() { + out.push('-'); + last_dash = true; + } + } + out.trim_matches('-').to_string() +} + +#[derive(Debug)] +struct Section { + heading: String, + body: String, +} + +fn sections(content: &str) -> Vec
{ + let mut sections = Vec::new(); + let mut current: Option
= None; + for line in content.lines() { + if let Some(heading) = line.strip_prefix("## ") { + if let Some(section) = current.take() { + sections.push(section); + } + current = Some(Section { + heading: heading.trim().to_string(), + body: String::new(), + }); + } else if let Some(section) = current.as_mut() { + section.body.push_str(line); + section.body.push('\n'); + } + } + if let Some(section) = current { + sections.push(section); + } + sections +} + +fn status_line(content: &str) -> Option<&str> { + content.lines().find_map(|line| { + line.strip_prefix("Status:") + .map(str::trim) + .filter(|status| !status.is_empty()) + }) +} + +fn valid_status(status: &str) -> bool { + status == "accepted" || status == "deprecated" || status.starts_with("superseded by ") +} + +fn valid_decision_filename(file_name: &str) -> bool { + let Some((prefix, rest)) = file_name.split_once('-') else { + return false; + }; + prefix.len() == 4 + && prefix.chars().all(|ch| ch.is_ascii_digit()) + && rest.ends_with(".md") + && rest + .trim_end_matches(".md") + .chars() + .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-') +} + +fn valid_delta_filename(file_name: &str) -> bool { + let Some(rest) = file_name.strip_prefix("DELTA-") else { + return false; + }; + let Some((number, slug)) = rest.split_once('-') else { + return false; + }; + number.len() == 3 + && number.chars().all(|ch| ch.is_ascii_digit()) + && slug.ends_with(".md") + && slug + .trim_end_matches(".md") + .chars() + .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-') +} + +fn source_line(content: &str) -> Option<&str> { + content.lines().find_map(|line| { + line.strip_prefix("Source:") + .map(str::trim) + .filter(|source| !source.is_empty()) + }) +} + +fn option_rows(body: &str) -> Vec<(String, String)> { + body.lines() + .filter_map(|line| { + let trimmed = line.trim(); + if !trimmed.starts_with('|') || !trimmed.ends_with('|') { + return None; + } + let cells: Vec<_> = trimmed + .trim_matches('|') + .split('|') + .map(str::trim) + .collect(); + if cells.len() < 2 { + return None; + } + let first = cells[0]; + let second = cells[1]; + if first.eq_ignore_ascii_case("option") + || first + .chars() + .all(|ch| ch == '-' || ch == ':' || ch.is_whitespace()) + { + return None; + } + Some((first.to_string(), second.to_string())) + }) + .collect() +} + +fn decision_shape_diagnostic( + root: &Path, + path: &Path, + evidence: String, + suggested_fix: &str, + _profile: Profile, +) -> Diagnostic { + diagnostic( + root, + path, + "VRS.ENF.meta-decision-shape", + evidence, + suggested_fix, + Severity::Error, + ) +} + +fn link_severity(profile: Profile) -> Severity { + match profile { + Profile::Local => Severity::Warning, + Profile::Strict => Severity::Error, + } +} + +fn companion_shape_severity(profile: Profile) -> Severity { + match profile { + Profile::Local => Severity::Warning, + Profile::Strict => Severity::Error, + } +} + +fn diagnostic( + root: &Path, + path: &Path, + rule: &'static str, + evidence: String, + suggested_fix: &str, + severity: Severity, +) -> Diagnostic { + Diagnostic { + schema_version: "axe.vrs.diagnostic.v1", + kind: "deterministic", + gate: match severity { + Severity::Error => "blocking", + Severity::Warning => "transitional", + Severity::Info => "advisory", + }, + severity, + artifact: relative_display(root, path), + owner: owner_for(root, path), + rule, + evidence, + suggested_fix: suggested_fix.to_string(), + } +} + +fn relative_display(root: &Path, path: &Path) -> String { + path.strip_prefix(root) + .unwrap_or(path) + .display() + .to_string() +} + +fn owner_for(root: &Path, path: &Path) -> String { + let relative = path.strip_prefix(root).unwrap_or(path); + relative + .components() + .next() + .map(|component| component.as_os_str().to_string_lossy().into_owned()) + .unwrap_or_else(|| ".".to_string()) +} + +fn severity_label(severity: &Severity) -> &'static str { + match severity { + Severity::Error => "error", + Severity::Warning => "warning", + Severity::Info => "info", + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn valid_fixture_passes_strict_checks() { + let tempdir = tempfile::tempdir().unwrap(); + let root = tempdir.path().join("context/vrs"); + fs::create_dir_all(&root).unwrap(); + write_valid_vrs(&root); + + let report = check_root(&root, Profile::Strict).unwrap(); + assert!( + report.diagnostics.is_empty(), + "unexpected diagnostics: {:?}", + report.diagnostics + ); + } + + #[test] + fn broken_links_and_decisions_are_reported() { + let tempdir = tempfile::tempdir().unwrap(); + let root = tempdir.path().join("context/vrs"); + fs::create_dir_all(&root).unwrap(); + write_valid_vrs(&root); + fs::write( + root.join("spec.md"), + "# Spec\n\nSee [missing](./missing.md) and [bad anchor](./requirements.md#missing).\n", + ) + .unwrap(); + fs::write( + root.join(".decisions/0001-bad.md"), + "# Bad\n\nStatus: maybe\n\n## Context\n\nx\n\n## Options\n\n| Option | Tradeoffs |\n| --- | --- |\n| A | x |\n", + ) + .unwrap(); + + let report = check_root(&root, Profile::Strict).unwrap(); + let rules: Vec<_> = report.diagnostics.iter().map(|d| d.rule).collect(); + assert!(rules.contains(&"VRS.ENF.link.local-target")); + assert!(rules.contains(&"VRS.ENF.meta-decision-shape")); + assert!(report + .diagnostics + .iter() + .all(|d| d.severity == Severity::Error)); + } + + #[test] + fn local_profile_still_blocks_for_decision_shape() { + let tempdir = tempfile::tempdir().unwrap(); + let root = tempdir.path().join("context/vrs"); + fs::create_dir_all(root.join(".decisions")).unwrap(); + fs::write( + root.join(".decisions/0001-bad.md"), + "# Bad\n\nStatus: maybe\n", + ) + .unwrap(); + + let report = check_root(&root, Profile::Local).unwrap(); + assert!(report + .diagnostics + .iter() + .any(|d| { d.rule == "VRS.ENF.meta-decision-shape" && d.severity == Severity::Error })); + } + + /* The extraction's acceptance bar is that `axe vrs` behaves identically, and the + only thing holding that up is the caller keeping its own default. Locked here + because a regression is silent: the wrong root still exits 0. */ + #[test] + fn an_absent_argument_falls_back_to_the_callers_layout() { + let axe = Defaults::corpus_root("context/vrs"); + assert_eq!(axe.root_or_default(None), PathBuf::from("context/vrs")); + assert_eq!( + axe.fixtures_or_default(None), + PathBuf::from("context/vrs/15-evaluation/semantic-review") + ); + + // Standalone `intent` checks the corpus it is run in. + let standalone = Defaults::default(); + assert_eq!(standalone.root_or_default(None), PathBuf::from(".")); + assert_eq!( + standalone.fixtures_or_default(None), + PathBuf::from("./15-evaluation/semantic-review") + ); + } + + #[test] + fn an_explicit_argument_always_beats_the_default() { + let defaults = Defaults::corpus_root("context/vrs"); + let explicit = PathBuf::from("/somewhere/else"); + assert_eq!(defaults.root_or_default(Some(explicit.clone())), explicit); + assert_eq!(defaults.fixtures_or_default(Some(explicit.clone())), explicit); + } + + /* Covers the branch that replaced the `context/vrs` sentinel. It is only ever + reached where there is no `.git` — a Nix build sandbox or a vendored source + tree — so it is invisible to any interactive run. */ + #[test] + fn review_workspace_falls_back_to_the_corpus_when_there_is_no_git() { + let tempdir = tempfile::tempdir().unwrap(); + let corpus = tempdir.path().join("some/corpus"); + fs::create_dir_all(&corpus).unwrap(); + + assert_eq!(review_workspace(&corpus), corpus); + } + + #[test] + fn review_workspace_prefers_the_enclosing_repository() { + let tempdir = tempfile::tempdir().unwrap(); + let repo = fs::canonicalize(tempdir.path()).unwrap(); + let corpus = repo.join("some/corpus"); + fs::create_dir_all(&corpus).unwrap(); + fs::create_dir(repo.join(".git")).unwrap(); + + assert_eq!(review_workspace(&corpus), repo); + } + + /* The assets travel with the corpus rather than the repository: that co-location + is the reason the tool was moved next to the corpus in the first place. */ + #[test] + fn enforcement_assets_resolve_under_the_corpus_not_the_repository() { + let tempdir = tempfile::tempdir().unwrap(); + let repo = tempdir.path(); + let corpus = repo.join("intent"); + fs::create_dir_all(corpus.join("16-enforcement")).unwrap(); + fs::write(corpus.join(REVIEW_PROMPT_ASSET), "# prompt\n").unwrap(); + + assert_eq!( + corpus_asset(&corpus, REVIEW_PROMPT_ASSET).unwrap(), + corpus.join(REVIEW_PROMPT_ASSET) + ); + + // A repository-relative copy must NOT satisfy a corpus-relative lookup. + fs::create_dir_all(repo.join("context/vrs/16-enforcement")).unwrap(); + fs::write(repo.join("context/vrs").join(REVIEW_SCHEMA_ASSET), "{}").unwrap(); + assert!(corpus_asset(&corpus, REVIEW_SCHEMA_ASSET).is_err()); + } + + fn write_valid_vrs(root: &Path) { + fs::create_dir(root.join(".decisions")).unwrap(); + fs::write( + root.join("requirements.md"), + "# Requirements\n\n## Context\n", + ) + .unwrap(); + fs::write( + root.join("spec.md"), + "# Spec\n\nSee [requirements](./requirements.md#context).\n", + ) + .unwrap(); + fs::write( + root.join(".decisions/0001-valid.md"), + "# Valid\n\nStatus: accepted\n\n## Context\n\nA choice was required.\n\n## Evidence and Argument\n\nA fixture proves the mechanical shape.\n\n## Options\n\n| Option | Tradeoffs |\n| --- | --- |\n| A | Simple but narrow. |\n| B | Broader but expensive. |\n\n## Decision\n\nChoose A because it is enough for this fixture.\n", + ) + .unwrap(); + } +} diff --git a/crates/intent/src/main.rs b/crates/intent/src/main.rs new file mode 100644 index 0000000..a2dd13d --- /dev/null +++ b/crates/intent/src/main.rs @@ -0,0 +1,9 @@ +use clap::Parser; +use std::process::ExitCode; + +/* The binary is a thin shell over the library entry point on purpose: `axe vrs` + calls `intent::run` directly, so anything that lived here would be behavior the + embedded caller silently does not get. */ +fn main() -> ExitCode { + intent::run(intent::VrsCli::parse()) +} From 3f513f54d64ca2bcf4730d55eb7c9c18dc60b503 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sat, 8 Aug 2026 12:57:11 +0200 Subject: [PATCH 2/6] docs(readme): drop the LiveStore reference and rest the claim on the page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The paragraph asserted that the conventions travel, and the only evidence it offered was a second project using them. Removing that sentence alone would leave the assertion standing on support that is no longer on the page — the unfalsifiable prose this corpus exists to catch. Rewritten so every claim left in it is carried by something a reader can see: the artifacts are plain Markdown, and the corpus is written in the conventions it specifies. The narrower claim is the one the closing paragraph actually needs — "conventions that need a bespoke toolchain do not get adopted" pairs with no-toolchain, not with adoption elsewhere. No outside project is substituted as evidence. Co-Authored-By: Claude Opus 5 agent-tool: Claude Code agent-tool-version: 2.1.220 agent-runtime: Claude Code 2.1.220 agent-session-lookup: unavailable tooling-profile: dotfiles@unknown-dirty --- README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 3adddc9..17ded8d 100644 --- a/README.md +++ b/README.md @@ -81,12 +81,12 @@ checker rather than the methodology. ## Two things worth knowing early -**The conventions travel.** They are plain Markdown with a naming discipline, and -they require no tool to author or read. `livestorejs/livestore` — a different -project, a different domain, no dependency on anything in this repository — uses -the same artifact set and the same numbered-subsystem structure, nested two levels -deep. Nothing in this corpus is coupled to the environment it grew up in; a text -editor is the only requirement. +**The conventions need no toolchain.** They are plain Markdown with a naming +discipline: a directory layout, a set of filenames, and rules about which file +owns which fact. Authoring and reading them takes a text editor and nothing +else. This corpus is its own worked example — the conventions it describes are +the conventions it is written in, so every rule it states can be seen applied +in the files you are already reading. **The rules are mechanically checkable, and checked.** `16-enforcement` is not aspirational — it defines concrete rules with stable identifiers, and a real From 9920399d7d5eb50d7d465643a7cb460564271b76 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sat, 8 Aug 2026 13:18:59 +0200 Subject: [PATCH 3/6] feat(nix): package the CLI as a flake and add a nix lane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Distribution is the flake only — there is no crates.io release — so this is how the CLI is consumed. `nix build .#intent` packages it; `nix flake check` gates fmt, clippy, the crate's test suite, a `--help` smoke test, and a proof that the packaged binary reads a real corpus. Two attributes are needed to package a crate that is not at the repository root, and they are not the same knob. `buildAndTestSubdir` moves only the build and test phases; `cargoSetupPostPatchHook` still reconciles the vendored lockfile against the repository root and fails with "Missing Cargo.lock from src". `cargoRoot` is what points that reconciliation at the crate. `src` stays the whole repository on purpose, so the corpus at `intent/` remains visible to a check aimed at it from the same source tree. The nix lane is a separate workflow rather than a job inside `ci.yml`. Folding a multi-minute Nix build in beside the fast corpus gates would couple them, and running the corpus gates through `nix flake check` would collapse `corpus-strict` and `semantic-review-fixtures` into a single check named `check` — a run would no longer show which gate concluded and how. This lane packages the CLI and re-gates nothing. Adopting the formatter and the lint required two changes to the lifted source: - The block comments carried hand-aligned continuation lines that rustfmt de-indents to column 0, which reads worse than what it replaced. Converted to line comments, which rustfmt leaves alone, so the wording is unchanged and the gate is real rather than permanently red or absent. - `automated_context_indicator` becomes the `Iterator::find` it was written as by hand. `require_section` keeps its eight arguments under an explicit `allow` with the reason inline: this crate is a lift whose acceptance bar is behaving identically, and grouping those arguments is a refactor motivated only by a style lint, better done on its own where a regression would be attributable. Verified locally: `nix flake check` passes all five checks, the packaged binary reports 246 graph nodes against the real corpus, and after these source edits the strict check still reports 0 diagnostics with the graph unchanged at 246/215. Co-Authored-By: Claude Opus 5 agent-tool: Claude Code agent-tool-version: 2.1.220 agent-runtime: Claude Code 2.1.220 agent-session-lookup: unavailable tooling-profile: dotfiles@unknown-dirty --- .github/workflows/nix.yml | 33 +++++++++ .gitignore | 4 + crates/intent/README.md | 10 +++ crates/intent/src/lib.rs | 57 +++++++------- crates/intent/src/main.rs | 6 +- flake.lock | 61 +++++++++++++++ flake.nix | 152 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 294 insertions(+), 29 deletions(-) create mode 100644 .github/workflows/nix.yml create mode 100644 flake.lock create mode 100644 flake.nix diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml new file mode 100644 index 0000000..3147f00 --- /dev/null +++ b/.github/workflows/nix.yml @@ -0,0 +1,33 @@ +name: nix + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +concurrency: + group: nix-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + # Deliberately its OWN workflow rather than a job inside `ci.yml`. The corpus + # gates there are fast and must stay separately named and independently + # readable; folding a multi-minute Nix build in beside them would couple the + # two, and collapsing them behind `nix flake check` would leave a run showing + # a single check named `check` instead of which corpus gate concluded and how. + # This lane is additive: it proves the CLI packages and that the packaged + # binary works, and it re-gates nothing. + check: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + - uses: DeterminateSystems/determinate-nix-action@v3 + # Builds the package — which runs the crate's test suite via doCheck — and + # evaluates every `checks.*`: fmt, clippy, the `--help` smoke test, and the + # proof that the packaged binary reads a real corpus. + - run: nix flake check --print-build-logs diff --git a/.gitignore b/.gitignore index ddfd60a..7f087a1 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,7 @@ target/ # Artifacts the CI steps write into the checkout root while running. /report.json /graph.json + +# `nix build` output symlinks. +/result +/result-* diff --git a/crates/intent/README.md b/crates/intent/README.md index d43ab23..6f5067c 100644 --- a/crates/intent/README.md +++ b/crates/intent/README.md @@ -62,3 +62,13 @@ than assume either location; `.github/workflows/ci.yml` does exactly that. cargo build --locked --manifest-path crates/intent/Cargo.toml cargo test --locked --manifest-path crates/intent/Cargo.toml ``` + +The repository is also a flake, which is the only supported distribution — there +is no crates.io release. `nix build .#intent` packages the CLI, `nix flake check` +runs fmt, clippy, the test suite and a proof that the packaged binary reads a +real corpus, and `nix develop` gives you the toolchain plus `jq` and +`check-jsonschema` that the corpus gates use. + +Note that `rust-toolchain.toml` pins the channel for rustup users only. A Nix +build uses whichever toolchain nixpkgs pins and does not read that file; the two +are not expected to agree on a patch version. diff --git a/crates/intent/src/lib.rs b/crates/intent/src/lib.rs index db0d522..8bfa80e 100644 --- a/crates/intent/src/lib.rs +++ b/crates/intent/src/lib.rs @@ -8,10 +8,10 @@ use std::io::{self, Write}; use std::path::{Path, PathBuf}; use std::process::{Command, ExitCode}; -/* Positions INSIDE a corpus, so they hold for any repository that adopts the - layout. Resolving the review assets relative to the corpus root rather than the - repository root is what keeps tool and corpus co-located: `review` reads both - from the filesystem at runtime, so a corpus that moves takes them with it. */ +// Positions INSIDE a corpus, so they hold for any repository that adopts the +// layout. Resolving the review assets relative to the corpus root rather than the +// repository root is what keeps tool and corpus co-located: `review` reads both +// from the filesystem at runtime, so a corpus that moves takes them with it. const SEMANTIC_REVIEW_SUBDIR: &str = "15-evaluation/semantic-review"; const REVIEW_PROMPT_ASSET: &str = "16-enforcement/review-prompt.md"; const REVIEW_SCHEMA_ASSET: &str = "16-enforcement/review-result.schema.json"; @@ -208,8 +208,8 @@ impl Defaults { arg.unwrap_or_else(|| self.corpus_root.clone()) } - /* Fixtures live at a fixed position INSIDE the corpus, so one caller-supplied - corpus root determines both defaults and they cannot drift apart. */ + // Fixtures live at a fixed position INSIDE the corpus, so one caller-supplied + // corpus root determines both defaults and they cannot drift apart. fn fixtures_or_default(&self, arg: Option) -> PathBuf { arg.unwrap_or_else(|| self.corpus_root.join(SEMANTIC_REVIEW_SUBDIR)) } @@ -1200,10 +1200,10 @@ pub fn graph_root(root: &Path) -> Result }) } -/* Corpus-relative only. The old second branch guessed `context/vrs/.decisions` to - cover being handed a repository root instead of a corpus root — a guess that was - silently wrong for any repository laid out differently, and that let a misaimed - invocation look like a clean one. Pointing this at a corpus is the caller's job. */ +// Corpus-relative only. The old second branch guessed `context/vrs/.decisions` to +// cover being handed a repository root instead of a corpus root — a guess that was +// silently wrong for any repository laid out differently, and that let a misaimed +// invocation look like a clean one. Pointing this at a corpus is the caller's job. fn meta_vrs_decision_dir(root: &Path) -> PathBuf { root.join(".decisions") } @@ -1537,6 +1537,11 @@ fn check_reference_shape( Ok(()) } +// Eight arguments, one over clippy's threshold. Left as-is deliberately: this crate +// is a lift of `axe vrs`, whose acceptance bar is that it behaves identically, and +// grouping these into a struct is a refactor whose only motivation is a style lint. +// Worth doing later, on its own, where a regression would be attributable. +#[allow(clippy::too_many_arguments)] fn require_section( root: &Path, path: &Path, @@ -1646,7 +1651,7 @@ fn corpus_asset(root: &Path, relative: &str) -> Result { } fn automated_context_indicator() -> Option<&'static str> { - for name in [ + [ "CI", "GITHUB_ACTIONS", "BUILDKITE", @@ -1659,12 +1664,9 @@ fn automated_context_indicator() -> Option<&'static str> { "CODEBUILD_BUILD_ID", "DRONE", "PRE_COMMIT", - ] { - if std::env::var(name).is_ok_and(|value| !value.is_empty() && value != "false") { - return Some(name); - } - } - None + ] + .into_iter() + .find(|&name| std::env::var(name).is_ok_and(|value| !value.is_empty() && value != "false")) } fn visit_markdown( @@ -2228,9 +2230,9 @@ mod tests { .any(|d| { d.rule == "VRS.ENF.meta-decision-shape" && d.severity == Severity::Error })); } - /* The extraction's acceptance bar is that `axe vrs` behaves identically, and the - only thing holding that up is the caller keeping its own default. Locked here - because a regression is silent: the wrong root still exits 0. */ + // The extraction's acceptance bar is that `axe vrs` behaves identically, and the + // only thing holding that up is the caller keeping its own default. Locked here + // because a regression is silent: the wrong root still exits 0. #[test] fn an_absent_argument_falls_back_to_the_callers_layout() { let axe = Defaults::corpus_root("context/vrs"); @@ -2254,12 +2256,15 @@ mod tests { let defaults = Defaults::corpus_root("context/vrs"); let explicit = PathBuf::from("/somewhere/else"); assert_eq!(defaults.root_or_default(Some(explicit.clone())), explicit); - assert_eq!(defaults.fixtures_or_default(Some(explicit.clone())), explicit); + assert_eq!( + defaults.fixtures_or_default(Some(explicit.clone())), + explicit + ); } - /* Covers the branch that replaced the `context/vrs` sentinel. It is only ever - reached where there is no `.git` — a Nix build sandbox or a vendored source - tree — so it is invisible to any interactive run. */ + // Covers the branch that replaced the `context/vrs` sentinel. It is only ever + // reached where there is no `.git` — a Nix build sandbox or a vendored source + // tree — so it is invisible to any interactive run. #[test] fn review_workspace_falls_back_to_the_corpus_when_there_is_no_git() { let tempdir = tempfile::tempdir().unwrap(); @@ -2280,8 +2285,8 @@ mod tests { assert_eq!(review_workspace(&corpus), repo); } - /* The assets travel with the corpus rather than the repository: that co-location - is the reason the tool was moved next to the corpus in the first place. */ + // The assets travel with the corpus rather than the repository: that co-location + // is the reason the tool was moved next to the corpus in the first place. #[test] fn enforcement_assets_resolve_under_the_corpus_not_the_repository() { let tempdir = tempfile::tempdir().unwrap(); diff --git a/crates/intent/src/main.rs b/crates/intent/src/main.rs index a2dd13d..cfedecf 100644 --- a/crates/intent/src/main.rs +++ b/crates/intent/src/main.rs @@ -1,9 +1,9 @@ use clap::Parser; use std::process::ExitCode; -/* The binary is a thin shell over the library entry point on purpose: `axe vrs` - calls `intent::run` directly, so anything that lived here would be behavior the - embedded caller silently does not get. */ +// The binary is a thin shell over the library entry point on purpose: `axe vrs` +// calls `intent::run` directly, so anything that lived here would be behavior the +// embedded caller silently does not get. fn main() -> ExitCode { intent::run(intent::VrsCli::parse()) } diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..5825cc2 --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1785967620, + "narHash": "sha256-IItrdb7Puk05RqOBWZYFC5X6Wl1sJmCfh5MWVHw5iMM=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "b7c2ada94fe99c15b0dbcf4d11fd7850b957a436", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..2dc9051 --- /dev/null +++ b/flake.nix @@ -0,0 +1,152 @@ +{ + description = "intent - deterministic checks, graph extraction and semantic review for a VRS corpus"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = + { + self, + nixpkgs, + flake-utils, + }: + flake-utils.lib.eachDefaultSystem ( + system: + let + pkgs = import nixpkgs { inherit system; }; + + # The crate is at `crates/intent`, not the repository root: it is a + # standalone package with no workspace above it. Every path below that + # reaches into the source has to say so, which is why the manifest, the + # lockfile and `buildAndTestSubdir` are all spelled out rather than + # defaulting to `./`. + crateDir = "crates/intent"; + + # Cargo.toml is the single source of truth for the version, so a release + # bump needs no matching edit here. + version = (builtins.fromTOML (builtins.readFile ./crates/intent/Cargo.toml)).package.version; + + intent = pkgs.rustPlatform.buildRustPackage { + pname = "intent"; + inherit version; + src = self; + + # No git or crates.io-yanked deps in the lockfile, so the lockfile alone + # pins every input reproducibly — no per-dep outputHashes, and nothing + # here to hand-patch when a dep bumps. + cargoLock.lockFile = ./crates/intent/Cargo.lock; + + # Builds and tests run inside the crate, while `src` stays the whole + # repository. That is deliberate: the corpus at `intent/` has to remain + # visible so a check can be aimed at it from the same source tree. + buildAndTestSubdir = crateDir; + + # Both are required, and they are not the same knob. + # `buildAndTestSubdir` only moves the build and test phases; + # `cargoSetupPostPatchHook` still reconciles the vendored lockfile + # against `$sourceRoot/Cargo.lock` — the REPOSITORY root — and fails + # with "Missing Cargo.lock from src" because none is there. `cargoRoot` + # is what points that reconciliation at the crate. + cargoRoot = crateDir; + + # `rust-toolchain.toml` pins the channel for rustup users. A Nix build + # deliberately does not honour it — the toolchain here is whichever one + # nixpkgs pins, which is the point of building this way. The file stays + # for native development; the two are not expected to agree on a patch + # version. + + meta = { + description = "Deterministic checks, graph extraction and semantic review for a VRS corpus"; + homepage = "https://github.com/compoundingtech/intent"; + mainProgram = "intent"; + }; + }; + in + { + packages.intent = intent; + packages.default = intent; + + # These gate the CRATE only. The corpus gates deliberately live as their + # own jobs in `.github/workflows/ci.yml` and are NOT mirrored here: folding + # them behind `nix flake check` would collapse `corpus-strict` and + # `semantic-review-fixtures` into a single check named `check`, and a run + # would no longer show which of the two concluded and how. This lane is + # additive — it packages the CLI, it does not re-gate the corpus. + checks.intent = intent; + + checks.fmt = + pkgs.runCommand "intent-fmt-${version}" + { + nativeBuildInputs = [ + pkgs.cargo + pkgs.rustfmt + ]; + } + '' + cd ${self}/${crateDir} + cargo fmt --check --manifest-path Cargo.toml + touch $out + ''; + + checks.clippy = intent.overrideAttrs (old: { + pname = "intent-clippy"; + nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [ pkgs.clippy ]; + # A custom `buildPhase` replaces `cargoBuildHook`, which is what + # `buildAndTestSubdir` acts through — so without this `pushd` cargo runs + # at the repository root and dies on "could not find `Cargo.toml`". + # Vendoring is set up under the crate too (see `cargoRoot`), so the + # working directory has to be the crate either way. + buildPhase = '' + runHook preBuild + pushd ${crateDir} + cargo clippy --locked --all-targets -- -D warnings + popd + runHook postBuild + ''; + # Nothing to test or install: the lint IS the result. `touch $out` keeps + # the derivation honest about producing an output. + doCheck = false; + installPhase = "touch $out"; + }); + + # Smoke test that the built binary actually runs and its command tree is + # wired, independent of the in-tree `cargo test`. + checks.help = pkgs.runCommand "intent-help-${version}" { } '' + ${intent}/bin/intent --help > /dev/null + ${intent}/bin/intent check --help > /dev/null + ${intent}/bin/intent graph --help > /dev/null + ${intent}/bin/intent review --help > /dev/null + touch $out + ''; + + # Proves the PACKAGED binary reads a real corpus, not just that it builds. + # `check` alone cannot carry this: it exits 0 on an empty directory, so a + # passing check is consistent with having read nothing. The graph is what + # discriminates — empty for both an empty directory and a wrong path. + checks.reads-the-corpus = pkgs.runCommand "intent-reads-the-corpus-${version}" { + nativeBuildInputs = [ pkgs.jq ]; + } '' + ${intent}/bin/intent graph ${self}/intent --json > graph.json + nodes="$(jq '.nodes | length' graph.json)" + echo "graph nodes: $nodes" + jq -e '(.nodes | length) > 0' graph.json > /dev/null \ + || { echo "packaged binary examined 0 artifacts" >&2; exit 1; } + touch $out + ''; + + devShells.default = pkgs.mkShell { + packages = [ + pkgs.cargo + pkgs.rustc + pkgs.clippy + pkgs.rustfmt + pkgs.rust-analyzer + pkgs.jq + pkgs.check-jsonschema + ]; + }; + } + ); +} From 497c354ba7eb4e18bb01d1ec006f438826d9d76b Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sat, 8 Aug 2026 13:27:35 +0200 Subject: [PATCH 4/6] docs(intent): explain the vacuity trap and the surviving `axe` identifiers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two sections authored by the seat that built the original extraction, taken as a patch rather than a push. The first exists because the CI here encodes a defence the README did not explain: `check` and `graph` both exit 0 against a path holding nothing, so a reader copying `corpus-strict` could keep the first assertion and drop the node-count one — which is the half that actually discriminates a clean corpus from an absent one. The second is load-bearing. `AXE.VRS-R*`, `axe.vrs.check.v1` and the `axe vrs …` message prefixes survive inside a binary called `intent` on purpose: they are the differential oracle proving the lift was faithful, and the schema version is a wire contract with a live consumer. There are 29 `axe vrs` occurrences in `lib.rs`, so this is the whole user-visible surface rather than a stray literal — a partial rename would be worse than either leaving it or doing it all at once, and one coordinated pass is scheduled. Without this written down, the next reader files a bug or, worse, "fixes" it. Also sharpens the toolchain note: rustup resolves `rust-toolchain.toml` from the working directory upward rather than from `--manifest-path`, so a command run at the repository root — every CI job here — never sees it, and neither does the Nix build. It applies when working inside `crates/intent/` with rustup, and nowhere else. Co-Authored-By: Claude Opus 5 agent-tool: Claude Code agent-tool-version: 2.1.220 agent-runtime: Claude Code 2.1.220 agent-session-lookup: unavailable tooling-profile: dotfiles@unknown-dirty --- crates/intent/README.md | 46 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 43 insertions(+), 3 deletions(-) diff --git a/crates/intent/README.md b/crates/intent/README.md index 6f5067c..63a4e65 100644 --- a/crates/intent/README.md +++ b/crates/intent/README.md @@ -16,6 +16,41 @@ embedded caller silently does not get. | `intent review ` | Semantic review via the Coding Agent Invocation Contract. | | `intent review-fixtures` | Grades semantic review against evaluation-fixture assertions. | +## `check` exiting 0 does not mean it read anything + +`intent check` on an empty directory, or on a path holding no VRS artifacts at all, +exits **0** with `"diagnostics": []`. `graph` exits 0 there too, with `"nodes": []`. +Neither exit code distinguishes "the corpus is clean" from "the corpus is not +there", so a CI gate built on the exit code alone passes just as happily against a +typo in a path. + +What discriminates is the **node count**: + +```console +intent check "$corpus" --profile strict --json > report.json +jq -e '(.diagnostics | length) == 0' report.json + +intent graph "$corpus" --json > graph.json +jq -e '(.nodes | length) > 0' graph.json # this is what proves a corpus was read +``` + +`.github/workflows/ci.yml` is the worked example. Copy the pair, not just the first +half. + +## `axe`-flavoured identifiers in an `intent` binary + +Rule ids are `AXE.VRS-R01..R19`, `check --json` reports +`"schema_version": "axe.vrs.check.v1"`, and diagnostics are prefixed +`axe vrs check:` / `axe vrs review:`. That is not an oversight. + +This crate was lifted out of `schickling/dotfiles`' `axe` binary, and the acceptance +bar for the lift is that behaviour is byte-identical to `axe vrs` across every +command. Renaming these strings now would destroy the differential oracle that +proves the extraction was faithful, and `axe.vrs.check.v1` is a wire contract with a +live consumer. They are carried to one coordinated rename pass — rule ids, +`schema_version`, the schema `$id` host and the message prefixes together, never +piecemeal. + ## Enforcement assets resolve under the corpus, never the repository `review` reads two assets from the filesystem at runtime: @@ -69,6 +104,11 @@ runs fmt, clippy, the test suite and a proof that the packaged binary reads a real corpus, and `nix develop` gives you the toolchain plus `jq` and `check-jsonschema` that the corpus gates use. -Note that `rust-toolchain.toml` pins the channel for rustup users only. A Nix -build uses whichever toolchain nixpkgs pins and does not read that file; the two -are not expected to agree on a patch version. +`rust-toolchain.toml` sits at `crates/intent/`, and it is in effect in fewer places +than it looks. rustup resolves it from the **working directory** upward, not from +`--manifest-path` — so a command run at the repository root never sees it, which +includes every CI job here. A Nix build does not read it either; it uses whichever +toolchain nixpkgs pins. In practice it applies when you are working inside +`crates/intent/` with rustup, and nowhere else. That is fine, because the crate +pins no MSRV — but the three toolchains are not expected to agree on a patch +version, so do not read agreement into a green run. From c2305e82ffbe1bced4ee1e287d933a82ef5d87d6 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sat, 8 Aug 2026 13:31:41 +0200 Subject: [PATCH 5/6] test(intent): port the integration suite that stayed behind in dotfiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The checker was lifted into this crate but its integration suite was not: 19 tests in `flakes/axe/tests/vrs_check.rs` stayed in `schickling/dotfiles` and drive the `axe` binary. They are the differential oracle for the extraction, so a tool whose real test suite lives in its own consumer is backwards — and invisible until the day that consumer stops depending on it. `cargo test` here reported 8 passing unit tests, which is a much weaker suite than the "19/19" figure the extraction has been quoted as having. Ported: the harness now drives `CARGO_BIN_EXE_intent` with top-level subcommands instead of the `axe vrs …` layer. The corpus-relative asset resolution the lift introduced needed checking rather than assuming, and it holds: the helpers pass `repo/context/vrs` — a corpus root, not a repository root — and the harness builds `16-enforcement/` underneath it, which is exactly what resolution now requires. 10 of the 19 travelled. The other 9 did not, and the file names each one and why. They exercise `review` / `review-fixtures` through the CAIC runner, a separate binary owned by `axe`, and they assert on the CAIC envelope itself (`coding_agent.result.v1`, `run.context_files`, `run.permission.effective`). This crate builds no such binary, and lifting `caic` here would invert the settled dependency — `axe` consumes `intent`, not the reverse. A stub could only make them pass by reimplementing the collaborator under test, which is the "green because the hard cases were dropped" failure in a more expensive disguise. Those 9 stay green in `axe`'s own suite, so the behaviour is still covered — but only while `axe` keeps consuming this crate. Closing the gap needs a CAIC boundary this crate can drive on its own, and that is not this commit. The two `review_*` tests that did travel are the ones that refuse before CAIC is ever executed, so no runner is needed to reach their assertions. `--coding-agent` is handed a deliberately non-existent path to keep that property honest: if a test ever needs it to run, that test does not belong in this file. Verified: 8 unit + 10 integration pass natively and inside `nix flake check`, where the sandbox runs the exec'd fake providers without complaint; fmt and `clippy -D warnings` clean. Co-Authored-By: Claude Opus 5 agent-tool: Claude Code agent-tool-version: 2.1.220 agent-runtime: Claude Code 2.1.220 agent-session-lookup: unavailable tooling-profile: dotfiles@unknown-dirty --- crates/intent/tests/vrs_check.rs | 578 +++++++++++++++++++++++++++++++ 1 file changed, 578 insertions(+) create mode 100644 crates/intent/tests/vrs_check.rs diff --git a/crates/intent/tests/vrs_check.rs b/crates/intent/tests/vrs_check.rs new file mode 100644 index 0000000..5f69d4e --- /dev/null +++ b/crates/intent/tests/vrs_check.rs @@ -0,0 +1,578 @@ +//! Integration tests ported from `flakes/axe/tests/vrs_check.rs` in +//! `schickling/dotfiles`, where the suite stayed behind when the checker was lifted +//! into this crate. They drive the built `intent` binary end to end and are the +//! differential oracle for the extraction. +//! +//! **10 of the original 19 travelled. The other 9 did not, and this is the record of +//! which and why** — a suite that looks complete because the hard cases were quietly +//! dropped is worse than a smaller honest one. +//! +//! The 9 omitted tests all exercise `review` or `review-fixtures` through the CAIC +//! runner, which is a SEPARATE binary (`coding-agent`, a shim over `axe::caic`) owned +//! by `axe`. This crate does not build one, and lifting `caic` here would invert the +//! dependency — `axe` consumes `intent`, not the other way round. Their assertions are +//! on the CAIC envelope itself (`coding_agent.result.v1`, `run.context_files`, +//! `run.permission.effective`), so a stub could only make them pass by reimplementing +//! the collaborator under test: +//! +//! - `semantic_review_fixture_inputs_never_enter_the_review_packet` +//! - `review_invokes_caic_read_only_with_generated_diagnostics` +//! - `review_supports_claude_backend_without_retired_tool_names` +//! - `review_report_writes_result_file_without_stdout` +//! - `review_fixtures_grades_a_result_that_meets_the_minimum_assertions` +//! - `review_fixtures_fails_a_misrouted_or_downgraded_finding` +//! - `review_fixtures_matches_workspace_absolute_artifact_paths` +//! - `review_fixtures_skips_fixtures_without_minimum_assertions` +//! - `review_fixtures_refuses_likely_automated_context` +//! +//! Those 9 remain green in `axe`'s own suite, so the behaviour is still covered — but +//! only for as long as `axe` keeps consuming this crate. Closing that gap needs a CAIC +//! boundary this crate can drive on its own; it is not closed by adding a fake here. +//! +//! The two `review_*` tests that DID travel are the ones that refuse before CAIC is +//! ever executed, so no runner is needed to reach their assertions. + +use serde_json::Value; +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::Path; +use std::path::PathBuf; +use std::process::{Command, Output}; + +struct Harness { + _tempdir: tempfile::TempDir, + intent: PathBuf, + /// Path handed to `--coding-agent`. The CAIC runner is a separate binary owned by + /// `axe`, and this crate does not build one, so this deliberately points at a file + /// that does not exist: the only test using it asserts that `review` refuses BEFORE + /// it would ever be executed. If a test ever needs this path to run, that test does + /// not belong here — see the omissions noted at the bottom of this file. + coding_agent: PathBuf, + repo: PathBuf, +} + +impl Harness { + fn new() -> Self { + let tempdir = tempfile::tempdir().expect("tempdir"); + let repo = tempdir.path().join("repo"); + fs::create_dir_all(repo.join("context/vrs/.decisions")).expect("repo"); + fs::create_dir_all(repo.join("context/vrs/16-enforcement")).expect("enforcement"); + fs::write(repo.join("context/vrs/spec.md"), "# Spec\n").expect("spec"); + fs::write( + repo.join("context/vrs/16-enforcement/review-prompt.md"), + "Return a schema-valid fake VRS review result.", + ) + .expect("review prompt"); + fs::write( + repo.join("context/vrs/16-enforcement/review-result.schema.json"), + r#"{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "required": ["schema_version", "summary", "findings"], + "properties": { + "schema_version": { "const": "axe.vrs.review.v1" }, + "summary": { "type": "string" }, + "findings": { "type": "array" } + }, + "additionalProperties": true +} +"#, + ) + .expect("review schema"); + fs::write( + repo.join("context/vrs/.decisions/0001-good.md"), + r#"# Good Decision + +Status: accepted + +## Context + +The context is concrete. + +## Evidence and Argument + +The evidence is named. + +## Options + +| Option | Tradeoffs | +| --- | --- | +| A | Simpler, but narrower. | +| B | Broader, but costlier. | + +## Decision + +Choose A because it fits the current scope. +"#, + ) + .expect("decision"); + + Self { + _tempdir: tempdir, + intent: PathBuf::from(env!("CARGO_BIN_EXE_intent")), + coding_agent: repo.join("no-caic-runner-is-built-by-this-crate"), + repo, + } + } + + fn check(&self, args: &[&str]) -> Output { + Command::new(&self.intent) + .arg("check") + .arg(self.repo.join("context/vrs")) + .args(args) + .output() + .expect("intent check") + } + + fn graph(&self, args: &[&str]) -> Output { + Command::new(&self.intent) + .arg("graph") + .arg(self.repo.join("context/vrs")) + .args(args) + .output() + .expect("intent graph") + } +} + +fn stdout_json(output: &Output) -> Value { + serde_json::from_slice(&output.stdout).expect("stdout json") +} + +fn write_executable(path: &Path, body: &str) -> PathBuf { + let body = body.replacen( + "#!/usr/bin/env bash", + &format!("#!{}", bash_path().display()), + 1, + ); + let candidate = path.with_extension("candidate"); + fs::write(&candidate, body).expect("write fake provider candidate"); + let mut permissions = fs::metadata(&candidate) + .expect("candidate metadata") + .permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&candidate, permissions).expect("chmod fake provider candidate"); + fs::rename(&candidate, path).expect("atomically publish fake provider"); + path.to_path_buf() +} + +fn bash_path() -> PathBuf { + std::env::var_os("PATH") + .and_then(|paths| { + std::env::split_paths(&paths) + .map(|path| path.join("bash")) + .find(|path| path.exists()) + }) + .expect("bash on PATH") +} + +#[test] +fn graph_json_exposes_files_ids_links_and_wikilinks() { + let h = Harness::new(); + fs::write( + h.repo.join("context/vrs/requirements.md"), + "# Requirements\n\n- **AXE.VRS-R08 Graph command:** emit graph JSON; refines: VRS-R27.\n", + ) + .expect("requirements"); + fs::write( + h.repo.join("context/vrs/spec.md"), + "# Spec\n\nSee [requirements](./requirements.md) and [[Graph Backlog|graph work]].\n\n```text\n[[IgnoredInFence]]\n```\n", + ) + .expect("spec"); + + let output = h.graph(&["--json"]); + assert!( + output.status.success(), + "stdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let graph = stdout_json(&output); + assert_eq!(graph["schema_version"], "axe.vrs.graph.v0"); + + let nodes = graph["nodes"].as_array().unwrap(); + assert!(nodes + .iter() + .any(|node| { node["id"] == "file:requirements.md" && node["kind"] == "file" })); + assert!(nodes.iter().any(|node| { + node["id"] == "AXE.VRS-R08" + && node["kind"] == "requirement" + && node["title"] == "Graph command" + && node["refines"] + .as_array() + .unwrap() + .iter() + .any(|id| id == "VRS-R27") + })); + assert!(nodes + .iter() + .any(|node| { node["id"] == "wiki:Graph Backlog" && node["kind"] == "wikilink" })); + assert!( + !nodes.iter().any(|node| node["id"] == "wiki:IgnoredInFence"), + "wikilinks in fenced code must not enter the derived graph" + ); + + let edges = graph["edges"].as_array().unwrap(); + assert!(edges.iter().any(|edge| { + edge["source"] == "file:requirements.md" + && edge["target"] == "AXE.VRS-R08" + && edge["kind"] == "contains" + })); + assert!(edges.iter().any(|edge| { + edge["source"] == "file:spec.md" + && edge["target"] == "file:requirements.md" + && edge["kind"] == "markdown_link" + })); + assert!(edges.iter().any(|edge| { + edge["source"] == "file:spec.md" + && edge["target"] == "wiki:Graph Backlog" + && edge["kind"] == "wikilink" + })); +} + +#[test] +fn valid_minimal_vrs_tree_passes_json_check() { + let h = Harness::new(); + + let output = h.check(&["--json"]); + assert!( + output.status.success(), + "stdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let report = stdout_json(&output); + assert_eq!(report["schema_version"], "axe.vrs.check.v1"); + assert_eq!(report["diagnostics"].as_array().unwrap().len(), 0); +} + +#[test] +fn missing_local_markdown_links_are_transitional_locally_and_blocking_in_strict_profile() { + let h = Harness::new(); + fs::write( + h.repo.join("context/vrs/spec.md"), + "# Spec\n\nSee [missing](./missing.md).\n", + ) + .expect("broken link"); + + let local = h.check(&["--json"]); + assert!(local.status.success()); + let local_report = stdout_json(&local); + assert_eq!( + local_report["diagnostics"][0]["rule"], + "VRS.ENF.link.local-target" + ); + assert_eq!(local_report["diagnostics"][0]["severity"], "warning"); + assert_eq!(local_report["diagnostics"][0]["gate"], "transitional"); + + let strict = h.check(&["--json", "--profile", "strict"]); + assert_eq!(strict.status.code(), Some(1)); + let strict_report = stdout_json(&strict); + assert_eq!(strict_report["diagnostics"][0]["severity"], "error"); + assert_eq!(strict_report["diagnostics"][0]["gate"], "blocking"); +} + +#[test] +fn meta_vrs_decision_shape_is_blocking() { + let h = Harness::new(); + fs::write( + h.repo.join("context/vrs/.decisions/0002-bad.md"), + r#"# Bad Decision + +Status: + +## Context + +Present. + +## Options + +No comparison table. + +## Decision +"#, + ) + .expect("bad decision"); + + let output = h.check(&["--json"]); + assert_eq!(output.status.code(), Some(1)); + let report = stdout_json(&output); + let diagnostics = report["diagnostics"].as_array().unwrap(); + assert!(diagnostics.iter().any(|diagnostic| { + diagnostic["rule"] == "VRS.ENF.meta-decision-shape" + && diagnostic["artifact"] + .as_str() + .unwrap() + .ends_with("0002-bad.md") + })); +} + +#[test] +fn proposed_decision_records_are_blocking() { + let h = Harness::new(); + fs::create_dir_all(h.repo.join("context/vrs/.decisions/.proposed")).expect("proposed dir"); + fs::write( + h.repo + .join("context/vrs/.decisions/.proposed/revisit-scope.md"), + "# Proposed\n", + ) + .expect("proposed decision"); + + let output = h.check(&["--json"]); + assert_eq!(output.status.code(), Some(1)); + let report = stdout_json(&output); + let diagnostics = report["diagnostics"].as_array().unwrap(); + assert!(diagnostics.iter().any(|diagnostic| { + diagnostic["rule"] == "VRS.ENF.proposed-decision" + && diagnostic["severity"] == "error" + && diagnostic["artifact"] + .as_str() + .unwrap() + .ends_with(".decisions/.proposed/revisit-scope.md") + })); +} + +#[test] +fn delta_record_shape_is_blocking() { + let h = Harness::new(); + fs::create_dir_all(h.repo.join("context/vrs/.delta")).expect("delta dir"); + fs::write( + h.repo.join("context/vrs/.delta/DELTA-001-good.md"), + r#"# DELTA-001: Good + +Status: open + +## Divergence + +The implementation and VRS differ. + +## VRS + +See [spec](../spec.md). + +## Implementation + +Observed in a local check. + +## Direction + +update VRS + +## Resolution Signal + +The spec reflects the implementation. +"#, + ) + .expect("good delta"); + fs::write( + h.repo.join("context/vrs/.delta/delta-bad.md"), + r#"# Bad Delta + +Status: closed + +## Divergence + +This is stale. +"#, + ) + .expect("bad delta"); + + let output = h.check(&["--json"]); + assert_eq!(output.status.code(), Some(1)); + let report = stdout_json(&output); + let diagnostics = report["diagnostics"].as_array().unwrap(); + assert!(diagnostics.iter().any(|diagnostic| { + diagnostic["rule"] == "VRS.ENF.delta-shape" + && diagnostic["severity"] == "error" + && diagnostic["artifact"] + .as_str() + .unwrap() + .ends_with(".delta/delta-bad.md") + })); + assert!(!diagnostics.iter().any(|diagnostic| { + diagnostic["artifact"] + .as_str() + .unwrap() + .ends_with(".delta/DELTA-001-good.md") + })); +} + +#[test] +fn semantic_review_fixture_inputs_are_not_treated_as_real_vrs_artifacts() { + let h = Harness::new(); + let fixture_delta = h.repo.join( + "context/vrs/15-evaluation/semantic-review/stale-delta/input/context/stale-delta/.delta", + ); + fs::create_dir_all(&fixture_delta).expect("fixture delta dir"); + fs::write( + fixture_delta.join("DELTA-001-intentionally-malformed.md"), + "# Fixture Delta\n\nStatus: closed\n\n## Divergence\n\nFixture input.\n", + ) + .expect("fixture delta"); + + let output = h.check(&["--json"]); + assert!( + output.status.success(), + "stdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let report = stdout_json(&output); + let diagnostics = report["diagnostics"].as_array().unwrap(); + assert!(diagnostics.iter().all(|diagnostic| { + !diagnostic["artifact"] + .as_str() + .unwrap() + .contains("semantic-review/stale-delta/input") + })); +} + +#[test] +fn experiment_and_reference_shape_are_transitional_locally_and_blocking_in_strict_profile() { + let h = Harness::new(); + fs::create_dir_all(h.repo.join("context/vrs/.experiments")).expect("experiments dir"); + fs::create_dir_all(h.repo.join("context/vrs/.reference")).expect("reference dir"); + fs::write( + h.repo.join("context/vrs/.experiments/smoke.md"), + "# Smoke\n\n## Question\n\nWhat happens?\n", + ) + .expect("experiment"); + fs::write( + h.repo.join("context/vrs/.reference/provider.md"), + "# Provider\n\n## Relevant Facts\n\nFact.\n", + ) + .expect("reference"); + + let local = h.check(&["--json"]); + assert!(local.status.success()); + let local_report = stdout_json(&local); + let local_diagnostics = local_report["diagnostics"].as_array().unwrap(); + assert!(local_diagnostics.iter().any(|diagnostic| { + diagnostic["rule"] == "VRS.ENF.experiment-shape" + && diagnostic["severity"] == "warning" + && diagnostic["gate"] == "transitional" + })); + assert!(local_diagnostics.iter().any(|diagnostic| { + diagnostic["rule"] == "VRS.ENF.reference-shape" + && diagnostic["severity"] == "warning" + && diagnostic["gate"] == "transitional" + })); + + let strict = h.check(&["--json", "--profile", "strict"]); + assert_eq!(strict.status.code(), Some(1)); + let strict_report = stdout_json(&strict); + let strict_diagnostics = strict_report["diagnostics"].as_array().unwrap(); + assert!(strict_diagnostics.iter().any(|diagnostic| { + diagnostic["rule"] == "VRS.ENF.experiment-shape" + && diagnostic["severity"] == "error" + && diagnostic["gate"] == "blocking" + })); + assert!(strict_diagnostics.iter().any(|diagnostic| { + diagnostic["rule"] == "VRS.ENF.reference-shape" + && diagnostic["severity"] == "error" + && diagnostic["gate"] == "blocking" + })); +} + +#[test] +fn review_refuses_likely_automated_context() { + let h = Harness::new(); + + let output = Command::new(&h.intent) + .arg("review") + .arg(h.repo.join("context/vrs")) + .arg("--coding-agent") + .arg(&h.coding_agent) + .env("CI", "true") + .output() + .expect("intent review"); + assert_eq!(output.status.code(), Some(2)); + assert!( + String::from_utf8_lossy(&output.stderr).contains("automated context (CI)"), + "stderr:\n{}", + String::from_utf8_lossy(&output.stderr) + ); +} + +#[test] +fn review_refuses_backend_without_review_contract_before_invoking_caic_run() { + let h = Harness::new(); + let fake_coding_agent = write_executable( + &h.repo.join("fake-coding-agent"), + FAKE_CODING_AGENT_UNSUPPORTED_DEFAULT, + ); + let marker = h.repo.join("caic-run-invoked"); + + let output = Command::new(&h.intent) + .arg("review") + .arg(h.repo.join("context/vrs")) + .arg("--coding-agent") + .arg(fake_coding_agent) + .env("FAKE_CAIC_RUN_MARKER", &marker) + .env_remove("CI") + .env_remove("GITHUB_ACTIONS") + .env_remove("BUILDKITE") + .output() + .expect("intent review"); + assert_eq!(output.status.code(), Some(2)); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("backend opencode does not satisfy axe vrs review preflight"), + "stderr:\n{stderr}" + ); + assert!( + stderr.contains("network_policies includes disabled"), + "stderr:\n{stderr}" + ); + assert!( + stderr.contains("approval_modes includes never"), + "stderr:\n{stderr}" + ); + assert!( + !stderr.contains("failed to start CAIC executable"), + "stderr:\n{stderr}" + ); + assert!( + !marker.exists(), + "axe vrs review must not invoke CAIC run after a failed capabilities preflight" + ); +} + +// A stand-in CAIC runner that answers `capabilities` with a backend which does NOT +// satisfy the review preflight, and records a marker if `run` is ever reached. It is +// a stub for the preflight negotiation only — the assertions it supports are about +// what `intent` refuses to do, never about what the real runner would return. +const FAKE_CODING_AGENT_UNSUPPORTED_DEFAULT: &str = r#"#!/usr/bin/env bash +set -euo pipefail +case "${1:-}" in + capabilities) + [ "${2:-}" = "--json" ] || exit 2 + cat <<'JSON' +{ + "schema_version": "coding_agent.capabilities.v1", + "default_backend": "opencode", + "backends": [ + { + "id": "opencode", + "modes": ["review"], + "permissions": ["read-only"], + "config_policies": ["isolated"], + "network_policies": ["provider-default"], + "approval_modes": ["on-request"], + "output_formats": ["json"], + "schema_output": false + } + ] +} +JSON + ;; + run) + touch "${FAKE_CAIC_RUN_MARKER:?}" + printf 'run should not be reached\n' >&2 + exit 99 + ;; + *) + printf 'unexpected fake coding-agent command: %s\n' "${1:-}" >&2 + exit 2 + ;; +esac +"#; From a3d34f8ec23b7dd06f2327d95df2329ef5403367 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sat, 8 Aug 2026 13:40:37 +0200 Subject: [PATCH 6/6] docs(intent): separate the three `axe`-flavoured surfaces, which are not one thing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The section as written said "Rule ids are `AXE.VRS-R01..R19`" alongside the `schema_version` and the message prefixes, which reads as though all three are part of what this binary emits. They are not, and measuring says so plainly: this binary emits **zero** `AXE.VRS-R*` ids. Those are requirement ids in the VRS documents. The `rule` field of a diagnostic carries a different vocabulary entirely — `VRS.ENF.link.local-target`, `VRS.ENF.delta-shape` and four others. The distinction is load-bearing for the coordinated rename the same section points at. Only `schema_version` and the message prefixes are parsed by a consumer, so only those are a wire contract; the requirement ids are documentation and can move on their own schedule. Conflating them would either over-constrain the rename or, worse, let someone treat the wire string as prose. Co-Authored-By: Claude Opus 5 agent-tool: Claude Code agent-tool-version: 2.1.220 agent-runtime: Claude Code 2.1.220 agent-session-lookup: unavailable tooling-profile: dotfiles@unknown-dirty --- crates/intent/README.md | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/crates/intent/README.md b/crates/intent/README.md index 63a4e65..7d25e9b 100644 --- a/crates/intent/README.md +++ b/crates/intent/README.md @@ -39,9 +39,16 @@ half. ## `axe`-flavoured identifiers in an `intent` binary -Rule ids are `AXE.VRS-R01..R19`, `check --json` reports -`"schema_version": "axe.vrs.check.v1"`, and diagnostics are prefixed -`axe vrs check:` / `axe vrs review:`. That is not an oversight. +`check --json` reports `"schema_version": "axe.vrs.check.v1"`, diagnostics are +prefixed `axe vrs check:` / `axe vrs review:`, and the corpus's own requirement ids +are `AXE.VRS-R01..R19`. That is not an oversight. + +Note the three are different surfaces, and it matters for anyone planning the rename +below. The `AXE.VRS-R*` ids are **requirement ids in the VRS documents** — this +binary never emits one. The `rule` field of a diagnostic carries a different +vocabulary entirely (`VRS.ENF.link.local-target`, `VRS.ENF.delta-shape`, and four +others). Only `schema_version` and the message prefixes are part of what a consumer +parses. This crate was lifted out of `schickling/dotfiles`' `axe` binary, and the acceptance bar for the lift is that behaviour is byte-identical to `axe vrs` across every