Repository navigation
v0.6.2
[0.6.2] — 2026-09-21
Added — Microsoft's cl.exe builds the tree, so both Windows front ends compile and both gate
The native Windows port (#44) built with clang-cl only; cl.exe stopped at the GCC/Clang language extensions
this tree is written in. Four classes of extension now go through a portability seam and both front ends build
on windows-latest in every full matrix, each running the same smoke steps (--version, ctest, a real crawl
of test/fixture, the two-run byte-identical contract, well-formed XML).
__BASE_FILE__, whichsrc/structlayout.hused to stamp a layout assertion with its own translation unit, is
supplied by CMake asRIPWIRE_LAYOUT_TUper source file; the header#errors rather than guess when neither
is available.__PRETTY_FUNCTION__,__int128and__BYTE_ORDER__are behindsrc/infra/platform.h— and, for the layer
below it that cannot include it,src/infra/Diagnostics.h§1c./bigobjis passed on both MSVC-ABI front ends:src/main.cppexceeds the COFF limit of 2^16 sections
(fatal error C1128, windows-latest CI, 2026-09-20).
hashutil's wrapping multiply is the one place where the two front ends get different code, and deliberately.
The unsigned __int128 product with a mask is not there for its value — that is plain modulo-2^64 arithmetic —
but to be sanitizer-clean: G1's integer group flags a wrapping uint64 multiply and -fno-sanitize-recover=all
makes it fatal, so a narrow body aborts every ASan-flavour crawl on the first byte of the first hash. GNU and
Clang therefore keep the wide multiply, proven byte-identical to the previous binary by an empty .s diff at
-O2 and under the sanitizer flags; MSVC, which has neither the wide type nor the check, gets the plain
multiply. The sanitizer is not suppressed on any platform that runs it.
test/osswitchcheck.sh gains arm H, which refuses a new __builtin_*, inline asm or __attribute__ outside
that seam pair, with a pinned per-row EXEMPT_COUNTS so a growing allowlist cannot pass unnoticed. A
Windows-breaking change now fails on every POSIX leg instead of being discovered on Windows. README.md,
CONTRIBUTING.md and this file say the same three things about the platform: both compilers build, CI verifies
both, the 647-gate suite does not run on Windows, and the ASan flavour is compiled there but never executed.
Changed — the README's top says what ripwire is before it says what it did
The page opened with a release note. It now opens with the differentiator — a map before an agent reads the repo,
and a check on what it writes — followed by one sentence naming what the check actually reports: blast radius,
the tests that reach a change, ten quality kinds reporting only what got worse, forgotten co-changes, fields read
and written, and names that resolve more than one way. Every clause was verified against the binary's own
--help and docs/COMMANDS.md before it was written. The 0.6.1 paragraph is condensed to the two figures that
carry it (46–66% smaller compact answers; 114 MB → 368 KB on llvm-project) with all three contributor credits
kept, and the badge row moves above the banner so the first screenful is signal rather than decoration.
Fixed — --adaptive no longer narrows a large pool of identically-named symbols on tie-break order
A name-exact route against a common word (update, run) puts dozens of unrelated, identically-named
definitions into one scoring pool. Their scores differ only by the ranker's tie-break, so the largest
relative gap in that pool is an artefact of ordering, not a relevance cliff — and --adaptive cut on it
anyway, taking update from 231 symbols to 6 and run from 148 to 10 while the header stamped
confidence="high". --adaptive now declines to narrow when a name-exact route's positive pool clears
kAdaptiveHomonymPoolFloor (50) and the proposed cut is at most twice the floor: the default top-N is
served instead, an in-band note says why (the pool size, and that the gap is tie-break order), and
confidence= stops claiming high on a cut it declined to trust. margin_pct= keeps the true measured
drop — a zero there still means "no cliff found", never "none exists".
The routing question behind the gate is now asked once. Three call sites — --for's own bundle, the
--format=candidates export and the MCP for verb — each decided "is this the name-exact lane" their own way,
and runForLens asked the negation of a different question (!isConceptualRoute), which is also true for
the third route state, no-route. So --no-route --adaptive read an un-routed ranking as name-exact and
reported a same-name count for a pool that did not exist, with the CLI and MCP disagreeing on the same
tree. isNameExactRouteTag is the single predicate all three now call.
Fixed — legend, capture and disclosure gaps found by a deferred-items sweep
Nine items carried from earlier trains, all in the disclosure surface rather than in analysis: eight stale
entries in the full/compact legend baseline closed; a live CHANGELOG.md figure for the tools/list
manifest corrected against a measurement (test/mcpmanifestcheck.sh arm (1b) now asserts it against a
live tools/list response, so the same rot fails a gate instead of waiting to be rediscovered);
test/showcasecapturecheck.sh gained staleness detection, which immediately caught a stale capture;
--pack-task's bodyless placeholder branch now emits the bodyless= count and a false capped="1" is
gone; a --around-depth row in docs/LINEAGE.md corrected; --readability carries an explicit note in
--help and README.md that its score is not validated against a human-judgement corpus; and stale
quality acknowledgements were healed through the binary rather than by hand. The rule for choosing which
gates a change must run — by verb, by fixture, by the files it touches, and by shard — now lives in
CONTRIBUTING.md instead of a train's own notes.
Fixed — --dead-code no longer claims a confidence its evidence cannot support
Every --dead-code root carried confidence="high", hardcoded: nothing in the candidate loop — internal
linkage plus zero indexed callers — varied it, so the attribute was a constant wearing the shape of a
finding. The claim is also the one most likely to be acted on destructively, since the row an agent deletes
on a false "high" is live code reached by a virtual call, a reflection hook or a macro-generated caller,
none of which a name-based graph can see. The attribute is removed rather than replaced by a derived
number: there is no per-finding signal to derive one from, and evidence="internal-linkage+zero-callers"
already states the one thing the code actually knows. The full and compact legends, --help for
--dead-code and for --safe-delete's dead_code_candidate=, and four skills that asserted the
confidence in prose all say the same thing now. test/deadprecisioncheck.sh asserts the attribute is
absent — the arm that used to pin the bug.
Fixed — reuse-decline fired on clone groups duplication already exempts
reportReusedClones (kind new-clone-of-reused-helper) reads the same clone vectors as its sibling
reportNewClones (kind duplication) but carried neither of that sibling's two demotions: the
all-test-script skip, which exempts a group of sibling gate scripts repeating the house harness
boilerplate by convention, and the idiom→minor demotion. So a shell gate script cloning the house test
helper was exempt as duplication and gating as reuse-decline, from the same input — and that shape is
where the kind's one real-history firing came from. Both demotions are now applied identically in both
reporters.
Fixed — a Java or Kotlin import is a dependency edge, not a call
queries/java/tags.scm and queries/kotlin/tags.scm captured an import_declaration/import_header as
@reference.call. That was inert while nothing owned a reference written outside every named definition;
once a top-level statement gained a <file-scope> owner (#60), an import line minted a real caller edge
and inflated --callers=/--impact= fan-in by one phantom caller per importing file. Both queries now
capture @reference.import, which routes to the existing RefRole::Import the C++ using ns::name; form
already used: the import stays fully visible on --uses as a role="import" use-site, and is excluded from
the call-graph CSR. No new C++ — the two query files are the whole change. kParserVer moves 118 → 119, so
any cache written by an earlier binary is refused and reparsed. Every other indexed language was checked:
Java and Kotlin were the only two that captured an import-shaped construct as a call.
A .kt file has no executable top level, so with this the Kotlin fixture correctly carries no module-scope
owner at all; test/kotlincheck.sh §1a pins that whole chain — no owner minted, the import still visible
with role="import", and square's fan-in counting real functions only.
Fixed — unseeded --slice-flow=both now says that it adds nothing the flat rows do not
Unioned over a function's whole variable inventory, --slice-flow=both's reach is byte-identical to the
union of the flat rows, which holds by construction: every flow row at depth ≥ 1 lands on an occurrence of
some other sliceable local, and that is already a row of that local's flat slice. Seeded with --at=, the
same analysis adds real reach. The unseeded case is not refused — the answer it returns is correct, just no
more informative — it is disclosed: the <slice> root carries flow_redundant="1" exactly when
--slice-flow=both runs with no --at= seed, never on back/fwd alone and never on a seeded run, with
one line in the legend pointing at the seed.
Fixed — a call written outside every named function now has a caller, so callers, impact, affected and test-gate stop answering one short (#60)
A reference was attributed to the innermost definition whose span contains it, so a call written where no
definition reaches — a module top-level statement, or a call inside an anonymous callback body — had no
caller at all. No edge was minted, and the four verbs that walk those edges each answered short: --affected
could report tests="0" for a source file a passing test genuinely exercises, and --callers could report
count="0" for a function that framework registration, DI wiring or route setup calls at module scope.
Measured before the change with --pin-census, as the share of call sites with no caller node: 72.8% of
vue-core, 54.8% of this repository, 1.6% of django, 1.0% of llvm-project. On a TypeScript corpus this was
the majority of the call graph.
Ingest now mints one module-scope owner per file that holds such a call, over exactly the population the
resolver counts as a call, so the two cannot disagree about what a call is. It is labelled rather than
disguised — t="modscope", named <file-scope>, a caller that nothing can name and so never a callee, with
no body: --expand on one answers bodyless="1" capped="0" rather than serving the file. Every legend that
can show the kind defines it, in the full and compact dialects alike, and only when a row is actually
present. The fix is language-neutral: twelve indexed languages were measured to carry the defect, and all
twelve take the same path. Two gaps are stated rather than fixed, because they are upstream: a Ruby bare-word
call without parentheses and a C# top-level-statements call produce no call reference to own.
What a reader will notice: --callers/--impact/--affected counts rise where such calls exist; a
--uses row for a file-scope call gains in_id=<file-scope>; the call graph gains edges (+14% on this
repository); and the map header's unresolved= rises — on shell-heavy trees substantially (5,370 → 12,232
here) — because calls that used to vanish into an ungauged bucket are now counted by the resolver gauges
they always belonged to. Node count grows 0.3–4.3% depending on corpus, so every k= rank shifts slightly.
Reported by @YogevKr in #60: a passing node:test test calls an imported function from an arrow
callback and --affected reports zero tests, while moving the assertion into a named function restores
detection. @alex-michaud added the arm that made a callback-only fix insufficient — the same root cause
drops --callers and --impact edges for a module top-level call in ordinary production code, with no test
file and no test framework anywhere in the tree.
Added — the legend once per session, so an agent stops paying for the same definitions on every call
Every XML answer carried its own legend, so an agent making repeated calls in one session bought the same
definitions again and again — and it bought them before the rows, which is exactly the part it has to read
past to reach the answer. An MCP session can now be served each definition once.
- The dictionary is an MCP resource.
initializedeclaresresources,resources/listnames
ripwire://legend-dict(a small core: how to read<about>,schema=, the paging window, the*_capped
readings) andripwire://legend-dict/full(everything). Reading the core once is what switches the session
on; a host that cannot read resources never switches, and every answer stays inline. Measured on this
repository: the core is 1,094 B, andinitialize's instructions grow 508 → 856 B to point at it. - The
refposture. After that read, an answer lists its rows first, carries a definition only the first
time this session meets it — in a comment after the rows — and ends with
<about … legend="ref" dict= dictv=/>as its last child, wheredictv=is the dictionary version those
definitions came from. Nothing is dropped: a ref answer carries the same attributes and the same rows as the
inline answer it replaces, and it is never longer than that answer — if it would be, the inline answer is
served unchanged. Measured on this repository,impactoncompactLegendText: 3,386 B inline, 2,571 B
on the second call of a switched session (−24.1%), with the bytes before the first row falling 78 → 8. --legend-dict[=roster]prints the same dictionary on the CLI — one definition per line, headed by its
dictv=;=rosterlists the completeness attributes it defines, as attribute/element/source rows. It is
answered wherever it appears on the command line and nothing else runs. On this build the dictionary is
68,316 B over 702 entries and the roster is 602 rows; a session receives only the entries its own answers
used, not the whole thing.--legend=refrefuses on the CLI, naming the resource and--legend-dict. A CLI run is a single answer
with no session to have been served anything, so a ref answer there would point its reader at definitions
that reader never received. CLI output is otherwise unchanged by this work.
This amends guardrail G4, which said the legend is emitted once at the top of an answer: it is now once per
answer on the CLI and once per session on the agent surfaces. CLAUDE.md and CONTRIBUTING.md carry the new
wording and name the gates that hold it — legendcoveragecheck (G) and compactlegendcheck (UG) for the
default posture, legendrefcheck for the ref posture.
Fixed — --quality-delta on a tree that is already HEAD stops reporting phantom debt
--quality-delta built its HEAD side by archiving the commit into a temp directory and re-ingesting it. That
tree is a different population from the working tree, and a dead-code verdict is a property of the whole
population — so a file present on only one side moved the verdict of a symbol in a file both sides shared. On a
working tree identical to HEAD, with untracked directories present, @hnipps saw 58 gating
preexisting-worse dead-code rows and exit 2 on a Python monolith of ~7,100 tracked files (#228) — every row
in a test file nobody had touched — and --quality-baseline then refused to pin a floor over that same
phantom debt, so the documented escape hatch was unavailable exactly where it was needed. The case that makes
it matter is the one reported: the exit code is meant to be a pre-commit gate, and a gate that fires on a
clean tree cannot be used.
When the tracked tree already is HEAD, ripwire now stops materializing a second tree: the baseline is this
tree's own snapshot, so the comparison is a snapshot against itself and no regression can exist in it. Files the
tree holds that HEAD does not track — untracked files, an untracked nested repository, a checked-out submodule —
stay in the crawl and in the graph, but not in the baseline, so their own debt is still reported as new-symbol
exactly as before, and the count is stated on stderr. The CLI delta, the CLI --quality-baseline pin and the MCP
quality_delta verb all take the same basis, through one function.
Measured on a nine-file fixture whose working tree is identical to HEAD, gating rows before → after: an untracked
directory 1 → 0, an untracked nested repository 2 → 0, a tracked file marked export-ignore 1 → 0, --no-ignore
over a gitignored same-named definition 1 → 0 — exit 2 → 0 in each. A shallow clone read zero both before and
after; it was a bystander in the report. A real edit that deletes a function's only caller still gates exactly one
dead-code row in the hardest of those shapes. New arms in test/qualitycheck.sh pin the invariant, its
sensitivity controls and its determinism across cold, warm and a fresh temp directory.
Two checkout shapes are NOT fixed, and the answer says so rather than implying otherwise. A tracked path
carrying git update-index --skip-worktree or --assume-unchanged hides its own bytes from git, so the
identity basis cannot be trusted over it and is refused: that tree takes the archived comparison and keeps
gating. For a hidden edit that is the right answer — the change is real, only concealed — and for a
sparse checkout it is a known gap: the archived tree is not sparse-aware, so an excluded caller can still
gate a phantom row. Both cases now name the basis on the root, head_basis="archived-index-hidden", instead of
leaving a reader to guess why the fast path vanished; head_basis="identity" marks the answers above, and its
absence is the ordinary archived comparison. Closing the sparse gap is follow-on work, tracked with the rest of
the archived-path population problem below.
A tree that carries tracked modifications still takes the archived-HEAD path, so a population difference can
still move a verdict there; prompts/help-wanted/quality-delta-unchanged-tree-zero.md is where that follow-on
lives.
Fixed — the MCP for bundle defines at=, ccx= and next=, which it was already emitting
for's bundle writes its own legend rather than going through the compact composer, and three attributes it
carries had no reading anywhere in it: at= on the root, ccx= and next= on its rows. A reader had to guess
them. Any native-legend answer carrying a completeness attribute its own legend never spells now gets that
attribute's compact reading, in one comment, before the answer is priced — so the next one is defined on
arrival rather than found later. Rows are byte-identical; the legend grows 55–115 B.
Added — affected and rank_by over MCP, so an agent can ask which tests to run and re-rank the map without leaving the server
The MCP server exposed 31 tools and neither of these two, so an agent mid-task had to shell out to the CLI for
"which tests reach what I just changed" and for a second ranking of the same graph. It now exposes 33.
Both serve the CLI's own renderer rather than a second implementation of it:
affected— the twin of--affected=F1,F2|SYM.--affected's resolve-and-render body moved out of the
CLI arm intotestmap.h::writeAffectedReport, which both surfaces now call; the CLI arm is a wrapper that
adds its own stderr wording on the two failure returns. The payload is byte-identical to the CLI's at the
same legend posture, moduloroot=(.on a relative CLI invocation against the absolute MCPpath) —
measured on this repository over four selectors and three postures.rank_by— the twin of--rank-by=pagerank|authority|hub|rrf, through the sameserialize()call
analyzeuses, varying the rank vector alone.churnandchurn-decayare refused by name, with the
CLI command that answers them, rather than read as an unknown value or quietly served aspagerank: they
mine git history through the CLI's parsed-argument path, which this server does not build.
Both declare legend, so they take their posture per request like the rest of the family — absent means
compact, legend:"full" restores the prose legend byte-for-byte, and the rows do not move between the two.
The tools/list manifest grows 43,500 → 46,493 B (method: test/mcpmanifestcheck.sh's own formula — the
compact JSON length of the served tools array — over one tools/list response on this repository's binary
before and after; its 46,600 B ceiling is unmoved, with 107 B of headroom — the 46,371 B this entry first
recorded was superseded, same round, by a rank_by description fix (test/mcpmanifestcheck.sh's own
RE-ANCHORED/TRAIN 10 FIX ROUND comment history), and this prose copy went uncorrected for five days before
being re-derived by hand; test/mcpmanifestcheck.sh arm (1b) now asserts this figure against a live
tools/list measurement so that rot is a gate failure, not a future rediscovery).
Fixed — rank_by over MCP no longer answers a different ranking than --rank-by on a tree with uncommitted changes
The warm MCP index caches a rank vector that is deliberately personalized toward the uncommitted diff, which is
right for analyze and wrong for a verb whose CLI twin runs an unbiased rankGraph. Reusing it made
rank_by diverge from --rank-by on any dirty tree — same document size, different bytes, so nothing that
compared lengths would have seen it. rank_by now computes a fresh unpersonalized rank unconditionally.
Measured on this repository with two tracked files edited and uncommitted: the personalization really is live
(analyze converges in 27 power iterations against the CLI map's 37), and all four rank_by modes are
byte-identical to their CLI twins in every posture on that same tree.
Fixed — --affected declares its root when a repeated root argument leaves only one
ripwire DIR DIR --affected=… drops the duplicate and indexes one root — the crawl says so on stderr — but
--affected's single-root test counted the roots as typed rather than the roots that survived. The report
then omitted root= while the runner index, which reads the deduplicated corpus, went on spelling run=
relative to that root: a relative command in a document giving the reader no anchor for it. The test now
reads the deduplicated corpus, which is the predicate --situ and the MCP twin already used, and the answer
to DIR DIR is byte-identical to the answer to DIR. A genuinely multi-root run is unchanged: it still
keeps absolute commands and declares no root. The runner index is now also given a root only when the
document declares one, so the two cannot disagree whatever a caller asks for.
Fixed — an MCP rank_by sent as an empty string is refused instead of read as the default
rank_by:"" was treated as an omitted field and answered pagerank, where the CLI's own --rank-by=
refuses with unknown value ''. A schema default applies to a field that is absent, not to one that is
present and outside the closed set. The two are now told apart by the same presence reader sections and
legend use, and a present-but-empty value gets the closed-set refusal with the CLI's wording. Omitting
rank_by still answers pagerank, byte for byte.
Fixed — an MCP call that omits a required files argument is told which argument is missing
The generic missing-required-field composer had no case for files, so it could not tell the field was absent
and fell through to the unknown-tool path — which answered with the tool's own name as the thing it did not
recognise. affected is the first tool whose required argument is files, which is where this surfaced.
affected with no arguments now refuses with -32602 and names the field and what it wants.
Changed — the CLI's default legend is compact; --legend=full restores the prose legend
Every XML answer now leads with the compact legend unless --legend=full is passed — --for included, whose
own ripwire.for/v1 header is its compact dialect. The compact legend defines every attribute the answer
carries — each verb's purpose, the completeness vocabulary (counts_floor=, shown=/total=/capped=, the
paging window, est_tokens=, over_ceiling=, the resolver gauges …) and every descriptive or cut attribute
(renames_window_truncated=, script_gates_unmodelled=, hcut=/rcut=, …), each reading present only where
the answer carries it. The full prose, which used to be the default, is one flag away and byte-identical to what
0.6.1 printed without it. Rows never change between the two, with one chosen exception: --expand serves the
bundle or the whole file by whichever the posture DELIVERS cheaper, so the two postures can serve different ones. The MCP server has defaulted to compact since
0.4.0, so both surfaces now agree. Measured on this repository: --callers 6,305 → 3,212 B, --edit-check
9,750 → 3,313 B, --affected=src/cli.h 2,103 → 1,234 B, --for 10,010 → 9,117 B (method: the same argv,
--legend=full against this default, wc -c).
What else moved with it, because a default has to be honest where an opt-in could be terse:
- Nothing the default prints is undefined, and nothing it drops is a disclosure.
--from-tracekeeps the
ceiling it applied (<!-- ledger: budget=N bytes (allowance M bytes …) -->),--noteskeeps its
notes= targets= dangling=counts,--pack-taskkeeps its budget ledger, and the withheld-map record reads
as a withheld map. - A compacted answer is priced at the bytes it delivers, and trimmed at that price.
est_tokens=used to
keep the full legend's price after compaction (--connecthere: 1,200 tokens for 1,037 bytes); it is now
moved by the removed bytes at the document's own rate.over_ceiling="1"and--pr-context's
budget-floor-exceededfollow the repriced number, the flagless map's--token-budgetgate decides on the
compact price, and--pr-context,--pack-task,--from-traceand--expand's serving choice decide their
cuts on the price the answer will print —--pr-contextat--token-budget=4000here kept 22 changed files
where it had kept 10, with the same budget. --forunder--token-budgetnever loses a row--legend=fullkeeps. Its compact sig ledger subtracted
the full dialect's enrichment clause (~450 B the compact header never carried); the charge is now capped at
the full dialect's. Re-measured over 66 budgets (300..3550 step 50) on a 72- and a 12-function fixture: the
default lands over its budget on 15 and 15,--legend=fullon 11 and 12; every overshoot is labelled
over_ceiling="1".- Nothing the default cannot shape fails because of it. A run whose answer has no XML legend passes through
unchanged.--legend=fullis accepted, as a no-op, by the read answers that only have the full form (--situ,
--recall,--report,--mermaid,--html,--plan-lanes,--sarif,--eval*); an asked
--legend=compactstill refuses there, and the writers and servers refuse either.--pin-censustakes a
posture (its map is the answer). The servers (--mcp,--listen,--lsp) and--jsonkeep no default. --helpnow promises "at least 40%" of a small--callers/--uses/--impact/--affectedanswer saved by
the compact legend, down from 45%: the definitions it gained cost--affectedfour points (41% measured).--query's<!-- routed: … -->note is kept where the root carries noroute=; the router's generated
commands no longer append--legend=compact; the prompt-route hooks readstatus=as an attribute, so they
route under either root attribute order.
Scripts that parse the full legend's prose, or match a root's first attribute byte for byte, should pass
--legend=full.
Added — --for lists a named file's one declaration/implementation partner first, and --situ puts partners before the blast radius
When a --for task names a path that resolves to exactly one indexed file, and that file has exactly one
same-directory, same-stem declaration/implementation partner (a source tries .h/.hpp/.hh; a header tries
.cc/.cpp/.c), the answer now opens with <hdr p="partner" of="named"/>, right after the legend. It is a
lookup by name, not a ranked or graph-derived row: an ambiguous, absent or convention-less named file gets no row
and no reordering, and a partner the task already names is not repeated. The row's legend clause is present only
when a row is, and is never dropped by the ceiling ladder. The MCP for verb serves the same row from the same
resolver. The rows are counted in est_tokens=, in over_ceiling= and in the --token-budget ceiling like every
other section, and they do not take space from the ranked signatures. --situ now prints its decl/def-partner and
lexical-sibling blocks before the [1] blast radius line instead of after it; the lines themselves are unchanged,
only their order. Gates: test/forhdrshapecheck.sh (new), test/situshapecheck.sh arm (13),
test/estchargecheck.sh #19.
Changed — --for collapses a <lego>/<compose> section to a counted stub when the stub is smaller
A <lego> or <compose> section in a --for answer is now replaced by a counted stub,
<lego total="N" shown="0" capped="1" next="…"/>, but only when the stub plus its legend clause is smaller than the section
it replaces; a small section stays whole. total= is the section's own pre-cap row count, and next= names the
new --sections=lego,compose flag, which restores both sections byte-identically in one call. The legend clause
is present only when a section was actually stubbed, in both legend dialects and on the MCP for verb
(sections argument). An empty value, a trailing comma or an empty segment (lego,) is refused rather than read
as a shorter list, on both surfaces. When the in-memory render that prices the section is unavailable, the section
is emitted whole, because nothing shows that the stub would be smaller. Gate: test/forsectioncollapsecheck.sh (new).
Fixed — a .ripwire_notes file that could not be fully read looked the same as no notes file
A sidecar with unparseable lines, or one refused because it is a symlink, was disclosed only by --notes
itself (lines_skipped=/refused=). Every other reader answered as if the tree had no notes file at all. The
map, --expand (including whole-file and --top-k=0), --for and --pack-task (XML and --json),
--edit-check, --handoff, --plan-lanes, and the MCP for/pack_task/from_trace/fetch_body verbs now
carry notes_degraded="1" ("notes_degraded":true in JSON) with a legend clause. The marker is absent on a
clean read, so output for a tree with no sidecar, or one that fully parses, is unchanged. Gate:
test/notesdegradecheck.sh (new). (CodeRabbit review on #295)
Fixed — a JSX tag starting with _/$ lost its call edge, treated as an intrinsic HTML/SVG tag
isJsxIntrinsicTagIdentifier (the filter that keeps <div>/<h1> from minting a phantom call edge to a
symbol that is never defined) tested !( c >= 'A' && c <= 'Z' ) — every non-uppercase-first-letter tag
counted as intrinsic, so <_Widget/> and <$Widget/> (both legal, non-lowercase leading characters for a
JS/TS component identifier) lost their call edge exactly like an ordinary HTML tag. The filter now tests
ASCII-lowercase directly: only a-z is intrinsic. --callers/--uses/--affected/--test-gate on such
a component now see the JSX call. kParserVer 117 → 118; a cache written before this under-counts their
callers. (CodeRabbit review on #295)
Fixed — --help-task routed on "reach"/"reaches" as a plain substring, firing inside "outreach"/"unreachable"
The reach-flow router (--help-task's "how does A reach B" shape) scored its single-word "reach"/"reaches"
cues with the same substring matcher its multi-word phrase cues use, so "reach" fired inside "outreach" and
"unreachable", and "reaches" scored both cues at once (10 points from one word). A task naming two indexed
files, an unrelated "reach"-containing word, and one cheap phrase cue ("how does") could cross the routing
threshold and wrongly recommend --for=task. The two single-word cues now match word-bounded instead.
(CodeRabbit review on #295)
Fixed — merge-scout refused a legal empty ingest as an unavailable tree, and never said why
A --merge-scout arm whose base ingested zero files — a fresh root commit, a base whose every path is
excluded, or one with no file of a supported extension — was refused as ok="0" (unavailable tree), the same
answer a real failure gets, and the same check marked head_conflicts_ok="0" for it too. The cause was
upstream: materializeCommitTree piped git archive into tar -x, so the pipeline reported only tar's
exit status, which is 0 on an empty stream whether the tree was genuinely empty or git archive failed and
piped nothing. It now archives to a file and extracts that file as a second command, so an archive failure and
an extract failure are each reported from the exit code of the command that had it. With materialize
trustworthy, a verified-successful materialize whose ingest is empty becomes a real, indexed, empty tree and
the arm compares normally. When an arm is still refused, the root carries reason="no_merge_base" or
reason="tree_unavailable" — present only when ok="0", and written in every build flavour, not only in a
debug trace. Both ok= postures and reason= are defined in the legend. Gates:
test/scoutheadconflictcheck.sh arms T9(a)–(e), test/mergescoutcheck.sh. (CodeRabbit review on #295)
Fixed — sixteen gates now share one GNU/BSD stat compat helper instead of a per-gate copy
stat -f is GNU coreutils' filesystem-stat flag, not BSD's format-string flag, so it succeeds with junk
instead of failing — a caller-local stat -f ... || stat -c ... one-liner never reaches its own fallback on
Linux. Sixteen gates each hand-rolled the same detect-once-and-redefine fix independently:
cachehashcheck.sh, cachesplitcheck.sh, clonecachecheck.sh, codexpromptroutecheck.sh,
evictioncheck.sh, g1freshcheck.sh, headsnapcachecheck.sh, mcpeditmodecheck.sh,
portablecachecheck.sh, prcontextcheck.sh, qsnapcachecheck.sh, qsnapprefetchcheck.sh,
statgatecheck.sh, cacheisolationcheck.sh, qsnapproducercheck.sh, sidecarsymlinkcheck.sh and
tempfilesymlinkcheck.sh all now source the new shared test/lib/statcompat.sh instead — one place defines
the GNU-vs-BSD stat compat logic, not seventeen. Centralised by @s0undt3ch in #298.
Fixed — a gate that builds a throwaway git repository could be aimed at the caller's repository instead
git -C DIR changes the working directory; it does not override the environment, and GIT_DIR,
GIT_WORK_TREE, GIT_COMMON_DIR, GIT_INDEX_FILE, GIT_OBJECT_DIRECTORY,
GIT_ALTERNATE_OBJECT_DIRECTORIES and GIT_PREFIX all outrank it. A gate that builds a fixture repo and
asks it a question therefore answered from somebody else's repository whenever one of those was exported —
by a git hook running the suite, a CI job, or a git rebase running the suite per commit — and then
passed or failed on data it never
selected. Measured at the reported call shape: with GIT_DIR set, git -C "$REPO" init created no .git
under $REPO at all, the gate's own commit landed in the ambient repository, and git -C "$REPO" rev-parse HEAD read that foreign sha back, with the gate still reporting ALL PASS. The clearing joins the
agent-home names in the shared test/lib/clean-env.sh rather than becoming a 156th call-site copy: 155
gates build a repo and were exposed, and the two that had already found this independently
(dispatchordercheck.sh, pagingsweepcheck.sh) had hand-rolled partial lists that each missed names the
other had. New gate test/gitenvhermeticcheck.sh proves the defect is live on this git, proves the helper
fixes it, pins the variable list against the helper, and sweeps the tree for a repo-building gate that does
not source it — with a control that strips the source line from a real gate and requires the sweep to flag
the copy. (CodeRabbit review on the train-13 branch)
Fixed — a gate that varies HOME= per invocation could still leak into an ambiently-set agent-home variable
CODEX_HOME/AGENTS_HOME/HERMES_HOME/CLAUDE_CONFIG_DIR/RIPWIRE_DATA_HOME override the default an
agent's tools derive from HOME, so a gate that only sets HOME= per invocation is not actually sandboxed
on a machine where any of these is already exported ambiently. codexpromptroutecheck.sh,
claudeconfigdircheck.sh, skillinstallcheck.sh and hermesinstallcheck.sh now source the new shared
test/lib/clean-env.sh before varying HOME=, closing that leak in each. claudeconfigdircheck.sh — the
gate that exists specifically to test CLAUDE_CONFIG_DIR relocation — was the one this hit hardest: with
CLAUDE_CONFIG_DIR exported ambiently (a developer whose real Claude Code config is relocated, exactly the
case this gate tests for), its "unset" baseline arm wrote real files into that directory and then failed
comparing against its own contaminated baseline. Found and closed by @s0undt3ch in #298; carrying the
same shape through the rest of the suite closed six more — hookcheck.sh, routehookcheck.sh,
agenttablecheck.sh, codexinstallhonestycheck.sh, meterdisclosurecheck.sh and releaseinstallcheck.sh.
Added — the advertised MCP verb roster is pinned by deriving it, so a count-preserving rename cannot slip through
test/mcpverbscheck.sh checked that tools/list advertises the expected number of verbs, which a rename
that swaps one name for another passes unchanged — and a renamed verb is a silently broken contract for every
agent that had wired up the old name. The gate now derives the roster from a live tools/list call and
compares the names, all 33 of them. Proved against the mutation it exists for: planting an added verb, a
removed verb and a count-preserving rename in the served stanza all redden the new arm, and the rename case
leaves the old count-only check green — which is the gap. Contributed by @pt-act in #291.
Fixed — two documentation claims that described behaviour the binary does not have
--expand=SYM on an ambiguous name was documented without the scoping the disclosed topk_default="0"
actually applies, and the directory-of-repos row described a guard that does not exist, where the real
behaviour is a silent merge. Both corrected in README.md and in the ripwire-navigate and ripwire-orient
skills, against a built binary rather than from reading the source. Contributed by @llvm-x86 in #302,
who built the Flask fixture that found them.
Added — native Windows x64 (clang-cl, MSVC ABI), behind src/infra/os.h — thanks to @lennix1337 (#44)
@lennix1337 ported ripwire to native Windows in #44 and then kept it alive through weeks of a moving main: UTF-8
and long paths end to end, the no-follow sidecar opens against symlinks, junctions and OneDrive placeholders,
--run-trace children inside a Job Object so a timeout ends the whole tree, Git Bash script bridges so cmd.exe
never expands a %, the LockFileEx edit lock their race trials proved, the MCP directory watcher, the
PATH/PATHEXT search, and an owner-and-Administrators ACL for the cache directory — plus a local validation run of
the Windows gates on their own machine, and a snapshot (win32-port-snapshot) with the fixes that run turned up.
Their commits are in this history as they wrote them.
This release carries that work in the shape os.h set out: every Windows body is in
src/infra/os_win32.cpp (the one translation unit that sees <windows.h>), declared by os.h's Windows branch
under the same POSIX names call sites already spell, and every piece of it that is not a Win32 call — the
Win32→errno table, UTF-8/UTF-16, CreateProcessW quoting (adapted from libuv, notice in THIRD_PARTY.md),
reparse-tag and wait-status decoding — is in src/infra/os_win32_logic.h, which every Linux and macOS leg now
compiles and tests (test/oswin32logiccheck.sh: 29 cases, 4,480,667 assertions, a sanitizer arm and a mutant arm
that must fail). A Windows build adds only cmake/Windows.cmake: os_win32.cpp, a manifest (longPathAware,
UTF-8 active code page), ws2_32/advapi32/shell32, /EHsc. No force-include, no compat headers, no
libc-renaming macros.
Linux and macOS pay nothing for it. Measured on macOS arm64, Release, this branch against main e54b688e: no
rw::os symbol in either binary, identical symbol sets (11,021), __text 9,172,004 B in both, and of 5,034
functions 5,031 disassemble identically and 3 differ only by the build stamps (the source-identity hash in the
quality snapshot's two serializers, the built_from length in --doctor). The same 71 cases the os.h refresh
used (map, 25 verbs, --run-trace, MCP stdio and --listen, the sidecar and index writes) are byte-identical.
Since proven on Windows, and exactly that far. @lennix1337's run against the D3 branch found five build failures, all fixed; a windows-latest CI job now builds with clang-cl and smoke-tests the result on every full matrix — configure, build, ctest (including the port's own oswin32logiccheck target), --version/--help, a real crawl of test/fixture, the two-run byte-identical determinism contract, well-formed XML, and the ASan flavour compiling. What is NOT proven: the gate suite does not run on Windows (it needs the harness), no sanitizer RUN happens there, and nothing exercises a UNC share, a junction, a non-ASCII path or a volume without a drive letter — the checklist on #44 is still open. MSVC cl.exe now builds too. The GCC/Clang language extensions the tree is written in go through a seam in src/infra/platform.h — and, for the layer below it that cannot include it, src/infra/Diagnostics.h §1c — so both front ends run the same CI steps and both gate. test/osswitchcheck.sh arm H refuses a new __builtin_*, inline asm or __attribute__ outside that pair, which makes a Windows-breaking change fail on every POSIX leg instead of on Windows. The same caveats still apply to both compilers: the gate suite does not run on Windows, and ASan is compiled there but never executed.
A review pass (no Windows machine, read plus a macOS/Linux-provable subset) found one MED and five LOWs, none
touching POSIX; this fix round closes the MED and three of the LOWs, still on top of @lennix1337's work:
- Paths past ~260 characters now get the
\\?\extended-length prefix (NativePath, reusing the existing
extendedLengthPathhelper behind a length threshold,os_win32.cpp/os_win32_logic.h): without it, a file
deeper than that on a default-policy Windows (LongPathsEnabled=0) failedCreateFileWwith
ERROR_PATH_NOT_FOUND—ENOENTfor a file that exists — and the crawl or sidecar silently skipped it. ERROR_IO_PENDING(997) now maps toEWOULDBLOCKin the Win32→errno table, alongsideERROR_LOCK_VIOLATION
(33), as a defence for the edit-lock contention loop.getline's buffer growth no longer reads*capacitywhile*lineisNULL(POSIX ignores it in that case).- The
stat_t::st_inocomment now says what the code does (BY_HANDLE_FILE_INFORMATION's 64-bit file index; a
ReFS/DevDrive volume's 128-bit id is not queried) instead of describing a fold that never happens. --in=,--doc-drift=and--exclude=join the path-valued flags normalized at intake (--scope=, a glob
set, and--layout=, a type name, deliberately do not).--doctor's binary-path row is markeddegraded="1"on Windows (Git Bash's MSYSwhichnever prints
.exe, so the row's file comparison can disagree with a correct install) rather than left to read as a false
mismatch; POSIX is unaffected. The honest fix — a nativeos::whichPATH/PATHEXT search — is a follow-up.openat's handle-anchored join (kept as-is this release, notNtCreateFile) now documents its residual: the
finalCreateFileWre-walks a freshly built path string, so a link swapped in above the anchored directory in
that narrow window is followed, unlike a true handle-relative open.
Left for a contributor's own Windows run (checklist in the coordination history): MSYS bash re-parsing
spawn_sh's command line, and openat on a volume mounted without a drive letter.
Fixed — an ambiguous --expand buried its body behind the ranked map, and the escape hatch was stderr-only
Reported by @mariadb-KyleHutchinson in #289: --expand=SYM on a name matching more than one definition, in a
file over --pack-budget-bytes, served mode="bundle" — the ~200-symbol default ranked map, then the
requested <bodies>. On a real ambiguous name (--expand=write on this repo's own source) the body landed
86% into the document, and the existing --top-k=0 escape hatch ("the ranked map rides along... add
--top-k=0 for the bodies alone") was stderr-only, invisible to a caller reading only stdout. When the map
rides along WITHOUT an explicit --top-k (the caller never asked for it, chooseExpandServe picked bundle
mode on its own), the requested bodies are now served before the map, and the escape hatch also rides the
document itself, as a note= attribute on the <ctx> root (stderr keeps its own copy, for a human tailing
the terminal). An explicit --top-k=N, on a unique or an ambiguous name alike, is unaffected: it keeps the
pre-existing order and carries no note=, exactly as --help=--expand already promises.
Added — a TS/JS import spelled with a .jsx runtime extension, or naming only a .d.ts/.d.mts/.d.cts declaration, now resolves
kJsRuntimeSourceExts (the one runtime→source table resolveTsImport's precise include tier and graph.h's
named-import binder both read) covered .js→.ts/.tsx, .mjs→.mts and .cjs→.cts, but not .jsx or the
declaration-only case: a specifier naming a file that exists only as a hand-written .d.ts (no .ts/.tsx
alongside it) stayed unresolved, and neither the CLI's --deps edge nor the named-import call binder saw it. Two
additions, both matched against tsc 7.0.2's own resolver (verified against tsc's traceResolution diagnostic output, node16/nodenext/bundler identical):
a .jsx row (./x.jsx tries .tsx then .ts), and a second, DECLARATION tier per runtime extension
(.js→.d.ts, .mjs→.d.mts, .cjs→.d.cts) tried only when the source tier finds nothing — ./both.js with
both both.ts and both.d.ts on disk still resolves to the source, the declaration untouched, matching tsc
exactly. The pre-existing unique-or-degrade rule for two real SOURCE candidates (e.g. both x.ts and x.tsx
present) is unchanged and applies identically to the new .jsx row — a deliberate, already-shipped conservative
choice, not something this change revisits. test/tsimportprecisecheck.sh's RUNTIME-EXTENSION section gains the
.jsx rows, a .jsx source-clash decoy, the three declaration-fallback rows, and a source-vs-declaration
precedence row. Split out of PR #44 (native Windows port); the shared runtime→source table is
@lennix1337's, this change completes its coverage.
Fixed — an edit could be reported "applied" while a concurrent writer silently undid it
The per-file advisory edit lock tried the lock for ~200 ms and then always proceeded lock-free once it gave up
— including when a live cooperating writer was still holding it. That writer could commit its own change after
this edit's rename, so the edit was reported applied for bytes that no longer existed on disk. The lock now
distinguishes why it never got in: only when every bounded attempt saw EWOULDBLOCK — another writer proven
live and holding it — does the edit refuse (-32603, "edit lock unavailable ... file left unchanged") instead of
racing it. A lockfile that cannot be opened, or a filesystem with no flock, proves no live holder and keeps
the existing lock-free degrade; refusing there would block every edit on such a machine without serializing
anything. test/mcpeditracecheck.sh arm F1b takes the lock from a separate process first and proves both the
refusal and that the same edit applies once the holder releases. Split out of #44 (native Windows port); the
original commit refused on every acquire failure, which this narrows to the proven-contention case. Thanks to
@lennix1337.
Fixed — sliceBodyLines's UTF-8 back-off read one byte past a slice ending on the body's last line
--expand=SYM:START-END's continuation-byte back-off, which trims a split multi-byte codepoint off a slice
boundary, read body[byteEnd] before checking whether byteEnd was still inside the body. When the
requested slice's last line is the body's own final line, byteEnd == body.size(). Every CLI call sits the
view over a std::string, whose operator[](size()) is defined to return the null terminator, so the read
stayed in-bounds by accident and no existing gate ever saw it; over any buffer that ends exactly at its own
allocation the same read is a real heap-buffer-overflow. The loop now stops at byteEnd < body.size() before
indexing. test/expandrangecheck.sh arm 11 compiles serialize.h into a standalone ASan/UBSan harness over
an exact-size buffer, so the read is sanitizer-catchable instead of silent. Split out of PR #44 (native
Windows port); the fix is lennix1337's.
Fixed — emptycorpuscheck's one-function arms never ran, and the gate said ALL PASS anyway
run_and_check named its output file after the test name with spaces stripped (tr -d ' '), so "onefn: default map" wrote to out_onefn:defaultmap while the three assertions below it read out_onefndefaultmap
— a colon apart. Each sat behind [ -s "$OUT_FILE" ] with no else, so the file was always missing, the
block silently never ran, and the gate exited 0 having never checked that a one-function corpus's map
actually contains symbols="1" and the function's name, or that --graph-query with the all source term counts it. The naming
rule now keeps [:alnum:]_ on write and read (and drops :, which Windows refuses in a filename anyway),
the assertion matches the header's files=1 symbols=1 instead of a bare symbols="1", and a missing or
empty output file now FAILs the block instead of skipping it.
Split out of #44 (native Windows port); the naming-rule and assertion fix is @lennix1337's. Evidence: on
origin/main, the gate exits 0 with zero onefn: map contains … / onefn: --graph-query 'all' has count
rows in its output — the content assertions for corpus (c) never print at all. Restoring the old tr -d ' '
rule under the new fail-closed guards FAILs both blocks (onefn: … is missing or empty), which is what
proves the old rule was truly vacuous rather than just differently spelled. test/emptycorpuscheck.sh is
the gate: on main its one-function checks never ran; with this fix they run and pass.
Added — a JSX element invocation (<Foo />, <Foo>…</Foo>) is now a call edge in TS/TSX/JS (#285)
--callers/--uses/--test-gate used to read a component invoked only via JSX as having zero callers —
<UniqueWidget /> sat in plain sight and --callers=UniqueWidget still answered count="0", because
queries/typescript/tags.scm and queries/javascript/tags.scm captured call_expression/new_expression
only, never a JSX element name. Both query files now bind the OPENING tag's name (self-closing has no
separate closing tag; a paired element's closing tag repeats the same name and is deliberately not
captured, so one invocation mints exactly one edge) as a call reference, for a plain identifier
(<Foo />) and a qualified one (<Foo.Bar />, which binds through member_expression exactly like
Foo.Bar() and carries the same receiver). An intrinsic tag (<div>, <h1>) parses as the identical
node shape a real component's tag has — the grammar carries no case distinction — so a new capture-time
filter (isJsxIntrinsicTagIdentifier, src/ingest_names.h) drops any tag whose name does not start with
an uppercase letter; a namespaced tag (<svg:rect />) needs no filter at all, because its name field is a
different grammar node (jsx_namespace_name) that no pattern names, and a <>…</> fragment has no name:
field to capture. .tsx moved to its own query (queries/tsx/tags.scm, querySub "tsx" rather than
"typescript"): the plain TypeScript grammar has no JSX node types at all, and tree-sitter refuses a
whole query the moment one pattern names a node type the grammar doesn't have, so sharing the query with
the new JSX patterns would have silently dropped every .ts symbol and reference along with them — see
that file's header for the measurement. kParserVer 116 → 117. Reported by
@mariadb-KyleHutchinson in #285, with fixture files that dropped straight into test/jsxcallfix/
(test/jsxcallcheck.sh is the gate — RED on the base binary, GREEN after). Not covered: the issue's
secondary ask — a synthetic framework-caller edge for a Next.js App Router entry point (page.tsx)
invoked by file-path convention with no in-repo call site at all — is a separate, smaller follow-up and is
not part of this change.
Changed — lane/os-header refreshed onto main (~1,400 commits, 30f14a27 → 57d713dd)
src/infra/os.h's POSIX seam (below) was built on v0.6.1; this refresh carries it forward onto everything main
grew since. The self-check rename (VERIFY/VERIFY_TEXT → ASSUME, DEGRADED_PATH_ALERT → DISCLOSE, and the
rest of the vocabulary in the entry below this one) landed on main while the branch slept, so every macro this
branch's own commits still spelled the old way is converted; test/selfcheckcheck.sh is the gate. Main also grew
POSIX call sites the branch predates — a tmp+rename publish RAII (rw::pathguard::ExclTempFile) and a
MemoryStream open_memstream holder replacing several hand-rolled buf/sz pairs, gitCmd()'s hardened
--no-optional-locks -c core.fsmonitor=false prefix in front of every git child, a saturatingNanoseconds fix
for stat timestamps past 2262, docparse.h's FIFO-safe regular-file reader, infra/stackthreads.h's
sized-stack thread pool (pthread attr/create/join — os::pthread_attr_init/setstacksize/destroy,
os::pthread_create, os::pthread_join, and os::pthread_t/os::pthread_attr_t added to the header), and the
LSP server's (src/lsp.h) root-directory resolution — each now routed through os:: so osswitchcheck passes
because the call sites went through the seam, not because the allowlist widened. Dropped one unused macro
(Diagnostics.h's RW_NO_UNIQUE_ADDRESS, an _MSC_VER branch with zero real call sites) rather than pull the
now-heavyweight os.h into a header that has to stay library-free for noaliascheck. osswitchcheck,
selfcheckcheck, manifestcheck, gateexitcheck and gatecountcheck are ALL PASS on the merged tree; three
duplication findings the merge's own restructuring surfaced (a network send-retry-loop and a file
write-retry-loop token-matching at 18/20/99 tokens — the same idiom, different syscalls, no shared helper to
call) are acked, --quality-delta gates 0. Plain build, --clean-first: 0 warnings.
Changed — every operating-system difference lives in src/infra/os.h, and a gate refuses one anywhere else
Main answered "which operating system is this?" wherever a call happened to need the answer: 19 OS-conditional
preprocessor lines in 6 files, three more OS tests spelled as feature macros (RIPWIRE_HAS_KQUEUE, MSG_NOSIGNAL,
SO_NOSIGPIPE), POSIX system headers included by 14 files, and 250 raw POSIX/libc call sites in 34 files (counted by a
scanner that strips comments and string literals). Each was right for macOS and Linux, and none was findable except by
reading — which is what a third platform would have had to do, site by site.
src/infra/os.h owns all of it now: namespace rw::os, POSIX names, POSIX signatures and the POSIX errno contract, so
a call site reads like Unix code with a prefix — os::lstat( path, &st ), os::rename( tmp, dst ), os::stat_t — and
asks nothing about the platform. Each POSIX body is the libc call itself, [[gnu::always_inline]], over the call's own
raw types. The few needs no POSIX call answers get a lowercase helper whose POSIX body is the code that used to sit at
the call site: os::exepath (--doctor's own binary), os::st_mtim/os::st_ctim (Darwin's st_mtimespec),
os::setsockopt_nosigpipe, the thread identity the profiler reports, os::dirwatch_* (the MCP server's kqueue watcher)
and os::spawn_sh (the --run-trace child). That child keeps its fork/exec: posix_spawn would report three failure
paths differently — an unexecutable /bin/sh, an unopenable /dev/null, a refused setpgid — and no gate can reach
them to show the two equal. The Windows branch is a hard #error until #44 supplies its declarations there and its
bodies in src/infra/os_win32.cpp. The kqueue build seam is spelled -DRW_OS_HAS_KQUEUE=0 now, because a
src/infra/ file may not name the project.
No behaviour change, and no cost. A differential run of the origin/main dev binary against this branch's, over the
same checkout with a separate cache directory each, cold then warm: 71 comparisons, 71 byte-identical — the flagless
map over src/, test/, docs/, the repository root and test/fixture; --legend=compact; --token-budget refused
and fitting; --for, --callers, --uses, --expand, --impact, --grep, --situ, --quality-delta,
--hotspots, --cochange, --owners, --pr-context, --merge-scout, --whereis, --edit-check, --pack-task,
--recall, --lint, --notes, --doctor, --scip, --from-trace; four --run-trace runs (exit code with stderr,
stdin from /dev/null, a timeout that kills a process group, a signal); --note-add and --index-out including the
bytes they write; a six-request --mcp stdio session; an MCP edit through the lock and the atomic write; and a
--listen session with a client that drops mid-request. Normalised: the binary's own path and stamp, a dev-build
alert's __LINE__ (main.cpp lost seven include lines), duration_ms, and the index artifact's wall-clock write stamp
with the checksum over it. Release against release (-DCMAKE_BUILD_TYPE=Release, ThinLTO, AppleClang 21): 4,166 of
4,168 functions compile to identical instructions; getIndex has the same 2,045 instructions with one two-instruction
load scheduled earlier, and runRunTrace lost one unreachable branch. __TEXT,__text is 8,671,392 B on origin/main and
8,671,388 B here, and nm finds no rw::os symbol in either build flavour. Getting there took the objdump: the first
helper shapes read cmd.c_str() in the parent instead of after fork, called __error() on a dead path, and — by
wrapping the kevent array in a struct — cost getIndex its stack protector; each shape now follows the code it
replaced. On Linux, main.cpp, ingest.cpp, pagerank.cpp, tsprobe.cpp and the profiler harness compile against
glibc 2.39 with g++ 13 and clang 22; the no-watcher path builds on a Mac with -DRW_OS_HAS_KQUEUE=0, links no
kqueue/kevent, passes mcpwatchercheck, mcpstalecheck and freshnesscheck, and answers an --mcp session with
the same bytes as origin/main built the same way. The ASan/UBSan build is clean over the touched paths
(sidecarsymlinkcheck, mcpeditracecheck, runtracecheck, cachefuzzcheck against it), and
preprocdeadscalecheck (D1)/(D2) and recallbudgetcheck pass their byte-identity arms against the origin/main binary.
--quality-delta over the branch gates on two 20-token "clones" — os::dirwatch_poll and os::setsockopt against
the profiler's one-line sys_perf_event_open — which are the one-line passthrough shape by design, acked with that
reason.
test/osswitchcheck.sh (new) refuses, outside os.h: an OS name in a preprocessor directive (A), an OS proxy macro or a
__has_include of a system header (A′), a POSIX or Windows system header (B), a raw POSIX call or type (F), and a
platform fact such as os::kApple or a reopened namespace os (G); and anywhere in src/ or CMake, a force-include or
a compat header tree (C) and a macro or compile definition named after a libc function (D). It was red on origin/main —
A 17, A′ 8, B 41, F 275 — and on #44's head C and D red with 18 and 21 violations; its one allowlisted file is
src/infra/profilePmc.h, the profiler's undocumented-ABI counter backends, and a row that stops exempting anything is
itself a failure. Every arm fires on a planted fixture and stays silent on a clean one on every run. (E), POSIX/Windows
declaration parity, is present and turns itself on when the Windows branch declares its first function.
test/namedfileinputcheck.sh's mechanism arm reads the os::open spelling. CONTRIBUTING §3 states the rule.
Changed — the self-check macro vocabulary is renamed to the conventional spellings, and degrade paths gain a sink form
VERIFY / VERIFY_TEXT → ASSUME; VERIFY_DEBUG_ONLY(_TEXT) → DASSERT; VERIFY_NOT_REACHED(_TEXT) →
UNREACHABLE; VERIFY_SAME_THREAD(_TEXT) → ASSUME_SAME_THREAD (kept — multithreaded checking stays important);
VERIFY_NO_ALIAS / 3 / _BUF → ASSUME_NO_ALIAS / 3 / _BUF; DYNMAP_VERIFY → DYNMAP_ASSUME;
TODO_IMPLEMENT is dropped (no use sites remained). VERIFY read as "evaluated in release" in SerenityOS, Unreal
and MFC — ours is the opposite, and the wrong reading once let external input reach an assumption
(gitmine.h's baseline-sha check, now VALIDATEd instead). Two words are new: EXPECTS/ENSURES (an ASSUME at
function entry/return that blames the caller/callee by name) and VALIDATE (external input — always evaluated,
never assumed, used only as a condition). No compatibility aliases: a gate refuses every old name outside dated
history.
DEGRADED_PATH_ALERT → DISCLOSE, in the same pass, with the same one-argument, debug-only-trace behaviour for
every existing site (839 renames, 179 files; test/*check.sh byte-identical PASS/FAIL sets, and ripwire . --no-cache
byte-identical between an origin/main binary and this one, on three trees). DISCLOSE also gains two new forms:
DISCLOSE( sink, why[, "msg"] ) calls sink.disclose( why ) in every build — the sink's contract is a C++20
concept (Diagnostics::DisclosureSink) checked at compile time, and why is a scoped enum the sink owns (one byte,
no runtime string) — and DISCLOSE( Diagnostics::answerUnchanged, "reason" ) for a degrade that changes cost, never
content. No existing site is converted to a sink in this pass; that is the next lane's job, tracked by a ratchet
that counts only sink-less DISCLOSE( msg ) sites in src/ (217 at landing) and may only go down.
Release-build differences: on GCC ≥ 13, ASSUME compiles to [[assume]] instead of the old
if( !e ) __builtin_unreachable(), so a non-pure predicate (e.g. std::is_sorted(...)) no longer evaluates in a
GCC release build — Clang was already this way via __builtin_assume. DASSERT now type-checks its expression
without evaluating it, in both debug and release. Assert banners name the failed word and who is to blame
(caller/callee/invariant) instead of one generic message. UNREACHABLE()'s release form is __builtin_unreachable()
directly (not std::unreachable(), which would pull <utility> into a header that must stay library-free for
noaliascheck).
New gate: test/selfcheckcheck.sh (registered in test/regression.sh, .github/pargates-shard-weights.json; not
exempt in test/binoverridecheck.sh). It is zero-ceremony for a new ASSUME on a genuine invariant — it bites only
on a side effect inside a check, an unseen non-accessor callee in a promise, ASSUME( false ), external input
handed to anything but VALIDATE, a new sink-less DISCLOSE, or an answerUnchanged without a literal reason.
Fixed — three degrade-alert arms asserted nothing on the plain build, and the gate harness now refuses that skip
A gate that asserts a DEGRADED_PATH_ALERT has to know whether the binary can print one, because Release compiles
the alert out. test/churnjoincheck.sh (G2), test/preproccondcheck.sh (the 600-deep guard stack) and
test/w3fixlegendcheck.sh (arm 6) found out by running --rank-by=churn --since=notadate and looking for an alert.
Since --since became a refusal (exit 1, before any degrade path runs), that run prints no alert on any build, so
all three skipped their alert arm on the plain build too: one SKIP each, zero failures, on the leg CI keeps
precisely to prove degrade paths. The build type --version names decides now, as in kotlincheck and
estchargecheck: Release, RelWithDebInfo and MinSizeRel skip, every other flavour asserts. G2 also asserts the join
alert itself, on the line after the NFD disclosure, where any [math degraded] line used to do. Arm 6's subject,
the wording of the --since alert, no longer exists, so it pins the refusal that replaced it: exit 1, no document,
one stderr line, and no alert, next to a positive control proving the binary prints alerts at all.
The suite could not see this, because an arm-level skip inside a passing gate counts as a pass. test/pargates.py
now reads the binary's build type once and fails any gate whose skip row blames NDEBUG or compiled-out alerts on a
build type that does not define NDEBUG. A skip for any other reason is untouched, and a binary that names no build
type disarms the check with a line in the summary. Gate: test/skipclassifycheck.sh arm (I), red on the old harness,
and the new harness fails all three unfixed gates (#261).
Fixed — the churn join gate read "2 weeks ago" twice, and checked the default window against the wall clock
test/churnjoincheck.sh compared ripwire's churn with git's own count, but the two did not always ask about the same
window. The live audit passed --since="2 weeks ago" to ripwire and "2 weeks ago" to git, and each evaluated it when
it ran, so a commit on the edge fell inside one window and outside the other: #265 went red on src/slice.h churn 15 vs
14. Every default-window oracle asked git for wall-clock "12 months ago", while ripwire anchors that window on HEAD,
so the fixtures' 2026-06 commits would have failed the gate from 2027-06-01. The offset arm beside them still selected a
retired p="./…" spelling and passed on zero rows. The gate now reads the clock once and hands one ISO-8601 instant to
both sides, computes the HEAD-anchored start for the default window, and re-derives all 163 offset rows. A new arm pins
both window edges to the second, with controls that stage the double reading through git's GIT_TEST_DATE_NOW. Measured
on the old gate with that staging: 4 FAIL rows for #265's straddle (src/slice.h 12 vs 11) and 12 FAIL rows with git's
clock at 2027-07-01; the new gate is 78 PASS under both stagings and without them
(#271).
Changed — the compiler checks the tables, switches, masks and layouts this tree's defects came from
Each check below is written against a defect this repository shipped or nearly shipped, and each was shown failing on a
deliberate break before it landed. None of them changes output. They are static_asserts, one template constraint, one
link-time stamp and two warning flags. Output was compared with the base binary on every fixture corpus, stdout and exit
code, and the only differences are the extension fix below.
- Language registration. Appending a
Langmeant updating five tables in four files. 02f798e (Dart), 9418e35 (five
unanalysed languages) and PR #233's.gdrow each stayed one language short.src/main.cppnow asserts that every
code language is analysed or disclosed as unanalysed by--nonlocal-state, and that it is named by the lint vocabulary,
the lint catalog andlangOfPath.src/ingest_crawl.hasserts thatlangOfPath's extensions and the crawl's are the
same. Each check returns the first INDEX that is wrong, so a zero-filled row cannot pass.isCodeLang(src/model.h)
is the one declared exemption, and it has nodefault:. -Werror=switch -Werror=implicit-fallthroughon ripwire's own C++ targets, for every compiler. GCC ran no
-Wswitchat all before this, because it enables it only under-Wall. The warning count was measured at 0 on
AppleClang 21 and Homebrew clang 22, debug and-DNDEBUG, for both binaries and the four test harnesses. A switch that
returns one answer per enumerator carries nodefault:any more. Eleven did, includingdependencyCapableand
dependencyDialect, where Dart was the one language never decided.- Cache and layout facts.
quality.h's mirror ofkParserVerandkCacheVersionis asserted equal to the real
constants; onlytest/qextractionkeycheck.shheld that before.CacheEntrymust have unique object representations,
becausesizeof == 32did not prove "no padding".qsnapPutis constrained the same way, so a padded struct or a float
cannot reach a byte-stable blob.ingest()carriessizeof( Symbol )andsizeof( IngestResult )in its mangled name.
CLAUDE.md records three mixed-layout builds that linked "successfully". Measured on this tree, an object pair compiled
against twoSymbollayouts now fails to link, where the same pair without the stamp linked and died with SIGBUS. - The redaction first-byte mask is compared bit for bit with the rule table it hand-numbers, so an inserted rule
cannot leave a later rule tried only at bytes its pattern cannot start with. - Shift width against count. Every mask a runtime value is shifted into has its count pinned to its width: the
language masks, the ensemble and quality-panel family masks, the naming-rule mask, the redaction rule mask, the
pack-task subset enumeration andstrkern's block masks. - Tables indexed by an enum. A table's extent is deduced and asserted against the enum's count, and the count is
proven exact beside the enum withinfra/enumcount.h(#241). A spelled extent had let several of these asserts restate
their own declaration, and let a missing row compile as a null pointer.kNodeFieldNamesrows now name their
enumerator, because the enum and the table are paired by index.skilleval's provenance counters were[3]for a
four-valueProv. A new gate,test/enumtablecheck.sh, refuses a literal-extent table indexed by an enum. It reports 14
subscripts over five tables onf8e6087c. Each of its three positive controls puts one real literal back and must
report exactly that table among the violations the literal adds, so a violation already in the tree fails the rule
arm alone instead of every control.
Fixed — a memory buffer that lost a write was read back as a whole document
Twenty-three places render into an open_memstream buffer and then read it back: the map's own children (XML and JSON),
the est_tokens payload charges, the --max-tokens fit probes, the --token-budget buffer, the --for lens's pre-rendered
blocks, the --from-trace blocks and seven MCP answers. Twenty-two of them flushed and closed the buffer without looking
at either result. The one that did look, renderToString, could not see the failure it looked for.
Measured, not assumed: a DYLD_INSERT_LIBRARIES interposer failed one chosen realloc inside an open_memstream on macOS
26.5.1 (Apple libc), over 5 KB, 50 KB and 200 KB streams written in 1 KB chunks. In all 19 runs where the failure landed
inside the stream, one fwrite came back short and the stream's error flag was set. Each run lost 152 to 976 bytes, as
late as chunk 177 of 200, so the hole sat in the middle of the document. fflush and fclose both returned 0 every time.
Read after that, the buffer is a shorter document with no sign that it is one. What that meant per site: a map or --json
map with a hole in it; a payload section, trace block or MCP answer cut mid-element; a --max-tokens probe that read a
too-small size as fitting; and a --token-budget map printed short at exit 0.
Every buffer is now owned by one type, rw::MemoryStream (src/infra/emit.h). Its finish() flushes, reads the error
flag, closes, and reports by value, and it is [[nodiscard]]. The destructor closes a stream nobody finished and frees the
buffer on every path, so no site frees or closes anything by hand. A buffer that did not finish whole takes the path a
failed open already took. The map and the JSON map are rendered again, straight to the output, with the modelled
est_tokens: the children became one renderer both paths call. A charged section streams uncharged, and a probe answers
"unmeasured". The --for blocks are emitted directly, and a secret redacted in the failed buffer is not counted again when the block
re-renders. The MCP answers answer as they do when the open fails, except uses, which answers -32603 instead of an
empty success. Two surfaces have
no second path, because the buffer holds the answer itself, and both refuse in every build instead of printing short.
The --token-budget map prints nothing, says write error — the --token-budget buffer lost bytes on stderr, and exits
--from-traceand--run-tracedo the same when the<trace>map, the test hop or the signature/body section loses
its buffer, at the open or at the finish, and the MCPfrom_traceverb answers-32603. Those blocks used to be left
out of a bundle printed at exit 0, which no Release build disclosed.
test/estchargecheck.sh gains two arms. #14f uses a new debug-only fault switch, INFRA_FAULT_MEMSTREAM_FINISH=1,
which makes every finish really close its stream and then report failure. It asserts four surfaces, not every site. The
--pack-signatures map and the --json map come out byte-identical to the undegraded run outside est_tokens,
well-formed, at exit 0. The --token-budget run and a --from-trace run each print 0 bytes and exit 1 where their
controls print the answer. Under the same switch, MCP uses answers -32603, and the --for redaction summary
matches its control in XML and --json. #14g reads src/ and refuses an open_memstream, a direct call of the charge opener, or
an fflush/fclose of a memory stream anywhere outside the type. On f8e6087c it reports 46 such lines. Its positive
control puts the two lines of one site back by hand, once per spelling of the opener (bare, ::, os::, rw::os::),
and must report exactly those two each time.
Fixed — five code extensions the index parses were no language at all to the dependency, state and lint verbs
The crawl indexes .metal, .cu and .cuh as C++, .pyi as Python and .phtml as PHP. langOfPath
(src/lintrules.h) is the verb-time classifier that --deps, --arch, co-change's dep_capable=, --nonlocal-state,
--quality-panel, the lint catalog and user --lint-rules use to bucket a file. It kept its own extension table "in sync
by hand", that table had drifted, and it called those five extensions Unknown. Every one of those verbs quietly left the
files out. includeLangOf (src/resolve.h) had the same four C++ and Python gaps, so even a counted file could not
resolve its includes.
Both tables now know all five, and the crawl's table and langOfPath's are asserted equal at compile time (above).
test/deplangscheck.sh arm (G) requires every dependency-counted extension to resolve too. It went red with only the
classifier rows added, naming .cu, .cuh, .metal and .pyi as counted but unresolvable (and .hxx the other way
round), which is why the resolver rows land in the same change. .hxx left both tables: the crawl admits no .hxx file, so neither row could ever be reached.
Measured by comparing stdout and exit code, --no-cache, between the base binary (f8e6087c) and this change, over all
162 fixture corpora under test/ and eight verbs: 1,296 runs. 14 differ. All 14 are on the six corpora that hold one of
the extensions, and only on --deps, --nonlocal-state and --quality-panel. The map, --json, --lint,
--lint-catalog and --pack-signatures are byte-identical everywhere.
test/cudafix--nonlocal-state:cells="0" functions="0"becamecells="5" functions="4". The CUDA kernel's
rk_scaleTable, read throughrk_clampScale, was invisible.test/cudafix--deps:dep_files="1"became3, and the kernel's include ofreduceShared.cuhnow counts
(afferent1 → 2,transitive1 → 2).test/metalfixalready printed the.metalshader's row. The shader now
counts indep_files(2 → 3), and its quote include ofAAPLSharedTypes.hresolves (the header'safferent1 → 2).
test/phpfix,pyshapefix,stdqualfixandmacroreparsefixeach gain the one file their denominator was missing.test/phpfix--nonlocal-state:unanalyzed_files="4"became5. The.phtmlview is disclosed as unanalysed PHP.- A user rule with
language: cpprun over a.metalshader and a.cukernel reportedfindings="0". It now reports 16. .pyityping stubs. A stub restates its module's globals (COUNT: intbesidem.py'sCOUNT = 0), so reading
both files counted one global twice: a two-file probe went fromcells="1"tocells="2"with every row still bound to
m.py.--nonlocal-stateand--quality-panelnow skip a stub whose.pyis indexed beside it. A stub with no
source, the shape a C extension ships, is the only declaration of its module and keeps its cells:test/pyshapefix's
stubs.pyiadds one (cells5 → 6). Gate:test/nonlocalstatecheck.sharm (J), red atcells="2"before the skip.
Dart stays outside the dependency denominator, now by a named case instead of a default:. Dart has no import capture,
and a two-file probe showed --deps printing no row for import 'util.dart';.
Changed — CI runs a light set on push to main and on train-member pull requests; the full matrix moves to a nightly schedule and workflow_dispatch
CI was the bottleneck: a merge to main re-ran the full 31-job matrix on a tree its pull request had already
tested, and a full run per landing-queue lane found almost nothing (measured 2026-09-14). .github/workflows/ci.yml
now computes one full output (from the event name, the pull request's labels and the ref) in a new plan job,
and every other job reads it instead of repeating the same condition:
- Light (the
stylejob plus the singleubuntu-24.04/Release/clangrelease leg, all 4 shards — it
already runs the determinism and G4 XML checks): a push tomain, or a pull request carrying the
train-memberlabel. Labelling is maintainer-only by construction — a fork pull request cannot label its own.
pull_requestnow also triggers onlabeled/unlabeled(on top of the GitHub default types) so toggling the
label re-evaluates the same pull request without a new push. - Full (all 31 jobs): every other pull request,
workflow_dispatch— run this against the exact commit
before any release tag, not an earlier green nightly — and a new nightlyscheduleat 05:41 UTC (off:00,
distinct fromnightly.yml's own 07:17 TSan run).release's matrix isplan's own computed output rather
than a second hand-typed list, because a job-levelif:cannot see the matrix context to prune legs directly.
A failure on the scheduled full-matrix run opens or updates ci.yml's own tracking issue, titled "Nightly
checks failing on main (full matrix)" — deliberately separate from nightly.yml's TSan one. Every tracking
issue carries the shared nightly-failure label (so "every nightly-scale failure" is one query) plus a
workflow-specific second label — nightly-full-matrix here, nightly-tsan in nightly.yml — and every
open/comment/close filters on both together, so a green run in one workflow can only ever touch the issue
carrying its own second label. (An earlier draft of this change shared one issue between the two workflows
with an uncoordinated close each; that let a green TSan night close an issue the full matrix had opened
while the matrix was still red, and the reverse — caught before merge, not shipped.) Top-level permissions
are contents: read; only the two report jobs widen, and only to issues: write on themselves.
test/g1configcheck.sh gates the split with ten new ciRows for ci.yml and one new labelscope row in
nightlyRows for nightly.yml, each proven red on its own mutated copy. Five of the ten ciRows extract the
plan job's decide script and execute it under synthetic event/label/ref combinations rather than guessing
at the bash from a regex.
Fixed — a cached 16-bit field wider than 16 bits was believed, and a --with-profile line past INT_MAX joined the wrong site
Two defects found by a new static gate that asks, of the crashes fixed this cycle, whether their shapes were visible
in source before they shipped. The ingest cache stores five def fields (ppAlt, humps, deepLoc, ev,
params) and a reference's argCount as 16-bit values in u32 slots, and read them back with
std::uint16_t( r.u32() ), which keeps the low bits of a value the writer can never have produced: a
checksum-valid record carrying 0x10000 was served as 0. ByteR::u16Of32 refuses such a record the way enumU8
refuses an enum byte past its count (that file reparses, the rest of the blob stands). Measured on the
cachefuzzcheck Part 3 fixture (15 files): 0x10000 and 0xFFFFFFFF in ppAlt, params and argCount were accepted
before (cached_records=15 of 15, 6 FAIL rows) and are refused after (14 of 15, output byte-identical to
--no-cache, clean under ASan/UBSan), while 0xFFFF is still accepted. Cost, Apple clang 21 -O2 -DNDEBUG on the
ingest TU: loadCache 3,962 → 3,992 instructions (+0.8%, six branchless compare-and-selects); no other function changed.
--with-profile read its #PROF_TSV line column with std::atoi, which is undefined past INT_MAX; libc kept
the low 32 bits, so a line of 4294967329 read as 33 and annotated the finding at line 38 with a site that is not
there (heat_joined="1"). The column now goes through std::from_chars, and a value that is not wholly a positive
int is a row that carries nothing joinable, like a short row (test/withprofilecheck.sh arm 8: red on the base,
heat_joined="0" after, with the same row at line 33 still joining as its control). --plan-lint's
std::filesystem::absolute fallback takes an std::error_code too.
The new gate test/hazardpatterncheck.sh runs ripwire's own --match over src/ (19 queries, about 5 s) for the
hazard classes no other gate holds, and registers every site it finds with the fact that makes it safe: (A) an enum
built from a byte reader outside ByteR::enumU8 (1 site, validated in place); (B) every catch handler that
records nothing (11 of 27 — 6 of them rows naming a drop that is silent in Release, for the disclosure lane) and
every throw with no try in its own function (3, all permitted seams); (C) a throwing or overflow-undefined
standard call where nothing may throw — std::sto*, .at( ), .value( ), the atoi family, std::filesystem
without its error_code (72 calls read), a range-for over a directory iterator; (D) a decoded value narrowed without a
check (1,269 casts read, 0 left); (E) a raw acquisition crashsweepcheck does not name — descriptors, the malloc
family, new, tree-sitter parsers, queries, cursors and trees — without an owning destructor (36 sites registered,
2 owned). Every registry is exact both ways: a new site fails, a moved count fails, and a row that matches no site
fails, except a row marked PENDING, whose fix is already written on another branch and which asks to be deleted once
it lands. Red on the base: 6 rule-C sites (four of them the --eval-skills and ripwire wrap filesystem throws that
the parser-crash lane fixes) and 6 rule-D sites; green after. A probe tree with one violation and one compliant twin
per rule proves each rule fires (13 planted violations, nothing else).
Added — reader fuzzers for ripwire's own parsers of bytes it did not create, and the two crashes they found
The fuzz suite fuzzed only the vendored tree-sitter grammars. test/fuzz/readers/ adds 16 libFuzzer targets
(ripwire_fuzz_reader_<name>, RIPWIRE_FUZZ_READERS in CMakeLists.txt) over the code hostile input reaches: the MCP
JSON-RPC scanner and HTTP request reader, the ipynb/HTML/CSV extractors, --from-trace, the skill scanner, the SCIP
decoder, tsconfig/go.mod aliases, lint-rule files, the qsnap/qchurn/history-oracle caches (header and digest rebuilt so
the fuzzer reaches the parse), the committed sidecars, --scope, ingest-cache records and frames, and the span-tier memo.
Seeds are 54 blobs the real writers produced (make_seeds.sh); run.sh replay fails a reader that ran 0 inputs or
refused a valid seed. Five minutes per reader under ASan, UBSan (with integer) and libc++ extensive hardening, Homebrew
clang 22, found: a SCIP varint whose 10th byte carried payload past bit 63 was accepted as a truncated number (and
aborted the G1 build); and openCacheFrame's exact-fit check summed a table offset near 2^64 through a wrap, while its
per-entry bound recOffset + recLength wrapped far enough to ACCEPT a record at 2^64-16 (blob_entries=6 where the
frame is corrupt). Both are fixed without the wrap, with red-first arms: scipcheck 5c and cachefuzzcheck's two
*_near_u64_max mutations plus a disclosure arm (corrupt-frame, blob_entries=0), and the minimized inputs are
regress-* replay seeds. The qsnap and qchurn readers hit the unbounded-count reserve #249 fixes (18 GB and 40 GB
allocations) within seconds.
Fixed — a term-rich --for/--pack-task query, an unbounded MCP stdio request line, or a pathological ASan trace could exhaust memory or stall the process
- A
--for/--pack-tasktask string with many distinct terms could cost gigabytes of RAM. The task
string is agent-supplied text, not a hand-typed query — an agent can paste a whole file, log or issue
body — and the query's DISTINCT term count had no ceiling:lexicalScoresTiered'stfFlatallocation
(symbols × unique query terms × 4 bytes) grew with the paste, not with the corpus. A measured 480 KB
task string cost 5.2 GB RSS on one request.dedupeQueryTermsnow caps the kept unique-term count at
kMaxUniqueQueryTerms(1024 — about 102× the longest real--for/--pack-taskquery on record in
bench/anddocs/), disclosed asterms_capped="1" terms_total="N"on the CLI's--forroot and on
the MCPfor/explore/pack_taskresponses, never a silent truncation. Gate:test/forblowupcheck.sh. - An MCP stdio request line had no size bound, unlike the HTTP transport.
runMcp()'s read loop grew
its line buffer without limit, so one long-lived--mcpserver could be pushed toward OOM one
oversized line at a time by a runaway or hostile peer — HTTP already bounded a request body at 8 MiB
before it reached the JSON-RPC layer, stdio had no equivalent.readByteSafeLineBounded
(src/infra/stdinline.h) now bounds a stdio request line atkMcpStdioLineMaxBytes(32 MiB) and drains
the remainder of an over-limit line without buffering it;runMcp()refuses it with a named JSON-RPC
error (code=-32600,id:null) and keeps serving the next request on the same connection. Gate:
test/mcpstdiolinecapcheck.sh. - A pathological
--from-traceline could turn 160 KB of text into ~4 s of CPU.parseAsan's search
for an ASan/UBSan frame's source location re-derived the whole candidate on every widening try
(a demangled C++ function name can itself contain spaces, so the split cannot just be the first one) —
O(k²) in the space-separated word count k. A line built from thousands of short, non-path-shaped
"words" with no valid trailing location (a fuzzer or minified-diagnostic shape) showed the worst case.
Every quantity the rescan recomputed is actually invariant once the growing window first reaches it, so
parseAsannow computes each once and walks word boundaries in a single backward pass — O(size), not
O(words²) — landing on the exact same candidate the original rescan would have found first. Gate:
test/traceasanlinearcheck.sh(40 KB/160 KB/640 KB/2.5 MB timing; the baseline binary times out past
640 KB on the same fixture).
Fixed — a cache blob, a file in the tree, or an MCP preview could crash, hang or starve the process
Each of these was reproduced before it was fixed, and each now has a gate that fails on the old code.
- A checksum-valid qsnap or qchurn cache blob with a huge record count aborted.
deserializeSnapshotand
deserializeRawCommitStreampassed a count read from the blob straight toreserve: 2^32−1 records is 32 GiB for
the qsnap vectors and 96–128 GiB for the qchurn commit and path lists. On Linux that isstd::bad_alloc, which
nothing on the CLI path catches, so--quality-delta,--edit-check,--for,--metricsand--exemplardied
with SIGABRT on every run until the blob was evicted (reproduced on Ubuntu, exit 134). Every count is now measured
against the bytes left in the blob first, and a count that cannot fit makes the blob corrupt: recompute, as for any
other damage. Gate:test/cachefuzzcheck.sh, Part 2's three vector-count rows and the new Part 5. The rows run
under an allocation bound (ulimit -von Linux, ASan'smax_allocation_size_mbanywhere), because macOS
overcommits the reservation and exits 0 against the defect. The old map-count and wrong-sha rows wrote at stale
offsets, so an earlier guard rejected the blob before the count was ever read; they now write where
deserializeSnapshotreads, and both tables check the good blob's layout first and fail loudly if a header change
would re-aim a row. - A short read leaked a file descriptor. The parse pool's
readFileclosed its stream inside
( got == want ) && ( std::fclose( fp ) == 0 ), so a file that came up short (truncated between the size probe
and the read) was never closed. A long-lived--mcpserver re-ingesting such a tree ran out of descriptors, and
every file it could then not open dropped out of the answer at exit 0. With an interposed short-read shim, 300 of
600 short-read streams stayed open, and underulimit -n 200all 20 ordinary files vanished from a--grep
answer. The stream now has an owner,rw::OwnedFile(src/infra/ownedfile.h), whose destructor closes it on
every path, and the whole-file readers iningest_crawl.h,docparse.handeditpreview.huse it. Gate:
test/crashsweepcheck.shB1. - A FIFO, a directory or a device link at
.ripwire_configor.ripwire_quality_ackshung or aborted. Both
were read through a blocking open on the name. A FIFO hung--quality-deltabefore any output, and a committed
symlink from the acks ledger to/dev/zeroor/dev/urandomnever reached end of file. A directory at
.ripwire_configopens on Linux; where a directory's seek reportsLLONG_MAX(overlayfs), the string that length
asks for aborts, the failureingest_crawl.h'sPathShapenote measured for--cache=<dir>. Both files now go
throughdocparse::detail::openRegularFileStream: it opens withO_NONBLOCK, asks the descriptor what it opened,
and reads anything but a regular file as absent, with a stderr line saying so. The ledger is still read one line at a
time. Gate:test/crashsweepcheck.shB2
(eight shapes). edit_checkwithnew_bodyraced the HEAD-snapshot prefetch worker. The preview's two ingests ran after the
verb's own ingest had released the process-wide ingest lock, so they could run alongside the detached prefetch
worker's ingest.ingest()installs compiled tags queries into a process-global cache and deletes the entry each
install displaces, and that is single-writer by design. On the ThreadSanitizer build the server reported a data
race at the parse-pool call and aborted (exit 134) mid-session. The preview's ingest now takes
the same lock as every other ingest a server runs. Gate:test/qsnapprefetchcheck.sh(f), whose red needs the
TSan build (RIPWIRE_BIN=tsan/ripwire).- A file dated after 2262 overflowed a signed multiply.
tv_sec * 1000000000 + tv_nsecdoes not fit in
long longpast 2262-04-11, a date ext4, XFS, tmpfs or a tar restore can store. That is undefined behaviour in
release and an abort in the sanitizer build (reproduced on Linux tmpfs:signed integer overflow: 10000000000 * 1000000000). Both stat readers now saturate throughrw::saturatingNanoseconds(src/infra/statclock.h); size
and ctime still tell apart two saturated timestamps. Gate:test/crashsweepcheck.shB3. APFS clamps timestamps
at 2262, so on macOS the arm says it cannot build its input.
Three of these shapes can be seen in the source, so test/crashsweepcheck.sh now also runs ripwire's own --match
over src/ and fails on them. Each rule is proven live against a probe tree that holds one violation and one
compliant twin.
- S1: an allocation sized by a count a byte reader decoded must be bounded earlier in the same function.
- S2: every raw
fopen/open/fdopen/opendir/open_memstream/popensite must be registered with the fact
that makes it safe, and a new one points atrw::OwnedFile. A close as the right operand of&&/||or an arm
of?:is refused outright. - S3: every body handed to a
std::threadmust benoexceptor a single try block. The eight bodies that were
neither are now declarednoexcept, as are three that were already one try block. None of them had a throw that
could escape except allocation failure, which already ended instd::terminate.
Red on the base, in order: S1 finds the three unbounded counts, S2 the short-circuited fclose, S3 eight bare
bodies. The gate's header states what each rule catches and what it misses.
Added — a nightly ThreadSanitizer run against main, which opens one tracking issue when it fails
ThreadSanitizer had a build mode (-DRIPWIRE_TSAN=ON) and one gate written for it, test/qsnapprefetchcheck.sh arm
(e), but nothing ran it against main: a data race could reach a tag if nobody happened to build TSan locally in
between. It is not added as a per-PR leg, because TSan builds already run often on contributors' machines and every PR
already waits on the macOS runners. .github/workflows/nightly.yml runs it once a day at 07:17 UTC instead, and skips
the heavy job when main has not moved since the last green scheduled run and no tracking issue is open.
The job builds TSan with clang in its own tree and runs ten gates against it, chosen for the threads they drive: the MCP
prefetch worker (qsnapprefetchcheck), the edit lock (mcpeditracecheck), a server's re-ingest at a 128-fd limit
(mcpwatchercheck), a long-lived server's re-ingest after an edit (mcpstalecheck), concurrent --quality-ack writers
(qackconcurrencycheck), the private cache directory (cacheisolationcheck), the parallel ingest and --match fan-out
over the repository (det-gate.sh), --grep's prefetch thread (grepfastcheck), the --doc-drift workers
(docdriftcheck) and the git-spawn pool (mergescoutcheck). A gate's own verdict is not trusted to notice a race:
reports go to per-gate files, and a wrapper fails the step on a non-zero exit or on any report file. Before any gate
runs, the job checks that every object of the ripwire target references the TSan runtime, and that the wrapper goes
red on a planted race and stays green on its race-free twin. Locally on Apple clang 21, the same wrapper failed on the
planted race and on a race whose exit code the command swallowed, and all ten gates passed through it against a TSan
build of 105666c with no report file (4 s to 404 s each, mergescoutcheck the slowest, on a machine at load 40-60).
A failing run on main opens one issue, "Nightly checks failing on main" (label nightly-failure), or comments on the
open one. The comment names the failing jobs and steps, the commit and the run, and quotes the head of the first report.
The next green scheduled run comments "green again at " and closes it. The top-level token is contents: read,
and only the two reporting jobs hold issues: write. A pull request that edits the workflow runs it without the
reporting. A placeholder marks where the Windows full-suite job goes (D3 of the #44 plan). test/g1configcheck.sh
gains six rows that pin the schedule, the skip probe, the TSan wiring, the permission scoping, the report conditions
and "no secret but GITHUB_TOKEN". Each row has a mutated copy that turns exactly that row red.
Fixed — a diagnostic notice could be split across lines by another thread's output, which is what kotlincheck §12 kept tripping on
The DEGRADED_PATH_ALERT notice, and the assert, panic and thread-violation banners, were built from a chain of
std::cerr insertions. With stdio sync on, each insertion is its own write to stderr, so a line another thread
wrote at the same moment could land inside a notice. kotlincheck §12 refuses two Kotlin files at once; when the
second parse worker's refusal line landed straight after [math degraded] , the arm's one-line grep failed with
"raised no DEGRADED_PATH_ALERT" although the alert was on stderr, whole, one line further down. That is the
failure eight CI jobs hit since Kotlin landed, three of them on main. Measured on f8e6087 by running §12's map
over its own fixture: 18 gate failures in 5,700 runs, and the notice torn in 32–73% of runs depending on load.
Every reporter now formats its whole notice into a fixed 4,096-byte stack buffer and hands it to stderr in ONE
stdio call, which no other stdio writer in the process can interleave, and which needs no heap in a reporter that
may be running because memory ran out. The text is byte-identical for every notice under the cap; a longer one is
cut and says so at its end (... [notice truncated: kept K of N bytes]). The reporters still flush stdout
before the notice, as std::cerr's tie to std::cout always did, so a trap or an abort right after it loses no
buffered output and >file 2>&1 keeps its order. Measured after the fix on §12's fixture, alternating
run by run with the f8e6087 binary under four busy loops: 0 gate failures and 0 torn notices in 2,100 runs,
against 6 failures and 726 torn notices from the old binary in the same 2,100 interleaved runs. The new gate
test/diagnoticecheck.sh counts the write(2) calls each reporter makes by giving it a datagram socket as fd 2,
which keeps write boundaries: red on the old reporters (9 writes for the degraded notice, 15 to 21 for the banners,
every one still byte-exact), green at one write each. Three 2>&1 cases leave text in stdout's buffer before a
degraded notice, an assert and a panic, and require it first and whole: byte-identical to the old reporters. It also carries a static arm with a mutation control, a
12,000-notice race against raw and stdio writers (red in 200 of 200 runs on the old reporters), a zero-allocation
arm (global operator new) measured with src/alloccount.cpp as a delta between otherwise identical runs, and an
ASan/UBSan pass. kotlincheck §12 now prints the first five lines of stderr when that arm fails, because
none of the eight CI logs could show what the notice had looked like. Not fixed here: the default map over the same
fixture says files=4 with no sign of the two refused files, a disclosure gap tracked by #157.
Fixed — a user's regular expression could abort the process, hang the skill scanner, or answer a question it never finished; one header owns them now
Only --regex screened a user's pattern before handing it to std::regex. Three other entry points took the same
engine unscreened and caught nothing at match time, so on Apple libc++ — whose engine throws error_complexity when
it gives up — ripwire <dir> --graph-query='file(all,"(a+)+z")' and an --arch rules file holding deny path zz/.* -> (a+)+z both died with an uncaught std::regex_error (rc 134), over a fixture whose directory name is a run of 44
a. --match swallowed the same throw and KEPT every row the #match? predicate never decided, at rc 0; a malformed
#match? pattern kept every row too. On libstdc++, which has no budget, each of these backtracks without end. A
--regex of 20,000 bytes died with SIGBUS (rc 138): std::regex compiles by recursion, and a grep worker runs on a
512 KiB stack. The skill scanner's EXFILTRATE:net-exfil regex was quadratic in the line — a 20,000-byte fenced curl curl … line took 5.9 s and a 200,000-byte one was still running at 60 s — and an engine throw inside it would have
ended wrap's noexcept scan. And file() matched the path with the checkout's own directories in front of it:
file(all,"alpha") selected every symbol in a clone named repo_alpha and none in repo_beta, and
file(all,"^src/") selected nothing under an absolute root.
src/regexguard.h now owns the screen (moved verbatim from src/search.h), the compile and the match, and is the one
place that catches std::regex_error and std::bad_alloc — by type, converted to a value behind a noexcept API
that static_asserts pin. A pattern the screen or the parser rejects is refused by name at exit 1 on every entry
point (--graph-query file(), an --arch FROM or TO path-rule, a #match? in --match or --lint-rules), in the
words --regex already printed. The screen also bounds a pattern at 2,048 bytes and 64 nested groups, under the
smallest stack overflow measured with a standalone probe (a 3,392-deep nesting and a 16,896-byte literal on a 512 KiB
libc++ thread; 960 and 3,648 on a 512 KiB libstdc++ one). A match the engine abandons part-way — overlapping
alternation such as (a|a)+z passes the structural screen — is refused by name at exit 1, never read as "no match":
the regex scan used to skip the rest of that file behind a DEGRADED_PATH_ALERT, which a Release build compiles out,
and still print hits= as a complete count. An --arch TO template that compiles for no capture rejects the rules
file at parse, and one that only breaks once an edge's captures are substituted (a{2,\1} becoming a{2,1}) is
refused naming the substituted text, where both used to leave the rule silently inert. file() matches the
root-relative path its own p= prints, the rule --arch adopted for its rules. The skill scanner's patterns go
through the same boundary and fail CLOSED — an undecided line is a CRITICAL SCAN-INCOMPLETE:regex-abandoned finding
— and net-exfil is decided by a linear scan derived from its regex. The built-in lint packs keep the old
keep-the-row fallback for their constant patterns.
Not fixed here, measured and disclosed: libstdc++'s matcher recurses once per consumed character, so on Linux a
--regex such as a*b crashes on a long enough matching line with an 8 MiB stack — from about 26 KB, build-dependent
(a standalone probe: 26,624 bytes, 13,312 for (a|b)*c; this tool's own earlier Linux builds: between 27 KB and 35 KB
for a gcc dev build, 35 KB and 45 KB for a clang Release one); a pattern bound cannot reach that.
Byte-identical: 45 of 45 comparisons of the origin/main binary against this one (dev build, one checkout, stdout,
stderr and exit code) across --grep/--regex (prefiltered and full-scan, context, compact, unindexed, the three
existing refusals, JSON), --graph-query, --arch, --match, --lint, --lint-rules (incl. SARIF), --scan-skill
and --scan-skills over every scanner fixture and this repository's own skills, and the map, plus a three-request
--mcp grep session; the only differences are the fixes above. Instructions retired (Release, /usr/bin/time -l,
median of 5, interleaved, the final commit against origin/main): --regex over an llvm-project checkout of 8,837
C/C++ files −2.4% to −3.3%, over this repository within ±0.5%, the literal --grep and map controls +0.3% to +1.6%;
release __TEXT,__text 8,684,280 → 8,704,196 bytes (+0.23%, the refusal texts and the scanner), grepScanText 443 →
437 instructions. No compile was added: once per query, per rule and per grep worker as before, and file() now
decides each FILE once instead of each symbol.
Gate: test/regexguardcheck.sh — (a) a catastrophic pattern refused by name on all five entry points, each with a
positive control; (b1) the non-NDEBUG fault switch RIPWIRE_FAULT_REGEX_MATCH=1 makes every guarded match throw and
each entry point must refuse naming the pattern (or, for --lint-rules, the rule); (b2) (a|a)+z against libc++'s
real engine; (c) no std::regex spelled in src/ outside the owner and a one-row allowlist (the constant redaction
table), with planted-file controls; (e) the --arch TO template refused at parse and after substitution; (f) the
linear net-exfil agrees with its regex over 1,200 generated lines, a 200,000-byte line scans in bounded time, and an
abandoned match fails closed; (g) the size and depth bounds, each with its limit still compiling; (h) an undecided
capture-typed #match? is reported by cause — a captured text the screen refused, one that does not compile, or an
abandoned match — naming the first site and its text; (i) --arch decides deny rules first, so an allow the engine
cannot finish refuses only when a deny fires and no allow matches; (d) two clones at different directory names,
absolute and relative roots, agree. Red on origin/main: 72 failures, ten of them a signal death (rc 134 or 138).
test/astqueryregexcheck.sh C4 now asserts the malformed-pattern refusal, and its golden's match-malformed section
is empty.
Fixed — --regex crashed on a long matching line on Linux; a literal pattern skips the engine, and a line too long for the engine's stack is skipped and counted
libstdc++'s regex matcher recurses once for every state it visits, so on Linux --regex='a*b' died with SIGSEGV
(exit 139) in a grep worker on a matching line of about 26–45 KB — the residual the entry above disclosed. Measured on
this lane's own gcc 13 Linux build before the fix: exit 139 on a 300 KB matching line, and again on a 2 MB line that
could not match at all. Three changes, in this order:
- The engine is not asked when the answer is a byte search.
src/regexguard.hreads each compiled pattern once. A
literal, or literals joined by|(one of them optionally^/$-anchored), is matched withstrkern.h's byte
kernels in the engine's own order — leftmost start first, then the first alternative written — so no line is too long
for it. Any other pattern's required literals (the unquantified literal runs outside groups, one set per top-level
alternative) rule out every line that holds none of them before the engine sees it:a*bnever reads a line with no
b. The full-scan switch the gates use turns both paths off, sotest/regexcheck.sh's prefiltered-versus-full-scan
diff checks them too. - The scan threads get a stack they can state. grep's workers and its unindexed scan run on 256 MiB POSIX threads
(src/infra/stackthreads.h), halving on refusal down to 8 MiB; pages are committed only as deep as a match recurses.
Starting 16 of them costs the same at 8 MiB and 256 MiB, within run-to-run noise, on macOS and on Linux. ONE size is
settled — the smallest any thread got — before a single file is read, and every thread is held to it, so no file's
answer depends on which thread picked it up; a size below 256 MiB is disclosed on the answer asregex_stack_bytes=,
withregex_line_max=beside it even when nothing was skipped. - A line past the measured bound is skipped and said so. A per-pattern model bounds the matcher's recursion; the
bytes one modelled visit may take (128) is the largest need measured over 35 pattern shapes and six builds — gcc
-O0/-O2, clang -O2 and -O3 -flto, gcc and clang under AddressSanitizer — rounded up, with half of every stack held
back, so the engine's measured crash is at least 2.5× the bound. On gcc 13 at 256 MiB the bound is 149,502 bytes
fora*band 33,757 for(a|b)*c. A longer line is never handed to the engine: every--regexroot now carries
regex_lines_skipped=(0 means none was), and when it is not 0,regex_line_max=andcounts_floor="1"ride with
it — hits= is a floor — defined in the full legend and the compact one. libc++ does not recurse per character, so
macOS has no bound and skips nothing. - Secret redaction no longer runs the engine.
src/redact.hredacts every emitted body by default, up to 4 MB, and
its rules went straight tostd::regex: on the gcc 13 Linux build,--expandover a file holdingsk-and a 200 KB
token run died with exit 139. Every rule is a literal prefix and character-class runs, so each is now matched by
reading its runs once (the PEM banner's word loop takes the regex's greedy choice); the regexes stay in the table as
the specification. Nothing is skipped, so there is no unscanned line to withhold.src/regexguard.h's allowlist of
files that may spell the engine is now empty.
Byte-identical: 61 of 61 comparisons against this lane's parent (dev builds, one checkout, stdout, stderr and exit
code) — 42 unchanged to the byte (--graph-query, --arch, --match, --lint, --lint-rules with SARIF, the map,
--scan-skill(s), the literal --grep, and every redaction seam: --pack-top-n, --expand and --recall over the
secrets fixture and this tree), and 19 --regex answers (literal, alternation, anchored, required-literal,
prefiltered and full-scan, context, compact, unindexed) identical once the new regex_lines_skipped="0" and its legend
sentence are removed. Instructions retired (Release, /usr/bin/time -l, median of 5, interleaved, against the parent):
--regex over an llvm-project checkout of 8,837 C/C++ files −83% to −90% (getOperand\w*\( 224.5G → 28.3G), over
this repository −80% to −93%; the full-scan switch, where every line still reaches the engine, −8% and −14%; the literal
--grep and map controls within ±0.7%. Release __TEXT,__text 8,715,336 → 8,732,840 bytes (+0.20%); grepScanText
437 → 1,609 instructions, because the line loop, the literal searches and their kernels now inline into it.
Gate: test/regexguardcheck.sh — (j) test/regexlines_harness.cpp diffs the literal plan, the required-literal filter
and the skip policy against std::regex itself: adversarial, generated and corpus patterns (every --regex the docs,
skills and gates spell, every #match? a query or lint pack carries) over CRLF, LF and empty-line texts, under both
syntax sets; 0 mismatches over about 80,000 cases on libc++ and on libstdc++, and a copy of the header whose
alternation resumes one byte past a match must go red; (k) a 300 KB matching line is matched or skipped-and-disclosed,
a 2 MB line with no b is ruled out in bounded time, a literal alternation over a 3 MB line answers, and a literal
--grep is unchanged; (n) with a test switch that gives half the scan threads half the stack, 48 files are answered
identically over five runs at one bound and the smaller stack is disclosed (the per-thread bound it replaced skipped 24
to 27 of the 48, varying run to run); (o) test/redactshape_harness.cpp gives each redaction rule's regex match length
at every position over 30,000 generated texts (682,859 checks, 0 mismatches on libc++ and libstdc++; a threshold moved
by one goes red), and the 200 KB sk- file exits 0 with the key redacted; (l) on a recursing engine, for three shapes, a line of exactly regex_line_max bytes is matched
at exit 0 and one byte more is skipped; (m) the non-NDEBUG fault switch RIPWIRE_FAULT_REGEX_LINE_BOUND=1 reaches the
skip path and its disclosure on every engine. Red on this lane's parent: 8 failures on macOS (a TIMEOUT on the 2 MB
line), and 8 on the gcc 13 Linux build, two of them a signal death (exit 139). test/emittertruthcheck.sh (Z2h) holds the
new "always present" claim at zero; test/compactlegendcheck.sh re-pins ripwire.grep/v1 360 → 440 (measured 422 on
--regex).
Changed — Intel macOS binaries end with 0.6.1
0.6.1 is the last release with a prebuilt Intel macOS binary. The macos-x64 release leg has had no Intel machine since
GitHub retired its macos-13 runner pool, which left v0.1.0's leg and the first v0.2.0 run queued for 24 hours until
the auto-cancel. From then on it cross-compiled on an arm64 runner with -DCMAKE_OSX_ARCHITECTURES=x86_64 and ran its
PGO training, its determinism diff and its smoke test under Rosetta 2, pinned to the one runner image whose Rosetta was
verified to execute the binary's x86-64-v3 instructions. Every step that proved the binary ran did so under a
translator. The leg is gone from release.yml, along with the deployment-target step and the minos check that only it
used. The Linux x86-64 binary and its x86-64-v3 floor are unchanged, and an Intel Mac can still build from source.
The installer was not told. Its arch map sends x86_64 to x64 on every OS, so an Intel Mac asking for a later release
would have heard release vX has no asset named ripwire-X-macos-x64.tar.gz: true, and silent on both the decision and
the two routes that still work. scripts/install.sh now stops an Intel Mac before any download for every release after
0.6.1, says Intel macOS binaries end with 0.6.1, and prints the exact command that pins RIPWIRE_VERSION=v0.6.1 and the
exact source build. Pinning 0.6.1 still installs its Intel binary. A Rosetta shell on Apple silicon, which also reports
x86_64, is sent to a native arm64 shell rather than told it owns an Intel Mac.
Gate: test/releaseinstallcheck.sh section H, nine rows. Five were red on main: the unpinned one-liner on an Intel Mac
(two rows), a v0.10.0 pin whose release still listed a macos-x64 asset and installed it, the Rosetta shell, and
release.yml still building the asset. The three installer controls (a v0.6.1 pin on an Intel Mac, Linux x86-64,
macOS arm64 on a later release) each went red against a mutant installer that refused one release too many, or keyed on
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
" 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.
--graph-querynested deep enough overflowed the stack. The evaluator recurses once per(, and a 50,000-level
kind(kind(…all…))chain died with SIGSEGV (exit 139). Nesting past 256 levels is now refused before evaluation,
exit 1 with the reason. Gate:test/graphqueryrefusecheck.sharm 6.- A
--layoutarray extent could crash its evaluator. A#defineextent nested 200,000 parentheses deep overflowed
the stack (exit 139).((0-1099511627776)*8388608/(0-1))divides INT64_MIN by −1, which is SIGFPE (exit 136) on
Linux x86-64, and1099511627776*1099511627776is signed overflow, which aborts the sanitizer build. Arithmetic is
now checked, that quotient is refused, and parenthesis nesting depth is bounded at 64 (a macro of many sibling
parenthesised terms nests one level and still sizes). Any of these reads as an unknown extent, with its caveat.
Gate:test/layoutcheck.sh§12. --layoutdropped a data member whose extent or initializer holds a parenthesis, and still said the size was
right.char a[(4)];,int x = (3);andint x{ (3) };were taken for member functions, because the test looked
for the first(anywhere in the statement. The field vanished while the struct reportedmodeled="1"and a size
short by its bytes. Only a(before the first[,=,{or bitfield:now opens a parameter list, and an
operatormember is still a function. Gate:test/layoutcheck.sh§13.--layoutdropped a data member whose declaration carries a(that belongs to analignas,
__attribute__ordecltypespecifier, or sits inside a template argument list, and still said the size was
right.alignas(8) int x,int x __attribute__((aligned(8))),decltype(1) xandstd::function<void(int)> cbwere all taken for member functions too, for the same reason as the row above: the scan still looked at the
first(in the statement, whichever(that was. The field vanished while the struct reportedmodeled="1"
and a size short by its bytes. That first(is now skipped when it opens one of those specifiers or s