Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
d9ad3e8
fix(resolve): the same tree answered differently under `ripwire .` an…
joyful-ii-V-I Sep 16, 2026
ae58f75
merge: origin/main (#240) into lane/root-spelling-invariance
joyful-ii-V-I Sep 16, 2026
03de352
test(quality): a cached HEAD snapshot was served to a build that reso…
joyful-ii-V-I Sep 16, 2026
eb55bbc
fix(quality): a HEAD snapshot another build computed was served, dead…
joyful-ii-V-I Sep 16, 2026
d8c225a
Merge remote-tracking branch 'origin/main' into lane/qsnap-producer-i…
joyful-ii-V-I Sep 16, 2026
31f9b4f
merge: origin/main (#247) into lane/root-spelling-invariance
joyful-ii-V-I Sep 16, 2026
09bf2fa
merge: origin/main (#241) into lane/root-spelling-invariance
joyful-ii-V-I Sep 16, 2026
cef6a03
Merge remote-tracking branch 'origin/main' into lane/qsnap-producer-i…
joyful-ii-V-I Sep 16, 2026
dba2711
fix(graph): two include resolvers still read the typed root, and seve…
joyful-ii-V-I Sep 16, 2026
14d282e
merge: origin/main (#245, #246) into lane/root-spelling-invariance
joyful-ii-V-I Sep 16, 2026
08f9a90
docs(changelog): the root-spelling entry sat at the top of Unreleased…
joyful-ii-V-I Sep 16, 2026
ae7e1e0
Merge remote-tracking branch 'origin/main' into lane/qsnap-producer-i…
joyful-ii-V-I Sep 16, 2026
5db5f9b
test(quality): a --quality-baseline pin another build computed was ho…
joyful-ii-V-I Sep 16, 2026
c0c1125
fix(quality): a --quality-baseline pin another build computed was the…
joyful-ii-V-I Sep 16, 2026
2a31bc7
merge: lane/qsnap-producer-identity at ae7e1e01 (origin/main 31e788ce…
joyful-ii-V-I Sep 16, 2026
8fb4dcd
docs(changelog): the qsnap entry said key and header carry the SHA-25…
joyful-ii-V-I Sep 17, 2026
47a7ae2
fix(quality): the producer-identity snapshot scheme is 14, because ro…
joyful-ii-V-I Sep 17, 2026
13dd6ae
merge: #253 lane/root-spelling-invariance (08f9a909) into integration…
joyful-ii-V-I Sep 17, 2026
828caff
merge: #255 lane/qsnap-producer-identity (47a7ae20) into integration/…
joyful-ii-V-I Sep 17, 2026
3189ccb
fix(mcp): the new message helper's `file` local was indexed as a symb…
joyful-ii-V-I Sep 17, 2026
9e74894
merge: #259 lane/qbaseline-producer-identity (3189ccb6) into integrat…
joyful-ii-V-I Sep 17, 2026
fb81ed7
reconcile(train-1): re-record printf_parity help_all on the merged bi…
joyful-ii-V-I Sep 17, 2026
07052c3
docs(train-1): review follow-ups
joyful-ii-V-I Sep 17, 2026
4fddca5
merge: origin/main f7bbb04a (#249 crash fixes) into integration/train-1
joyful-ii-V-I Sep 17, 2026
124bede
test(cachefuzz): the qsnap ASan sweep passed unread when the ASan bui…
joyful-ii-V-I Sep 17, 2026
310935e
Merge remote-tracking branch 'origin/main' into integration/train-1
joyful-ii-V-I Sep 17, 2026
e73b3aa
Merge remote-tracking branch 'origin/main' into integration/train-1
joyful-ii-V-I Sep 17, 2026
7461430
Merge remote-tracking branch 'origin/main' into integration/train-1
joyful-ii-V-I Sep 17, 2026
02568cd
merge: origin/main b5ef1700 (#252 slice/parser crash fixes, #262 nigh…
joyful-ii-V-I Sep 17, 2026
09c7f4f
merge: origin/main bcd3b016 (#265 integration/train-2) into integrati…
joyful-ii-V-I Sep 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/pargates-shard-weights.json
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,7 @@
"pyshapecheck.sh": 0.5,
"qackconcurrencycheck.sh": 13.2,
"qackorigincheck.sh": 5.7,
"qbaselineproducercheck.sh": 5.0,
"qchurncheck.sh": 3.7,
"qchurnmemocheck.sh": 4.3,
"qdrefpaircheck.sh": 92.2,
Expand All @@ -402,6 +403,7 @@
"qschemetripcheck.sh": 0.2,
"qsnapcachecheck.sh": 22.8,
"qsnapprefetchcheck.sh": 56.7,
"qsnapproducercheck.sh": 5.2,
"qualifiedresolvecheck.sh": 3.0,
"qualitycheck.sh": 18.1,
"qualitycrosslangcheck.sh": 2.8,
Expand Down Expand Up @@ -454,6 +456,7 @@
"ripwirepubliccheck.sh": 107.5,
"rootrelcheck.sh": 89.2,
"rootrelemitcheck.sh": 108.7,
"rootspellingcheck.sh": 23.4,
"routecheck.sh": 7.2,
"routeedgecheck.sh": 7.3,
"routehookcheck.sh": 39.0,
Expand Down
105 changes: 105 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,79 @@ macOS arm64 on a later release) each went red against a mutant installer that re
the arch or the OS alone. `test/portablebuildcheck.sh` #2h, which held the leg to its verified runner, Xcode and
deployment target, retires with it.

### Fixed — `--quality-delta` answered from a dead-code baseline another build computed

`--quality-delta` caches the snapshot it computes for `HEAD`, keyed on the repository, the commit, the excludes, the
file-size ceiling and the parser version. Its dead-code half also depends on call resolution — a function is dead when
nothing calls it — and a change to how calls resolve moves none of those, because resolution runs over facts already
extracted. So two builds that resolve calls differently, sharing a cache directory on one commit, answered from each
other's snapshot: after an upgrade on a repository whose `HEAD` has not moved, or with an installed `ripwire` and a local
build on one checkout. Measured on main `a55b118e` against the same tree with the `std::`-qualified call guard switched
off, over a two-file fixture where `std::launder( &v )` may or may not bind an in-repo `Pool::launder`: on a cold cache
the guarded build reports `regressions="0"`, and after the unguarded build warmed the cache it reports a gating
`dead-code` row on the untouched `Pool::launder` and exits 2. The other order hides a real one: deleting the only call is
a gating regression on a cold cache (exit 2) and nothing on the warm one (exit 0). Both runs used one cache file.

Each build now has a source identity: a SHA-256 over every file under `src/` and `queries/`, computed by
`cmake/source_identity.cmake` on each build (49 ms on this tree) and compiled in as one generated definition. The
snapshot and window-ref body caches fold its full 64-hex spelling into the material their filename key hashes, and the
blob header stores its `fnv1a64`, which a reader must match. It is derived rather than a version to bump because bumps
are what this cache has missed: an `isDeadCandidate` exemption and a parser-version change each shipped without one, and
no resolution change ever had one. The price is that any source edit renames the snapshot, including one that changes
nothing it means. On ripwire's own tree, five runs each with a private cache, a warm `--quality-delta` took a median
3.04 s (2.78–3.21) and one whose snapshot had to be recomputed 5.12 s (4.85–5.34), identical output throughout; the
parse cache underneath keeps its key, so that recompute reads a warm parse. On the fixed tree the two-build experiment
writes one snapshot per build and matches a cold cache in both orders. The snapshot and window-ref body caches move to
schemes 14 and 4.

Gate: `test/qsnapproducercheck.sh`, 16 rows. Its core is a matched pair over a real cached snapshot: dead entries
dropped (or added) with the producer bytes kept, a control that must change the answer and does, and the same forgery
with those bytes flipped, which must be refused with output byte-identical to a cold cache. Against main 8 of its 13
rows failed: the forgery had no producer bytes to flip and was served in both directions, and the key, header,
derivation and blob arms found nothing. Under ASan the gate passes with no sanitizer report on any child's stderr, and
`test/cachefuzzcheck.sh`'s snapshot sweep stays clean. Pin moved: `test/qschemetrip.hash`.

### Fixed — a pinned `--quality-baseline` was honored by a build that resolves calls differently

The same defect, in the file you write on purpose. `--quality-baseline` pins a floor to `.ripwire_quality_baseline`,
dead-code records included, and stamped it with nothing but the `HEAD` commit, so `--quality-delta` honored the pin
whenever the commit matched, whichever build had written it. Pin with the installed `ripwire`, then check with a local
build (or upgrade) before the next commit, and the floor's dead set came from one resolver while the working tree's came
from another. Measured with two builds of the previous commit, `d8c225a2` as built and with the `std::`-qualified call
guard switched off, over the fixture above: pinned by the unguarded build and checked by the guarded one, an untouched
tree reported a gating `dead-code` row on `Pool::launder` and exited 2 (the guarded build pinning its own floor: exit 0).
In the other order a real regression disappeared: deleting the only `std::launder` call exited 0 where the unguarded
build, against its own pin, exits 2.

The sidecar is now format v6 and carries a `producer` record, the same source identity the snapshot cache uses. A pin at
the current `HEAD` whose producer is missing or names another build is not the floor: `--quality-delta` falls back to
the `HEAD` tree this build computes and says so as `baseline="git-HEAD (foreign sidecar ignored)"`, with one stderr line
and a legend sentence naming the two ways back (run the delta with the build that pinned it, or re-pin on a clean tree
(commit or stash first)). Unlike a stale pin the file is never deleted, by the CLI or by the MCP `quality_delta` verb,
because the build that wrote it can still use it. A root with no git has nothing to fall back to and exits 1 naming the
foreign pin. Demoting the dead-code rows instead was ruled out: it cannot surface a regression whose row never appears,
and another build can compute any kind differently. A pin at another commit is still stale first and still self-heals.
On the same two fixed builds all four cross-build runs give the same-build answer: exit 0 and exit 2, both marked
foreign, the sidecar still on disk.

Every existing sidecar is v5, which only a build without the stamp can have written, so the version rule refuses it.
That refusal used to be reported as `baseline="git-HEAD"`, which means no sidecar existed, under a stderr line saying
there was no `.ripwire_quality_baseline`, one line below the line naming the refused file. It now reads
`baseline="git-HEAD (sidecar unreadable)"` with the matching stderr line, and a root with no git no longer says "no
<file>" about an unreadable sidecar on either arm. Upgrading costs one re-pin on a clean tree (commit or stash first).
An older binary also refuses a v6 pin rather than honoring it without its stamp, but reports the refusal the old way, as
`baseline="git-HEAD"` with a line saying there is no `.ripwire_quality_baseline`; the file is intact. A v5 pin left at
an older commit is no longer removed by the stale-pin self-heal: it is refused as unreadable, with two stderr lines on
every run, until it is re-pinned or deleted.

Gate: `test/qbaselineproducercheck.sh`, 26 rows. Matched pairs over a real pin: dead records dropped (or one added)
with the producer kept, a control that must change the answer and does, and the same forgery with one hex digit of the
producer flipped, which must give the no-sidecar answer and leave the file byte-identical. Beside them: an unstamped v6
pin, a v5 pin, a stale foreign pin, a root with no git, the MCP verb, the legend and `--help`. Against the previous
commit 12 rows failed, both forged directions among them. `test/qrevtokencheck.sh`'s hand-written sidecars move to the
v6 header so its hostile head stamps still reach the head-stamp path. Pin moved: `test/printf_parity.manifest`
(`help_all` only, `UPDATE_GOLDEN_EXPECT` matched).

### Fixed — a deep or odd-shaped argument, source file or skills tree could crash or stall a verb

Each of these was reproduced before it was fixed, and the gate that already owns each verb now fails on the old code.
Expand Down Expand Up @@ -245,6 +318,38 @@ with Python's `sorted()` and went red on both macos-26 CI shards. A sweep of eve
other 26 pass by luck of their current names. All 27 now run under `LC_ALL=C`, and each fixed gate passes under both
`LC_ALL=C` and `LC_ALL=en_US.UTF-8`.

### Fixed — an answer depended on how the root was typed (`ripwire .` and `ripwire "$PWD"` disagreed)

Reported by **@hnipps** in #228: `--quality-delta` on an unchanged tree gated. Part of that report is how the root is
spelled, and it was a graph defect, not a delta one. The crawl stores every path with the root exactly as typed, and the
include/import index and the path predicates read that spelling raw. Three things followed. Python's root-relative
import probe joined onto an empty base, which is the crawl root only under `ripwire .`. Under `"$PWD"`, which is every
MCP session and the `--quality-delta` HEAD side (always an absolute temp root), `from pkg.store import load` stopped
resolving and the name ladder bound a same-directory `load` instead. A root typed `../repo` lost every include and
import edge in every language, because `lexicalNormalize` refuses a path that starts above its base. And a checkout that
merely lives under a `tests/` or `fixtures/` directory had every file tagged `layer="test"`, exempted from dead-code and
seeded as a test under an absolute root, and none of that under `.`. The fix is one seam: `ingest()` records the root
once, and `rootRelPath` (`src/model.h`) gives the root-relative view (a prefix strip, no syscall, no allocation). The
include/import index, the Python/JS/C declaration indexes, the module vocabularies, the test, fixture, layer and tier
predicates, the path-mention and stack-trace suffix matches, the `--lint` byte cap and the map's byte model now read
that view. Stored and printed paths are unchanged. `rootRelativeUri` also trims a trailing `/`, so `--pack-task` rows
stop printing the whole absolute path under `"$PWD/"`. On a shallow Django clone (2b30f62, 3,449 indexed files,
`--no-cache`, map header), `"$PWD"` went from 62,591 edges, `ambiguous=3135` and `declined=49153` to what `.` always
gave: 74,972, 5,958 and 40,681: 12,381 more (caller, callee) edges, and more calls reaching a definition set at all,
which is why the ambiguous gauge rises with them. On the same clone, `--quality-delta` with a fresh cache gated 14 rows
under `.` and `./`, 8 under `../dj` and 0 under `"$PWD"`; it now reports 0 under all four. A `--top-k=300` map flipped
to `order=important-last(auto:fill)` under `"$PWD"` alone and now agrees. Rooting at a test directory now answers the
way `cd tests && ripwire .` does: `ripwire tests/` no longer counts its own files as tests (no `layer="test"`, no test
seeds for `--affected`/`--test-gate`, no dead-code exemption). The `.` answer itself moves slightly on byte-capped
output, because the byte models now charge a path as printed: the default `--lint` page on this repo went from 680 to
689 rows. `kQSnapCacheScheme` moves 12 → 13 so a HEAD Snapshot computed before this fix is never served.
`test/rootspellingcheck.sh` holds six spellings (`.`, `./`, `"$PWD"`, `"$PWD/"`, a symlink and `../name`) to
byte-identical output, once the printed `root=` and `est_tokens=` are normalised, across the committed four-file repro,
eight language import fixtures and a C++ header selector whose answer rests on an include proof. It also checks a
tests/fixtures placement, a real-edit sensitivity arm and, given a pre-fix binary, the scheme upgrade. On origin/main it
fails 57 of its 86 rows. The checkout-shape half of #228 (export-ignore, submodules, sparse checkouts, skip-worktree,
`--no-ignore`) stays open.

### Fixed — a cached enum byte past its enum's last value was believed, and a span-tier memo byte wrote past a stack array

Two on-disk readers built enums straight from bytes with no range check. **The ingest cache** read ten of them —
Expand Down
18 changes: 14 additions & 4 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -296,9 +296,13 @@ else()
set(_ripwire_build_type_str "${CMAKE_BUILD_TYPE}")
endif()
set(_ripwire_version_header "${_ripwire_generated_dir}/version.h")
# Always run before the two executable targets. copy_if_different inside the script keeps the header's
# mtime stable when neither HEAD nor tracked bytes changed, so this gives exact provenance without
# recompiling main.cpp on every incremental build.
# The producer identity (cmake/source_identity.cmake): one generated DEFINITION, compiled into both
# executables, that src/quality.h keys its Snapshot caches on — so a blob one build computed is never
# served to a build whose sources differ. A .cpp rather than a header: see src/sourceidentity.h.
set(_ripwire_source_identity_cpp "${_ripwire_generated_dir}/source_identity.cpp")
# Always run before the two executable targets. copy_if_different inside both scripts keeps each output's
# mtime stable when its input did not change, so this gives exact provenance without recompiling main.cpp
# on every incremental build.
add_custom_target(ripwire_version_stamp ALL
COMMAND ${CMAKE_COMMAND}
-DRIPWIRE_SOURCE_DIR=${CMAKE_CURRENT_SOURCE_DIR}
Expand All @@ -308,7 +312,11 @@ add_custom_target(ripwire_version_stamp ALL
-DRIPWIRE_COMPILER_ID=${CMAKE_CXX_COMPILER_ID}
-DRIPWIRE_COMPILER_VER=${CMAKE_CXX_COMPILER_VERSION}
-P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/version_stamp.cmake
BYPRODUCTS ${_ripwire_version_header}
COMMAND ${CMAKE_COMMAND}
-DRIPWIRE_SOURCE_DIR=${CMAKE_CURRENT_SOURCE_DIR}
-DRIPWIRE_OUTPUT=${_ripwire_source_identity_cpp}
-P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/source_identity.cmake
BYPRODUCTS ${_ripwire_version_header} ${_ripwire_source_identity_cpp}
VERBATIM)

# ---- tree-sitter CORE (static) ----
Expand Down Expand Up @@ -558,6 +566,7 @@ find_package(Threads REQUIRED)
add_executable(ripwire_probe
src/tsprobe.cpp
${RIPWIRE_SRCS}
${_ripwire_source_identity_cpp}
${RIPWIRE_TS_OBJECTS})
target_link_libraries(ripwire_probe PRIVATE tree-sitter Threads::Threads)
target_include_directories(ripwire_probe PRIVATE
Expand All @@ -573,6 +582,7 @@ add_dependencies(ripwire_probe ripwire_version_stamp)
add_executable(ripwire
src/main.cpp
${RIPWIRE_SRCS}
${_ripwire_source_identity_cpp}
${RIPWIRE_TS_OBJECTS})
target_link_libraries(ripwire PRIVATE tree-sitter Threads::Threads)
target_include_directories(ripwire PRIVATE
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1817,9 +1817,9 @@ wrong, and it has. These are the results that say so, all in-tree, all published
### In the tests

<details>
<summary><b>621 gate scripts</b>, five contracts no unit test can hold, and the house rule: write the gate before the code it measures</summary> <!-- gatecount -->
<summary><b>624 gate scripts</b>, five contracts no unit test can hold, and the house rule: write the gate before the code it measures</summary> <!-- gatecount -->

`test/regression.sh` names **621 gate scripts** and is the authoritative list; <!-- gatecount -->
`test/regression.sh` names **624 gate scripts** and is the authoritative list; <!-- gatecount -->
`python3 test/pargates.py . ./build/ripwire -j 6` runs the same set in parallel. On top of them sit the
contracts that do not fit a unit test: two runs byte-identical, warm output identical to cold, output
that pipes clean through `xmllint --noout`, a sanitizer build with `-fno-sanitize-recover=all`, and a
Expand Down
53 changes: 53 additions & 0 deletions cmake/source_identity.cmake
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# The PRODUCER IDENTITY of a build: SHA-256 over the sorted (relative path, SHA-256) of every file under
# src/ and queries/ — the program text and the tags queries it embeds. src/quality.h folds it into the qsnap
# and qbody cache keys and blob header, so a Snapshot one build computed is never served to a build whose
# sources differ (a resolver change moves the dead set; nothing else in the key moved with it). Derived per
# build rather than bumped by hand, because a hand-bumped version is exactly what this cache has missed before.
# See the note at src/quality.h producerIdentity.
#
# Content, not git: a dirty tree, an untracked new header and a tarball with no .git all get the identity of
# what was actually compiled. Path components starting with '.' are skipped (.DS_Store, editor swap files);
# any other stray file only renames the cache, which costs a cold HEAD snapshot and never a wrong answer.
# Out of scope, and why that is sound: third_party/ (vendored grammars move kParserVer by the extraction rule,
# and kParserVer is already in the key) and the compiler (output must not depend on it — the determinism
# contract).
#
# cmake -DRIPWIRE_SOURCE_DIR=<tree> -P cmake/source_identity.cmake
# prints `-- source_identity=<64 hex>` — what test/qsnapproducercheck.sh compares a blob against
# cmake -DRIPWIRE_SOURCE_DIR=<tree> -DRIPWIRE_OUTPUT=<file.cpp> -P cmake/source_identity.cmake
# writes the one definition src/sourceidentity.h declares; copy_if_different, so an unchanged
# tree recompiles nothing
if(NOT RIPWIRE_SOURCE_DIR)
message(FATAL_ERROR "source_identity.cmake: RIPWIRE_SOURCE_DIR is required")
endif()

file(GLOB_RECURSE _files LIST_DIRECTORIES false RELATIVE "${RIPWIRE_SOURCE_DIR}"
"${RIPWIRE_SOURCE_DIR}/src/*"
"${RIPWIRE_SOURCE_DIR}/queries/*")
list(FILTER _files EXCLUDE REGEX "(^|/)\\.")
list(SORT _files)
list(LENGTH _files _count)
if(_count EQUAL 0)
message(FATAL_ERROR "source_identity.cmake: no files under ${RIPWIRE_SOURCE_DIR}/src or queries — refusing to stamp an empty identity")
endif()

set(_manifest "")
foreach(_f IN LISTS _files)
file(SHA256 "${RIPWIRE_SOURCE_DIR}/${_f}" _h)
string(APPEND _manifest "${_f}\t${_h}\n")
endforeach()
string(SHA256 _identity "${_manifest}")

if(NOT RIPWIRE_OUTPUT)
message(STATUS "source_identity=${_identity}")
return()
endif()

set(_body "// Generated at build time by cmake/source_identity.cmake — do not edit.\n// ${_count} files under src/ and queries/.\nnamespace rw\n{\nextern const char kRipwireSourceIdentity[];\nconst char kRipwireSourceIdentity[] = \"${_identity}\";\n} // namespace rw\n")
set(_tmp "${RIPWIRE_OUTPUT}.tmp")
file(WRITE "${_tmp}" "${_body}")
execute_process(COMMAND "${CMAKE_COMMAND}" -E copy_if_different "${_tmp}" "${RIPWIRE_OUTPUT}" RESULT_VARIABLE _copy_rc)
file(REMOVE "${_tmp}")
if(NOT _copy_rc EQUAL 0)
message(FATAL_ERROR "could not write ${RIPWIRE_OUTPUT}")
endif()
Loading
Loading