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/.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 new file mode 100644 index 0000000..7f087a1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +# 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 + +# `nix build` output symlinks. +/result +/result-* 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 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..7d25e9b --- /dev/null +++ b/crates/intent/README.md @@ -0,0 +1,121 @@ +# 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. | + +## `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 + +`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 +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: + +``` +/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 +``` + +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. + +`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. 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..8bfa80e --- /dev/null +++ b/crates/intent/src/lib.rs @@ -0,0 +1,2327 @@ +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(()) +} + +// 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, + 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> { + [ + "CI", + "GITHUB_ACTIONS", + "BUILDKITE", + "GITLAB_CI", + "CIRCLECI", + "JENKINS_URL", + "TEAMCITY_VERSION", + "TF_BUILD", + "CONTINUOUS_INTEGRATION", + "CODEBUILD_BUILD_ID", + "DRONE", + "PRE_COMMIT", + ] + .into_iter() + .find(|&name| std::env::var(name).is_ok_and(|value| !value.is_empty() && value != "false")) +} + +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..cfedecf --- /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()) +} 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 +"#; 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 + ]; + }; + } + ); +}