From 100ed43d02386604e8dcab529b9bd0bfc6d3a9b3 Mon Sep 17 00:00:00 2001 From: joyful-ii-V-I Date: Sat, 12 Sep 2026 22:38:06 -0400 Subject: [PATCH 01/13] perf(testmap): tests-to-run rows without a runner paid the not-derivable disclosure once per row The defect. A tests-to-run row with no derivable runner said so on the row (run_unknown="1" in XML, "run_unknown":true in JSON, "(run: not derivable)" in --situ's text). On a corpus where almost no harness has a runner that is the same 16 or 23 bytes repeated per row: rocksdb's --affected=db/write_batch.cc listed 127 tests, 126 runner-less, and spent 2,016 B of XML and 2,898 B of text saying one thing 126 times. The disclosure is right (an absence is not a disclosure, M21(b)); its per-row placement was the cost. E1 / A4-2 in the output-routing loop, owner call 2026-09-12: say it once per GROUP. The fix. Rows come in evidence order (changed, partner, hops asc, path), so runner-less rows whose per-row attributes are byte-equal are served as ONE row, emitted where the first member stood: (JSON: "p"/"test" becomes an array beside "n"; text: "[hops=2] (17): a, b, c (run: not derivable)"). Rows with a runner stay single; a group of one stays a ; a comma in an XML path is , (columnar.h's precedent); every path is kept verbatim. All twelve emitters (--affected, --exercises, --test-gate XML/JSON, --situ, --pr-context, --handoff, --flags --flip, --pack-task XML/JSON, the MCP situational_awareness twin, the edit receipt) render through one seam in testmap.h (partitionTestRows / testRowsRendered / testRowsJoined); the ""-means-not-derivable test stays in runHint alone. kRunHintLegendClause defines in the same sentence ("a or row carries one or the other, never neither"); the compact dialect gains two present-only terms (run_unknown=, and the reading qualified to that element). --affected, --exercises, --pack-task and the partitioned outer legend splice the clause rows-gated, so a zero-row answer pays nothing. --pack-task's byte-budgeted tests section caps a group at its own budget (an uncapped group is one ~3 KB row its 10% quota cannot hold, measured shown="0") and its shown=/total= and JSON tests_total/tests_kept keep counting FILES. Measured (rocksdb, same cache, same commit, wc -c): --affected=db/write_batch.cc 10,668 -> 6,839 B; --test-gate=db/write_batch.cc 13,242 -> 9,594 B, its JSON 11,055 -> 7,121 B; --situ=db/write_batch.cc 11,769 -> 7,313 B; 7 rows replace 124 single rows; the residual spent on the disclosure is 144 B (XML) and 207 B (text) per list. --pack-task names 54 of 109 tests where it named 28 (12,347 B vs 11,993). On this tree every harness has a runner, so nothing groups; the deltas are the legend (affected +371 B rows-gated, test-gate +180 B, situ +79 B, JSON and pack-task unchanged). Gates. test/testrowruncheck.sh: XROW/JROW learn the and array shapes, the arm-0 census moves to the seam's call sites (mcpedit.h joins it), and arm 12 proves the multiset of paths in every dialect on a fixture with three hop groups and a runner row in the middle of one (RED on the previous binary: "expected >=3 rows at distinct hops=, got hops=[]" x2 and "expected >=3 group lines, got 0"; GREEN after). Consumers taught the row: affectedcheck tset(), listingpagingcheck (C)/(D), w3fixlegendcheck's [2] count, bench/arb/run_arb.py. Pins moved with the measured number: testgatelegendbudgetcheck 2720 -> 2900 (measured 2843; the +180 B sentence in the row-gated clause), compactlegendcheck ripwire.pack-task/v1 820 -> 880 (measured 865; the fixture's runner-less rows now define run_unknown= in compact), and the printf-parity manifest re-pinned for pack_task alone (UPDATE_GOLDEN=1, diff reviewed: one label). Determinism (diff -q) and xmllint on every changed verb; ASan on the gate fixtures and the rocksdb list; --quality-delta gating="0" after the seam took the two real rows (runExercises complexity, the test-gate twins' duplication) and --quality-ack --ack-only=short-horizon-churn took the family's in-window churn. Co-Authored-By: Claude Fable 5.1 --- .ripwire_quality_acks | 30 ++-- CHANGELOG.md | 28 ++++ README.md | 6 +- bench/arb/run_arb.py | 10 +- src/compactlegend.h | 6 + src/flipimpact.h | 9 +- src/handoff.h | 9 +- src/mcpedit.h | 10 +- src/mcpverbs.h | 14 +- src/packtask.h | 85 ++++++---- src/partition.h | 12 +- src/prcontext.h | 10 +- src/situ.h | 25 +-- src/testmap.h | 247 +++++++++++++++++++++++++++++- src/verbs_change.h | 48 +++--- test/affectedcheck.sh | 10 +- test/compactlegendcheck.sh | 2 +- test/listingpagingcheck.sh | 11 +- test/printf_parity.manifest | 2 +- test/testgatelegendbudgetcheck.sh | 14 +- test/testrowruncheck.sh | 135 +++++++++++++++- test/w3fixlegendcheck.sh | 4 +- 22 files changed, 594 insertions(+), 133 deletions(-) diff --git a/.ripwire_quality_acks b/.ripwire_quality_acks index 259dda583..c6db8ec69 100644 --- a/.ripwire_quality_acks +++ b/.ripwire_quality_acks @@ -738,15 +738,15 @@ ack short-horizon-churn 0e08ebeb77eaf664 8 W1-S2 churn-keying fix (pathQualified ack short-horizon-churn 1039e3c8e0fc3667 21 cid=4efcfe9cb7f739a2 M12 (capture-audit L9): path-spelling fixes — collectUseSites gained a root parameter (default-valued, back-compat) to root-relativize in_id=; runVerify grew from adding root=/verPathRel/the multi-root roots table it never had; short-horizon-churn rows are every function this finding's fix touched this session. ack short-horizon-churn 105c48e20c80c896 3 cid=720fab31ea99ebde A2: short-horizon churn on resolveOneForEdit and unmeasuredHintNote is this fix round itself. Both symbols were touched by 4db6fb3 (A1: the NUL-payload refusal and the never-parsed disclosure) and again here (A2: the absolute-hint frame). churn=self, three commits in one lane on the surface the lane was assigned; it is not instability in the code, it is the audit being executed one item per commit as instructed. ack short-horizon-churn 106ba93f707963e4 6 cid=f64a7a84df923458 OPTREMARKS F3 (docs/OPTREMARKS.md §8b): the ~430 per-AST-node std::strcmp( t, "literal" ) sites in the five ingest walk sections become rw::kindIs (src/infra/nodekind.h) — an inline compare, because strcmp is an external symbol LTO cannot inline and on macOS costs two dyld stub hops before it starts. Measured: 10.6% of busy CPU in strcmp leaves on a cold llvm run, 6-12% on four other corpora; 0.23% after. Output byte-identical across 7 corpora x 5 verbs, argvdiffcheck 640/642 vectors identical (the 2 that differ are the +dirty build stamp in --version). WHAT THESE ROWS ARE. (a) 95 short-horizon-churn rows, churn=self: the mechanical rewrite touches essentially every function in ingest_{metrics,binds,sidecap,relations,names}.h, so every one of them shows this lane's own single edit. Not thrash — one commit. (b) 7 duplication rows and 1 new-clone-of-reused-helper. These are REAL new clone groups (--clones, uncapped: 407 groups before, 409 after; the rewrite adds 7 and removes 5) and they are IDIOM COLLISIONS, not copies. kindIs( t, "x" ) is shorter than std::strcmp( t, "x" ) == 0, so short predicate bodies that were previously above the clone threshold now match each other's normalized token stream. Six of the seven pair a node-kind || -chain with an unrelated || -chain over a DISJOINT literal set in a different subsystem — cc_isParamList (tree-sitter parameter-list kinds) against predicatePrefixed (English name prefixes is/has/can), against sliceIsJsPatternKind (JS destructuring kinds), rubyCallIsAssignmentTarget against slice.h's JS binding probes. The tool's own rule is that two ladders over the SAME enum are a copy; these share no non-keyword identifier and no domain, and merging any pair would need a helper parameterised on an unrelated literal table — a wrong abstraction to satisfy a lint. The seventh, kindIs | lexTokenEqualsLowered, is the same shape at 56 tokens with materially different contracts: lexTokenEqualsLowered takes an explicit length and case-folds one side, kindIs takes its length from the literal's type and compares the terminating NUL as an ordinary byte — and that NUL comparison is precisely the safety property kindIs depends on (test/nodekindcheck.sh arm B proves the absence of a read past it with an mprotect(PROT_NONE) guard page). Folding them together would erase the one property being gated. Gate: test/nodekindcheck.sh, 4 arms, 1,348,096 enumerated (candidate, literal) pairs against std::strcmp plus two mutation controls that each turn an arm red. -ack short-horizon-churn 1085f731a3dde7c8 7 cid=b89f92c2b2936f1f M12 (lane L9, capture-audit-2026-09-04): the deliberate cost of one root-relative path spelling across --affected/--test-gate/edit receipts/fetch_body plus the in_id= legend trim. runAffected grows the same mvSingleRoot/mvRootPrefix/mvRootAttr block verbs_report.h's dispatcher already threads (complexity 13->18, verbosity +17, mostly the comment naming the finding); writeTestGateReport/Json's duplication is the XML/JSON twin pair staying in lockstep, which is the property mcpclidiffcheck asserts; every short-horizon-churn row is this lane editing its own targets three times in one afternoon. +ack short-horizon-churn 1085f731a3dde7c8 33 cid=11e32cce5f112d11 E1/A4-2: every tests_to_run emitter re-routed through testmap.h's one seam (testRowsRendered) so runner-less rows group by evidence; the 12 sites were rewritten in-window by M21(b) and F1, and touching that family once more is the item itself | prior: M12 (lane L9, capture-audit-2026-09-04): the deliberate cost of one root-relative path spelling across --affected/--test-gate/edit receipts/fetch_body plus the in_id= legend trim. runAffected grows the same mvSingleRoot/mvRootPrefix/mvRootAttr block verbs_report.h's dispatcher already threads (complexity 13->18, verbosity +17, mostly the comment naming the finding); writeTestGateReport/Json's duplication is the XML/JSON twin pair staying in lockstep, which is the property mcpclidiffcheck asserts; every short-horizon-churn row is this lane editing its own targets three times in one afternoon. ack short-horizon-churn 109200616e8e6d9e 3 cid=bea14e1133ee095b ingest-path disclosure: eval prints ingest: lex=rich|scan and --doctor prints rich_verbs= derived by asking needsValueUses (now ONE function in cli.h) per verb. short-horizon-churn on runEvalRetrieval is this session's repeated edits to it, not instability; mutation control in knownitemcheck proves both new arms fail when the eval verbs leave the predicate. | prior: rich-ingest promotion for the eval verbs: --eval-retrieval/--eval-mined/--eval-skills now request captureValueUses so lexicalScoresTiered takes its persisted-stats path instead of re-tokenizing the corpus per query (94% of eval user time). short-horizon-churn rows are this session's repeated edits to those functions, not instability; verbosity on dispatchMain is one added comment. Scores byte-identical, postingscheck ALL PASS. ack short-horizon-churn 10f47dd5a3f35d86 33 fnbody-require lane: deliberate additive API widening (optional out-params, default nullptr, every existing caller unaffected) to disclose the lazy require/import distinction on --impact's importer tier; residual complexity/duplication is the twin-dialect emitter shape and the dual-mode importersOfFiles scan this feature requires, already extracted where a helper genuinely reduced it (scanImporterEdges, recordLazyPair); short-horizon-churn is this same commit's own edit history ack short-horizon-churn 111000c203e47c35 5 cid=12c85505363fa80e OPTREMARKS F3 (docs/OPTREMARKS.md §8b): the ~430 per-AST-node std::strcmp( t, "literal" ) sites in the five ingest walk sections become rw::kindIs (src/infra/nodekind.h) — an inline compare, because strcmp is an external symbol LTO cannot inline and on macOS costs two dyld stub hops before it starts. Measured: 10.6% of busy CPU in strcmp leaves on a cold llvm run, 6-12% on four other corpora; 0.23% after. Output byte-identical across 7 corpora x 5 verbs, argvdiffcheck 640/642 vectors identical (the 2 that differ are the +dirty build stamp in --version). WHAT THESE ROWS ARE. (a) 95 short-horizon-churn rows, churn=self: the mechanical rewrite touches essentially every function in ingest_{metrics,binds,sidecap,relations,names}.h, so every one of them shows this lane's own single edit. Not thrash — one commit. (b) 7 duplication rows and 1 new-clone-of-reused-helper. These are REAL new clone groups (--clones, uncapped: 407 groups before, 409 after; the rewrite adds 7 and removes 5) and they are IDIOM COLLISIONS, not copies. kindIs( t, "x" ) is shorter than std::strcmp( t, "x" ) == 0, so short predicate bodies that were previously above the clone threshold now match each other's normalized token stream. Six of the seven pair a node-kind || -chain with an unrelated || -chain over a DISJOINT literal set in a different subsystem — cc_isParamList (tree-sitter parameter-list kinds) against predicatePrefixed (English name prefixes is/has/can), against sliceIsJsPatternKind (JS destructuring kinds), rubyCallIsAssignmentTarget against slice.h's JS binding probes. The tool's own rule is that two ladders over the SAME enum are a copy; these share no non-keyword identifier and no domain, and merging any pair would need a helper parameterised on an unrelated literal table — a wrong abstraction to satisfy a lint. The seventh, kindIs | lexTokenEqualsLowered, is the same shape at 56 tokens with materially different contracts: lexTokenEqualsLowered takes an explicit length and case-folds one side, kindIs takes its length from the literal's type and compares the terminating NUL as an ordinary byte — and that NUL comparison is precisely the safety property kindIs depends on (test/nodekindcheck.sh arm B proves the absence of a read past it with an mprotect(PROT_NONE) guard page). Folding them together would erase the one property being gated. Gate: test/nodekindcheck.sh, 4 arms, 1,348,096 enumerated (candidate, literal) pairs against std::strcmp plus two mutation controls that each turn an arm red. ack short-horizon-churn 1251aefc21a466dc 6 cid=813f4fac69d7378d OPTREMARKS F3 (docs/OPTREMARKS.md §8b): the ~430 per-AST-node std::strcmp( t, "literal" ) sites in the five ingest walk sections become rw::kindIs (src/infra/nodekind.h) — an inline compare, because strcmp is an external symbol LTO cannot inline and on macOS costs two dyld stub hops before it starts. Measured: 10.6% of busy CPU in strcmp leaves on a cold llvm run, 6-12% on four other corpora; 0.23% after. Output byte-identical across 7 corpora x 5 verbs, argvdiffcheck 640/642 vectors identical (the 2 that differ are the +dirty build stamp in --version). WHAT THESE ROWS ARE. (a) 95 short-horizon-churn rows, churn=self: the mechanical rewrite touches essentially every function in ingest_{metrics,binds,sidecap,relations,names}.h, so every one of them shows this lane's own single edit. Not thrash — one commit. (b) 7 duplication rows and 1 new-clone-of-reused-helper. These are REAL new clone groups (--clones, uncapped: 407 groups before, 409 after; the rewrite adds 7 and removes 5) and they are IDIOM COLLISIONS, not copies. kindIs( t, "x" ) is shorter than std::strcmp( t, "x" ) == 0, so short predicate bodies that were previously above the clone threshold now match each other's normalized token stream. Six of the seven pair a node-kind || -chain with an unrelated || -chain over a DISJOINT literal set in a different subsystem — cc_isParamList (tree-sitter parameter-list kinds) against predicatePrefixed (English name prefixes is/has/can), against sliceIsJsPatternKind (JS destructuring kinds), rubyCallIsAssignmentTarget against slice.h's JS binding probes. The tool's own rule is that two ladders over the SAME enum are a copy; these share no non-keyword identifier and no domain, and merging any pair would need a helper parameterised on an unrelated literal table — a wrong abstraction to satisfy a lint. The seventh, kindIs | lexTokenEqualsLowered, is the same shape at 56 tokens with materially different contracts: lexTokenEqualsLowered takes an explicit length and case-folds one side, kindIs takes its length from the literal's type and compares the terminating NUL as an ordinary byte — and that NUL comparison is precisely the safety property kindIs depends on (test/nodekindcheck.sh arm B proves the absence of a read past it with an mprotect(PROT_NONE) guard page). Folding them together would erase the one property being gated. Gate: test/nodekindcheck.sh, 4 arms, 1,348,096 enumerated (candidate, literal) pairs against std::strcmp plus two mutation controls that each turn an arm red. ack short-horizon-churn 1273fad87a99a9f2 6 cid=e6e7ad831a784a6c M10 (capture-audit L9): at= anchor family added to --for/--situ/--naming-calibration/--merge-scout/--stray-content/--dmm/--handoff. forRootRelPathsLegendShort gained a 2nd bool param (default-valued, back-compat) to fold at= into the existing short root-rel comment under --for's byte ceiling; runForLens grew from splicing the stamp through the ceiling ladder's byte accounting; the coPairAttr clone pair is a coincidental 2-bool-dispatch shape collision (different domains, no real duplication); short-horizon-churn rows are every function this finding's fix touched this session. ack short-horizon-churn 129d3c8d5a763870 7 E1 answer grader + questions task source + claude-runner control isolation (F3). churn=self on run_one/main/build_prompt/_claude_metrics/_harness_metrics/_execute/prepare_codex_environment/prepare_opencode_environment is this one change's own edit window on bench/agentloop/run_agentloop.py, which the opencode round touched days ago: the three preparers now share ephemeral_run_home()+link_credential() and the three command builders share build_harness_command(), so every one of those symbols is edited by the same commit that introduces the claude preparer. run_one's complexity regression was FIXED rather than acked (25->under the bar, by extracting build_harness_command/question_timeout/prepare_environment) and the two dead-code rows were fixed by restoring explicit dispatch — a dict of callables had hidden the codex/opencode preparers from the resolver. The remaining duplication row (prepare_claude_environment | prepare_codex_environment, 101 tokens, down from 282) is DELIBERATE and not further factored: the residue is 'ephemeral home named by one env var + credentials symlinked + set the var', and collapsing it would need a nine-parameter helper that opencode still could not use (it derives every path from xdg-basedir at module load, so five dirs plus HOME must move). Each preparer is asserted independently by its own canary gate — agentloopclaudecheck/agentloopopencodecheck/agentloopcodexcheck — and keeping the three recipes separately readable is the point of those gates. grade_answers.py's new-symbol rows are the six protocol grader types plus the closed accept-rule clause grammar; apply_clauses is a flat per-clause-kind dispatch with no nesting, and transcript_answer_text's error-masking row is the deliberate 'schema drift degrades to nulls, never raises' contract the retained transcript makes safe. Gates: agentloopgradercheck (new, born red on origin/main at exit 2) and agentloopclaudecheck (new, born red at exit 1) both green; codex/opencode canaries and analyze.py --self-test unchanged and green. -ack short-horizon-churn 131068a6cedf0864 10 cid=bef0a5079a2bf8e4 R2: short-horizon churn on the three --pr-context symbols this round has been editing (V1 repriced them yesterday, V3 labels them today) — not new debt; writePrContext's complexity and verbosity are unchanged by this commit -ack short-horizon-churn 15061a69cb5b451f 4 R-E CORRECTION lane (2026-08-19), the W2-E root-relative fix round. Every row here belongs to ONE change with one purpose: the 2026-08-17 R-E landing emitted root-relative p= on ~30 verbs, defined root= in NO legend, converted the CLI arm of exemplar/impact/uses/owners/cochange/mentions/find_symbol and not the MCP twin, appended root= AFTER at= (breaking the r26 at=-stays-LAST rule --owners own emitter comment states), and left three verbs (--exemplar, --lego, --expand) serving relative paths against a root they never named. Full suite green at the end: gates=429 pass=427 skip=2 fail=0, ASan+LSan clean, byte-deterministic x3, xmllint clean. api-surface: kRootRelPathsLegend/rootRelPathsLegend are the ONE shared definition of root=, hoisted rather than pasted into eighteen legends (the S B4 echo-site rule); connectEstTokens gains an extraBytes param (1 to 2) because the first landing put root= in the connect start tag and left the estimator alone, i.e. the exact under-report kConnectRootBytes own comment forbids, and both the trim-loop fit check and the printed est_tokens must read the SAME number so it is passed, never re-derived. complexity: exemplarText 14 to 18, usesText 23 to 27, runDefaultMap 191 to 197, serialize 190 to 191, runTargetedViews 37 to 39 — every point is the single-root-condition ternary the CLI arm already carries at twenty-plus sites, applied to the twin so the two surfaces cannot answer one question in two path dialects; no new nesting level and no new control flow beyond that one conditional. verbosity: the added lines are overwhelmingly the WHY comments this repo requires on a re-pin or a degrade path, plus the guarded root= clause; no new logic rides in them. short-horizon-churn: churn=self on every symbol this correction edited is this one edit window, the same shape the two --lint acks above record and for the same reason. No duplication, dead-code, error-masking, param or reuse-decline finding appears in this report. +ack short-horizon-churn 131068a6cedf0864 17 cid=a171fda5c18fa950 E1/A4-2: every tests_to_run emitter re-routed through testmap.h's one seam (testRowsRendered) so runner-less rows group by evidence; the 12 sites were rewritten in-window by M21(b) and F1, and touching that family once more is the item itself | prior: R2: short-horizon churn on the three --pr-context symbols this round has been editing (V1 repriced them yesterday, V3 labels them today) — not new debt; writePrContext's complexity and verbosity are unchanged by this commit +ack short-horizon-churn 15061a69cb5b451f 25 cid=c11a1cfb11d0325d E1/A4-2: every tests_to_run emitter re-routed through testmap.h's one seam (testRowsRendered) so runner-less rows group by evidence; the 12 sites were rewritten in-window by M21(b) and F1, and touching that family once more is the item itself | prior: R-E CORRECTION lane (2026-08-19), the W2-E root-relative fix round. Every row here belongs to ONE change with one purpose: the 2026-08-17 R-E landing emitted root-relative p= on ~30 verbs, defined root= in NO legend, converted the CLI arm of exemplar/impact/uses/owners/cochange/mentions/find_symbol and not the MCP twin, appended root= AFTER at= (breaking the r26 at=-stays-LAST rule --owners own emitter comment states), and left three verbs (--exemplar, --lego, --expand) serving relative paths against a root they never named. Full suite green at the end: gates=429 pass=427 skip=2 fail=0, ASan+LSan clean, byte-deterministic x3, xmllint clean. api-surface: kRootRelPathsLegend/rootRelPathsLegend are the ONE shared definition of root=, hoisted rather than pasted into eighteen legends (the S B4 echo-site rule); connectEstTokens gains an extraBytes param (1 to 2) because the first landing put root= in the connect start tag and left the estimator alone, i.e. the exact under-report kConnectRootBytes own comment forbids, and both the trim-loop fit check and the printed est_tokens must read the SAME number so it is passed, never re-derived. complexity: exemplarText 14 to 18, usesText 23 to 27, runDefaultMap 191 to 197, serialize 190 to 191, runTargetedViews 37 to 39 — every point is the single-root-condition ternary the CLI arm already carries at twenty-plus sites, applied to the twin so the two surfaces cannot answer one question in two path dialects; no new nesting level and no new control flow beyond that one conditional. verbosity: the added lines are overwhelmingly the WHY comments this repo requires on a re-pin or a degrade path, plus the guarded root= clause; no new logic rides in them. short-horizon-churn: churn=self on every symbol this correction edited is this one edit window, the same shape the two --lint acks above record and for the same reason. No duplication, dead-code, error-masking, param or reuse-decline finding appears in this report. ack short-horizon-churn 1520fa02411735c3 4 cid=69e2cb4c55a88771 C1 F-06/F-07/F-10 (the listing-paging round): three listing verbs learn to disclose and page their row listings, and every gating row is that one change. api-surface 14 = ONE trailing DEFAULTED parameter each (an int pageOffset, an McpPageArgs window, a SituPageArgs, or the next= invocation a header now carries) on the emitters that must be TOLD their window — writeFlags/writeGate, writeCappedRows/writeCappedList/writeFlip/writeFlipHeader/writeFlipLights, computeFlip, nearestGateNames (which gains its cap and its TOTAL, the disclosure itself), situShowingNote/writeSituation, and the three MCP twins flagsText/flipText/situationDiffJson; additive by construction, every pre-existing call site compiles unchanged, and the alternative — a second capped emitter per verb — is the drift this repo removes rather than adds, because two emitters that disagree about a window can drop the row that IS the answer. verbosity 3 = kDocDriftLegend +20 lines and writeDocDriftPage +10 are the in-band vocabulary a reader needs to read shown_failed=/failed_capped=/failed_total= where they meet it (the rationale and the next= scan were already hoisted OUT of the body into their own function and comment, which took the complexity row to zero and the LOC row from +48 to +10); dispatchMcpLine +12 is two pagedResult wrappers on a pre-existing 1376-line dispatcher this lane adds to rather than creates. complexity 1 = the same dispatcher, +9 on a base of 518. duplication 1 = flagsText | flipText at 110 tokens, down from 131 after the shared mcpRowCap fold; the residual is getIndex + compute + captureXml, the shape EVERY index-backed MCP twin in this file has, and merging two verbs that return different results behind one entry point would be worse code than the clone. short-horizon-churn 19 = this lane's own footprint across cli.h/docdrift.h/darkflags.h/flipimpact.h/situ.h/mcp*, plus cli.h symbols three other lanes touched the same day; none foreign, none thrash. ack short-horizon-churn 15754e3561a34f40 39 cid=93c7392c7b677557 P7 (terminality round A, lane R): the flat rank-ordered lens — ambient short-horizon churn on the JSON collector and its row structs (fileSlot field, flat emission), on fromTraceBundleText's legend line ('rows in r= order, p=file'), and +2..4 LOC on trimSigLadder (rank-major step F comment), narrowLegoToRenderedSigs (row p= scan) and packSignaturesJson (flat emission loop) — the P7 change itself; gate test/forrankordercheck.sh; --eval-retrieval byte-identical | prior: deep-tail lane (docs/EVALS.md Deep-tail serving; gate test/deeptailcheck.sh): the rank fact + file-grain tail land on every lens serving path at once, so the serving emitters carry the lane's own diff. api-surface jsonSigRowHead 6->7 = the defaulted globalRank param (0 = key absent; every existing caller source-compatible). complexity/verbosity runForLens +4/+39 and emitForLensJson +13 = the four seams a charged section costs (render, ladder charge, est charge, emission) after the fit logic was already extracted to renderForFileTailXml/forLensJsonTailStanza; forTaskText +17 = the MCP twin's parity wiring. churn=self rows are this one lane's diff on the emitters it owns, not thrash. The tail/r fit logic itself lives in serialize.h free functions, gate-covered red-first vs d8e257d. ack short-horizon-churn 1610c5acaa7d4806 6 cid=545658032a875c30 OPTREMARKS F3 (docs/OPTREMARKS.md §8b): the ~430 per-AST-node std::strcmp( t, "literal" ) sites in the five ingest walk sections become rw::kindIs (src/infra/nodekind.h) — an inline compare, because strcmp is an external symbol LTO cannot inline and on macOS costs two dyld stub hops before it starts. Measured: 10.6% of busy CPU in strcmp leaves on a cold llvm run, 6-12% on four other corpora; 0.23% after. Output byte-identical across 7 corpora x 5 verbs, argvdiffcheck 640/642 vectors identical (the 2 that differ are the +dirty build stamp in --version). WHAT THESE ROWS ARE. (a) 95 short-horizon-churn rows, churn=self: the mechanical rewrite touches essentially every function in ingest_{metrics,binds,sidecap,relations,names}.h, so every one of them shows this lane's own single edit. Not thrash — one commit. (b) 7 duplication rows and 1 new-clone-of-reused-helper. These are REAL new clone groups (--clones, uncapped: 407 groups before, 409 after; the rewrite adds 7 and removes 5) and they are IDIOM COLLISIONS, not copies. kindIs( t, "x" ) is shorter than std::strcmp( t, "x" ) == 0, so short predicate bodies that were previously above the clone threshold now match each other's normalized token stream. Six of the seven pair a node-kind || -chain with an unrelated || -chain over a DISJOINT literal set in a different subsystem — cc_isParamList (tree-sitter parameter-list kinds) against predicatePrefixed (English name prefixes is/has/can), against sliceIsJsPatternKind (JS destructuring kinds), rubyCallIsAssignmentTarget against slice.h's JS binding probes. The tool's own rule is that two ladders over the SAME enum are a copy; these share no non-keyword identifier and no domain, and merging any pair would need a helper parameterised on an unrelated literal table — a wrong abstraction to satisfy a lint. The seventh, kindIs | lexTokenEqualsLowered, is the same shape at 56 tokens with materially different contracts: lexTokenEqualsLowered takes an explicit length and case-folds one side, kindIs takes its length from the literal's type and compares the terminating NUL as an ordinary byte — and that NUL comparison is precisely the safety property kindIs depends on (test/nodekindcheck.sh arm B proves the absence of a read past it with an mprotect(PROT_NONE) guard page). Folding them together would erase the one property being gated. Gate: test/nodekindcheck.sh, 4 arms, 1,348,096 enumerated (candidate, literal) pairs against std::strcmp plus two mutation controls that each turn an arm red. @@ -830,7 +830,7 @@ ack short-horizon-churn 3be1c13661e5a63c 5 pack-task budget round (verifier K1+K ack short-horizon-churn 3c06a3d024349e5f 5 cid=f88a6b24c26cb6b9 OPTREMARKS F3 (docs/OPTREMARKS.md §8b): the ~430 per-AST-node std::strcmp( t, "literal" ) sites in the five ingest walk sections become rw::kindIs (src/infra/nodekind.h) — an inline compare, because strcmp is an external symbol LTO cannot inline and on macOS costs two dyld stub hops before it starts. Measured: 10.6% of busy CPU in strcmp leaves on a cold llvm run, 6-12% on four other corpora; 0.23% after. Output byte-identical across 7 corpora x 5 verbs, argvdiffcheck 640/642 vectors identical (the 2 that differ are the +dirty build stamp in --version). WHAT THESE ROWS ARE. (a) 95 short-horizon-churn rows, churn=self: the mechanical rewrite touches essentially every function in ingest_{metrics,binds,sidecap,relations,names}.h, so every one of them shows this lane's own single edit. Not thrash — one commit. (b) 7 duplication rows and 1 new-clone-of-reused-helper. These are REAL new clone groups (--clones, uncapped: 407 groups before, 409 after; the rewrite adds 7 and removes 5) and they are IDIOM COLLISIONS, not copies. kindIs( t, "x" ) is shorter than std::strcmp( t, "x" ) == 0, so short predicate bodies that were previously above the clone threshold now match each other's normalized token stream. Six of the seven pair a node-kind || -chain with an unrelated || -chain over a DISJOINT literal set in a different subsystem — cc_isParamList (tree-sitter parameter-list kinds) against predicatePrefixed (English name prefixes is/has/can), against sliceIsJsPatternKind (JS destructuring kinds), rubyCallIsAssignmentTarget against slice.h's JS binding probes. The tool's own rule is that two ladders over the SAME enum are a copy; these share no non-keyword identifier and no domain, and merging any pair would need a helper parameterised on an unrelated literal table — a wrong abstraction to satisfy a lint. The seventh, kindIs | lexTokenEqualsLowered, is the same shape at 56 tokens with materially different contracts: lexTokenEqualsLowered takes an explicit length and case-folds one side, kindIs takes its length from the literal's type and compares the terminating NUL as an ordinary byte — and that NUL comparison is precisely the safety property kindIs depends on (test/nodekindcheck.sh arm B proves the absence of a read past it with an mprotect(PROT_NONE) guard page). Folding them together would erase the one property being gated. Gate: test/nodekindcheck.sh, 4 arms, 1,348,096 enumerated (candidate, literal) pairs against std::strcmp plus two mutation controls that each turn an arm red. ack short-horizon-churn 3c07d993bfdbce53 8 cid=3718024f74a80d86 arise-h2h lane 2026-08-31: the four gating rows are all short-horizon-churn churn=self on this lane's own multi-line-statement flow fix (SliceOcc gains stmtLine, sliceWalk anchors it, sliceFlowCompute delegates to the extracted expand helpers, sliceBundleText legend sentence) - the edits are this round's deliberate red-first fix (sliceflowcheck arm 25), no foreign debt absorbed; complexity/nesting/verbosity on sliceFlowCompute were fixed by extraction, not acked | prior: lane/tc-sliceat: the --at/@FILE:LINE line-seed reaches --slice (ARISE (file,line[,var]) seed). sliceBundleText +1 defaulted param (seedInfo, the flowSpec shape) and its seed=/seed_vars=/var_from= emission + conditional legend; runSlice grows the seed wiring (resolve/pre-pick/disclosure) with the narrowing itself extracted to sliceApplyAtSeed; scanReportVerbPrecedence churn is the one-line --at compose row. All rows this lane's own diff, gate-covered red-first in test/sliceflowcheck.sh arms 11-24 ack short-horizon-churn 3c7a8e2ee4351734 11 fix-grep lane 2026-08-15: this symbol's ONLY change is the boolean line-scope branch swapping the 512-byte-capped DISPLAY helper grepMatchedLine for the new uncapped grepWholeLine. That cap was the bug in both directions (a required --and term past byte 512 dropped a real hit; a forbidden --not term past byte 512 failed to exclude its row), so churn=self here IS the fix and nothing else. Proven by an independent oracle: test/grepandcheck.sh (3a)/(3b)/(3c) now derive truth from /usr/bin/grep over a fixture whose second term sits at ~col 640, and all three arms are RED against the pre-fix binary and green after. Identity restored on this repo: --grep=stale --and=stale 289 -> 292 = plain --grep=stale; --grep=symbol --and=symbol 3028 -> 3030; the five mcp.h sites at cols 513-790 the verifier named are back -ack short-horizon-churn 3d87404c1cdf50ec 88 cid=15a146f0ee876b3f C1 F-06/F-07/F-10 (the listing-paging round): three listing verbs learn to disclose and page their row listings, and every gating row is that one change. api-surface 14 = ONE trailing DEFAULTED parameter each (an int pageOffset, an McpPageArgs window, a SituPageArgs, or the next= invocation a header now carries) on the emitters that must be TOLD their window — writeFlags/writeGate, writeCappedRows/writeCappedList/writeFlip/writeFlipHeader/writeFlipLights, computeFlip, nearestGateNames (which gains its cap and its TOTAL, the disclosure itself), situShowingNote/writeSituation, and the three MCP twins flagsText/flipText/situationDiffJson; additive by construction, every pre-existing call site compiles unchanged, and the alternative — a second capped emitter per verb — is the drift this repo removes rather than adds, because two emitters that disagree about a window can drop the row that IS the answer. verbosity 3 = kDocDriftLegend +20 lines and writeDocDriftPage +10 are the in-band vocabulary a reader needs to read shown_failed=/failed_capped=/failed_total= where they meet it (the rationale and the next= scan were already hoisted OUT of the body into their own function and comment, which took the complexity row to zero and the LOC row from +48 to +10); dispatchMcpLine +12 is two pagedResult wrappers on a pre-existing 1376-line dispatcher this lane adds to rather than creates. complexity 1 = the same dispatcher, +9 on a base of 518. duplication 1 = flagsText | flipText at 110 tokens, down from 131 after the shared mcpRowCap fold; the residual is getIndex + compute + captureXml, the shape EVERY index-backed MCP twin in this file has, and merging two verbs that return different results behind one entry point would be worse code than the clone. short-horizon-churn 19 = this lane's own footprint across cli.h/docdrift.h/darkflags.h/flipimpact.h/situ.h/mcp*, plus cli.h symbols three other lanes touched the same day; none foreign, none thrash. +ack short-horizon-churn 3d87404c1cdf50ec 118 cid=7f345cc46d2fd3d8 E1/A4-2: every tests_to_run emitter re-routed through testmap.h's one seam (testRowsRendered) so runner-less rows group by evidence; the 12 sites were rewritten in-window by M21(b) and F1, and touching that family once more is the item itself | prior: C1 F-06/F-07/F-10 (the listing-paging round): three listing verbs learn to disclose and page their row listings, and every gating row is that one change. api-surface 14 = ONE trailing DEFAULTED parameter each (an int pageOffset, an McpPageArgs window, a SituPageArgs, or the next= invocation a header now carries) on the emitters that must be TOLD their window — writeFlags/writeGate, writeCappedRows/writeCappedList/writeFlip/writeFlipHeader/writeFlipLights, computeFlip, nearestGateNames (which gains its cap and its TOTAL, the disclosure itself), situShowingNote/writeSituation, and the three MCP twins flagsText/flipText/situationDiffJson; additive by construction, every pre-existing call site compiles unchanged, and the alternative — a second capped emitter per verb — is the drift this repo removes rather than adds, because two emitters that disagree about a window can drop the row that IS the answer. verbosity 3 = kDocDriftLegend +20 lines and writeDocDriftPage +10 are the in-band vocabulary a reader needs to read shown_failed=/failed_capped=/failed_total= where they meet it (the rationale and the next= scan were already hoisted OUT of the body into their own function and comment, which took the complexity row to zero and the LOC row from +48 to +10); dispatchMcpLine +12 is two pagedResult wrappers on a pre-existing 1376-line dispatcher this lane adds to rather than creates. complexity 1 = the same dispatcher, +9 on a base of 518. duplication 1 = flagsText | flipText at 110 tokens, down from 131 after the shared mcpRowCap fold; the residual is getIndex + compute + captureXml, the shape EVERY index-backed MCP twin in this file has, and merging two verbs that return different results behind one entry point would be worse code than the clone. short-horizon-churn 19 = this lane's own footprint across cli.h/docdrift.h/darkflags.h/flipimpact.h/situ.h/mcp*, plus cli.h symbols three other lanes touched the same day; none foreign, none thrash. ack short-horizon-churn 3e0a074094789d60 15 cid=1556a2e06eef44c3 2026-09-06 stranger-audit fixes: doctor compares bytes and fails off-PATH, html names its root+commit+version, at= carries +shallow, edit-lock sweep — the contract change (writeDocumentShell takes the title) and runDoctor's two new verdicts are deliberate; the churn rows are this edit itself ack short-horizon-churn 3e7c1221865b5b52 13 cid=f636d73c8428270e OPTREMARKS F3 (docs/OPTREMARKS.md §8b): the ~430 per-AST-node std::strcmp( t, "literal" ) sites in the five ingest walk sections become rw::kindIs (src/infra/nodekind.h) — an inline compare, because strcmp is an external symbol LTO cannot inline and on macOS costs two dyld stub hops before it starts. Measured: 10.6% of busy CPU in strcmp leaves on a cold llvm run, 6-12% on four other corpora; 0.23% after. Output byte-identical across 7 corpora x 5 verbs, argvdiffcheck 640/642 vectors identical (the 2 that differ are the +dirty build stamp in --version). WHAT THESE ROWS ARE. (a) 95 short-horizon-churn rows, churn=self: the mechanical rewrite touches essentially every function in ingest_{metrics,binds,sidecap,relations,names}.h, so every one of them shows this lane's own single edit. Not thrash — one commit. (b) 7 duplication rows and 1 new-clone-of-reused-helper. These are REAL new clone groups (--clones, uncapped: 407 groups before, 409 after; the rewrite adds 7 and removes 5) and they are IDIOM COLLISIONS, not copies. kindIs( t, "x" ) is shorter than std::strcmp( t, "x" ) == 0, so short predicate bodies that were previously above the clone threshold now match each other's normalized token stream. Six of the seven pair a node-kind || -chain with an unrelated || -chain over a DISJOINT literal set in a different subsystem — cc_isParamList (tree-sitter parameter-list kinds) against predicatePrefixed (English name prefixes is/has/can), against sliceIsJsPatternKind (JS destructuring kinds), rubyCallIsAssignmentTarget against slice.h's JS binding probes. The tool's own rule is that two ladders over the SAME enum are a copy; these share no non-keyword identifier and no domain, and merging any pair would need a helper parameterised on an unrelated literal table — a wrong abstraction to satisfy a lint. The seventh, kindIs | lexTokenEqualsLowered, is the same shape at 56 tokens with materially different contracts: lexTokenEqualsLowered takes an explicit length and case-folds one side, kindIs takes its length from the literal's type and compares the terminating NUL as an ordinary byte — and that NUL comparison is precisely the safety property kindIs depends on (test/nodekindcheck.sh arm B proves the absence of a read past it with an mprotect(PROT_NONE) guard page). Folding them together would erase the one property being gated. Gate: test/nodekindcheck.sh, 4 arms, 1,348,096 enumerated (candidate, literal) pairs against std::strcmp plus two mutation controls that each turn an arm red. ack short-horizon-churn 3e91cee9f02f22a2 12 cid=76f4b09ef77556bd R1 (wave-2 verifier): the redaction-marker write gate rewritten from a payload substring scan to a comparison against the bytes it would replace — short-horizon churn on the five symbols this round has been editing repeatedly, not new debt; the complexity/verbosity of all three write surfaces is unchanged or lower after the shared redactionMarkerRefusalFor helper | prior: A5/A7: short-horizon churn on editplan::prepare and ::receipt is this fix round itself -- five assigned defects on one small surface, committed one per item, so the same handful of symbols falls inside the churn window repeatedly. churn=self, not instability in the code. The duplication row this pass also raised (withinDir vs rw::pathIsUnder) was FIXED rather than acked: both that helper and a hand-rolled lexicalNormalize were deleted in favour of the existing resolve.h primitives. @@ -853,6 +853,7 @@ ack short-horizon-churn 472cc93317130a8b 6 cid=dcdfa60eb93788ed OPTREMARKS F3 (d ack short-horizon-churn 476ab6f670e5d871 13 cid=bb27e8c78edbdb2f OPTREMARKS F3 (docs/OPTREMARKS.md §8b): the ~430 per-AST-node std::strcmp( t, "literal" ) sites in the five ingest walk sections become rw::kindIs (src/infra/nodekind.h) — an inline compare, because strcmp is an external symbol LTO cannot inline and on macOS costs two dyld stub hops before it starts. Measured: 10.6% of busy CPU in strcmp leaves on a cold llvm run, 6-12% on four other corpora; 0.23% after. Output byte-identical across 7 corpora x 5 verbs, argvdiffcheck 640/642 vectors identical (the 2 that differ are the +dirty build stamp in --version). WHAT THESE ROWS ARE. (a) 95 short-horizon-churn rows, churn=self: the mechanical rewrite touches essentially every function in ingest_{metrics,binds,sidecap,relations,names}.h, so every one of them shows this lane's own single edit. Not thrash — one commit. (b) 7 duplication rows and 1 new-clone-of-reused-helper. These are REAL new clone groups (--clones, uncapped: 407 groups before, 409 after; the rewrite adds 7 and removes 5) and they are IDIOM COLLISIONS, not copies. kindIs( t, "x" ) is shorter than std::strcmp( t, "x" ) == 0, so short predicate bodies that were previously above the clone threshold now match each other's normalized token stream. Six of the seven pair a node-kind || -chain with an unrelated || -chain over a DISJOINT literal set in a different subsystem — cc_isParamList (tree-sitter parameter-list kinds) against predicatePrefixed (English name prefixes is/has/can), against sliceIsJsPatternKind (JS destructuring kinds), rubyCallIsAssignmentTarget against slice.h's JS binding probes. The tool's own rule is that two ladders over the SAME enum are a copy; these share no non-keyword identifier and no domain, and merging any pair would need a helper parameterised on an unrelated literal table — a wrong abstraction to satisfy a lint. The seventh, kindIs | lexTokenEqualsLowered, is the same shape at 56 tokens with materially different contracts: lexTokenEqualsLowered takes an explicit length and case-folds one side, kindIs takes its length from the literal's type and compares the terminating NUL as an ordinary byte — and that NUL comparison is precisely the safety property kindIs depends on (test/nodekindcheck.sh arm B proves the absence of a read past it with an mprotect(PROT_NONE) guard page). Folding them together would erase the one property being gated. Gate: test/nodekindcheck.sh, 4 arms, 1,348,096 enumerated (candidate, literal) pairs against std::strcmp plus two mutation controls that each turn an arm red. ack short-horizon-churn 48c3932de16d9cdd 9 cid=51e0c2d620ad5d17 by=src/* lane F (F-05/F-06/F-13): any-member scope symmetry for ack suppression + foreign-acks, out-of-scope disclosure now unconditional (never ack-ratcheted), .ripwire_config unrecognized-key/inert-name disclosure — new helpers are the feature surface, short-horizon-churn reflects this round's own edit sequence on these functions ack short-horizon-churn 4975dcbd128411d7 5 cid=9b40c150a9616ae3 OPTREMARKS F3 (docs/OPTREMARKS.md §8b): the ~430 per-AST-node std::strcmp( t, "literal" ) sites in the five ingest walk sections become rw::kindIs (src/infra/nodekind.h) — an inline compare, because strcmp is an external symbol LTO cannot inline and on macOS costs two dyld stub hops before it starts. Measured: 10.6% of busy CPU in strcmp leaves on a cold llvm run, 6-12% on four other corpora; 0.23% after. Output byte-identical across 7 corpora x 5 verbs, argvdiffcheck 640/642 vectors identical (the 2 that differ are the +dirty build stamp in --version). WHAT THESE ROWS ARE. (a) 95 short-horizon-churn rows, churn=self: the mechanical rewrite touches essentially every function in ingest_{metrics,binds,sidecap,relations,names}.h, so every one of them shows this lane's own single edit. Not thrash — one commit. (b) 7 duplication rows and 1 new-clone-of-reused-helper. These are REAL new clone groups (--clones, uncapped: 407 groups before, 409 after; the rewrite adds 7 and removes 5) and they are IDIOM COLLISIONS, not copies. kindIs( t, "x" ) is shorter than std::strcmp( t, "x" ) == 0, so short predicate bodies that were previously above the clone threshold now match each other's normalized token stream. Six of the seven pair a node-kind || -chain with an unrelated || -chain over a DISJOINT literal set in a different subsystem — cc_isParamList (tree-sitter parameter-list kinds) against predicatePrefixed (English name prefixes is/has/can), against sliceIsJsPatternKind (JS destructuring kinds), rubyCallIsAssignmentTarget against slice.h's JS binding probes. The tool's own rule is that two ladders over the SAME enum are a copy; these share no non-keyword identifier and no domain, and merging any pair would need a helper parameterised on an unrelated literal table — a wrong abstraction to satisfy a lint. The seventh, kindIs | lexTokenEqualsLowered, is the same shape at 56 tokens with materially different contracts: lexTokenEqualsLowered takes an explicit length and case-folds one side, kindIs takes its length from the literal's type and compares the terminating NUL as an ordinary byte — and that NUL comparison is precisely the safety property kindIs depends on (test/nodekindcheck.sh arm B proves the absence of a read past it with an mprotect(PROT_NONE) guard page). Folding them together would erase the one property being gated. Gate: test/nodekindcheck.sh, 4 arms, 1,348,096 enumerated (candidate, literal) pairs against std::strcmp plus two mutation controls that each turn an arm red. +ack short-horizon-churn 49a806e993ebe684 11 cid=fae3fb658b95ef51 E1/A4-2: every tests_to_run emitter re-routed through testmap.h's one seam (testRowsRendered) so runner-less rows group by evidence; the 12 sites were rewritten in-window by M21(b) and F1, and touching that family once more is the item itself ack short-horizon-churn 49e172c2aa455e68 4 cid=0ece6180949ad4aa C1 F-06/F-07/F-10 (the listing-paging round): three listing verbs learn to disclose and page their row listings, and every gating row is that one change. api-surface 14 = ONE trailing DEFAULTED parameter each (an int pageOffset, an McpPageArgs window, a SituPageArgs, or the next= invocation a header now carries) on the emitters that must be TOLD their window — writeFlags/writeGate, writeCappedRows/writeCappedList/writeFlip/writeFlipHeader/writeFlipLights, computeFlip, nearestGateNames (which gains its cap and its TOTAL, the disclosure itself), situShowingNote/writeSituation, and the three MCP twins flagsText/flipText/situationDiffJson; additive by construction, every pre-existing call site compiles unchanged, and the alternative — a second capped emitter per verb — is the drift this repo removes rather than adds, because two emitters that disagree about a window can drop the row that IS the answer. verbosity 3 = kDocDriftLegend +20 lines and writeDocDriftPage +10 are the in-band vocabulary a reader needs to read shown_failed=/failed_capped=/failed_total= where they meet it (the rationale and the next= scan were already hoisted OUT of the body into their own function and comment, which took the complexity row to zero and the LOC row from +48 to +10); dispatchMcpLine +12 is two pagedResult wrappers on a pre-existing 1376-line dispatcher this lane adds to rather than creates. complexity 1 = the same dispatcher, +9 on a base of 518. duplication 1 = flagsText | flipText at 110 tokens, down from 131 after the shared mcpRowCap fold; the residual is getIndex + compute + captureXml, the shape EVERY index-backed MCP twin in this file has, and merging two verbs that return different results behind one entry point would be worse code than the clone. short-horizon-churn 19 = this lane's own footprint across cli.h/docdrift.h/darkflags.h/flipimpact.h/situ.h/mcp*, plus cli.h symbols three other lanes touched the same day; none foreign, none thrash. ack short-horizon-churn 49e838ca0844258b 5 cid=9d23ed0d32fea19d OPTREMARKS F3 (docs/OPTREMARKS.md §8b): the ~430 per-AST-node std::strcmp( t, "literal" ) sites in the five ingest walk sections become rw::kindIs (src/infra/nodekind.h) — an inline compare, because strcmp is an external symbol LTO cannot inline and on macOS costs two dyld stub hops before it starts. Measured: 10.6% of busy CPU in strcmp leaves on a cold llvm run, 6-12% on four other corpora; 0.23% after. Output byte-identical across 7 corpora x 5 verbs, argvdiffcheck 640/642 vectors identical (the 2 that differ are the +dirty build stamp in --version). WHAT THESE ROWS ARE. (a) 95 short-horizon-churn rows, churn=self: the mechanical rewrite touches essentially every function in ingest_{metrics,binds,sidecap,relations,names}.h, so every one of them shows this lane's own single edit. Not thrash — one commit. (b) 7 duplication rows and 1 new-clone-of-reused-helper. These are REAL new clone groups (--clones, uncapped: 407 groups before, 409 after; the rewrite adds 7 and removes 5) and they are IDIOM COLLISIONS, not copies. kindIs( t, "x" ) is shorter than std::strcmp( t, "x" ) == 0, so short predicate bodies that were previously above the clone threshold now match each other's normalized token stream. Six of the seven pair a node-kind || -chain with an unrelated || -chain over a DISJOINT literal set in a different subsystem — cc_isParamList (tree-sitter parameter-list kinds) against predicatePrefixed (English name prefixes is/has/can), against sliceIsJsPatternKind (JS destructuring kinds), rubyCallIsAssignmentTarget against slice.h's JS binding probes. The tool's own rule is that two ladders over the SAME enum are a copy; these share no non-keyword identifier and no domain, and merging any pair would need a helper parameterised on an unrelated literal table — a wrong abstraction to satisfy a lint. The seventh, kindIs | lexTokenEqualsLowered, is the same shape at 56 tokens with materially different contracts: lexTokenEqualsLowered takes an explicit length and case-folds one side, kindIs takes its length from the literal's type and compares the terminating NUL as an ordinary byte — and that NUL comparison is precisely the safety property kindIs depends on (test/nodekindcheck.sh arm B proves the absence of a read past it with an mprotect(PROT_NONE) guard page). Folding them together would erase the one property being gated. Gate: test/nodekindcheck.sh, 4 arms, 1,348,096 enumerated (candidate, literal) pairs against std::strcmp plus two mutation controls that each turn an arm red. ack short-horizon-churn 49ea9954a4e77a06 2 cid=ad0e2ee1266299ec selector-parity + degraded-routing round (2026-08-30): churn=self on emitGrepReport and classifySkipHealth is this one edit window — each was edited twice within the round because the round's own quality-delta demanded the second pass (the parse_degraded= inline predicate and classifySkipHealth's errNodes test both re-routed through the ONE fileParseDegraded predicate hoisted to model.h, so the three degraded surfaces cannot drift). Final state carries no complexity/verbosity/duplication finding; selectorscopecheck 8 arms + degradedhintcheck 8 arms green. @@ -871,7 +872,7 @@ ack short-horizon-churn 4f90e22ea95dea67 22 cid=fe301df9e2f3c6db by=src/* rung 3 ack short-horizon-churn 4fb850ad3a3bb9c3 5 cid=efacb2a5f9e0435a OPTREMARKS F3 (docs/OPTREMARKS.md §8b): the ~430 per-AST-node std::strcmp( t, "literal" ) sites in the five ingest walk sections become rw::kindIs (src/infra/nodekind.h) — an inline compare, because strcmp is an external symbol LTO cannot inline and on macOS costs two dyld stub hops before it starts. Measured: 10.6% of busy CPU in strcmp leaves on a cold llvm run, 6-12% on four other corpora; 0.23% after. Output byte-identical across 7 corpora x 5 verbs, argvdiffcheck 640/642 vectors identical (the 2 that differ are the +dirty build stamp in --version). WHAT THESE ROWS ARE. (a) 95 short-horizon-churn rows, churn=self: the mechanical rewrite touches essentially every function in ingest_{metrics,binds,sidecap,relations,names}.h, so every one of them shows this lane's own single edit. Not thrash — one commit. (b) 7 duplication rows and 1 new-clone-of-reused-helper. These are REAL new clone groups (--clones, uncapped: 407 groups before, 409 after; the rewrite adds 7 and removes 5) and they are IDIOM COLLISIONS, not copies. kindIs( t, "x" ) is shorter than std::strcmp( t, "x" ) == 0, so short predicate bodies that were previously above the clone threshold now match each other's normalized token stream. Six of the seven pair a node-kind || -chain with an unrelated || -chain over a DISJOINT literal set in a different subsystem — cc_isParamList (tree-sitter parameter-list kinds) against predicatePrefixed (English name prefixes is/has/can), against sliceIsJsPatternKind (JS destructuring kinds), rubyCallIsAssignmentTarget against slice.h's JS binding probes. The tool's own rule is that two ladders over the SAME enum are a copy; these share no non-keyword identifier and no domain, and merging any pair would need a helper parameterised on an unrelated literal table — a wrong abstraction to satisfy a lint. The seventh, kindIs | lexTokenEqualsLowered, is the same shape at 56 tokens with materially different contracts: lexTokenEqualsLowered takes an explicit length and case-folds one side, kindIs takes its length from the literal's type and compares the terminating NUL as an ordinary byte — and that NUL comparison is precisely the safety property kindIs depends on (test/nodekindcheck.sh arm B proves the absence of a read past it with an mprotect(PROT_NONE) guard page). Folding them together would erase the one property being gated. Gate: test/nodekindcheck.sh, 4 arms, 1,348,096 enumerated (candidate, literal) pairs against std::strcmp plus two mutation controls that each turn an arm red. ack short-horizon-churn 4fc8bad5056ad3f3 7 recall.h is the intended edit target for lane L4 (structure-aware doc truncation); truncateRecallBody and buildSectionGranularBody are the two functions the task names, and the file is a legitimately hot area (many prior feature rounds) — churn is inherent to the assignment, not a code-shape defect to fix ack short-horizon-churn 5274ab1a5e193cb4 7 cid=5cfc96d7c5424d21 OPTREMARKS F3 (docs/OPTREMARKS.md §8b): the ~430 per-AST-node std::strcmp( t, "literal" ) sites in the five ingest walk sections become rw::kindIs (src/infra/nodekind.h) — an inline compare, because strcmp is an external symbol LTO cannot inline and on macOS costs two dyld stub hops before it starts. Measured: 10.6% of busy CPU in strcmp leaves on a cold llvm run, 6-12% on four other corpora; 0.23% after. Output byte-identical across 7 corpora x 5 verbs, argvdiffcheck 640/642 vectors identical (the 2 that differ are the +dirty build stamp in --version). WHAT THESE ROWS ARE. (a) 95 short-horizon-churn rows, churn=self: the mechanical rewrite touches essentially every function in ingest_{metrics,binds,sidecap,relations,names}.h, so every one of them shows this lane's own single edit. Not thrash — one commit. (b) 7 duplication rows and 1 new-clone-of-reused-helper. These are REAL new clone groups (--clones, uncapped: 407 groups before, 409 after; the rewrite adds 7 and removes 5) and they are IDIOM COLLISIONS, not copies. kindIs( t, "x" ) is shorter than std::strcmp( t, "x" ) == 0, so short predicate bodies that were previously above the clone threshold now match each other's normalized token stream. Six of the seven pair a node-kind || -chain with an unrelated || -chain over a DISJOINT literal set in a different subsystem — cc_isParamList (tree-sitter parameter-list kinds) against predicatePrefixed (English name prefixes is/has/can), against sliceIsJsPatternKind (JS destructuring kinds), rubyCallIsAssignmentTarget against slice.h's JS binding probes. The tool's own rule is that two ladders over the SAME enum are a copy; these share no non-keyword identifier and no domain, and merging any pair would need a helper parameterised on an unrelated literal table — a wrong abstraction to satisfy a lint. The seventh, kindIs | lexTokenEqualsLowered, is the same shape at 56 tokens with materially different contracts: lexTokenEqualsLowered takes an explicit length and case-folds one side, kindIs takes its length from the literal's type and compares the terminating NUL as an ordinary byte — and that NUL comparison is precisely the safety property kindIs depends on (test/nodekindcheck.sh arm B proves the absence of a read past it with an mprotect(PROT_NONE) guard page). Folding them together would erase the one property being gated. Gate: test/nodekindcheck.sh, 4 arms, 1,348,096 enumerated (candidate, literal) pairs against std::strcmp plus two mutation controls that each turn an arm red. -ack short-horizon-churn 52c2e3e32753dd28 10 cid=2ffb98cdf6477093 R2: short-horizon churn on the three --pr-context symbols this round has been editing (V1 repriced them yesterday, V3 labels them today) — not new debt; writePrContext's complexity and verbosity are unchanged by this commit | prior: F4 (lane F): churn=self on writePrContext is this session's own two edits to that symbol inside one window while the empty-root tail converged (inline first, then extracted to prEmptyRootTail), not accumulated debt — the extraction is why the complexity and verbosity rows the inline version carried are gone. +ack short-horizon-churn 52c2e3e32753dd28 17 cid=9973e66bb068786d E1/A4-2: every tests_to_run emitter re-routed through testmap.h's one seam (testRowsRendered) so runner-less rows group by evidence; the 12 sites were rewritten in-window by M21(b) and F1, and touching that family once more is the item itself | prior: R2: short-horizon churn on the three --pr-context symbols this round has been editing (V1 repriced them yesterday, V3 labels them today) — not new debt; writePrContext's complexity and verbosity are unchanged by this commit ack short-horizon-churn 52de3cd8a58763d1 7 cid=462928d2141114f5 OPTREMARKS F3 (docs/OPTREMARKS.md §8b): the ~430 per-AST-node std::strcmp( t, "literal" ) sites in the five ingest walk sections become rw::kindIs (src/infra/nodekind.h) — an inline compare, because strcmp is an external symbol LTO cannot inline and on macOS costs two dyld stub hops before it starts. Measured: 10.6% of busy CPU in strcmp leaves on a cold llvm run, 6-12% on four other corpora; 0.23% after. Output byte-identical across 7 corpora x 5 verbs, argvdiffcheck 640/642 vectors identical (the 2 that differ are the +dirty build stamp in --version). WHAT THESE ROWS ARE. (a) 95 short-horizon-churn rows, churn=self: the mechanical rewrite touches essentially every function in ingest_{metrics,binds,sidecap,relations,names}.h, so every one of them shows this lane's own single edit. Not thrash — one commit. (b) 7 duplication rows and 1 new-clone-of-reused-helper. These are REAL new clone groups (--clones, uncapped: 407 groups before, 409 after; the rewrite adds 7 and removes 5) and they are IDIOM COLLISIONS, not copies. kindIs( t, "x" ) is shorter than std::strcmp( t, "x" ) == 0, so short predicate bodies that were previously above the clone threshold now match each other's normalized token stream. Six of the seven pair a node-kind || -chain with an unrelated || -chain over a DISJOINT literal set in a different subsystem — cc_isParamList (tree-sitter parameter-list kinds) against predicatePrefixed (English name prefixes is/has/can), against sliceIsJsPatternKind (JS destructuring kinds), rubyCallIsAssignmentTarget against slice.h's JS binding probes. The tool's own rule is that two ladders over the SAME enum are a copy; these share no non-keyword identifier and no domain, and merging any pair would need a helper parameterised on an unrelated literal table — a wrong abstraction to satisfy a lint. The seventh, kindIs | lexTokenEqualsLowered, is the same shape at 56 tokens with materially different contracts: lexTokenEqualsLowered takes an explicit length and case-folds one side, kindIs takes its length from the literal's type and compares the terminating NUL as an ordinary byte — and that NUL comparison is precisely the safety property kindIs depends on (test/nodekindcheck.sh arm B proves the absence of a read past it with an mprotect(PROT_NONE) guard page). Folding them together would erase the one property being gated. Gate: test/nodekindcheck.sh, 4 arms, 1,348,096 enumerated (candidate, literal) pairs against std::strcmp plus two mutation controls that each turn an arm red. ack short-horizon-churn 5383f63ad718935c 8 cid=c7781d76aeed7d13 R8 printf-family pilot: std::printf/fprintf/snprintf -> std::print/std::format across every call site in verbs_lint.h, a file independently hot this round; converting a printf call necessarily rewrites the same recently-touched line, so self-thrash is inherent to the conversion, not new debt. Byte parity verified via test/printffmtparitycheck.sh (RED on an injected 1-byte mutation, GREEN on this pilot). ack short-horizon-churn 5391ffd9aa5765bf 44 W1-S2 dead-code top-level fix: isDeadCandidate param add + the edits to computeDelta/computeSnapshot are the fix itself @@ -901,7 +902,7 @@ ack short-horizon-churn 61d6cde8defa73ad 7 cid=5d0a21a4321d8300 OPTREMARKS F3 (d ack short-horizon-churn 61e5df9e1e40ff70 13 cid=e2155dd6082b880a E2 (terminality round A, lane E): +1 defaulted out-param: the receipt's ONE next= is read off the fold it renders (callers 2, incompatible 0) ack short-horizon-churn 623e9c51c095e307 3 S2b sweep-escalation lane: hooks/ripwire-nudge.sh was rewritten twice in 24h by the S2 meter lane and again here, so every meter_* function trips short-horizon-churn on any edit at all. The churn is the file's recent history, not a property of this change (the legend calls this kind preexisting by construction); the verbosity growth it came with WAS fixed, by splitting meter_classify_git and meter_classify_other out of meter_classify_bash. ack short-horizon-churn 624a465290b8a040 3 cid=7b2469337b9ad141 lane E close (terminality round A): run_editsuite.py: the ripwire half split into classify_ripwire_call; remaining rows are churn on the change's home -ack short-horizon-churn 6302e2e27e23bcde 4 cid=7202bbc7db7cc1da C1 F-06/F-07/F-10 (the listing-paging round): three listing verbs learn to disclose and page their row listings, and every gating row is that one change. api-surface 14 = ONE trailing DEFAULTED parameter each (an int pageOffset, an McpPageArgs window, a SituPageArgs, or the next= invocation a header now carries) on the emitters that must be TOLD their window — writeFlags/writeGate, writeCappedRows/writeCappedList/writeFlip/writeFlipHeader/writeFlipLights, computeFlip, nearestGateNames (which gains its cap and its TOTAL, the disclosure itself), situShowingNote/writeSituation, and the three MCP twins flagsText/flipText/situationDiffJson; additive by construction, every pre-existing call site compiles unchanged, and the alternative — a second capped emitter per verb — is the drift this repo removes rather than adds, because two emitters that disagree about a window can drop the row that IS the answer. verbosity 3 = kDocDriftLegend +20 lines and writeDocDriftPage +10 are the in-band vocabulary a reader needs to read shown_failed=/failed_capped=/failed_total= where they meet it (the rationale and the next= scan were already hoisted OUT of the body into their own function and comment, which took the complexity row to zero and the LOC row from +48 to +10); dispatchMcpLine +12 is two pagedResult wrappers on a pre-existing 1376-line dispatcher this lane adds to rather than creates. complexity 1 = the same dispatcher, +9 on a base of 518. duplication 1 = flagsText | flipText at 110 tokens, down from 131 after the shared mcpRowCap fold; the residual is getIndex + compute + captureXml, the shape EVERY index-backed MCP twin in this file has, and merging two verbs that return different results behind one entry point would be worse code than the clone. short-horizon-churn 19 = this lane's own footprint across cli.h/docdrift.h/darkflags.h/flipimpact.h/situ.h/mcp*, plus cli.h symbols three other lanes touched the same day; none foreign, none thrash. +ack short-horizon-churn 6302e2e27e23bcde 6 cid=5f0d97b6b89f7781 E1/A4-2: every tests_to_run emitter re-routed through testmap.h's one seam (testRowsRendered) so runner-less rows group by evidence; the 12 sites were rewritten in-window by M21(b) and F1, and touching that family once more is the item itself | prior: C1 F-06/F-07/F-10 (the listing-paging round): three listing verbs learn to disclose and page their row listings, and every gating row is that one change. api-surface 14 = ONE trailing DEFAULTED parameter each (an int pageOffset, an McpPageArgs window, a SituPageArgs, or the next= invocation a header now carries) on the emitters that must be TOLD their window — writeFlags/writeGate, writeCappedRows/writeCappedList/writeFlip/writeFlipHeader/writeFlipLights, computeFlip, nearestGateNames (which gains its cap and its TOTAL, the disclosure itself), situShowingNote/writeSituation, and the three MCP twins flagsText/flipText/situationDiffJson; additive by construction, every pre-existing call site compiles unchanged, and the alternative — a second capped emitter per verb — is the drift this repo removes rather than adds, because two emitters that disagree about a window can drop the row that IS the answer. verbosity 3 = kDocDriftLegend +20 lines and writeDocDriftPage +10 are the in-band vocabulary a reader needs to read shown_failed=/failed_capped=/failed_total= where they meet it (the rationale and the next= scan were already hoisted OUT of the body into their own function and comment, which took the complexity row to zero and the LOC row from +48 to +10); dispatchMcpLine +12 is two pagedResult wrappers on a pre-existing 1376-line dispatcher this lane adds to rather than creates. complexity 1 = the same dispatcher, +9 on a base of 518. duplication 1 = flagsText | flipText at 110 tokens, down from 131 after the shared mcpRowCap fold; the residual is getIndex + compute + captureXml, the shape EVERY index-backed MCP twin in this file has, and merging two verbs that return different results behind one entry point would be worse code than the clone. short-horizon-churn 19 = this lane's own footprint across cli.h/docdrift.h/darkflags.h/flipimpact.h/situ.h/mcp*, plus cli.h symbols three other lanes touched the same day; none foreign, none thrash. ack short-horizon-churn 639de1c3670999f9 30 cid=ffcb81de788c329a C1 F-06/F-07/F-10 (the listing-paging round): three listing verbs learn to disclose and page their row listings, and every gating row is that one change. api-surface 14 = ONE trailing DEFAULTED parameter each (an int pageOffset, an McpPageArgs window, a SituPageArgs, or the next= invocation a header now carries) on the emitters that must be TOLD their window — writeFlags/writeGate, writeCappedRows/writeCappedList/writeFlip/writeFlipHeader/writeFlipLights, computeFlip, nearestGateNames (which gains its cap and its TOTAL, the disclosure itself), situShowingNote/writeSituation, and the three MCP twins flagsText/flipText/situationDiffJson; additive by construction, every pre-existing call site compiles unchanged, and the alternative — a second capped emitter per verb — is the drift this repo removes rather than adds, because two emitters that disagree about a window can drop the row that IS the answer. verbosity 3 = kDocDriftLegend +20 lines and writeDocDriftPage +10 are the in-band vocabulary a reader needs to read shown_failed=/failed_capped=/failed_total= where they meet it (the rationale and the next= scan were already hoisted OUT of the body into their own function and comment, which took the complexity row to zero and the LOC row from +48 to +10); dispatchMcpLine +12 is two pagedResult wrappers on a pre-existing 1376-line dispatcher this lane adds to rather than creates. complexity 1 = the same dispatcher, +9 on a base of 518. duplication 1 = flagsText | flipText at 110 tokens, down from 131 after the shared mcpRowCap fold; the residual is getIndex + compute + captureXml, the shape EVERY index-backed MCP twin in this file has, and merging two verbs that return different results behind one entry point would be worse code than the clone. short-horizon-churn 19 = this lane's own footprint across cli.h/docdrift.h/darkflags.h/flipimpact.h/situ.h/mcp*, plus cli.h symbols three other lanes touched the same day; none foreign, none thrash. | prior: wave-3 close, H7 hosts: runCrossRef hosts the --plan and --stray-content refusal sites the fix routes through the shared sentence (verify-wave2 lanes edited it days earlier — the churn is the fix's, self) ack short-horizon-churn 649ed79cefcd6fc6 6 cid=a3636823a3a67d9f by=src/* lane/n6-d, the registered offset-table retry of docs/EVALS.md 'The auto-cache key ignores --exclude' (bands (6)-(8)). All seven gating rows are this lane's own footprint on the two cache seams; the three rows that were REAL are FIXED rather than acked (below). (1) api-surface contract-change loadCache 4->5 and runParsePool 7->8. loadCache's old fourth parameter was 'long long& blobWriteNsOut'; it is replaced by the crawled-file list plus a CacheLoadStats out-struct, because the whole point of v15 is that a load deserialises ONLY the records for the files THIS crawl asked for, and a load that is not told the crawl cannot do that. runParsePool takes that same struct through so the RIPWIRE_CACHE_STATS line can report cached_records=/blob_entries= — the two numbers that make band (2) an executable fact instead of a wall-clock claim (test/cacheoffsetcheck.sh check (e)). Both are internal to ingest.cpp's single TU, one call site each, updated in the same commit; no consumer outside the TU ever saw either signature. (2) five short-horizon-churn churn=self rows on kCacheVersion, kIngestCacheVersionMirror, loadCache, saveCache and runParsePool: the footprint of editing exactly the symbols a format bump must edit, in a window that also holds the gate commit. Not thrash — a version constant and its gated mirror must move together in one commit by construction (qextractionkeycheck). WHAT WAS FIXED INSTEAD OF ACKED, because it was real: saveCache's complexity 94->125 and verbosity 285->408 are gone (zero regression) after the seven per-file fact-grouping loops moved to buildCacheFileIndexes, the path/order prologue to buildCachePathKeys, and the plan/carry/trailer work to buildCacheWritePlan/appendCarryRecord/finishCacheBlob; and the duplication row against ingest_sidecap.h TreeGuard::operator= is gone because ReadFd dropped its move-assignment for an openOnce() that fills an empty guard, the only mutation the type needs. Verification at this head: test/cacheoffsetcheck.sh ALL PASS (written RED first at 8411f7e), the whole cache family green, ASan+UBSan+LSan clean on cold store, warm load, subset load and carry-over save on both the fixture and this repo, three-run byte determinism, warm==--no-cache, xmllint clean. ack short-horizon-churn 6652d5114718eb63 7 cid=497a477af2561c0a OPTREMARKS F3 (docs/OPTREMARKS.md §8b): the ~430 per-AST-node std::strcmp( t, "literal" ) sites in the five ingest walk sections become rw::kindIs (src/infra/nodekind.h) — an inline compare, because strcmp is an external symbol LTO cannot inline and on macOS costs two dyld stub hops before it starts. Measured: 10.6% of busy CPU in strcmp leaves on a cold llvm run, 6-12% on four other corpora; 0.23% after. Output byte-identical across 7 corpora x 5 verbs, argvdiffcheck 640/642 vectors identical (the 2 that differ are the +dirty build stamp in --version). WHAT THESE ROWS ARE. (a) 95 short-horizon-churn rows, churn=self: the mechanical rewrite touches essentially every function in ingest_{metrics,binds,sidecap,relations,names}.h, so every one of them shows this lane's own single edit. Not thrash — one commit. (b) 7 duplication rows and 1 new-clone-of-reused-helper. These are REAL new clone groups (--clones, uncapped: 407 groups before, 409 after; the rewrite adds 7 and removes 5) and they are IDIOM COLLISIONS, not copies. kindIs( t, "x" ) is shorter than std::strcmp( t, "x" ) == 0, so short predicate bodies that were previously above the clone threshold now match each other's normalized token stream. Six of the seven pair a node-kind || -chain with an unrelated || -chain over a DISJOINT literal set in a different subsystem — cc_isParamList (tree-sitter parameter-list kinds) against predicatePrefixed (English name prefixes is/has/can), against sliceIsJsPatternKind (JS destructuring kinds), rubyCallIsAssignmentTarget against slice.h's JS binding probes. The tool's own rule is that two ladders over the SAME enum are a copy; these share no non-keyword identifier and no domain, and merging any pair would need a helper parameterised on an unrelated literal table — a wrong abstraction to satisfy a lint. The seventh, kindIs | lexTokenEqualsLowered, is the same shape at 56 tokens with materially different contracts: lexTokenEqualsLowered takes an explicit length and case-folds one side, kindIs takes its length from the literal's type and compares the terminating NUL as an ordinary byte — and that NUL comparison is precisely the safety property kindIs depends on (test/nodekindcheck.sh arm B proves the absence of a read past it with an mprotect(PROT_NONE) guard page). Folding them together would erase the one property being gated. Gate: test/nodekindcheck.sh, 4 arms, 1,348,096 enumerated (candidate, literal) pairs against std::strcmp plus two mutation controls that each turn an arm red. @@ -923,6 +924,7 @@ ack short-horizon-churn 7331622b497469cf 2 cid=3594ad7d1f7d9cbd fix-round follow ack short-horizon-churn 733665960f2dc458 35 cid=085fdb748171c083 by=src/* lane/n6-d, the registered offset-table retry of docs/EVALS.md 'The auto-cache key ignores --exclude' (bands (6)-(8)). All seven gating rows are this lane's own footprint on the two cache seams; the three rows that were REAL are FIXED rather than acked (below). (1) api-surface contract-change loadCache 4->5 and runParsePool 7->8. loadCache's old fourth parameter was 'long long& blobWriteNsOut'; it is replaced by the crawled-file list plus a CacheLoadStats out-struct, because the whole point of v15 is that a load deserialises ONLY the records for the files THIS crawl asked for, and a load that is not told the crawl cannot do that. runParsePool takes that same struct through so the RIPWIRE_CACHE_STATS line can report cached_records=/blob_entries= — the two numbers that make band (2) an executable fact instead of a wall-clock claim (test/cacheoffsetcheck.sh check (e)). Both are internal to ingest.cpp's single TU, one call site each, updated in the same commit; no consumer outside the TU ever saw either signature. (2) five short-horizon-churn churn=self rows on kCacheVersion, kIngestCacheVersionMirror, loadCache, saveCache and runParsePool: the footprint of editing exactly the symbols a format bump must edit, in a window that also holds the gate commit. Not thrash — a version constant and its gated mirror must move together in one commit by construction (qextractionkeycheck). WHAT WAS FIXED INSTEAD OF ACKED, because it was real: saveCache's complexity 94->125 and verbosity 285->408 are gone (zero regression) after the seven per-file fact-grouping loops moved to buildCacheFileIndexes, the path/order prologue to buildCachePathKeys, and the plan/carry/trailer work to buildCacheWritePlan/appendCarryRecord/finishCacheBlob; and the duplication row against ingest_sidecap.h TreeGuard::operator= is gone because ReadFd dropped its move-assignment for an openOnce() that fills an empty guard, the only mutation the type needs. Verification at this head: test/cacheoffsetcheck.sh ALL PASS (written RED first at 8411f7e), the whole cache family green, ASan+UBSan+LSan clean on cold store, warm load, subset load and carry-over save on both the fixture and this repo, three-run byte determinism, warm==--no-cache, xmllint clean. ack short-horizon-churn 73ac37ff2580b0b0 103 cid=b07db8431c1b7c18 R1 identity round (harvest Wave-2 R1, 2026-08-24). Five gating rows, ONE change: finding identity now survives a rename or a move. (1) runQualityDelta complexity 201->205 and verbosity 419->462: the +43 lines are overwhelmingly the LEGEND this repo requires — the identity mechanism is a claim about what an ack means, so the report has to say what it rested on (both mechanisms, both truncation caps, the two per-route counters), and legendcoveragecheck arm (A) requires every new attribute be named there. The code added to this dispatcher is three lines: the ledger is now taken from the already-healed basis instead of re-read (re-reading it here would undo the healing), one countAckRescues call, and one call to quality::identityDisclosure — which is itself an EXTRACTION made in this lane precisely because --quality-delta flagged the inline version. The residual +4 complexity is the cid ternary at the ack-write site. Decomposing runQualityDelta is its own round, recorded as such by the W2-E ack above and unchanged by this one. (2) resolveDeltaBasis verbosity 73->86 + churn=self: the two healIdentity calls (ref-pair and working-tree arms) and the comment explaining why the healing must precede computeDelta — heal the baseline after the delta is taken and the delta has already been computed against the stale identity. churn=self on it and on mergescout::injectFileLevelFallback is this one edit window, not a history signal. (3) injectFileLevelFallback: its hand-rolled fopen/fseek/fread/fclose block was retired onto docparse::detail::readWholeFile, the canonical whole-file read, after this lane extracted quality.h's copy and the reuse-decline kind reported the pair as a 320-token clone. Re-read before the swap: short read or ftell failure clears the buffer and the file contributes nothing, byte-identical to what it replaced. Gate: test/identitycheck.sh, 19 arms, 9 RED on the pre-lane binary. ack short-horizon-churn 763ff87870373241 102 cid=c8f6829a03ecca15 A4: short-horizon churn on runCliEdit is this fix round itself -- the lane touched it for A1 (the NUL-payload arm) and again here (the resolved-identity advice), one commit per assigned item. churn=self, not instability in the code. +ack short-horizon-churn 765e0d29d1478ba2 6 cid=b2e777292ce742d0 E1/A4-2: every tests_to_run emitter re-routed through testmap.h's one seam (testRowsRendered) so runner-less rows group by evidence; the 12 sites were rewritten in-window by M21(b) and F1, and touching that family once more is the item itself ack short-horizon-churn 775b773b1a3d2349 6 cid=ae06371e2a89c87b rich-ingest promotion for the eval verbs: --eval-retrieval/--eval-mined/--eval-skills now request captureValueUses so lexicalScoresTiered takes its persisted-stats path instead of re-tokenizing the corpus per query (94% of eval user time). short-horizon-churn rows are this session's repeated edits to those functions, not instability; verbosity on dispatchMain is one added comment. Scores byte-identical, postingscheck ALL PASS. | prior: preloaded-corpus hoist: lexicalScores/lexicalScoresTiered gain one optional defaulted preloadedFileText param so a caller scoring many queries against one tree reads the corpus once instead of per call (--eval-retrieval was ~11.8M file opens/run, 48% of its CPU in the kernel). api-surface rows ARE the intended additive change; short-horizon-churn is this session's own edits to those two functions, not instability. Scores proven byte-identical on an identical tree. ack short-horizon-churn 775cfb65ec71343a 4 cid=b9f27a565025cb61 wave-3 close: compactlegend.h self churn from replacing the hand-rolled startsWithSv (a 45-token clone of darkflags endsWithView in the ec5e3c3..HEAD delta) with std::string_view::starts_with at its 11 uses — a deletion, one day after L7 created the file ack short-horizon-churn 779387a679944627 5 cid=b026f45951caaa1d OPTREMARKS F3 (docs/OPTREMARKS.md §8b): the ~430 per-AST-node std::strcmp( t, "literal" ) sites in the five ingest walk sections become rw::kindIs (src/infra/nodekind.h) — an inline compare, because strcmp is an external symbol LTO cannot inline and on macOS costs two dyld stub hops before it starts. Measured: 10.6% of busy CPU in strcmp leaves on a cold llvm run, 6-12% on four other corpora; 0.23% after. Output byte-identical across 7 corpora x 5 verbs, argvdiffcheck 640/642 vectors identical (the 2 that differ are the +dirty build stamp in --version). WHAT THESE ROWS ARE. (a) 95 short-horizon-churn rows, churn=self: the mechanical rewrite touches essentially every function in ingest_{metrics,binds,sidecap,relations,names}.h, so every one of them shows this lane's own single edit. Not thrash — one commit. (b) 7 duplication rows and 1 new-clone-of-reused-helper. These are REAL new clone groups (--clones, uncapped: 407 groups before, 409 after; the rewrite adds 7 and removes 5) and they are IDIOM COLLISIONS, not copies. kindIs( t, "x" ) is shorter than std::strcmp( t, "x" ) == 0, so short predicate bodies that were previously above the clone threshold now match each other's normalized token stream. Six of the seven pair a node-kind || -chain with an unrelated || -chain over a DISJOINT literal set in a different subsystem — cc_isParamList (tree-sitter parameter-list kinds) against predicatePrefixed (English name prefixes is/has/can), against sliceIsJsPatternKind (JS destructuring kinds), rubyCallIsAssignmentTarget against slice.h's JS binding probes. The tool's own rule is that two ladders over the SAME enum are a copy; these share no non-keyword identifier and no domain, and merging any pair would need a helper parameterised on an unrelated literal table — a wrong abstraction to satisfy a lint. The seventh, kindIs | lexTokenEqualsLowered, is the same shape at 56 tokens with materially different contracts: lexTokenEqualsLowered takes an explicit length and case-folds one side, kindIs takes its length from the literal's type and compares the terminating NUL as an ordinary byte — and that NUL comparison is precisely the safety property kindIs depends on (test/nodekindcheck.sh arm B proves the absence of a read past it with an mprotect(PROT_NONE) guard page). Folding them together would erase the one property being gated. Gate: test/nodekindcheck.sh, 4 arms, 1,348,096 enumerated (candidate, literal) pairs against std::strcmp plus two mutation controls that each turn an arm red. @@ -944,6 +946,7 @@ ack short-horizon-churn 80221b58c708d30c 5 cid=1bcf5c4c17466d1b OPTREMARKS F3 (d ack short-horizon-churn 802417eb4f7f22a6 5 cid=bd29ae0cb0947813 OPTREMARKS F3 (docs/OPTREMARKS.md §8b): the ~430 per-AST-node std::strcmp( t, "literal" ) sites in the five ingest walk sections become rw::kindIs (src/infra/nodekind.h) — an inline compare, because strcmp is an external symbol LTO cannot inline and on macOS costs two dyld stub hops before it starts. Measured: 10.6% of busy CPU in strcmp leaves on a cold llvm run, 6-12% on four other corpora; 0.23% after. Output byte-identical across 7 corpora x 5 verbs, argvdiffcheck 640/642 vectors identical (the 2 that differ are the +dirty build stamp in --version). WHAT THESE ROWS ARE. (a) 95 short-horizon-churn rows, churn=self: the mechanical rewrite touches essentially every function in ingest_{metrics,binds,sidecap,relations,names}.h, so every one of them shows this lane's own single edit. Not thrash — one commit. (b) 7 duplication rows and 1 new-clone-of-reused-helper. These are REAL new clone groups (--clones, uncapped: 407 groups before, 409 after; the rewrite adds 7 and removes 5) and they are IDIOM COLLISIONS, not copies. kindIs( t, "x" ) is shorter than std::strcmp( t, "x" ) == 0, so short predicate bodies that were previously above the clone threshold now match each other's normalized token stream. Six of the seven pair a node-kind || -chain with an unrelated || -chain over a DISJOINT literal set in a different subsystem — cc_isParamList (tree-sitter parameter-list kinds) against predicatePrefixed (English name prefixes is/has/can), against sliceIsJsPatternKind (JS destructuring kinds), rubyCallIsAssignmentTarget against slice.h's JS binding probes. The tool's own rule is that two ladders over the SAME enum are a copy; these share no non-keyword identifier and no domain, and merging any pair would need a helper parameterised on an unrelated literal table — a wrong abstraction to satisfy a lint. The seventh, kindIs | lexTokenEqualsLowered, is the same shape at 56 tokens with materially different contracts: lexTokenEqualsLowered takes an explicit length and case-folds one side, kindIs takes its length from the literal's type and compares the terminating NUL as an ordinary byte — and that NUL comparison is precisely the safety property kindIs depends on (test/nodekindcheck.sh arm B proves the absence of a read past it with an mprotect(PROT_NONE) guard page). Folding them together would erase the one property being gated. Gate: test/nodekindcheck.sh, 4 arms, 1,348,096 enumerated (candidate, literal) pairs against std::strcmp plus two mutation controls that each turn an arm red. ack short-horizon-churn 80a2f3a7c92fbed4 7 cid=df52b38ce1ca6bb9 by=src/* Phase 5 (docs/EVALS.md): the external-name veto (@external, externalnames.h tables, ExternalVeto predicate, import-name bindings at ingest) and the receiver MRO walk (rule1BaseWalk, SuperObj); every gating row is this one change — the four deliberate contract changes (serialize/serializeJson gain externalCalls, captureIncludes gains binds, methodOnTypeOrBases gains skipSelf/unionOnMulti), the ladder's two new steps in buildGraph, the census's tenth mechanism, kParserVer 77, and the two lexical clone false-positives (a one-line binary_search vs a counts total; a key-buffer probe vs a JSON string writer) ack short-horizon-churn 80b08c75913ae76c 2 cid=2b1373e91d63396e by=src/* lane/n6-d, the registered offset-table retry of docs/EVALS.md 'The auto-cache key ignores --exclude' (bands (6)-(8)). All seven gating rows are this lane's own footprint on the two cache seams; the three rows that were REAL are FIXED rather than acked (below). (1) api-surface contract-change loadCache 4->5 and runParsePool 7->8. loadCache's old fourth parameter was 'long long& blobWriteNsOut'; it is replaced by the crawled-file list plus a CacheLoadStats out-struct, because the whole point of v15 is that a load deserialises ONLY the records for the files THIS crawl asked for, and a load that is not told the crawl cannot do that. runParsePool takes that same struct through so the RIPWIRE_CACHE_STATS line can report cached_records=/blob_entries= — the two numbers that make band (2) an executable fact instead of a wall-clock claim (test/cacheoffsetcheck.sh check (e)). Both are internal to ingest.cpp's single TU, one call site each, updated in the same commit; no consumer outside the TU ever saw either signature. (2) five short-horizon-churn churn=self rows on kCacheVersion, kIngestCacheVersionMirror, loadCache, saveCache and runParsePool: the footprint of editing exactly the symbols a format bump must edit, in a window that also holds the gate commit. Not thrash — a version constant and its gated mirror must move together in one commit by construction (qextractionkeycheck). WHAT WAS FIXED INSTEAD OF ACKED, because it was real: saveCache's complexity 94->125 and verbosity 285->408 are gone (zero regression) after the seven per-file fact-grouping loops moved to buildCacheFileIndexes, the path/order prologue to buildCachePathKeys, and the plan/carry/trailer work to buildCacheWritePlan/appendCarryRecord/finishCacheBlob; and the duplication row against ingest_sidecap.h TreeGuard::operator= is gone because ReadFd dropped its move-assignment for an openOnce() that fills an empty guard, the only mutation the type needs. Verification at this head: test/cacheoffsetcheck.sh ALL PASS (written RED first at 8411f7e), the whole cache family green, ASan+UBSan+LSan clean on cold store, warm load, subset load and carry-over save on both the fixture and this repo, three-run byte determinism, warm==--no-cache, xmllint clean. +ack short-horizon-churn 813b954f28ce86af 31 cid=5d418e33b2f1f9d5 E1/A4-2: every tests_to_run emitter re-routed through testmap.h's one seam (testRowsRendered) so runner-less rows group by evidence; the 12 sites were rewritten in-window by M21(b) and F1, and touching that family once more is the item itself ack short-horizon-churn 815fbf65eea5df47 5 cid=98131c6d45bba16a OPTREMARKS F3 (docs/OPTREMARKS.md §8b): the ~430 per-AST-node std::strcmp( t, "literal" ) sites in the five ingest walk sections become rw::kindIs (src/infra/nodekind.h) — an inline compare, because strcmp is an external symbol LTO cannot inline and on macOS costs two dyld stub hops before it starts. Measured: 10.6% of busy CPU in strcmp leaves on a cold llvm run, 6-12% on four other corpora; 0.23% after. Output byte-identical across 7 corpora x 5 verbs, argvdiffcheck 640/642 vectors identical (the 2 that differ are the +dirty build stamp in --version). WHAT THESE ROWS ARE. (a) 95 short-horizon-churn rows, churn=self: the mechanical rewrite touches essentially every function in ingest_{metrics,binds,sidecap,relations,names}.h, so every one of them shows this lane's own single edit. Not thrash — one commit. (b) 7 duplication rows and 1 new-clone-of-reused-helper. These are REAL new clone groups (--clones, uncapped: 407 groups before, 409 after; the rewrite adds 7 and removes 5) and they are IDIOM COLLISIONS, not copies. kindIs( t, "x" ) is shorter than std::strcmp( t, "x" ) == 0, so short predicate bodies that were previously above the clone threshold now match each other's normalized token stream. Six of the seven pair a node-kind || -chain with an unrelated || -chain over a DISJOINT literal set in a different subsystem — cc_isParamList (tree-sitter parameter-list kinds) against predicatePrefixed (English name prefixes is/has/can), against sliceIsJsPatternKind (JS destructuring kinds), rubyCallIsAssignmentTarget against slice.h's JS binding probes. The tool's own rule is that two ladders over the SAME enum are a copy; these share no non-keyword identifier and no domain, and merging any pair would need a helper parameterised on an unrelated literal table — a wrong abstraction to satisfy a lint. The seventh, kindIs | lexTokenEqualsLowered, is the same shape at 56 tokens with materially different contracts: lexTokenEqualsLowered takes an explicit length and case-folds one side, kindIs takes its length from the literal's type and compares the terminating NUL as an ordinary byte — and that NUL comparison is precisely the safety property kindIs depends on (test/nodekindcheck.sh arm B proves the absence of a read past it with an mprotect(PROT_NONE) guard page). Folding them together would erase the one property being gated. Gate: test/nodekindcheck.sh, 4 arms, 1,348,096 enumerated (candidate, literal) pairs against std::strcmp plus two mutation controls that each turn an arm red. ack short-horizon-churn 8192a44ad5eb2510 3 cid=ff366a47a1cdb76d by=src/* member-variable round (card A3), side-table rule: symbols this round created (collectFieldUseSites, FieldUseAnswer, memberOwnerRefusal, declaredFieldSet, isInstanceFieldSite, dropFieldDefinitionSites, fieldCaptureKept) and touched twice within it while fields moved from ing.symbols to the IngestResult::fields side table under the orchestrator's rule; collectFacts/buildDefSpanIndex each carry ONE deliberate edit ack short-horizon-churn 81efad81c80fc1cd 10 cid=99f4094a45b32fa2 F6 (lane F): runDoctor +14 LOC is one emitted attribute (volatile=) plus the comment recording the three rounds of gate flake it retires and why removing the fields would be worse; runDoctor is a 223-LOC row emitter already far over the bar. churn=self on runDoctor and on shapingflagcheck's fnorm is this session's own edits inside one window while the F6 disclosure converged (declare, then re-pin the two determinism gates onto the shared helper). @@ -968,6 +971,7 @@ ack short-horizon-churn 8a5a734986085360 5 S2c classifier-gap round: churn=self ack short-horizon-churn 8a92173ded649e17 39 cid=d49e411bf180293e by=src/* P7 (terminality round A, lane R): short-horizon churn on the lens legend clauses (kForFileTailLegend/Compact, kPackTaskBundleLegendBody: 'rows in r= order, p= the file') and on the two packers this lane rewrote (packSignatures, sigRowHead) — the P7 shape change itself, not drift; gate test/forrankordercheck.sh | prior: A2 (dropped_positive, 2026-09-03): packSignatures gained one trailing droppedPositiveOut out-param and the post-ladder accounting inside the rank-adaptive branch — same shared-arithmetic reason as its JSON sibling. ack short-horizon-churn 8aa1aca1ece52982 3 R-I --quality-delta=A..B: printUsage + runQualityDelta grow by the new form's --help rows and its legend disclosure (both are contract surfaces, not incidental bulk); the two churn=self rows are this lane rewriting main.cpp/dmm.h bodies that recent commits also rewrote. Complexity was fixed by extracting resolveDeltaBasis, not acked. ack short-horizon-churn 8ac0e9a4081272f8 3 cid=285c05e869b57171 abstention round-2 lane: short-horizon churn on the ARB harness is the lane's own second edit to bench/arb this wave — run_arb.py had to learn the three new facts and sweep() had to write them; the churn is the round, not new debt in the tool +ack short-horizon-churn 8ad0db7b7210fd71 18 cid=eed9f2eeec92e17a E1/A4-2: every tests_to_run emitter re-routed through testmap.h's one seam (testRowsRendered) so runner-less rows group by evidence; the 12 sites were rewritten in-window by M21(b) and F1, and touching that family once more is the item itself ack short-horizon-churn 8b4e4611159af966 13 fnbody-require lane: deliberate additive API widening (optional out-params, default nullptr, every existing caller unaffected) to disclose the lazy require/import distinction on --impact's importer tier; residual complexity/duplication is the twin-dialect emitter shape and the dual-mode importersOfFiles scan this feature requires, already extracted where a helper genuinely reduced it (scanImporterEdges, recordLazyPair); short-horizon-churn is this same commit's own edit history ack short-horizon-churn 8b78fc9166769dc2 5 cid=5543359c040daa6f at-seed decision round (owners/mentions rebind + edit-verb seeds): complexity/verbosity on mentionsJson/ownersText are the @-seed rebind arm + sym disclosure after the shared resolution was already hoisted to atSeedDefOr; runMentions/runMaintenanceViews carry the CLI twins' sym= attr + legend clause inside pre-existing dispatcher bulk (decomposing those is its own recorded round); printUsage grows the help lines that ARE the selector's teaching surface; runEditVerb/atSeedNameOr/qualifiedSelectorRefusal short-horizon-churn is this lane's own edit history. Clone + resolveTarget growth were fixed structurally (receiptField inlined, resolveSeedTarget extracted) before this ack ack short-horizon-churn 8c36cf4c37be4542 8 cid=15b667b8b5141501 C4 recall budget spreading: recall.h's budget loop was rewritten load-allocate-emit, which necessarily edits committed lines the 2026-09-04 capture-audit round last touched. The churn is real and correctly measured; the edit IS the fix for the defect those lines carry (recallbudgetcheck 8.1/8.2/8.9). Nothing to refactor away. @@ -1014,8 +1018,9 @@ ack short-horizon-churn a75ae033caada1b3 10 cid=163ea9af8189c427 lane C plain-te ack short-horizon-churn a783c75feea9f253 44 W1-S2 churn-keying fix (pathQualifiedKey): bodyHashesBySym's pathQualified-param drop is the deliberate contract change (one keying, no mode); pathQualifiedKey is canonicalId/voteKey-SHAPED but a distinct key domain — canonicalId's bare-name degrade IS the bug this fixes, voteKey is a rename-vote pair with a different separator and value type; churn=self is this fix's own edit trail; gate: qualitysignalcheck.sh §1d ack short-horizon-churn a7ec845d7950d0b5 6 cid=383e339bb184adff OPTREMARKS F3 (docs/OPTREMARKS.md §8b): the ~430 per-AST-node std::strcmp( t, "literal" ) sites in the five ingest walk sections become rw::kindIs (src/infra/nodekind.h) — an inline compare, because strcmp is an external symbol LTO cannot inline and on macOS costs two dyld stub hops before it starts. Measured: 10.6% of busy CPU in strcmp leaves on a cold llvm run, 6-12% on four other corpora; 0.23% after. Output byte-identical across 7 corpora x 5 verbs, argvdiffcheck 640/642 vectors identical (the 2 that differ are the +dirty build stamp in --version). WHAT THESE ROWS ARE. (a) 95 short-horizon-churn rows, churn=self: the mechanical rewrite touches essentially every function in ingest_{metrics,binds,sidecap,relations,names}.h, so every one of them shows this lane's own single edit. Not thrash — one commit. (b) 7 duplication rows and 1 new-clone-of-reused-helper. These are REAL new clone groups (--clones, uncapped: 407 groups before, 409 after; the rewrite adds 7 and removes 5) and they are IDIOM COLLISIONS, not copies. kindIs( t, "x" ) is shorter than std::strcmp( t, "x" ) == 0, so short predicate bodies that were previously above the clone threshold now match each other's normalized token stream. Six of the seven pair a node-kind || -chain with an unrelated || -chain over a DISJOINT literal set in a different subsystem — cc_isParamList (tree-sitter parameter-list kinds) against predicatePrefixed (English name prefixes is/has/can), against sliceIsJsPatternKind (JS destructuring kinds), rubyCallIsAssignmentTarget against slice.h's JS binding probes. The tool's own rule is that two ladders over the SAME enum are a copy; these share no non-keyword identifier and no domain, and merging any pair would need a helper parameterised on an unrelated literal table — a wrong abstraction to satisfy a lint. The seventh, kindIs | lexTokenEqualsLowered, is the same shape at 56 tokens with materially different contracts: lexTokenEqualsLowered takes an explicit length and case-folds one side, kindIs takes its length from the literal's type and compares the terminating NUL as an ordinary byte — and that NUL comparison is precisely the safety property kindIs depends on (test/nodekindcheck.sh arm B proves the absence of a read past it with an mprotect(PROT_NONE) guard page). Folding them together would erase the one property being gated. Gate: test/nodekindcheck.sh, 4 arms, 1,348,096 enumerated (candidate, literal) pairs against std::strcmp plus two mutation controls that each turn an arm red. ack short-horizon-churn a8b774025a21bdc6 83 cid=fa890c64b85771c2 answer-safe --edit-check window: the three contract-change rows are ONE defaulted paging pair (pageLimit/pageOffset, 0/0 = the verb's own default cap) plumbed through the ONE assembler and its two front doors — editCheckBundleText, editpreview::run, editCheckText — rather than a second capped emitter, because two emitters would drift and a page that drifted could drop the flagged caller that IS the answer. The complexity/verbosity growth in the assembler is the partition-preserving row loop (the window advances on UNFLAGGED rows only, so a flagged row and its sites_l= ride every page uncut) plus the in-band legend that says what pages and what never does; the churn rows are this lane's own footprint across cli.h/mcp*/editcheck*, none foreign. | prior: M1: runBatchSub gains ONE defaulted parameter, compactLegend, so a batched slice is built by the same emitter path as its standalone twin (batchcheck (h) measured the divergence: 1,542 B vs 606 B). Both call sites pass it; --edit-check reports callers=2 incompatible=0. The params bar is 5 and this is 6, taken deliberately rather than threading a second struct through a 14-arm dispatch. -ack short-horizon-churn a9f76a08efdb3d50 24 cid=93c35f3d948f24a3 F3 (lane F): runAffected +4 LOC and printUsage +3 help lines are exactly the --affected test-partition fix and the sentence that documents seed_test_files=/seed_kind=. Both were already far over their verbosity bar before this change (printUsage 1478, runAffected 80). The short-horizon-churn row on runAffected is churn=self — this session's own two edits to that symbol inside one window while the fix converged — not accumulated debt. | prior: M12 (lane L9, capture-audit-2026-09-04): the deliberate cost of one root-relative path spelling across --affected/--test-gate/edit receipts/fetch_body plus the in_id= legend trim. runAffected grows the same mvSingleRoot/mvRootPrefix/mvRootAttr block verbs_report.h's dispatcher already threads (complexity 13->18, verbosity +17, mostly the comment naming the finding); writeTestGateReport/Json's duplication is the XML/JSON twin pair staying in lockstep, which is the property mcpclidiffcheck asserts; every short-horizon-churn row is this lane editing its own targets three times in one afternoon. +ack short-horizon-churn a9f76a08efdb3d50 31 cid=fb7678445c3ca588 E1/A4-2: every tests_to_run emitter re-routed through testmap.h's one seam (testRowsRendered) so runner-less rows group by evidence; the 12 sites were rewritten in-window by M21(b) and F1, and touching that family once more is the item itself | prior: F3 (lane F): runAffected +4 LOC and printUsage +3 help lines are exactly the --affected test-partition fix and the sentence that documents seed_test_files=/seed_kind=. Both were already far over their verbosity bar before this change (printUsage 1478, runAffected 80). The short-horizon-churn row on runAffected is churn=self — this session's own two edits to that symbol inside one window while the fix converged — not accumulated debt. ack short-horizon-churn aa71fcdf69942431 66 2026-08-15 harvest wave-level pass (orchestrator): 12-lane wave measured as one delta vs origin/main 4b9386c per verifier finding 6. All 21 gating rows triaged individually: emitGrepReport/grepHitsJson/runCallHierarchy/runDefaultMap/collectSources/printUsage/Config/runMcpHttp = feature absorption by design (grouping+boolean+corpus disclosure, file-root, bodyless_defs+legend, estimator guard, new flags), each converged and gate-verified at lane level; short-horizon-churn rows = single-wave multi-lane edits of shared hubs, process artifact; sym=main rows are main.cpp::main growth mislabeled to analyze.py by the bare-name canonId collision (path-qualified keying fix d593de3 still unpushed). emitGrepReport cx 25->63 flagged as W2 split candidate in PLAN round record. +ack short-horizon-churn aaea365e24a94358 25 cid=a9ae44c1e9ef0617 E1/A4-2: every tests_to_run emitter re-routed through testmap.h's one seam (testRowsRendered) so runner-less rows group by evidence; the 12 sites were rewritten in-window by M21(b) and F1, and touching that family once more is the item itself ack short-horizon-churn ab7737f3582352d5 4 cid=47b3fc03a5d497f1 C1 F-06/F-07/F-10 (the listing-paging round): three listing verbs learn to disclose and page their row listings, and every gating row is that one change. api-surface 14 = ONE trailing DEFAULTED parameter each (an int pageOffset, an McpPageArgs window, a SituPageArgs, or the next= invocation a header now carries) on the emitters that must be TOLD their window — writeFlags/writeGate, writeCappedRows/writeCappedList/writeFlip/writeFlipHeader/writeFlipLights, computeFlip, nearestGateNames (which gains its cap and its TOTAL, the disclosure itself), situShowingNote/writeSituation, and the three MCP twins flagsText/flipText/situationDiffJson; additive by construction, every pre-existing call site compiles unchanged, and the alternative — a second capped emitter per verb — is the drift this repo removes rather than adds, because two emitters that disagree about a window can drop the row that IS the answer. verbosity 3 = kDocDriftLegend +20 lines and writeDocDriftPage +10 are the in-band vocabulary a reader needs to read shown_failed=/failed_capped=/failed_total= where they meet it (the rationale and the next= scan were already hoisted OUT of the body into their own function and comment, which took the complexity row to zero and the LOC row from +48 to +10); dispatchMcpLine +12 is two pagedResult wrappers on a pre-existing 1376-line dispatcher this lane adds to rather than creates. complexity 1 = the same dispatcher, +9 on a base of 518. duplication 1 = flagsText | flipText at 110 tokens, down from 131 after the shared mcpRowCap fold; the residual is getIndex + compute + captureXml, the shape EVERY index-backed MCP twin in this file has, and merging two verbs that return different results behind one entry point would be worse code than the clone. short-horizon-churn 19 = this lane's own footprint across cli.h/docdrift.h/darkflags.h/flipimpact.h/situ.h/mcp*, plus cli.h symbols three other lanes touched the same day; none foreign, none thrash. ack short-horizon-churn ab9b3f1db516af63 21 cid=0b950315073c2901 by=src/* M12 (capture-audit L9): path-spelling fixes — collectUseSites gained a root parameter (default-valued, back-compat) to root-relativize in_id=; runVerify grew from adding root=/verPathRel/the multi-root roots table it never had; short-horizon-churn rows are every function this finding's fix touched this session. | prior: A6 (survey card A6, agent-lsp): tested/untested partition on --impact/--callers/--callees rows, reusing the isTestSymbol-seeded lens computeQMetrics/--safe-delete already run (graph.h::testSymbolForwardReach/countTestedIn, shared, not duplicated per-verb). api-surface (2, contract-change): emitColumnarSymbolRows/printJsonSymbolRows gain one optional testReach pointer param (default nullptr, byte-identical on every pre-existing caller) so the columnar/json dialects can carry the same tested= column the XML dialect carries. complexity (1, runCallHierarchy 80->83): the partition's counting loop itself was factored out to graph.h::countTestedIn (shared with --impact, verified this ack run no longer lists emitColumnarSymbolRows/runImpact, which carried the identical loop before extraction); the residual 3 points are the hop_tested=/hop_untested= attribute wiring on an already-large pre-existing dispatcher (its own header comment already names it 'this file's largest dispatcher') - splitting it into per-dialect emitters the way --impact already is would be a correct follow-up but is a materially larger, separate diff. duplication (2, testSymbolForwardReach vs situ.h::testSeedForwardReach / vs csharpInFileTestScope, 38/28 tokens): the shared seed-collect-then-forwardReach shape now lives in ONE template (graph.h::seedForwardReachIf) that both isTestSymbol- and isTestPath-seeded callers delegate to in one line each - the residual similarity is between two intentionally-DIFFERENT predicates the L8 comment on computeQMetrics explains why cannot be merged (a Rust in-file cfg-test mod has no isTestPath file at all), and csharpInFileTestScope is an unrelated short predicate the token-shape coincidentally now resembles post-extraction. short-horizon-churn (8) and verbosity (2): the partition touches --impact's three dialect emitters (XML/columnar/JSON), --callers/--callees' single dispatcher, the shared row helpers, and the MCP impact twin (mcpclidiffcheck parity) in one change - one feature landing across its natural surface, not incidental edits. Full assigned gate list green (reachcheck/callerscheck/impactimportcheck/testedreachcheck/testgatecheck/testgatepagecheck/testgaterefusecheck/graphlegendbudgetcheck/floormarkcheck/legendcoveragecheck/manifestcheck/mcpclidiffcheck), determinism + xmllint clean. ack short-horizon-churn ac88b70b8c51cc70 14 L2 stale-ack disclosure: unavoidable growth/self-churn on runQualityDelta and qualityDeltaJson, the two pre-existing quality-delta dispatch hubs every new axis has to touch; logic already extracted to quality.h (staleForXxx/staleAcksXml/staleAcksJsonArray) to minimize the added footprint @@ -1073,7 +1078,7 @@ ack short-horizon-churn ce968bdf8848c214 13 cid=3c5d6c07cbc4425e OPTREMARKS F3 ( ack short-horizon-churn d03215f48bec3886 3 cid=c7f3e74cf288dfc2 R1 (wave-2 verifier): the redaction-marker write gate rewritten from a payload substring scan to a comparison against the bytes it would replace — short-horizon churn on the five symbols this round has been editing repeatedly, not new debt; the complexity/verbosity of all three write surfaces is unchanged or lower after the shared redactionMarkerRefusalFor helper ack short-horizon-churn d05476877df0016e 6 cid=94feac15a58025e5 OPTREMARKS F3 (docs/OPTREMARKS.md §8b): the ~430 per-AST-node std::strcmp( t, "literal" ) sites in the five ingest walk sections become rw::kindIs (src/infra/nodekind.h) — an inline compare, because strcmp is an external symbol LTO cannot inline and on macOS costs two dyld stub hops before it starts. Measured: 10.6% of busy CPU in strcmp leaves on a cold llvm run, 6-12% on four other corpora; 0.23% after. Output byte-identical across 7 corpora x 5 verbs, argvdiffcheck 640/642 vectors identical (the 2 that differ are the +dirty build stamp in --version). WHAT THESE ROWS ARE. (a) 95 short-horizon-churn rows, churn=self: the mechanical rewrite touches essentially every function in ingest_{metrics,binds,sidecap,relations,names}.h, so every one of them shows this lane's own single edit. Not thrash — one commit. (b) 7 duplication rows and 1 new-clone-of-reused-helper. These are REAL new clone groups (--clones, uncapped: 407 groups before, 409 after; the rewrite adds 7 and removes 5) and they are IDIOM COLLISIONS, not copies. kindIs( t, "x" ) is shorter than std::strcmp( t, "x" ) == 0, so short predicate bodies that were previously above the clone threshold now match each other's normalized token stream. Six of the seven pair a node-kind || -chain with an unrelated || -chain over a DISJOINT literal set in a different subsystem — cc_isParamList (tree-sitter parameter-list kinds) against predicatePrefixed (English name prefixes is/has/can), against sliceIsJsPatternKind (JS destructuring kinds), rubyCallIsAssignmentTarget against slice.h's JS binding probes. The tool's own rule is that two ladders over the SAME enum are a copy; these share no non-keyword identifier and no domain, and merging any pair would need a helper parameterised on an unrelated literal table — a wrong abstraction to satisfy a lint. The seventh, kindIs | lexTokenEqualsLowered, is the same shape at 56 tokens with materially different contracts: lexTokenEqualsLowered takes an explicit length and case-folds one side, kindIs takes its length from the literal's type and compares the terminating NUL as an ordinary byte — and that NUL comparison is precisely the safety property kindIs depends on (test/nodekindcheck.sh arm B proves the absence of a read past it with an mprotect(PROT_NONE) guard page). Folding them together would erase the one property being gated. Gate: test/nodekindcheck.sh, 4 arms, 1,348,096 enumerated (candidate, literal) pairs against std::strcmp plus two mutation controls that each turn an arm red. ack short-horizon-churn d3afca728392d688 6 cid=5765fc3aca7af273 OPTREMARKS F3 (docs/OPTREMARKS.md §8b): the ~430 per-AST-node std::strcmp( t, "literal" ) sites in the five ingest walk sections become rw::kindIs (src/infra/nodekind.h) — an inline compare, because strcmp is an external symbol LTO cannot inline and on macOS costs two dyld stub hops before it starts. Measured: 10.6% of busy CPU in strcmp leaves on a cold llvm run, 6-12% on four other corpora; 0.23% after. Output byte-identical across 7 corpora x 5 verbs, argvdiffcheck 640/642 vectors identical (the 2 that differ are the +dirty build stamp in --version). WHAT THESE ROWS ARE. (a) 95 short-horizon-churn rows, churn=self: the mechanical rewrite touches essentially every function in ingest_{metrics,binds,sidecap,relations,names}.h, so every one of them shows this lane's own single edit. Not thrash — one commit. (b) 7 duplication rows and 1 new-clone-of-reused-helper. These are REAL new clone groups (--clones, uncapped: 407 groups before, 409 after; the rewrite adds 7 and removes 5) and they are IDIOM COLLISIONS, not copies. kindIs( t, "x" ) is shorter than std::strcmp( t, "x" ) == 0, so short predicate bodies that were previously above the clone threshold now match each other's normalized token stream. Six of the seven pair a node-kind || -chain with an unrelated || -chain over a DISJOINT literal set in a different subsystem — cc_isParamList (tree-sitter parameter-list kinds) against predicatePrefixed (English name prefixes is/has/can), against sliceIsJsPatternKind (JS destructuring kinds), rubyCallIsAssignmentTarget against slice.h's JS binding probes. The tool's own rule is that two ladders over the SAME enum are a copy; these share no non-keyword identifier and no domain, and merging any pair would need a helper parameterised on an unrelated literal table — a wrong abstraction to satisfy a lint. The seventh, kindIs | lexTokenEqualsLowered, is the same shape at 56 tokens with materially different contracts: lexTokenEqualsLowered takes an explicit length and case-folds one side, kindIs takes its length from the literal's type and compares the terminating NUL as an ordinary byte — and that NUL comparison is precisely the safety property kindIs depends on (test/nodekindcheck.sh arm B proves the absence of a read past it with an mprotect(PROT_NONE) guard page). Folding them together would erase the one property being gated. Gate: test/nodekindcheck.sh, 4 arms, 1,348,096 enumerated (candidate, literal) pairs against std::strcmp plus two mutation controls that each turn an arm red. -ack short-horizon-churn d42c85b67bd0956f 28 cid=44e97a2de5235603 C1 F-06/F-07/F-10 (the listing-paging round): three listing verbs learn to disclose and page their row listings, and every gating row is that one change. api-surface 14 = ONE trailing DEFAULTED parameter each (an int pageOffset, an McpPageArgs window, a SituPageArgs, or the next= invocation a header now carries) on the emitters that must be TOLD their window — writeFlags/writeGate, writeCappedRows/writeCappedList/writeFlip/writeFlipHeader/writeFlipLights, computeFlip, nearestGateNames (which gains its cap and its TOTAL, the disclosure itself), situShowingNote/writeSituation, and the three MCP twins flagsText/flipText/situationDiffJson; additive by construction, every pre-existing call site compiles unchanged, and the alternative — a second capped emitter per verb — is the drift this repo removes rather than adds, because two emitters that disagree about a window can drop the row that IS the answer. verbosity 3 = kDocDriftLegend +20 lines and writeDocDriftPage +10 are the in-band vocabulary a reader needs to read shown_failed=/failed_capped=/failed_total= where they meet it (the rationale and the next= scan were already hoisted OUT of the body into their own function and comment, which took the complexity row to zero and the LOC row from +48 to +10); dispatchMcpLine +12 is two pagedResult wrappers on a pre-existing 1376-line dispatcher this lane adds to rather than creates. complexity 1 = the same dispatcher, +9 on a base of 518. duplication 1 = flagsText | flipText at 110 tokens, down from 131 after the shared mcpRowCap fold; the residual is getIndex + compute + captureXml, the shape EVERY index-backed MCP twin in this file has, and merging two verbs that return different results behind one entry point would be worse code than the clone. short-horizon-churn 19 = this lane's own footprint across cli.h/docdrift.h/darkflags.h/flipimpact.h/situ.h/mcp*, plus cli.h symbols three other lanes touched the same day; none foreign, none thrash. | prior: L10b finding 8: --situ distinguishes clean-tree from changed-but-symbol-free wording (small branch added) +ack short-horizon-churn d42c85b67bd0956f 33 cid=30fecf6174abfa33 E1/A4-2: every tests_to_run emitter re-routed through testmap.h's one seam (testRowsRendered) so runner-less rows group by evidence; the 12 sites were rewritten in-window by M21(b) and F1, and touching that family once more is the item itself | prior: C1 F-06/F-07/F-10 (the listing-paging round): three listing verbs learn to disclose and page their row listings, and every gating row is that one change. api-surface 14 = ONE trailing DEFAULTED parameter each (an int pageOffset, an McpPageArgs window, a SituPageArgs, or the next= invocation a header now carries) on the emitters that must be TOLD their window — writeFlags/writeGate, writeCappedRows/writeCappedList/writeFlip/writeFlipHeader/writeFlipLights, computeFlip, nearestGateNames (which gains its cap and its TOTAL, the disclosure itself), situShowingNote/writeSituation, and the three MCP twins flagsText/flipText/situationDiffJson; additive by construction, every pre-existing call site compiles unchanged, and the alternative — a second capped emitter per verb — is the drift this repo removes rather than adds, because two emitters that disagree about a window can drop the row that IS the answer. verbosity 3 = kDocDriftLegend +20 lines and writeDocDriftPage +10 are the in-band vocabulary a reader needs to read shown_failed=/failed_capped=/failed_total= where they meet it (the rationale and the next= scan were already hoisted OUT of the body into their own function and comment, which took the complexity row to zero and the LOC row from +48 to +10); dispatchMcpLine +12 is two pagedResult wrappers on a pre-existing 1376-line dispatcher this lane adds to rather than creates. complexity 1 = the same dispatcher, +9 on a base of 518. duplication 1 = flagsText | flipText at 110 tokens, down from 131 after the shared mcpRowCap fold; the residual is getIndex + compute + captureXml, the shape EVERY index-backed MCP twin in this file has, and merging two verbs that return different results behind one entry point would be worse code than the clone. short-horizon-churn 19 = this lane's own footprint across cli.h/docdrift.h/darkflags.h/flipimpact.h/situ.h/mcp*, plus cli.h symbols three other lanes touched the same day; none foreign, none thrash. ack short-horizon-churn d44595768a7cf3af 2 cid=21539523996cfb13 rich-ingest promotion for the eval verbs: --eval-retrieval/--eval-mined/--eval-skills now request captureValueUses so lexicalScoresTiered takes its persisted-stats path instead of re-tokenizing the corpus per query (94% of eval user time). short-horizon-churn rows are this session's repeated edits to those functions, not instability; verbosity on dispatchMain is one added comment. Scores byte-identical, postingscheck ALL PASS. ack short-horizon-churn d557a0077677ebd4 39 cid=7e6e8b041d54e368 P7 (terminality round A, lane R): the flat rank-ordered lens — ambient short-horizon churn on the JSON collector and its row structs (fileSlot field, flat emission), on fromTraceBundleText's legend line ('rows in r= order, p=file'), and +2..4 LOC on trimSigLadder (rank-major step F comment), narrowLegoToRenderedSigs (row p= scan) and packSignaturesJson (flat emission loop) — the P7 change itself; gate test/forrankordercheck.sh; --eval-retrieval byte-identical ack short-horizon-churn d63db6944aa504a7 35 cid=7d756152fbee4105 lane/helptask-precision 2026-09-10, MCP no_route (audit F-R1-07): all eight gating rows are this one change and nothing rides with it. api-surface forTaskText 4->5 and packTaskText 5->6 params: ONE DEFAULTED bool each (noRoute), so every pre-existing call site compiles unchanged and was verified to; the alternative — a second overload per verb — is the clone seam this repo removes rather than adds. complexity +4 on each of the same two: the four !noRoute gates are a MIRROR of verbs_for.h's own four (cfg.noRoute gates the shape demotion, the mention anchor, the co-change prior and the route note), and collapsing them would be the MCP dialect deciding for itself what --no-route means — the exact drift mcpforparitycheck exists to prevent. Both were already far over the ccx bar (55/29) before this change; decomposing forTaskText is its own round. short-horizon-churn churn=self on dispatchMcpLine, kMcpVerbFields, forTaskText and packTaskText is the footprint of having edited four symbols this window already touched. FIXED rather than acked in the same pass: the verbosity row on dispatchMcpLine (1376 -> 1387) is gone — the second hand-rolled five-line boolean accumulate was replaced by ONE guarded boolArg reader that post_check now shares, the rule intArg already states for the numeric fields, netting the dispatcher SMALLER than before. Manifest re-anchored 41300 -> 41650 for obliged schema bytes only: descriptions are BYTE-IDENTICAL at 19632 B | prior: C1 F-06/F-07/F-10 (the listing-paging round): three listing verbs learn to disclose and page their row listings, and every gating row is that one change. api-surface 14 = ONE trailing DEFAULTED parameter each (an int pageOffset, an McpPageArgs window, a SituPageArgs, or the next= invocation a header now carries) on the emitters that must be TOLD their window — writeFlags/writeGate, writeCappedRows/writeCappedList/writeFlip/writeFlipHeader/writeFlipLights, computeFlip, nearestGateNames (which gains its cap and its TOTAL, the disclosure itself), situShowingNote/writeSituation, and the three MCP twins flagsText/flipText/situationDiffJson; additive by construction, every pre-existing call site compiles unchanged, and the alternative — a second capped emitter per verb — is the drift this repo removes rather than adds, because two emitters that disagree about a window can drop the row that IS the answer. verbosity 3 = kDocDriftLegend +20 lines and writeDocDriftPage +10 are the in-band vocabulary a reader needs to read shown_failed=/failed_capped=/failed_total= where they meet it (the rationale and the next= scan were already hoisted OUT of the body into their own function and comment, which took the complexity row to zero and the LOC row from +48 to +10); dispatchMcpLine +12 is two pagedResult wrappers on a pre-existing 1376-line dispatcher this lane adds to rather than creates. complexity 1 = the same dispatcher, +9 on a base of 518. duplication 1 = flagsText | flipText at 110 tokens, down from 131 after the shared mcpRowCap fold; the residual is getIndex + compute + captureXml, the shape EVERY index-backed MCP twin in this file has, and merging two verbs that return different results behind one entry point would be worse code than the clone. short-horizon-churn 19 = this lane's own footprint across cli.h/docdrift.h/darkflags.h/flipimpact.h/situ.h/mcp*, plus cli.h symbols three other lanes touched the same day; none foreign, none thrash. @@ -1088,17 +1093,17 @@ ack short-horizon-churn dbe6ed5269d328a4 21 cid=9c4a7dc830d23192 M12 (capture-au ack short-horizon-churn dcb3ea81a3caeba3 7 cid=0d02d50c1afd1bec ingest-path disclosure: eval prints ingest: lex=rich|scan and --doctor prints rich_verbs= derived by asking needsValueUses (now ONE function in cli.h) per verb. short-horizon-churn on runEvalRetrieval is this session's repeated edits to it, not instability; mutation control in knownitemcheck proves both new arms fail when the eval verbs leave the predicate. ack short-horizon-churn dd02b378ae6b5b75 5 root-relative coverage round (verifier E1-E4 + two gaps the widened gate exposed, 2026-08-19): every gating row here is the SAME three-line pattern every verb in the original root-relative round already pays — a singleRoot bool, a rootPrefix, a rootAttr, and one ternary per path emission (the shape clones/prcontext/situ/mcp-path all carry verbatim). --tree (runStructureText) +8 ccx / +13 LOC and --quality-panel (writePanelReport) +4 ccx / +12 LOC are those lines plus the finding comment; forTaskText and packTaskBundleText are argument threading only. packBodiesJson api-surface 3 to 4 params is a DELIBERATE contract change: a defaulted trailing rootArg, identical in name, position and default to the one packSignatures/packBodies/packLego/packOutline already take, so the emitter family stays one shape and every existing call site is unaffected. churn=self/ambient is this change's own edit window. Payoff: 1340 absolute paths removed from four surfaces (tree 1212, analyze 85, panel 40, mcp-for 3) plus 5 in the pack-task JSON tail that the gate had been scoring on an empty document, and every single-root run now discloses its root exactly once. All red-first in test/rootrelcheck.sh ack short-horizon-churn dd2f935e1818171a 4 R-E CORRECTION lane (2026-08-19), the W2-E root-relative fix round. Every row here belongs to ONE change with one purpose: the 2026-08-17 R-E landing emitted root-relative p= on ~30 verbs, defined root= in NO legend, converted the CLI arm of exemplar/impact/uses/owners/cochange/mentions/find_symbol and not the MCP twin, appended root= AFTER at= (breaking the r26 at=-stays-LAST rule --owners own emitter comment states), and left three verbs (--exemplar, --lego, --expand) serving relative paths against a root they never named. Full suite green at the end: gates=429 pass=427 skip=2 fail=0, ASan+LSan clean, byte-deterministic x3, xmllint clean. api-surface: kRootRelPathsLegend/rootRelPathsLegend are the ONE shared definition of root=, hoisted rather than pasted into eighteen legends (the S B4 echo-site rule); connectEstTokens gains an extraBytes param (1 to 2) because the first landing put root= in the connect start tag and left the estimator alone, i.e. the exact under-report kConnectRootBytes own comment forbids, and both the trim-loop fit check and the printed est_tokens must read the SAME number so it is passed, never re-derived. complexity: exemplarText 14 to 18, usesText 23 to 27, runDefaultMap 191 to 197, serialize 190 to 191, runTargetedViews 37 to 39 — every point is the single-root-condition ternary the CLI arm already carries at twenty-plus sites, applied to the twin so the two surfaces cannot answer one question in two path dialects; no new nesting level and no new control flow beyond that one conditional. verbosity: the added lines are overwhelmingly the WHY comments this repo requires on a re-pin or a degrade path, plus the guarded root= clause; no new logic rides in them. short-horizon-churn: churn=self on every symbol this correction edited is this one edit window, the same shape the two --lint acks above record and for the same reason. No duplication, dead-code, error-masking, param or reuse-decline finding appears in this report. -ack short-horizon-churn dd627540f10bba76 23 cid=e05706d6ee52ed9e lift-disclosure round (2026-09-10): applyStructuralExpansion/applySiblingLift's optional *LiftInfo out-param is the disclosure hook itself (api-surface contract-change, purely additive/default-nullptr per G5) - and the 4 short-horizon-churn(self) rows are the necessary --for/--pack-task integration points (computeLensRanking, forLensHeaderText, runForLens, packTaskBundleText) in files under active development; duplication/complexity/verbosity this round introduced were fixed, not acked | prior: paper-shape lane (2026-08-28), --compress on served bodies (arXiv 2607.09691). api-surface/params on restatePackTaskBodiesWrapper: the +1 DEFAULTED bool is the compress disclosure thread — the wrapper REWRITES the open tag, so without it the restated bundle would silently claim uncompressed bodies (forcompresscheck arm 5); every other caller is byte-identical (default false). short-horizon-churn churn=self on packTaskBundleText/restatePackTaskBodiesWrapper is this lane's own edit window (the in.compress pass-through + the two hand-formatted wrapper tags). Red-first gate test/forcompresscheck.sh vs 1dc7b01; flagless byte-identity proven on 10 vectors incl. this repo's src; 607-vector argvdiff clean bar --help/--version/--compress-guard. 22 targeted gates green. +ack short-horizon-churn dd627540f10bba76 25 cid=f2adaaacf49b9a14 E1/A4-2: every tests_to_run emitter re-routed through testmap.h's one seam (testRowsRendered) so runner-less rows group by evidence; the 12 sites were rewritten in-window by M21(b) and F1, and touching that family once more is the item itself | prior: lift-disclosure round (2026-09-10): applyStructuralExpansion/applySiblingLift's optional *LiftInfo out-param is the disclosure hook itself (api-surface contract-change, purely additive/default-nullptr per G5) - and the 4 short-horizon-churn(self) rows are the necessary --for/--pack-task integration points (computeLensRanking, forLensHeaderText, runForLens, packTaskBundleText) in files under active development; duplication/complexity/verbosity this round introduced were fixed, not acked ack short-horizon-churn dda0db55532bd5e1 4 cid=8515eb8f5b5e8953 answer-safe --edit-check window: the three contract-change rows are ONE defaulted paging pair (pageLimit/pageOffset, 0/0 = the verb's own default cap) plumbed through the ONE assembler and its two front doors — editCheckBundleText, editpreview::run, editCheckText — rather than a second capped emitter, because two emitters would drift and a page that drifted could drop the flagged caller that IS the answer. The complexity/verbosity growth in the assembler is the partition-preserving row loop (the window advances on UNFLAGGED rows only, so a flagged row and its sites_l= ride every page uncut) plus the in-band legend that says what pages and what never does; the churn rows are this lane's own footprint across cli.h/mcp*/editcheck*, none foreign. | prior: R1 (wave-2 verifier): the redaction-marker write gate rewritten from a payload substring scan to a comparison against the bytes it would replace — short-horizon churn on the five symbols this round has been editing repeatedly, not new debt; the complexity/verbosity of all three write surfaces is unchanged or lower after the shared redactionMarkerRefusalFor helper ack short-horizon-churn df44625235cf2046 8 cid=a32d38683c291330 R8 printf-family pilot: std::printf/fprintf/snprintf -> std::print/std::format across every call site in verbs_lint.h, a file independently hot this round; converting a printf call necessarily rewrites the same recently-touched line, so self-thrash is inherent to the conversion, not new debt. Byte parity verified via test/printffmtparitycheck.sh (RED on an injected 1-byte mutation, GREEN on this pilot). ack short-horizon-churn dfc9aa3f1867944b 7 cid=4e20d921b9294272 OPTREMARKS F3 (docs/OPTREMARKS.md §8b): the ~430 per-AST-node std::strcmp( t, "literal" ) sites in the five ingest walk sections become rw::kindIs (src/infra/nodekind.h) — an inline compare, because strcmp is an external symbol LTO cannot inline and on macOS costs two dyld stub hops before it starts. Measured: 10.6% of busy CPU in strcmp leaves on a cold llvm run, 6-12% on four other corpora; 0.23% after. Output byte-identical across 7 corpora x 5 verbs, argvdiffcheck 640/642 vectors identical (the 2 that differ are the +dirty build stamp in --version). WHAT THESE ROWS ARE. (a) 95 short-horizon-churn rows, churn=self: the mechanical rewrite touches essentially every function in ingest_{metrics,binds,sidecap,relations,names}.h, so every one of them shows this lane's own single edit. Not thrash — one commit. (b) 7 duplication rows and 1 new-clone-of-reused-helper. These are REAL new clone groups (--clones, uncapped: 407 groups before, 409 after; the rewrite adds 7 and removes 5) and they are IDIOM COLLISIONS, not copies. kindIs( t, "x" ) is shorter than std::strcmp( t, "x" ) == 0, so short predicate bodies that were previously above the clone threshold now match each other's normalized token stream. Six of the seven pair a node-kind || -chain with an unrelated || -chain over a DISJOINT literal set in a different subsystem — cc_isParamList (tree-sitter parameter-list kinds) against predicatePrefixed (English name prefixes is/has/can), against sliceIsJsPatternKind (JS destructuring kinds), rubyCallIsAssignmentTarget against slice.h's JS binding probes. The tool's own rule is that two ladders over the SAME enum are a copy; these share no non-keyword identifier and no domain, and merging any pair would need a helper parameterised on an unrelated literal table — a wrong abstraction to satisfy a lint. The seventh, kindIs | lexTokenEqualsLowered, is the same shape at 56 tokens with materially different contracts: lexTokenEqualsLowered takes an explicit length and case-folds one side, kindIs takes its length from the literal's type and compares the terminating NUL as an ordinary byte — and that NUL comparison is precisely the safety property kindIs depends on (test/nodekindcheck.sh arm B proves the absence of a read past it with an mprotect(PROT_NONE) guard page). Folding them together would erase the one property being gated. Gate: test/nodekindcheck.sh, 4 arms, 1,348,096 enumerated (candidate, literal) pairs against std::strcmp plus two mutation controls that each turn an arm red. ack short-horizon-churn dfdf15c302800e6f 39 cid=c3a68b23304447a1 P7 (terminality round A, lane R): short-horizon churn on the lens legend clauses (kForFileTailLegend/Compact, kPackTaskBundleLegendBody: 'rows in r= order, p= the file') and on the two packers this lane rewrote (packSignatures, sigRowHead) — the P7 shape change itself, not drift; gate test/forrankordercheck.sh ack short-horizon-churn dfe94683f484669b 12 T3 disclosure-gap fix 2026-08-22: verbosity/churn on the two emitters + harness trace persistence are the registered disclosure's own bytes and comments; the gate-helper clone follows the self-contained-MCP-gate convention (every mcp gate carries its own mcp_call) ack short-horizon-churn e0a00f3a5f72168d 22 cid=8df40da9967f24e3 by=src/* rung 3 flow-sensitive reaching definitions (docs/EVALS.md 'Flow-sensitive slice in the small', 2026-09-03, lane/n6-b). edgesOf: the contract change IS the point — the diff now reads the scan's reach table (scan, rowOfOcc, cap) so rows/flow/diff share one edge oracle. sliceEmitBody +4 cx: the rd= emission per use row (its formatting already factored into sliceAppendReachAttr). The three duplication rows are the idiomatic tree-sitter named-child loop (seq/hasStructureBelow vs ingest helpers) and a two-way family ternary — no shared logic to lift. short-horizon-churn = self-churn of the two files this lane owns. sliceLegendText +11 lines = the registered per-construct disclosures the band requires on the first screen. -ack short-horizon-churn e14feca13c7ae680 10 cid=ae82b91f5a239bac cap-disclosure lane (2026-09-10), --from-trace + --handoff: every row is this one change's own footprint. The gating churn=self row is renderTraceBlock, whose two emitTo format strings are exactly where the name_ladder_capped= attribute has to be written - the file is hot this week, so modifying its emitter at all reads as in-window thrash; nothing was rewritten, two attribute slots were added. partitionTraceFrames 14->16 is the ladder-total plumb (one local + one push into the parallel unresolvedLadderTotal vector). The api-surface rows are the new conditional-disclosure seams (NameLadder, TraceNameBinding, nameLadderAttr/hasNameLadderCut/ladderLegendOf, VerifiedFileRow/verifiedFileRow, kNameLadderLegend, kHandoffSymsCapClause) - all header-inline, all with their own call sites in the same commit. writeHandoffPacket got SMALLER: extracting verifiedFileRow removed its inner symbol loop, so its pre-existing complexity 57 and 240 LOC both fell below baseline instead of growing. Gate: test/tracehandoffcapcheck.sh, red-first against the parent binary. +ack short-horizon-churn e14feca13c7ae680 12 cid=89b4fab9e89a8924 E1/A4-2: every tests_to_run emitter re-routed through testmap.h's one seam (testRowsRendered) so runner-less rows group by evidence; the 12 sites were rewritten in-window by M21(b) and F1, and touching that family once more is the item itself | prior: cap-disclosure lane (2026-09-10), --from-trace + --handoff: every row is this one change's own footprint. The gating churn=self row is renderTraceBlock, whose two emitTo format strings are exactly where the name_ladder_capped= attribute has to be written - the file is hot this week, so modifying its emitter at all reads as in-window thrash; nothing was rewritten, two attribute slots were added. partitionTraceFrames 14->16 is the ladder-total plumb (one local + one push into the parallel unresolvedLadderTotal vector). The api-surface rows are the new conditional-disclosure seams (NameLadder, TraceNameBinding, nameLadderAttr/hasNameLadderCut/ladderLegendOf, VerifiedFileRow/verifiedFileRow, kNameLadderLegend, kHandoffSymsCapClause) - all header-inline, all with their own call sites in the same commit. writeHandoffPacket got SMALLER: extracting verifiedFileRow removed its inner symbol loop, so its pre-existing complexity 57 and 240 LOC both fell below baseline instead of growing. Gate: test/tracehandoffcapcheck.sh, red-first against the parent binary. ack short-horizon-churn e17ff0a2142942c4 4 PHP + Lua language port (lane/lang-php-lua, 2026-08-21). All SEVEN remaining gating rows are the SAME class — short-horizon-churn with churn=self, i.e. 'this symbol was edited recently and you edited it again'. That is this change's own edit window, not a history signal, and every one of the seven is a site a language port CANNOT avoid touching: (1) src/model.h::Lang — the enum gains Php(18)/Lua(19); appending is the only safe move (inserting would renumber every on-disk cache key). (2) src/ingest.cpp::kLangTable — the extension->grammar rows for .php/.phtml/.lua, plus the extent 37->40 the compiler enforces. (3) src/main.cpp::computeLangCounts — its two tallies are sized on the LAST enum member, so a new member is a mechanical edit there by construction. (4) src/clones.h::kHashLineCommentLangMask — PHP joins (# IS a PHP line comment), Lua does not (its comment is --, and #t is the length operator). (5) src/lintrules.h::dependencyCapable — PHP true (namespace_use_declaration is captured), Lua false (require is an ordinary call, like Ruby). (6) cc_walk and (7) ev_noteNode — both call isDecisionType/cc_isNestingControl, which now take a Lang so Lua's do...end (a bare scope block, NOT a loop) stops being counted as a decision; every other language is byte-identical. The STRUCTURAL regressions this round did produce were FIXED, not acked: cc_walk +12 cx / +13 LOC from the inline boolean-operator test was extracted to cc_isBooleanJoin, and the duplication that extraction then created against cc_boolOp was removed by giving both ONE shared cc_operatorText. Gates: test/phpcheck.sh + test/luacheck.sh, both shown red (36 and 21 failing arms) against a cd30104-built binary. ack short-horizon-churn e1f67e3817c9c097 7 cid=ff9ddf76360520be OPTREMARKS F3 (docs/OPTREMARKS.md §8b): the ~430 per-AST-node std::strcmp( t, "literal" ) sites in the five ingest walk sections become rw::kindIs (src/infra/nodekind.h) — an inline compare, because strcmp is an external symbol LTO cannot inline and on macOS costs two dyld stub hops before it starts. Measured: 10.6% of busy CPU in strcmp leaves on a cold llvm run, 6-12% on four other corpora; 0.23% after. Output byte-identical across 7 corpora x 5 verbs, argvdiffcheck 640/642 vectors identical (the 2 that differ are the +dirty build stamp in --version). WHAT THESE ROWS ARE. (a) 95 short-horizon-churn rows, churn=self: the mechanical rewrite touches essentially every function in ingest_{metrics,binds,sidecap,relations,names}.h, so every one of them shows this lane's own single edit. Not thrash — one commit. (b) 7 duplication rows and 1 new-clone-of-reused-helper. These are REAL new clone groups (--clones, uncapped: 407 groups before, 409 after; the rewrite adds 7 and removes 5) and they are IDIOM COLLISIONS, not copies. kindIs( t, "x" ) is shorter than std::strcmp( t, "x" ) == 0, so short predicate bodies that were previously above the clone threshold now match each other's normalized token stream. Six of the seven pair a node-kind || -chain with an unrelated || -chain over a DISJOINT literal set in a different subsystem — cc_isParamList (tree-sitter parameter-list kinds) against predicatePrefixed (English name prefixes is/has/can), against sliceIsJsPatternKind (JS destructuring kinds), rubyCallIsAssignmentTarget against slice.h's JS binding probes. The tool's own rule is that two ladders over the SAME enum are a copy; these share no non-keyword identifier and no domain, and merging any pair would need a helper parameterised on an unrelated literal table — a wrong abstraction to satisfy a lint. The seventh, kindIs | lexTokenEqualsLowered, is the same shape at 56 tokens with materially different contracts: lexTokenEqualsLowered takes an explicit length and case-folds one side, kindIs takes its length from the literal's type and compares the terminating NUL as an ordinary byte — and that NUL comparison is precisely the safety property kindIs depends on (test/nodekindcheck.sh arm B proves the absence of a read past it with an mprotect(PROT_NONE) guard page). Folding them together would erase the one property being gated. Gate: test/nodekindcheck.sh, 4 arms, 1,348,096 enumerated (candidate, literal) pairs against std::strcmp plus two mutation controls that each turn an arm red. -ack short-horizon-churn e3c54d39d366fbaa 7 cid=402f938df07371e3 M12 (lane L9, capture-audit-2026-09-04): the deliberate cost of one root-relative path spelling across --affected/--test-gate/edit receipts/fetch_body plus the in_id= legend trim. runAffected grows the same mvSingleRoot/mvRootPrefix/mvRootAttr block verbs_report.h's dispatcher already threads (complexity 13->18, verbosity +17, mostly the comment naming the finding); writeTestGateReport/Json's duplication is the XML/JSON twin pair staying in lockstep, which is the property mcpclidiffcheck asserts; every short-horizon-churn row is this lane editing its own targets three times in one afternoon. +ack short-horizon-churn e3c54d39d366fbaa 33 cid=c62e78b54758b435 E1/A4-2: every tests_to_run emitter re-routed through testmap.h's one seam (testRowsRendered) so runner-less rows group by evidence; the 12 sites were rewritten in-window by M21(b) and F1, and touching that family once more is the item itself | prior: M12 (lane L9, capture-audit-2026-09-04): the deliberate cost of one root-relative path spelling across --affected/--test-gate/edit receipts/fetch_body plus the in_id= legend trim. runAffected grows the same mvSingleRoot/mvRootPrefix/mvRootAttr block verbs_report.h's dispatcher already threads (complexity 13->18, verbosity +17, mostly the comment naming the finding); writeTestGateReport/Json's duplication is the XML/JSON twin pair staying in lockstep, which is the property mcpclidiffcheck asserts; every short-horizon-churn row is this lane editing its own targets three times in one afternoon. ack short-horizon-churn e47e183ce409b3d1 5 cid=4a71f6160a322754 answer-safe --edit-check window: the three contract-change rows are ONE defaulted paging pair (pageLimit/pageOffset, 0/0 = the verb's own default cap) plumbed through the ONE assembler and its two front doors — editCheckBundleText, editpreview::run, editCheckText — rather than a second capped emitter, because two emitters would drift and a page that drifted could drop the flagged caller that IS the answer. The complexity/verbosity growth in the assembler is the partition-preserving row loop (the window advances on UNFLAGGED rows only, so a flagged row and its sites_l= ride every page uncut) plus the in-band legend that says what pages and what never does; the churn rows are this lane's own footprint across cli.h/mcp*/editcheck*, none foreign. | prior: E3 (terminality round A, lane E): editpreviewcheck's fourth normalisation (strip the preview-only child), stated in the gate ack short-horizon-churn e514a69013d0934c 88 cid=aa51679e8cf66000 lane/helptask-precision 2026-09-10, MCP no_route (audit F-R1-07): all eight gating rows are this one change and nothing rides with it. api-surface forTaskText 4->5 and packTaskText 5->6 params: ONE DEFAULTED bool each (noRoute), so every pre-existing call site compiles unchanged and was verified to; the alternative — a second overload per verb — is the clone seam this repo removes rather than adds. complexity +4 on each of the same two: the four !noRoute gates are a MIRROR of verbs_for.h's own four (cfg.noRoute gates the shape demotion, the mention anchor, the co-change prior and the route note), and collapsing them would be the MCP dialect deciding for itself what --no-route means — the exact drift mcpforparitycheck exists to prevent. Both were already far over the ccx bar (55/29) before this change; decomposing forTaskText is its own round. short-horizon-churn churn=self on dispatchMcpLine, kMcpVerbFields, forTaskText and packTaskText is the footprint of having edited four symbols this window already touched. FIXED rather than acked in the same pass: the verbosity row on dispatchMcpLine (1376 -> 1387) is gone — the second hand-rolled five-line boolean accumulate was replaced by ONE guarded boolArg reader that post_check now shares, the rule intArg already states for the numeric fields, netting the dispatcher SMALLER than before. Manifest re-anchored 41300 -> 41650 for obliged schema bytes only: descriptions are BYTE-IDENTICAL at 19632 B | prior: L10b finding: route= trim (no leading space+bracket), one-line change in each of the three routeNote construction sites ack short-horizon-churn e602fc1bbc735406 5 cid=d2100a9e6dc3a52c OPTREMARKS F3 (docs/OPTREMARKS.md §8b): the ~430 per-AST-node std::strcmp( t, "literal" ) sites in the five ingest walk sections become rw::kindIs (src/infra/nodekind.h) — an inline compare, because strcmp is an external symbol LTO cannot inline and on macOS costs two dyld stub hops before it starts. Measured: 10.6% of busy CPU in strcmp leaves on a cold llvm run, 6-12% on four other corpora; 0.23% after. Output byte-identical across 7 corpora x 5 verbs, argvdiffcheck 640/642 vectors identical (the 2 that differ are the +dirty build stamp in --version). WHAT THESE ROWS ARE. (a) 95 short-horizon-churn rows, churn=self: the mechanical rewrite touches essentially every function in ingest_{metrics,binds,sidecap,relations,names}.h, so every one of them shows this lane's own single edit. Not thrash — one commit. (b) 7 duplication rows and 1 new-clone-of-reused-helper. These are REAL new clone groups (--clones, uncapped: 407 groups before, 409 after; the rewrite adds 7 and removes 5) and they are IDIOM COLLISIONS, not copies. kindIs( t, "x" ) is shorter than std::strcmp( t, "x" ) == 0, so short predicate bodies that were previously above the clone threshold now match each other's normalized token stream. Six of the seven pair a node-kind || -chain with an unrelated || -chain over a DISJOINT literal set in a different subsystem — cc_isParamList (tree-sitter parameter-list kinds) against predicatePrefixed (English name prefixes is/has/can), against sliceIsJsPatternKind (JS destructuring kinds), rubyCallIsAssignmentTarget against slice.h's JS binding probes. The tool's own rule is that two ladders over the SAME enum are a copy; these share no non-keyword identifier and no domain, and merging any pair would need a helper parameterised on an unrelated literal table — a wrong abstraction to satisfy a lint. The seventh, kindIs | lexTokenEqualsLowered, is the same shape at 56 tokens with materially different contracts: lexTokenEqualsLowered takes an explicit length and case-folds one side, kindIs takes its length from the literal's type and compares the terminating NUL as an ordinary byte — and that NUL comparison is precisely the safety property kindIs depends on (test/nodekindcheck.sh arm B proves the absence of a read past it with an mprotect(PROT_NONE) guard page). Folding them together would erase the one property being gated. Gate: test/nodekindcheck.sh, 4 arms, 1,348,096 enumerated (candidate, literal) pairs against std::strcmp plus two mutation controls that each turn an arm red. @@ -1124,7 +1129,8 @@ ack short-horizon-churn f3ea5dbe8b5720cb 6 cid=8b8d47057aed710b M10 (capture-aud ack short-horizon-churn f43e1f2748e92444 7 cid=0fba983986a21ff4 OPTREMARKS F3 (docs/OPTREMARKS.md §8b): the ~430 per-AST-node std::strcmp( t, "literal" ) sites in the five ingest walk sections become rw::kindIs (src/infra/nodekind.h) — an inline compare, because strcmp is an external symbol LTO cannot inline and on macOS costs two dyld stub hops before it starts. Measured: 10.6% of busy CPU in strcmp leaves on a cold llvm run, 6-12% on four other corpora; 0.23% after. Output byte-identical across 7 corpora x 5 verbs, argvdiffcheck 640/642 vectors identical (the 2 that differ are the +dirty build stamp in --version). WHAT THESE ROWS ARE. (a) 95 short-horizon-churn rows, churn=self: the mechanical rewrite touches essentially every function in ingest_{metrics,binds,sidecap,relations,names}.h, so every one of them shows this lane's own single edit. Not thrash — one commit. (b) 7 duplication rows and 1 new-clone-of-reused-helper. These are REAL new clone groups (--clones, uncapped: 407 groups before, 409 after; the rewrite adds 7 and removes 5) and they are IDIOM COLLISIONS, not copies. kindIs( t, "x" ) is shorter than std::strcmp( t, "x" ) == 0, so short predicate bodies that were previously above the clone threshold now match each other's normalized token stream. Six of the seven pair a node-kind || -chain with an unrelated || -chain over a DISJOINT literal set in a different subsystem — cc_isParamList (tree-sitter parameter-list kinds) against predicatePrefixed (English name prefixes is/has/can), against sliceIsJsPatternKind (JS destructuring kinds), rubyCallIsAssignmentTarget against slice.h's JS binding probes. The tool's own rule is that two ladders over the SAME enum are a copy; these share no non-keyword identifier and no domain, and merging any pair would need a helper parameterised on an unrelated literal table — a wrong abstraction to satisfy a lint. The seventh, kindIs | lexTokenEqualsLowered, is the same shape at 56 tokens with materially different contracts: lexTokenEqualsLowered takes an explicit length and case-folds one side, kindIs takes its length from the literal's type and compares the terminating NUL as an ordinary byte — and that NUL comparison is precisely the safety property kindIs depends on (test/nodekindcheck.sh arm B proves the absence of a read past it with an mprotect(PROT_NONE) guard page). Folding them together would erase the one property being gated. Gate: test/nodekindcheck.sh, 4 arms, 1,348,096 enumerated (candidate, literal) pairs against std::strcmp plus two mutation controls that each turn an arm red. ack short-horizon-churn f4dcea0b719bea78 2 cid=8d14b541671382e9 by=src/* member-variable round (card A3): kUsesLegendOpen gains the one-sentence pointer to the member form, buildDefSpanIndex zero-widths field spans so containment attribution is byte-identical, kParserVer 74->75 for the new SymKind::Field + member use-site capture — each a single deliberate edit on a symbol other rounds touched recently ack short-horizon-churn f6dedd851e4b2a25 8 cid=282de1477b560a22 arise-h2h lane 2026-08-31: the four gating rows are all short-horizon-churn churn=self on this lane's own multi-line-statement flow fix (SliceOcc gains stmtLine, sliceWalk anchors it, sliceFlowCompute delegates to the extracted expand helpers, sliceBundleText legend sentence) - the edits are this round's deliberate red-first fix (sliceflowcheck arm 25), no foreign debt absorbed; complexity/nesting/verbosity on sliceFlowCompute were fixed by extraction, not acked -ack short-horizon-churn f7cf3b3c2851b336 10 cid=d6c978525c223cb7 2026-09-06 stranger-audit fixes: doctor compares bytes and fails off-PATH, html names its root+commit+version, at= carries +shallow, edit-lock sweep — the contract change (writeDocumentShell takes the title) and runDoctor's two new verdicts are deliberate; the churn rows are this edit itself | prior: M1: three named helpers, each replacing an inline duplicate rather than adding surface. mcpVerbDeclaresLegend/mcpCompactLegendHint are the ONE place the compact-legend family and its schema keys are read (mcpCompactLegendHint is not new — it moved down from mcp.h, which includes mcpverbs.h, so the batch assembler can call it); applyCompactToBatchSubs is the shared rule both batch front doors call, CLI and MCP, so the posture cannot fork; appendPackTaskRootExtras splices the bundle's owed root attributes onto BOTH root spellings, which is exactly the bug it prevents. The two ambient-churn rows are the same edit measured by recency. +ack short-horizon-churn f78020a188f5b4c8 25 cid=705ca15ee956afa0 E1/A4-2: every tests_to_run emitter re-routed through testmap.h's one seam (testRowsRendered) so runner-less rows group by evidence; the 12 sites were rewritten in-window by M21(b) and F1, and touching that family once more is the item itself +ack short-horizon-churn f7cf3b3c2851b336 25 cid=697fe208251fea62 E1/A4-2: every tests_to_run emitter re-routed through testmap.h's one seam (testRowsRendered) so runner-less rows group by evidence; the 12 sites were rewritten in-window by M21(b) and F1, and touching that family once more is the item itself | prior: 2026-09-06 stranger-audit fixes: doctor compares bytes and fails off-PATH, html names its root+commit+version, at= carries +shallow, edit-lock sweep — the contract change (writeDocumentShell takes the title) and runDoctor's two new verdicts are deliberate; the churn rows are this edit itself ack short-horizon-churn f8d747d123b4143d 5 R-E CORRECTION lane (2026-08-19), the W2-E root-relative fix round. Every row here belongs to ONE change with one purpose: the 2026-08-17 R-E landing emitted root-relative p= on ~30 verbs, defined root= in NO legend, converted the CLI arm of exemplar/impact/uses/owners/cochange/mentions/find_symbol and not the MCP twin, appended root= AFTER at= (breaking the r26 at=-stays-LAST rule --owners own emitter comment states), and left three verbs (--exemplar, --lego, --expand) serving relative paths against a root they never named. Full suite green at the end: gates=429 pass=427 skip=2 fail=0, ASan+LSan clean, byte-deterministic x3, xmllint clean. api-surface: kRootRelPathsLegend/rootRelPathsLegend are the ONE shared definition of root=, hoisted rather than pasted into eighteen legends (the S B4 echo-site rule); connectEstTokens gains an extraBytes param (1 to 2) because the first landing put root= in the connect start tag and left the estimator alone, i.e. the exact under-report kConnectRootBytes own comment forbids, and both the trim-loop fit check and the printed est_tokens must read the SAME number so it is passed, never re-derived. complexity: exemplarText 14 to 18, usesText 23 to 27, runDefaultMap 191 to 197, serialize 190 to 191, runTargetedViews 37 to 39 — every point is the single-root-condition ternary the CLI arm already carries at twenty-plus sites, applied to the twin so the two surfaces cannot answer one question in two path dialects; no new nesting level and no new control flow beyond that one conditional. verbosity: the added lines are overwhelmingly the WHY comments this repo requires on a re-pin or a degrade path, plus the guarded root= clause; no new logic rides in them. short-horizon-churn: churn=self on every symbol this correction edited is this one edit window, the same shape the two --lint acks above record and for the same reason. No duplication, dead-code, error-masking, param or reuse-decline finding appears in this report. ack short-horizon-churn f99ee113e972ca80 10 cid=5d47fafedace31ba by=src/* M12 (lane L9, capture-audit-2026-09-04): the deliberate cost of one root-relative path spelling across --affected/--test-gate/edit receipts/fetch_body plus the in_id= legend trim. runAffected grows the same mvSingleRoot/mvRootPrefix/mvRootAttr block verbs_report.h's dispatcher already threads (complexity 13->18, verbosity +17, mostly the comment naming the finding); writeTestGateReport/Json's duplication is the XML/JSON twin pair staying in lockstep, which is the property mcpclidiffcheck asserts; every short-horizon-churn row is this lane editing its own targets three times in one afternoon. | prior: M12 (capture-audit L9): path-spelling fixes — collectUseSites gained a root parameter (default-valued, back-compat) to root-relativize in_id=; runVerify grew from adding root=/verPathRel/the multi-root roots table it never had; short-horizon-churn rows are every function this finding's fix touched this session. ack short-horizon-churn fa6dd5532dd57a3c 3 cid=3104c098edd02883 A6: short-horizon churn on editplan::run, ::receipt and ::rollbackMessage is this fix round itself -- four assigned defects on one small surface, committed one per item, so the same three symbols fall inside the churn window more than once. churn=self, not instability in the code. diff --git a/CHANGELOG.md b/CHANGELOG.md index 36d21f470..c608976f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,34 @@ not published here — see `docs/EVALS.md` for the instruments behind the headli ## [Unreleased] +### Changed — tests-to-run rows without a runner are grouped by hop distance + +Every tests-to-run row that had no derivable runner said so on the row — `run_unknown="1"` in XML, +`"run_unknown":true` in JSON, `(run: not derivable)` in `--situ`'s text — and on a corpus where almost +no harness has a runner that was the same 16 or 23 bytes repeated once per row: on the RocksDB tree, +`--affected=db/write_batch.cc` listed 127 tests, 126 of them runner-less, and paid 2,016 B of XML and +2,898 B of text for one fact. Rows already come in evidence order (changed, partner, hops ascending, +path), so runner-less rows whose per-row attributes are byte-equal are now served as one row, +`` (JSON: `"p"` — or `"test"` — becomes an array beside +`"n"`; text: `[hops=2] (17): a, b, c (run: not derivable)`), emitted where its first member stood. Rows +with a runner stay single, a group of one stays a `` row, a comma inside an XML path is `,`, and +every path is kept verbatim — the multiset of paths before and after is identical, which is what +`test/testrowruncheck.sh` arm 12 proves on a fixture with three hop groups and a runner row in the +middle of one of them, in all three dialects (red on the previous binary). All twelve emitters — +`--affected`, `--exercises`, `--test-gate` XML and JSON, `--situ`, `--pr-context`, `--handoff`, +`--flags --flip`, `--pack-task` XML and JSON, the MCP `situational_awareness` twin and the edit +receipt — render through one seam in `testmap.h`, and the M21(b) rule keeps its meaning: a `` or +`` row carries `run=` or `run_unknown="1"`, never neither. Measured on RocksDB (`wc -c`, same cache, +same commit): `--affected=db/write_batch.cc` 10,668 → 6,839 B, `--test-gate=db/write_batch.cc` 13,242 → +9,594 B (its JSON 11,055 → 7,121 B), `--situ=db/write_batch.cc` 11,769 → 7,313 B; 7 `` rows replace +124 single rows and the residual spent on the disclosure is 144 B (XML) and 207 B (text) per list. +`--pack-task`'s byte-budgeted tests section caps a group at its own budget and counts `shown=`/`total=` +in files, so the same bundle now names 54 of 109 tests where it named 28. On this tree every harness +has a runner, so nothing groups and the only change is the legend that now defines ``: the +`--test-gate` legend pin moves 2,720 → 2,900 B (measured 2,843) and the `ripwire.pack-task/v1` compact +pin 820 → 880 B (measured 865), both because the compact dialect and every rows-bearing full legend now +define `run_unknown=` and `` — a definition `--affected` and the compact dialect never carried. + ### Added — Elixir module and arity resolution (parser version 95) Elixir calls now resolve by module, name and arity, with lexical aliases, filtered imports, default diff --git a/README.md b/README.md index 486de35f9..5079d8d19 100644 --- a/README.md +++ b/README.md @@ -1284,8 +1284,10 @@ $ ripwire . --test-gate # exit code: 4 ``` A `run=` attribute appears only when a runner is derivable from real evidence — a test-dir script -whose stem matches the harness, or whose text names it. No `run=` means *not derivable*, never a -guessed suite command. `script_gates_unmodelled="332"` is the same discipline: script-to-binary is not +whose stem matches the harness, or whose text names it. A row with none says so — `run_unknown="1"`, +never a guessed suite command — and runner-less rows that share their evidence are served as one +`` row, every path verbatim, so the disclosure is paid +once per group rather than once per row. `script_gates_unmodelled="332"` is the same discipline: script-to-binary is not a call edge, so those gates are invisible to this walk, and the number says so rather than letting `tests="2"` read as complete. The `` rows are the untested blast radius: impacted symbols that no test in the corpus reaches. diff --git a/bench/arb/run_arb.py b/bench/arb/run_arb.py index e6fcfd286..a9d36bf49 100644 --- a/bench/arb/run_arb.py +++ b/bench/arb/run_arb.py @@ -315,11 +315,19 @@ def trace_files(bin_path, snap, trace_text): TEST_RE = re.compile(r' row; split its p= and +# undo the , comma escape so every path is read verbatim, as it was on the single rows. +GROUP_RE = re.compile(r']*?\bp="([^"]+)"') def affected_tests(bin_path, snap, changed_file): out = run_bin_or_none(bin_path, snap, ["--affected=%s" % changed_file]) - return [norm_path(m, snap) for m in TEST_RE.findall(out)] if out is not None else [] + if out is None: + return [] + paths = TEST_RE.findall(out) + for grp in GROUP_RE.findall(out): + paths += [p.replace(",", ",") for p in grp.split(",")] + return [norm_path(m, snap) for m in paths] def impact_files(bin_path, snap, seeds): diff --git a/src/compactlegend.h b/src/compactlegend.h index b8aaa213a..c6a78bb59 100644 --- a/src/compactlegend.h +++ b/src/compactlegend.h @@ -285,6 +285,12 @@ inline constexpr CompactCompletenessTerm kCompactCompletenessTerms[] = // --uses=Owner.field's member form (fielduses.h appends kUsesFieldLegend to that answer alone). owner_candidates= is a // row attribute that exists only beside member=, so one head term defines the whole form. { "member", "member=Owner.field: rows use that field; pinned=/amb_sites= rows with one owner/with owner_candidates=K; owners_of_name= fields so named" }, + // M21(b) / E1 (2026-09-12): the tests_to_run family's not-derivable disclosure, and the group row it rides + // once per group. Row-level (every dialect puts it on the row), present-only; the reading is qualified + // to that element so a document of single rows never pays for it, and a --flags document's own never + // triggers it (that element carries no run_unknown=). + { "run_unknown", "run_unknown=1: no runner derivable (a guess would be worse)", true }, + { "run_unknown", ": n runner-less rows with equal attrs as ONE row, every path verbatim (, a comma)", true, "g" }, { "hits_capped", "hits_capped=1: hits= is a floor" }, // Both also ride the map header: est_tokens= alone there under order=stable (the root drops it), over_ceiling=1 there // under max-tokens. Same number, same reading, so one row reads both places. diff --git a/src/flipimpact.h b/src/flipimpact.h index ce81f346f..528d5cab8 100644 --- a/src/flipimpact.h +++ b/src/flipimpact.h @@ -1261,11 +1261,12 @@ inline void writeFlip( std::FILE* out, const FlipResult& res, const IngestResult // with 26 reachable tests named 25 of them and dropped the 26th because it sorted last. --test-gate's // own listing has never been windowed for exactly this reason; this listing is the same obligation // read from a different seed, so it is served whole on every page. SIZE_MAX, not maxRows. + // E1: rows without a runner are grouped (testmap.h's seam), so the listing is rendered whole and wrapped here + // exactly as writeCappedList wraps an uncut list — `` with no cut attributes, n= the FILE count. const rw::TestRunnerIndex flipRunners( ing ); - writeCappedList( out, "tests", res.tests, SIZE_MAX, [ & ]( std::uint32_t f ) - { - rw::emitTo( out, "", ex( rel( f ) ).c_str(), rw::runAttrDisclosed( flipRunners, f, ex ).c_str() ); - } ); + rw::emitTo( out, "", res.tests.size() ); + rw::emitRaw( out, rw::testRowsJoined( flipRunners, rw::testRowsOutOf( res.tests, rel ), rw::TestRowShape{ rw::RowDialect::Xml, "t" }, ex ).c_str() ); + rw::emitRaw( out, "" ); writeCappedList( out, "untested", res.untested, maxRows, [ & ]( NodeId u ) { const Symbol& s = ing.symbols[u]; diff --git a/src/handoff.h b/src/handoff.h index 4ed47930c..3cd88b908 100644 --- a/src/handoff.h +++ b/src/handoff.h @@ -279,14 +279,7 @@ inline int writeHandoffPacket( std::FILE* out, const std::string& root, const In // TestRunnerIndex is lazy: a packet with no test row reads no runner script. const rw::TestRunnerIndex hoRunners( ing ); const auto hoEsc = [ & ]( std::string_view t ) { return std::string( escapeXml( t, esc ) ); }; - for( const std::uint32_t f : facts.tests ) - { - v += " where no runner is derivable v += ""; // ── heuristic rows, priority order (dropped TAIL-FIRST under a budget) ─────────────────────────── diff --git a/src/mcpedit.h b/src/mcpedit.h index 29ce9bb98..10e41610b 100644 --- a/src/mcpedit.h +++ b/src/mcpedit.h @@ -1035,10 +1035,11 @@ namespace mcpedit const auto jesc = []( std::string_view t ) { return mcpdetail::jsonEscape( std::string( t ) ); }; const std::string prefix = rw::sarif::rootPrefixOf( root ); std::string out = ",\"tests_to_run\":["; + std::vector rcRows; + rcRows.reserve( ans.rows.size() ); for( std::size_t i = 0; i < ans.rows.size(); ++i ) { TestRow row = ans.rows[i]; // by value: see below - if( i ) { out += ","; } // A matched TEST file's changed= is spelled seed_kind="test" on --affected (verbs_change.h does // exactly this), because "the argument matched it, run it" is a different fact from "you edited // a file this test reaches". The receipt stands in for that verb, so it spells it the same way. @@ -1048,11 +1049,10 @@ namespace mcpedit // use, so a receipt row can never say less than the verb it stands in for. A row that arrived on // partner= or seed_kind= alone is a WEAKER claim than a graph-reached one, and dropping the // attribute would serve it as though it were the same. - out += "{\"p\":\"" + mcpdetail::jsonEscape( std::string( rw::sarif::rootRelativeUri( ing.files[ row.fileId ], prefix ) ) ) + "\"" - + ( seedTest ? ",\"seed_kind\":\"test\"" : "" ) - + rw::testRowEvidence( row, rw::EvDialect::Json ) - + rw::runFieldJsonDisclosed( runners, row.fileId, jesc ) + "}"; + rcRows.push_back( { row.fileId, std::string( rw::sarif::rootRelativeUri( ing.files[ row.fileId ], prefix ) ), + std::string( seedTest ? ",\"seed_kind\":\"test\"" : "" ) + rw::testRowEvidence( row, rw::EvDialect::Json ) } ); } + out += rw::testRowsJoined( runners, rcRows, rw::TestRowShape{ rw::RowDialect::Json, "p" }, jesc, "," ); // E1: --affected's , "p" an array out += "]"; // the root-level companions --affected carries beside its rows, so the two documents disclose the // same facts about the same list diff --git a/src/mcpverbs.h b/src/mcpverbs.h index 6a04769ef..07ea2ac1e 100644 --- a/src/mcpverbs.h +++ b/src/mcpverbs.h @@ -1330,18 +1330,8 @@ inline std::string situationDiffJson( const std::string& root, const std::string out += "]"; out += graphCountFloorAttrJson( ix.g ); // H5/M15: blast_radius[].dependent_symbols is read off the name-based CSR — a floor, with the gauge out += ",\"tests_to_run\":["; - { - bool first = true; - for( std::uint32_t f : facts.tests ) - { - if( !first ) - { - out += ","; - } - first = false; - out += "{\"test\":\"" + mcpdetail::jsonEscape( std::string( situJPathRel( f ) ) ) + "\"" + runFieldJsonDisclosed( runners, f, jsonEsc ) + "}"; - } - } + // E1: grouped where no runner is derivable — "test" is then an ARRAY of paths (testmap.h's seam) + out += testRowsJoined( runners, testRowsOutOf( facts.tests, situJPathRel ), TestRowShape{ RowDialect::Json, "test" }, jsonEsc, "," ); // F3: the decl/def partners of the changed set — the header/impl relationship the blast_radius array // above cannot carry, because a header does not transitively depend on the source that implements it. diff --git a/src/packtask.h b/src/packtask.h index 8e151471d..16cdebaed 100644 --- a/src/packtask.h +++ b/src/packtask.h @@ -144,7 +144,10 @@ inline constexpr int kPackTaskQuotaTestsPct = 10; // the cascaded remainder static_assert( kPackTaskQuotaRankingPct + kPackTaskQuotaBodiesPct + kPackTaskQuotaCallersPct + kPackTaskQuotaNotesPct + kPackTaskQuotaTestsPct == 100, "pack-task section quotas must sum to 100%" ); -struct PackTaskSection { std::string xml; std::size_t kept = 0; }; +// E1 (2026-09-12): a list entry may carry several UNITS (a test row is one entry of n= files). The cut is +// still per entry (the budget is bytes per entry), but shown=/total= — and the JSON tests_total/tests_kept — +// count units when the caller passes them, so "28 of 109 tests" stays 28 of 109 and never reads as "1 of 4". +struct PackTaskSection { std::string xml; std::size_t kept = 0; std::size_t keptUnits = 0; std::size_t totalUnits = 0; }; // W3FIX H2/M1 — the pieces the header comment is made of, so the header can be REBUILT in three shapes (as // built / task echo dropped / that plus route=) for serialize.h's climbCeilingLadder to price. A free function @@ -165,6 +168,8 @@ struct PackTaskHeaderParts std::string_view rootArg; // R-E (2026-08-17): the single-root run's own root= — the ladder's // route-dropped rebuild below calls ctxRootOpen a second time and // must carry the SAME root as the pre-built rootOpenStr did. + std::string_view runClause; // M21(b)/E1: testmap.h's run=/run_unknown=/ clause — ROWS-GATED (empty when + // the tests section kept no row), so a bundle that omits tests pays nothing }; // P10 (L7): the bundle legend's BODY, one constant, spliced by the standalone header (packTaskHeaderText) and @@ -276,6 +281,7 @@ inline std::string packTaskHeaderText( const PackTaskHeaderParts& p, bool withRo h.append( p.expandNote ); h.append( p.docMentionNote ); h += kPackTaskBundleLegendBody; // P10 (L7): the body is ONE constant — the partitioned document states it once for all slices + h.append( p.runClause ); // M21(b)/E1: the row's run=/run_unknown= rule and the group row, testmap.h's ONE wording — rows-gated h.append( p.report ); h.append( extraNotes ); h += " -->"; @@ -297,9 +303,16 @@ inline std::string packTaskHeaderText( const PackTaskHeaderParts& p, bool withRo // emitted _total/_kept for the same sections. Fixing the shared helper fixes , , // and in one place, which is why it is fixed here and not at four call sites. inline PackTaskSection packTaskListSection( std::string_view tag, std::string_view extraAttr, - const std::vector& entries, std::size_t budget, std::size_t wrapReserve ) + const std::vector& entries, std::size_t budget, std::size_t wrapReserve, + const std::vector* unitsPerEntry = nullptr ) { PackTaskSection out; + VERIFY( unitsPerEntry == nullptr || unitsPerEntry->size() == entries.size() ); + const auto unitsOf = [ & ]( std::size_t i ) -> std::size_t { return unitsPerEntry ? ( *unitsPerEntry )[i] : 1; }; + for( std::size_t i = 0; i < entries.size(); ++i ) + { + out.totalUnits += unitsOf( i ); + } if( entries.empty() || budget <= wrapReserve ) { return out; @@ -312,6 +325,7 @@ inline PackTaskSection packTaskListSection( std::string_view tag, std::string_vi break; } used += e.size(); + out.keptUnits += unitsOf( out.kept ); ++out.kept; } if( out.kept == 0 ) @@ -319,7 +333,7 @@ inline PackTaskSection packTaskListSection( std::string_view tag, std::string_vi return out; } char open[ 160 ]; - rw::formatTo( open, sizeof( open ), "<{}{} shown=\"{}\" total=\"{}\" capped=\"{}\">", std::string_view( tag.data(), tag.size() ), std::string_view( extraAttr.data(), extraAttr.size() ), out.kept, entries.size(), + rw::formatTo( open, sizeof( open ), "<{}{} shown=\"{}\" total=\"{}\" capped=\"{}\">", std::string_view( tag.data(), tag.size() ), std::string_view( extraAttr.data(), extraAttr.size() ), out.keptUnits, out.totalUnits, out.kept < entries.size() ? 1 : 0 ); out.xml = open; for( std::size_t i = 0; i < out.kept; ++i ) @@ -907,14 +921,14 @@ inline MonotoneRoll monotoneRoll( bool sectionCapped, std::size_t granted, std:: // first lap's conservative rule again, for the first lap's reason. inline std::size_t reflowListSection( PackTaskSection& section, std::string_view tag, std::string_view extraAttr, const std::vector& entries, std::size_t& budget, - std::size_t wrapReserve, std::size_t reflow ) + std::size_t wrapReserve, std::size_t reflow, const std::vector* unitsPerEntry = nullptr ) { if( reflow == 0 || section.kept >= entries.size() ) { return reflow; } budget += reflow; - section = packTaskListSection( tag, extraAttr, entries, budget, wrapReserve ); + section = packTaskListSection( tag, extraAttr, entries, budget, wrapReserve, unitsPerEntry ); return section.kept < entries.size() || budget <= section.xml.size() ? 0 : budget - section.xml.size(); } @@ -1367,8 +1381,11 @@ inline std::string packTaskBundleText( const IngestResult& ing, const Graph& g, remaining = remaining > notesRoll.charge ? remaining - notesRoll.charge : 0; // ── section 5 — tests_to_run for the top files (the --affected mining: tests that transitively reach) ─── - std::vector testRows; - std::vector testFiles; // hoisted for the L2 --json tail below + std::vector testRows; + std::vector testFiles; // hoisted for the L2 --json tail below + std::vector> testPartition; // E1: the XML rows' single/group partition, reused by the JSON tail so both dialects serve the same rows + std::vector testUnits; // E1: files per row (n= on a row), so shown=/total= keep counting FILES + std::size_t testsBudget = sectionBudget( kPackTaskQuotaTestsPct, carry ); // E1: known before the rows, so a group row can be capped at it { std::vector testSeeds; for( NodeId b : bodyIds ) @@ -1402,22 +1419,26 @@ inline std::string packTaskBundleText( const IngestResult& ing, const Graph& g, // §A9.5 / §P11.4: the one-call bundle names the tests you owe; it now also names how to RUN them, // from the same TestRunnerIndex --affected / --situ / --test-gate / --exercises read. Built here, // inside the section's scope, because it is lazy — a bundle with no test row reads no runner script. - const rw::TestRunnerIndex runners( ing ); - for( std::uint32_t f : testFiles ) + // §B14 — std::string rows, not char[512]: a row carries TWO unbounded interpolands (the test path AND + // the runner command), so it was the widest of the six breaching sites. + const rw::TestRunnerIndex runners( ing ); + const std::string ptPrefix = in.rootArg.empty() ? std::string() : rw::sarif::rootPrefixOf( in.rootArg ); + const std::vector ptRows = rw::testRowsOutOf( testFiles, [ & ]( std::uint32_t f ) -> std::string_view + { + return in.rootArg.empty() ? std::string_view( ing.files[f] ) : rw::sarif::rootRelativeUri( ing.files[f], ptPrefix ); + } ); + // E1: runner-less rows are grouped (testmap.h's seam). This section is byte-budgeted per ROW, so a group is + // capped at the section's own budget: rocksdb's 126 runner-less rows would otherwise be ONE ~3 KB row that + // a 10% tests quota cannot hold — measured shown="0" where the single rows had filled the section. + const std::size_t groupCap = testsBudget > kPackTaskWrapReserve ? testsBudget - kPackTaskWrapReserve : 0; + testPartition = rw::partitionTestRows( runners, ptRows, groupCap ); + for( rw::RenderedTestRow& row : rw::testRowsRendered( runners, ptRows, rw::TestRowShape{ rw::RowDialect::Xml, "test", {}, groupCap }, ex, &testPartition ) ) { - // §B14 — std::string, not char[512]. This row carried TWO unbounded interpolands (the test path - // AND the runner command), so it was the widest of the six breaching sites. - const std::string_view rp = in.rootArg.empty() ? std::string_view( ing.files[f] ) : rw::sarif::rootRelativeUri( ing.files[f], rw::sarif::rootPrefixOf( in.rootArg ) ); - std::string row = " row is one + // entry of n= files); the JSON tail serves the SAME partition the XML section was cut over, so the two dialects + // list the same rows. + const std::size_t testsShown = std::min( testsKept, testRows.size() ); + { char b[ 96 ]; rw::formatTo( b, sizeof( b ), ",\"tests_total\":{},\"tests_kept\":{},\"tests_to_run\":[", tests.totalUnits, tests.keptUnits ); j += b; } // §A9.5: the JSON sibling of the XML run= above — situ's tests_to_run already carries it, and one // computation path must not serialize two different obligations. - const rw::TestRunnerIndex jsonRunners( ing ); - const auto jrun = [ & ]( std::string_view s ) { return jsonStr( s ); }; - for( std::size_t i = 0; i < testsShown; ++i ) + const rw::TestRunnerIndex jsonRunners( ing ); + const auto jrun = [ & ]( std::string_view s ) { return jsonStr( s ); }; + const std::vector jsonRows = rw::testRowsRendered( jsonRunners, rw::testRowsOutOf( testFiles, jPathRel ), rw::TestRowShape{ rw::RowDialect::Json, "p" }, jrun, &testPartition ); + for( std::size_t i = 0; i < testsShown && i < jsonRows.size(); ++i ) { - j += std::string( i == 0 ? "" : "," ) + "{\"p\":\"" + jsonStr( std::string( jPathRel( testFiles[i] ) ) ) + "\"" - + rw::runFieldJsonDisclosed( jsonRunners, testFiles[i], jrun ) + "}"; + j += std::string( i == 0 ? "" : "," ) + jsonRows[i].text; } j += "]"; @@ -1677,7 +1701,7 @@ inline std::string packTaskBundleText( const IngestResult& ing, const Graph& g, report += "bodies: " + listStatus( bodiesTotal, bodiesStr, bodiesKept ) + ( bodiesTotal > 0 && !bodiesStr.empty() && bodiesKept < bodiesTotal ? " (capped)" : "" ) + " | "; report += "callers: " + listStatus( callersTotal, callersStr, callersKept ) + " | "; report += "notes: " + listStatus( notesTotal, notesStr, notesKept ) + " | "; - report += "tests: " + listStatus( testsTotal, testsStr, testsKept ); + report += "tests: " + listStatus( tests.totalUnits, testsStr, tests.keptUnits ); // E1: files, as the section's shown=/total= say report += " | far: " + listStatus( farTotal, rankOut.farXml, farKept ); // R2: d2plus name-only tier (nested in ) // A2 (survey card, 2026-09-03) — the pack-task twin of --for's dropped_positive= root fact: how many // rank>0 eligibleIds the section-1 ladder cut. Emitted ONLY when nonzero (the pr_converged precedent, @@ -1710,7 +1734,8 @@ inline std::string packTaskBundleText( const IngestResult& ing, const Graph& g, droppedPositiveAttr += lr.capAttrs; const PackTaskHeaderParts headerParts{ task, rootOpenStr, taskNote, mentionNote, boostNote, - docMentionNote, sibliftNote, expandNote, report, droppedPositiveAttr, in.rootArg }; + docMentionNote, sibliftNote, expandNote, report, droppedPositiveAttr, in.rootArg, + tests.kept > 0 ? rw::kRunHintLegendClause : std::string_view() }; const auto buildHeader = [ & ]( bool withRouteAttr, bool withTaskEcho, std::string_view extraNotes ) { if( in.innerBundle ) // P10 (L7): a partition slice — the outer legend speaks once for all of them diff --git a/src/partition.h b/src/partition.h index 351c72b3a..47b1d5381 100644 --- a/src/partition.h +++ b/src/partition.h @@ -593,7 +593,17 @@ inline std::string packTaskPartitionText( const IngestResult& ing, const Graph& whole += partitionSummaryAttrs( plan, sum, ov ); whole += ">"; whole += kPartitionLegend; - whole += ""; // P10 (L7): stated once + // M21(b)/E1: the / row rule rides the outer legend ONCE for every slice, and only when some slice kept a + // test row (a section exists exactly then — packTaskListSection emits nothing at kept=0), the same + // rows-gating the single bundle applies to its own header; test/partitioncheck.sh P10 holds the two within 1.3x. + bool anySliceTests = core.xml.find( " is the same transitive reach // --impact reports, so the same floor applies to hundreds of attributes in this one document. // The shared constants, never a pr-context wording — that is the §B4 echo-site rule. - + rw::graphCountDisclosure( hasUnindexed ) + "-->"; + + rw::graphCountDisclosure( hasUnindexed ) + + std::string( rw::kRunHintLegendClause ) // M21(b)/E1: the row's run=/run_unknown= rule and the group row, testmap.h's ONE wording + + "-->"; } inline constexpr std::string_view kPrEmptyDiffBody = @@ -1044,10 +1046,8 @@ inline int writePrContext( std::FILE* out, const std::string& root, const Ingest if( trim.testCap > 0 ) { rw::emitTo( o, "", testFiles.size(), tSc.shown, tSc.capped ); - for( std::size_t i = 0; i < tSc.shown; ++i ) - { - rw::emitTo( o, "", ex( prPathRel( testFiles[i] ) ).c_str(), runAttrDisclosed( prRunners, testFiles[i], ex ).c_str() ); // §A9.5 - } + // §A9.5 / E1: the shown window, grouped where no runner is derivable (testmap.h's seam) + rw::emitRaw( o, testRowsJoined( prRunners, testRowsOutOf( std::span( testFiles ).first( tSc.shown ), prPathRel ), TestRowShape{ RowDialect::Xml, "test" }, ex ).c_str() ); rw::emitRaw( o, "" ); } else diff --git a/src/situ.h b/src/situ.h index dbf0a18aa..0f369fb74 100644 --- a/src/situ.h +++ b/src/situ.h @@ -594,17 +594,14 @@ inline void writeSituation( std::FILE* out, const std::string& root, const Inges // disclose when nothing can be dropped. rw::emitTo( out, " [2] tests to run ({}){}", tests.size(), tests.empty() ? ": (none transitively reach these files)\n" - : " — evidence order: [changed] you edited it, [partner] named after a changed file, then hops (1 = calls a changed symbol directly):\n" ); + : " — evidence order: [changed] you edited it, [partner] named after a changed file, then hops (1 = calls a changed symbol directly); " + "a line (n): a, b lists n files sharing that evidence with no derivable runner:\n" ); // §P11.4: this section says "tests to run" and named files that are not commands. The runner is appended // where one is DERIVABLE and omitted where it is not — see testmap.h; a guessed command is worse than none. + // E1: runner-less rows with equal evidence are ONE `[hops=N] (n): a, b` line — testmap.h's seam, the multiset unchanged const TestRunnerIndex situRunners( ing ); - for( std::size_t i = 0; i < testRows.size(); ++i ) - { - const TestRow& r = testRows[i]; - const std::string_view rp = situPathRel( r.fileId ); - rw::emitTo( out, " {}{}{}\n", std::string_view( rp.data(), rp.size() ), testRowEvidence( r, EvDialect::Text ).c_str(), - runSuffixTextDisclosed( situRunners, r.fileId ).c_str() ); - } + rw::emitRaw( out, testRowsJoined( situRunners, evidenceRowsOut( testRows, EvDialect::Text, situPathRel ), TestRowShape{ RowDialect::Text, {}, " " }, + []( std::string_view s ) { return std::string( s ); } ).c_str() ); // §B7.3: this section inherits --affected's blind spot without --affected's disclosure — a shell harness // runs the compiled BINARY as a subprocess, which is not a call edge, so no test/*.sh gate can EVER be // named above, however much of the change it exercises. Same number, same counter as --affected's @@ -1181,11 +1178,7 @@ inline void writeTestGateReport( std::FILE* out, const IngestResult& ing, const pagingDisclosure( uab, sizeof( uab ), r.untested.size(), uw.end, pageLimit, pageOffset ), gitstamp::atAttr( root ).c_str(), tgRootAttr.c_str(), nextAttrXml( testGateNextInvocation( ing, r, gateRunners ) ).c_str() ); // P3 (L7) - for( const TestRow& row : r.testRows ) - { - rw::emitTo( out, "", ex( tgPathRel( row.fileId ) ).c_str(), testRowEvidence( row, EvDialect::Xml ).c_str(), - runAttrDisclosed( gateRunners, row.fileId, ex ).c_str() ); - } + rw::emitRaw( out, testRowsJoined( gateRunners, evidenceRowsOut( r.testRows, EvDialect::Xml, tgPathRel ), TestRowShape{ RowDialect::Xml, "t" }, ex ).c_str() ); // E1: where no runner is derivable for( const ShellGateObligation& gate : r.shellGates.obligations ) { rw::emitTo( out, "", ex( tgPathRel( gate.fileId ) ).c_str(), gate.evidence, @@ -1254,11 +1247,7 @@ inline void writeTestGateReportJson( std::FILE* out, const IngestResult& ing, co nextFieldJson( testGateNextInvocation( ing, r, gateRunnersJ ) ).c_str() ); // P3 (L7): the XML twin's next= const TestRunnerIndex gateRunners( ing ); // §P11.4, the JSON sibling of the XML run= const auto jesc = []( std::string_view s ) { return jsonStr( s ); }; - for( std::size_t i = 0; i < r.testRows.size(); ++i ) - { - rw::emitTo( out, "{}{{\"p\":\"{}\"{}{}}}", i == 0 ? "" : ",", jsonStr( tgJPathRel( r.testRows[i].fileId ) ).c_str(), - testRowEvidence( r.testRows[i], EvDialect::Json ).c_str(), runFieldJsonDisclosed( gateRunners, r.testRows[i].fileId, jesc ).c_str() ); - } + rw::emitRaw( out, testRowsJoined( gateRunners, evidenceRowsOut( r.testRows, EvDialect::Json, tgJPathRel ), TestRowShape{ RowDialect::Json, "p" }, jesc, "," ).c_str() ); // E1: the XML twin's , "p" an array for( std::size_t i = 0; i < r.shellGates.obligations.size(); ++i ) { const ShellGateObligation& gate = r.shellGates.obligations[i]; diff --git a/src/testmap.h b/src/testmap.h index 316f39785..d5e413125 100644 --- a/src/testmap.h +++ b/src/testmap.h @@ -674,11 +674,256 @@ inline std::string runSuffixTextDisclosed( const TestRunnerIndex& idx, std::uint return suffix.empty() ? std::string( " (run: not derivable)" ) : suffix; } +// ── E1 / A4-2 (output-routing loop, 2026-09-12, owner call) — runner-less rows GROUPED, the disclosure once ── +// On a corpus where almost no harness has a derivable runner (rocksdb: 126 of 127 rows), every row paid the +// same 16 bytes of `run_unknown="1"` (23 in --situ's text) — 2.0–2.5 KB per answer for one fact said 127 +// times. The rows come in EVIDENCE order (changed, partner, hops asc, path), so consecutive runner-less rows +// share their attributes; those are served as ONE row: +// +// XML (the per-row attrs, then n= p=) +// {"p":["a","b","c"],"hops":2,"n":7,"run_unknown":true} JSON ("p" — or "test" — becomes an ARRAY) +// [hops=2] (7): a, b, c (run: not derivable) text +// +// What the grouping may never change — test/testrowruncheck.sh arm 12 proves it on every dialect — is the +// MULTISET of paths: every path verbatim (a reader's grep for a file name still hits; E3's brace-grouped +// directories were disqualified on exactly that), each exactly once, in the order the single rows had. The +// rules, stated once here because they decide every emitter: +// * a row WITH a runner stays a single / row exactly as before — run= is per row; +// * only rows whose remaining per-row attributes are BYTE-EQUAL group (hops=, partner=, changed=, +// seed_kind= — the `attrs` string is the key), so a group never blurs two kinds of evidence; +// * a group is emitted where its FIRST member stood, its members in list order; a single row with a +// runner in the middle of a group stays where it was, so evidence order is preserved row for row; +// * a group of ONE is a single row (the spelling is shorter and a consumer has one less shape); +// * a ',' inside an XML path is spelled , (columnar.h's precedent: ordinary entity decoding restores +// it and escapeXml never emits a bare ',' itself); JSON needs nothing — the array carries the paths; +// * `maxGroupBytes` (pack-task, whose section is byte-budgeted per ROW): a group is split into +// consecutive rows so no single row can starve the section (rocksdb's list would otherwise be ONE +// 3 KB row that the tests quota cannot hold — measured shown="0"). 0 = unbounded, every other verb. +// The ""-means-not-derivable test stays in runHint alone: the single rows below go through the Disclosed +// wrappers, and a group exists only where commandFor is empty — one seam, one rule. +struct TestRowOut +{ + std::uint32_t fileId = 0; + std::string path; // as the verb spells it (root-relative or not), UNESCAPED + std::string attrs; // the per-row attributes in the dialect's spelling (testRowEvidence + seed_kind=), possibly empty — the group key +}; + +enum class RowDialect : std::uint8_t { Xml, Json, Text }; + +struct TestRowShape +{ + RowDialect dialect = RowDialect::Xml; + std::string_view tag = "t"; // XML element name ("t" | "test") or JSON key ("p" | "test") + std::string_view indent = ""; // text dialect: the line prefix + std::size_t maxGroupBytes = 0; // split a group so no row exceeds this (pre-escape estimate); 0 = never +}; + +// One rendered row: the text, and how many test FILES it carries (1 for a single row, n for a group), so a +// caller counting files (pack-task's kept/shown arithmetic) never mistakes rows for tests. +struct RenderedTestRow +{ + std::string text; + std::uint32_t files = 1; +}; + +// The partition: index lists into `rows`, a run of one for a single row, a run of ≥2 for a group. +inline std::vector> partitionTestRows( const TestRunnerIndex& idx, std::span rows, std::size_t maxGroupBytes ) +{ + std::vector> groups; + std::vector taken( rows.size(), 0 ); + for( std::uint32_t i = 0; i < rows.size(); ++i ) + { + if( taken[i] ) + { + continue; + } + taken[i] = 1; + if( !idx.commandFor( rows[i].fileId ).empty() ) + { + groups.push_back( { i } ); + continue; + } + // every later runner-less row with the same attrs joins; the scan is O(rows²) on lists of a few hundred rows + std::vector members{ i }; + std::size_t bytes = rows[i].attrs.size() + 48 + rows[i].path.size(); + for( std::uint32_t j = i + 1; j < rows.size(); ++j ) + { + if( taken[j] || rows[j].attrs != rows[i].attrs || !idx.commandFor( rows[j].fileId ).empty() ) + { + continue; + } + if( maxGroupBytes != 0 && members.size() >= 2 && bytes + rows[j].path.size() + 1 > maxGroupBytes ) + { + groups.push_back( std::move( members ) ); // this chunk is full: close it, the next member opens another at the same key + members = {}; + bytes = rows[i].attrs.size() + 48; + } + taken[j] = 1; + members.push_back( j ); + bytes += rows[j].path.size() + 1; + } + groups.push_back( std::move( members ) ); + } + return groups; +} + +// A single row, in the dialect — the disclosure through the Disclosed wrappers above, never re-spelled. +template +inline std::string renderSingleTestRow( const TestRunnerIndex& idx, const TestRowOut& r, const TestRowShape& shape, EscapeFn esc ) +{ + std::string s; + switch( shape.dialect ) + { + case RowDialect::Xml: + s += "<"; s.append( shape.tag ); s += " p=\""; s += esc( r.path ); s += "\""; s += r.attrs; s += runAttrDisclosed( idx, r.fileId, esc ); s += "/>"; + break; + case RowDialect::Json: + s += "{\""; s.append( shape.tag ); s += "\":\""; s += esc( r.path ); s += "\""; s += r.attrs; s += runFieldJsonDisclosed( idx, r.fileId, esc ); s += "}"; + break; + case RowDialect::Text: + s.append( shape.indent ); s += r.path; s += r.attrs; s += runSuffixTextDisclosed( idx, r.fileId ); s += "\n"; + break; + } + return s; +} + +// A group row (≥2 members, no runner by construction), in the dialect. +template +inline std::string renderTestRowGroup( std::span rows, std::span members, const TestRowShape& shape, EscapeFn esc ) +{ + VERIFY( members.size() >= 2 ); + const TestRowOut& first = rows[ members[0] ]; + std::string s; + switch( shape.dialect ) + { + case RowDialect::Xml: + { + s += ""; + break; + } + case RowDialect::Json: + { + s += "{\""; s.append( shape.tag ); s += "\":["; + for( std::size_t k = 0; k < members.size(); ++k ) + { + if( k ) { s += ','; } + s += '"'; s += esc( rows[ members[k] ].path ); s += '"'; + } + s += "]"; s += first.attrs; s += ",\"n\":"; s += std::to_string( members.size() ); s += ",\"run_unknown\":true}"; + break; + } + case RowDialect::Text: + { + s.append( shape.indent ); + if( !first.attrs.empty() ) + { + s.append( first.attrs.substr( first.attrs.front() == ' ' ? 1 : 0 ) ); s += ' '; // " [hops=2]" -> "[hops=2] " + } + s += '('; s += std::to_string( members.size() ); s += "): "; + for( std::size_t k = 0; k < members.size(); ++k ) + { + if( k ) { s += ", "; } + s += rows[ members[k] ].path; + } + s += " (run: not derivable)\n"; + break; + } + } + return s; +} + +// The two ways a caller has its rows: a bare file list (--exercises' seeds, --pr-context, --handoff, --flags +// --flip, --pack-task, situational_awareness — no per-row attributes), or rankTestRows' evidence rows +// (--situ's three dialects). ONE builder each, so six sites do not carry six copies of the same loop. +template +inline std::vector testRowsOutOf( std::span files, PathFn pathRel ) +{ + std::vector rows; + rows.reserve( files.size() ); + for( std::uint32_t f : files ) + { + rows.push_back( { f, std::string( pathRel( f ) ), {} } ); + } + return rows; +} + +template +inline std::vector evidenceRowsOut( std::span rows, EvDialect d, PathFn pathRel ) +{ + std::vector out; + out.reserve( rows.size() ); + for( const TestRow& r : rows ) + { + out.push_back( { r.fileId, std::string( pathRel( r.fileId ) ), testRowEvidence( r, d ) } ); + } + return out; +} + +// THE SEAM every tests_to_run emitter calls (test/testrowruncheck.sh arm 0 censuses its call sites): the +// partition and the rows, in the order the reader gets them. A caller that needs two dialects of ONE +// partition (pack-task's XML section and its JSON tail) passes the partition it already has. +template +inline std::vector testRowsRendered( const TestRunnerIndex& idx, std::span rows, const TestRowShape& shape, EscapeFn esc, + const std::vector>* partition = nullptr ) +{ + const std::vector> own = partition ? std::vector>{} : partitionTestRows( idx, rows, shape.maxGroupBytes ); + const std::vector>& groups = partition ? *partition : own; + std::vector out; + out.reserve( groups.size() ); + for( const std::vector& members : groups ) + { + if( members.size() == 1 ) + { + out.push_back( { renderSingleTestRow( idx, rows[ members[0] ], shape, esc ), 1 } ); + } + else + { + out.push_back( { renderTestRowGroup( rows, members, shape, esc ), std::uint32_t( members.size() ) } ); + } + } + return out; +} + +// The joined form, for the emitters that print the list in one go (`sep` between rows: "," for JSON, "" else). +template +inline std::string testRowsJoined( const TestRunnerIndex& idx, std::span rows, const TestRowShape& shape, EscapeFn esc, std::string_view sep = {} ) +{ + std::string joined; + bool first = true; + for( const RenderedTestRow& r : testRowsRendered( idx, rows, shape, esc ) ) + { + if( !first ) { joined.append( sep ); } + first = false; + joined += r.text; + } + return joined; +} + // The ONE sentence every legend that carries a tests_to_run row splices, so the seven cannot drift into // seven wordings of one rule. Deliberately short: it rides on --test-gate's own byte ratchets. +// E1 (2026-09-12): the row is defined in the same sentence, because it is the same rule said once per +// group — and legendcoveragecheck wants n= defined wherever a document carries it. inline constexpr std::string_view kRunHintLegendClause = "run= is the command that discharges a test row; run_unknown=\"1\" means none is derivable for that " - "harness (a guess would be worse than none) — a row carries one or the other, never neither. "; + "harness (a guess would be worse than none) — a or row carries one or the other, never neither. " + " is 2+ runner-less rows with equal attributes served as ONE row: n= how many, p= their paths " + "in list order (, a comma in a path), every path verbatim. "; + +// The clause is a rule about ROWS, so a legend splices it only when the rendered rows are non-empty — a +// tests="0" answer pays nothing for it (--affected/--exercises; --test-gate and --pack-task gate it the same way). +inline std::string_view runHintClauseIfRows( std::string_view rowsRendered ) noexcept +{ + return rowsRendered.empty() ? std::string_view() : kRunHintLegendClause; +} // ── P9 (capture-audit 2026-09-04) — the tests_to_run row set for ONE changed file ──────────────────── // The FILE reading of --affected, seeded by file id rather than by a path pattern, for callers that already diff --git a/src/verbs_change.h b/src/verbs_change.h index d23c50e24..300fab290 100644 --- a/src/verbs_change.h +++ b/src/verbs_change.h @@ -123,6 +123,21 @@ std::optional runAffected( const MainDispatch& d ) { return rw::sarif::rootRelativeUri( ing.files[ fileId ], afRootPrefix ); }; + // §P11.4 / E1: the rows are rendered FIRST (testmap.h's seam: run= where a REAL runner is derivable, + // runner-less rows with equal evidence grouped into one row) so the legend below can splice the + // run=/run_unknown=/ clause only when there are rows for it to be a rule about — a tests="0" answer, + // the common clean case, pays nothing for it. The index is constructed here (not hoisted into + // MainDispatch) because it is lazy — a run with no test row reads no script. + const rw::TestRunnerIndex runners( ing ); + std::vector afRows; + afRows.reserve( answer.rows.size() ); + for( rw::TestRow row : answer.rows ) // by value: a matched test file's changed= is spelled seed_kind="test" on this verb + { + const std::uint32_t f = row.fileId; + row.changed = false; + afRows.push_back( { f, std::string( afPathRel( f ) ), std::string( answer.isSeedTestFile[f] ? " seed_kind=\"test\"" : "" ) + rw::testRowEvidence( row, rw::EvDialect::Xml ) } ); + } + const std::string afRowsXml = rw::testRowsJoined( runners, afRows, rw::TestRowShape{ rw::RowDialect::Xml, "test" }, ex ); // seeded_by= is the honesty half of the file-first rule: the two readings answer DIFFERENT questions // over the same argument string and return different counts, so which one fired is a fact about the // measurement, not a detail. seeds= is the resolved seed-symbol count (1 for a lone function, ~84 @@ -134,7 +149,8 @@ std::optional runAffected( const MainDispatch& d ) "script-to-binary edges are NOT modelled, so those gates are invisible to this walk and never counted in tests=/reached=. " "{}" // H2H-Graft F1: the evidence-order clause, testmap.h's ONE wording (changed= is spelled seed_kind="test" here: the argument matched it) "order=evidence says so on the root; partners= counts the partner rows. " - "{}{}-->{}", rw::kTestRowEvidenceLegend, + "{}" // M21(b)/E1: the run=/run_unknown= rule and the group row, testmap.h's ONE wording — rows-gated + "{}{}-->{}", rw::kTestRowEvidenceLegend, rw::runHintClauseIfRows( afRowsXml ), // H1: the decl→def residue resolveAffectedSeeds summed over the symbol items. A file:name item whose // definitions were dropped seeded the walk with declarations alone, which reached the reader as a bare // tests="0" — on the verb whose answer is the list of tests to run. Exactly when the root carries it. @@ -148,16 +164,7 @@ std::optional runAffected( const MainDispatch& d ) rw::testRowPartnerCount( answer.rows ), // F1: how many rows stand on the name convention alone or as well afRootAttr.c_str(), // M12: root= says what every below is relative to rw::graphCountFloorAttrXml( g ).c_str() ); // H5/M15: gauge + marker; tests=/reached= are a transitive-caller walk over the name-based CSR - // §P11.4: run= where a REAL runner is derivable, absent where it is not. The index is constructed - // here (not hoisted into MainDispatch) because it is lazy — a run with no test row reads no script. - const rw::TestRunnerIndex runners( ing ); - for( rw::TestRow row : answer.rows ) // by value: a matched test file's changed= is spelled seed_kind="test" on this verb - { - const std::uint32_t f = row.fileId; - row.changed = false; - rw::emitTo( stdout, "", ex( afPathRel( f ) ).c_str(), answer.isSeedTestFile[f] ? " seed_kind=\"test\"" : "", - rw::testRowEvidence( row, rw::EvDialect::Xml ).c_str(), rw::runAttrDisclosed( runners, f, ex ).c_str() ); - } + rw::emitRaw( stdout, afRowsXml.c_str() ); // E1: the rows rendered above — runner-less rows with equal evidence as ONE row, the multiset unchanged rw::emitRaw( stdout, "" ); return 0; } @@ -231,11 +238,21 @@ std::optional runExercises( const MainDispatch& d ) // leading dashes (the same reason every other doc comment in the tool writes "quality-delta", not the // flag spelling). xmllint is the gate that catches a regression here. const std::string harnessAttr = exercisesHarnessAttr( ing, sel.testFiles ); // §A9.1, empty for a .cpp/.py harness + // §P11.4 / E1: the seed rows are the tests you are about to re-run — rendered before the legend so the + // run=/run_unknown=/ clause rides only a document that has rows (testmap.h's seam; grouped where no runner is derivable) + const rw::TestRunnerIndex runners( ing ); + const auto exPathRel = [ & ]( std::uint32_t f ) -> std::string_view + { + return exSingleRoot ? rw::sarif::rootRelativeUri( ing.files[f], exRootPrefix ) : std::string_view( ing.files[f] ); + }; + const std::vector exRows = rw::testRowsOutOf( std::span( sel.testFiles ).first( shownSeed ), exPathRel ); + const std::string exRowsXml = rw::testRowsJoined( runners, exRows, rw::TestRowShape{ rw::RowDialect::Xml, "t" }, ex ); rw::emitTo( stdout, "{}", rw::graphCountFloorBrief( g.unindexedFiles > 0 ).c_str(), rw::renderDisclosure( prD, rw::DiscloseAs::LegendClause ).c_str(), rw::rootRelPathsLegend( exSingleRoot ) ); + "{}" // M21(b)/E1: the run=/run_unknown= rule and the group row, testmap.h's ONE wording — rows-gated + "{}{}-->{}", rw::runHintClauseIfRows( exRowsXml ), rw::graphCountFloorBrief( g.unindexedFiles > 0 ).c_str(), rw::renderDisclosure( prD, rw::DiscloseAs::LegendClause ).c_str(), rw::rootRelPathsLegend( exSingleRoot ) ); const std::string exRootAttr = exSingleRoot ? ( " root=\"" + ex( cfg.roots[0] ) + "\"" ) : std::string(); rw::emitTo( stdout, "", ex( cfg.exercisesFile ).c_str(), sel.testFiles.size(), shownSeed, @@ -244,12 +261,7 @@ std::optional runExercises( const MainDispatch& d ) + rw::renderDisclosure( prD, rw::DiscloseAs::XmlAttrs ) ).c_str(), exRootAttr.c_str(), rw::graphCountFloorAttrXml( g ).c_str() ); // H5/M15: gauge + marker; reaches= is a transitive-callee walk over the name-based CSR - const rw::TestRunnerIndex runners( ing ); // §P11.4: the seed rows are the tests you are about to re-run - for( std::size_t i = 0; i < shownSeed; ++i ) - { - const std::string_view rp = exSingleRoot ? rw::sarif::rootRelativeUri( ing.files[ sel.testFiles[i] ], exRootPrefix ) : std::string_view( ing.files[ sel.testFiles[i] ] ); - rw::emitTo( stdout, "", ex( rp ).c_str(), rw::runAttrDisclosed( runners, sel.testFiles[i], ex ).c_str() ); - } + rw::emitRaw( stdout, exRowsXml.c_str() ); // E1: the seed rows rendered above for( std::size_t i = epw.begin; i < epw.end; ++i ) { const Symbol& s = ing.symbols[ show[i] ]; diff --git a/test/affectedcheck.sh b/test/affectedcheck.sh index 42496158b..21c759f16 100755 --- a/test/affectedcheck.sh +++ b/test/affectedcheck.sh @@ -69,8 +69,9 @@ printf 'void unrelated_helper() { }\n' > "$R/test/de run(){ perl -e 'alarm 15; exec @ARGV' "$BIN" "$R" "$@" --no-cache 2>/dev/null; } runec(){ perl -e 'alarm 15; exec @ARGV' "$BIN" "$R" "$@" --no-cache >/dev/null 2>"$TMP/err.txt"; } -# extract the basenames of the emitted entries, sorted -tset(){ printf '%s' "$1" | grep -oE ' GROUP row (testmap.h), so the p= value is split on ',' — every path is still verbatim. +tset(){ printf '%s' "$1" | grep -oE '<(test|g) [^>]*/>' | grep -oE ' p="[^"]*"' | sed 's/^ p="//; s/"$//' | tr ',' '\n' | sed 's|.*/||' | sort | tr '\n' ','; } cnt(){ printf '%s' "$1" | grep -oE 'tests="[0-9]+"' | head -1 | grep -oE '[0-9]+'; } # ── 1) change core.cpp → exactly the two tests that reach its symbols ──────────────────────────────── @@ -285,7 +286,10 @@ printf '%s' "$D" | grep -q ']*order="evidence"' && printf '%s' "$D" # negative: no stem partner exists for core.cpp, so no row may claim one printf '%s' "$A" | grep -q 'partner="1"' && no "(7f) core.cpp has no *_test partner yet a row claims partner=\"1\"" \ || ok "(7f) partner= never fires without a stem match" -printf '%s' "$A" | grep -q ' group row when a +# sibling shares its evidence, and a single row otherwise — hops="1" is asserted either way. +printf '%s' "$A" | grep -qE ' rows equal the tests= total at the DEFAULT cap T_TOTAL="$( attr "$TMP/fp_def" flip tests )" -T_ROWS="$( countrows fp_def t )" +# E1 (2026-09-12): runner-less rows with equal attributes ride ONE row, so the answer is +# counted in test FILES: the single rows plus every row's n=. +T_ROWS="$( { countrows fp_def t; grep -oE ']*/>' "$TMP/fp_def" | grep -oE ' n="[0-9]+"' | grep -oE '[0-9]+'; } | awk '{ s += $1 } END { print s + 0 }' )" if [ "${T_TOTAL:-0}" = "$T_ROWS" ]; then ok "(C) answer: all ${T_TOTAL:-0} tests_to_run rows ride the default page — never windowed" else no "(C) answer: tests=\"$T_TOTAL\" but $T_ROWS rows at the default cap — the ANSWER rows are being paged"; fi @@ -395,7 +397,12 @@ for text, which in ( ( d, "bare" ), ( a, "--limit=1000000" ) ): hdr = [ l for l in text.splitlines() if l.startswith( " [2] tests to run" ) ] line = hdr[ 0 ] if hdr else "" total = int( re.search( r'\((\d+)\)', line ).group( 1 ) ) if re.search( r'\((\d+)\)', line ) else 0 - served = len( rows( text, " [2] tests to run", r'^ \S' ) ) - 1 # minus the script-gates disclosure line + # E1 (2026-09-12): a `[hops=N] (n): a, b, …` line serves n files on one line — count FILES, not lines + served = 0 + for r in rows( text, " [2] tests to run", r'^ \S' ): + gm = re.match( r'^ (?:\[[^\]]*\] )*\((\d+)\): ', r ) + served += int( gm.group( 1 ) ) if gm else 1 + served -= 1 # minus the script-gates disclosure line if total <= 25: print( " FAIL (D) answer: only %d test rows (%s) — the fixture cannot show the retired 25-row cap is gone" % ( total, which ) ); fail = 1 elif "showing" in line or "capped=" in line: diff --git a/test/printf_parity.manifest b/test/printf_parity.manifest index 3ea144ad8..2e689938d 100644 --- a/test/printf_parity.manifest +++ b/test/printf_parity.manifest @@ -18,7 +18,7 @@ path 0 7058d89f7bab7aabe0a5cbf921959bc8a2c346fe65888b776996f879f04a58a2 e3b0c442 connect 0 31c7e3a689a6dcddf5eae17283740823005c3efdfdf3945be5770b2660a152b7 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 grep 0 b171aa1e5c28b47827f9148c2a5fc6948fb22938d3da21e6551b12432f492472 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pack_signatures 0 4a22aeda1d2e36fd390065dac1c07955e9f8942e7ba858435281863653f373ba e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 -pack_task 0 f397f2886049eb96b4bd3028d8108c1624e3959f2d428ca4687b0005144f3154 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 +pack_task 0 acc6ad4d23144bb195865986006ce3600bce6761f4971553ee9d0424c20f97d0 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 arch 2 6014e2f18ba2d587f70d59fcbe62f64c38e8c556291006ae3dfd76ef7259315a e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 seams 0 23b824f67984709f3c01dc7cc97c4c682b2afbaa5b63677a79ce56a1a58e6454 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 skipped 0 5b9749fdafec842ae75c6ad07e9eac8eb435653f8e5fc1ce452853fc36ff96f2 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 diff --git a/test/testgatelegendbudgetcheck.sh b/test/testgatelegendbudgetcheck.sh index 6d3c65be4..066bb3bd9 100755 --- a/test/testgatelegendbudgetcheck.sh +++ b/test/testgatelegendbudgetcheck.sh @@ -90,10 +90,18 @@ EOF # --affected/--situ/--test-gate alike so the emitters cannot drift. Written long (477 B), measured, cut to # the shortest honest form (281 B). Measured on this fixture: 2205 -> 2486 B; 2540 leaves ~54 B — the same # posture as every pin above. -if [ "$legend" -le 2720 ]; then - ok "(a) --test-gate legend is $legend B (<= 2540 B budget; total=$total payload=$payload)" +# RE-PINNED 2720 -> 2900 (2026-09-12, output-routing loop E1 / A4-2, owner call). ONE new FACT, in the SAME +# row-gated clause (testmap.h kRunHintLegendClause, so the zero-row report still pays nothing): +# +180 B the group row — 2+ runner-less rows with equal evidence attributes served as ONE row, n= how +# many, p= their paths in list order (, a comma), every path verbatim. It is what lets the +# not-derivable disclosure be said once per GROUP instead of once per row (rocksdb, 127 rows: 126 +# `run_unknown="1"` -> 9, test-gate 13,242 -> 9,594 B) and legendcoveragecheck wants n= defined +# wherever a document carries it. +# Measured on this fixture: 2663 -> 2843 B; 2900 leaves ~57 B — the same posture as every pin above. +if [ "$legend" -le 2900 ]; then + ok "(a) --test-gate legend is $legend B (<= 2900 B budget; total=$total payload=$payload)" else - no "(a) --test-gate legend is $legend B (> 2540 B budget) — the essay re-inflated" + no "(a) --test-gate legend is $legend B (> 2900 B budget) — the essay re-inflated" fi # (b) the honesty vocabulary + the §B12.5 cross-verb UNIT-collision anchors (test/testgatecheck.sh arm (g) diff --git a/test/testrowruncheck.sh b/test/testrowruncheck.sh index b32dff245..eb0522132 100755 --- a/test/testrowruncheck.sh +++ b/test/testrowruncheck.sh @@ -17,9 +17,17 @@ # `"run_unknown":true` / `(run: not derivable)` disclosure — never neither. Plus M21(b)'s second half: the # untested blast-radius `` rows carry `l=`, the line their sibling --flags --flip rows have always had. # +# E1 / A4-2 (2026-09-12, owner call): runner-less rows that share their per-row attributes are served as ONE +# `` row (JSON `{"p":[…],…,"n":N,"run_unknown":true}`, text +# `[hops=N] (n): a, b, c (run: not derivable)`), so the disclosure is said once per GROUP. The rule keeps +# its meaning — "a or row carries one or the other, never neither" — and arm 12 proves what the +# grouping must never change: the MULTISET of paths (every path verbatim, each exactly once, across every +# dialect), on a fixture with three hop groups and a runner row in the middle of one of them. +# # ARM 0 is the DERIVATION arm: it enumerates the row emitters out of src/ and fails when a site appears that # the arms below do not drive. That is what makes this a family gate rather than seven instance gates — a -# NEW verb that grows a tests_to_run row is a FAILURE here until it is driven and disclosed. +# NEW verb that grows a tests_to_run row is a FAILURE here until it is driven and disclosed. Since E1 every +# emitter renders its rows through testmap.h's ONE seam (testRowsRendered), so the census is its call sites. # # Usage: test/testrowruncheck.sh # uses build/ripwire # RIPWIRE_BIN=asan/ripwire test/testrowruncheck.sh @@ -40,8 +48,8 @@ echo "testrowruncheck: BIN=$BIN" # ── ARM 0 — the emitter census, derived from src/ ────────────────────────────────────────────────────── # Every site that prints a tests_to_run row. The list is the CONTRACT: a site added to src/ and not added # here fails, which is the only way a family gate stays a family gate. -EXPECTED_SITES="src/verbs_change.h src/situ.h src/prcontext.h src/packtask.h src/handoff.h src/flipimpact.h src/mcpverbs.h" -FOUND_SITES="$( cd "$ROOT" && grep -lE '"<(t|test) p=\\"|\{\\"test\\":|\{\\"p\\":\\"%s\\"%s\}' src/*.h src/*.cpp 2>/dev/null \ +EXPECTED_SITES="src/verbs_change.h src/situ.h src/prcontext.h src/packtask.h src/handoff.h src/flipimpact.h src/mcpverbs.h src/mcpedit.h" +FOUND_SITES="$( cd "$ROOT" && grep -lE 'testRows(Rendered|Joined)\(|"<(t|test) p=\\"|\{\\"test\\":|\{\\"p\\":\\"%s\\"%s\}' src/*.h src/*.cpp 2>/dev/null \ | grep -vE 'src/(serialize|testmap)\.h' | sort | tr '\n' ' ' | sed 's/ $//' )" WANT_SITES="$( printf '%s\n' $EXPECTED_SITES | sort | tr '\n' ' ' | sed 's/ $//' )" [ "$FOUND_SITES" = "$WANT_SITES" ] \ @@ -142,10 +150,12 @@ rows_disclosed(){ || printf ' NOTE %s listed no not-derivable row (verb reached only the covered harness)\n' "$label" } -XROW='<(t|test) p="[^"]*"[^>]*/>' +# E1: a group row is a tests_to_run row too (its p= lists several paths); the JSON twin's "p"/"test" +# is then an ARRAY. Both shapes must carry the disclosure like any single row. +XROW='<(t|test) p="[^"]*"[^>]*/>|]*/>' XHAS=' run="' XUNK=' run_unknown="1"' -JROW='\{"(p|test)":"[^"]*"[^}]*\}' +JROW='\{"(p|test)":("[^"]*"|\[[^]]*\])[^}]*\}' JHAS='"run":"' JUNK='"run_unknown":true' # the JSON dialects embed the tests_to_run LIST inside a document that also carries file rows keyed "p"; @@ -232,5 +242,120 @@ else printf ' SKIP (11) this document emitted no run_unknown row\n' fi +# ── ARM 12 — E1: grouping never changes the MULTISET of paths, in any dialect ───────────────────────── +# A fixture with THREE hop groups (tests reaching the changed symbol at depth 1, 2 and 3) and a runner row +# in the MIDDLE of the depth-1 group (t_leaf_b.sh stem-matches t_leaf_b.cpp; path order a < b < c), so the +# arm sees: a runner-less group interrupted by a single run= row, groups at three distinct hops=, and the +# same seven paths in --affected, --test-gate (XML and JSON) and --situ's text. What it proves: every path +# appears exactly once (verbatim — a reader's grep for a file name must still hit), the run= row stays a +# single row, at least three rows exist with distinct hops=, and the root's tests= count is the number +# of FILES, not rows. Red on the pre-E1 binary (no row at all). +command -v python3 >/dev/null 2>&1 || no "(12) python3 missing — the multiset arm cannot run" +W2="$( mktemp -d )"; trap 'rm -rf "$WORK" "$W2"' EXIT +mkdir -p "$W2/src" "$W2/test" +printf 'int leaf( int x )\n{\n return x + 1;\n}\n' > "$W2/src/leaf.cpp" +printf 'int leaf( int x );\nint mid( int x )\n{\n return leaf( x );\n}\n' > "$W2/src/mid.cpp" +printf 'int mid( int x );\nint top( int x )\n{\n return mid( x );\n}\n' > "$W2/src/top.cpp" +for n in leaf_a leaf_b leaf_c; do printf 'int leaf( int x );\nint test_%s( void )\n{\n return leaf( 1 );\n}\n' "$n" > "$W2/test/t_$n.cpp"; done +for n in mid_a mid_b; do printf 'int mid( int x );\nint test_%s( void )\n{\n return mid( 1 );\n}\n' "$n" > "$W2/test/t_$n.cpp"; done +for n in top_a top_b; do printf 'int top( int x );\nint test_%s( void )\n{\n return top( 1 );\n}\n' "$n" > "$W2/test/t_$n.cpp"; done +printf '#!/usr/bin/env bash\necho leaf_b\n' > "$W2/test/t_leaf_b.sh"; chmod +x "$W2/test/t_leaf_b.sh" +( cd "$W2" && git init -q && git config user.email t@t && git config user.name t && git add -A && git commit -qm init >/dev/null 2>&1 ) +printf 'int leaf2( int x ) { return x + 2; }\n' >> "$W2/src/leaf.cpp" +rw2(){ ( cd "$W2" && "$BIN" . "$@" --no-cache 2>/dev/null ); } +A12="$( rw2 --affected=src/leaf.cpp )" +G12="$( rw2 --test-gate=src/leaf.cpp )" +J12="$( rw2 --test-gate=src/leaf.cpp --json )" +S12="$( rw2 --situ=src/leaf.cpp )" +python3 - "$A12" "$G12" "$J12" "$S12" <<'PY12' +import sys, re, json +aff, tg, tgj, situ = sys.argv[1:5] +EXPECT = sorted( "test/t_%s.cpp" % n for n in ( "leaf_a", "leaf_b", "leaf_c", "mid_a", "mid_b", "top_a", "top_b" ) ) +fails = [] +def xml_paths( doc ): + out, groups, singles_run = [], [], [] + for m in re.finditer( r'<(t|test|g)( [^>]*)/>', doc ): + tag, attrs = m.group( 1 ), m.group( 2 ) + at = dict( re.findall( r' ([a-z_]+)="([^"]*)"', attrs ) ) + if tag == "g": + ps = [ p.replace( ",", "," ) for p in at["p"].split( "," ) ] + if int( at.get( "n", "-1" ) ) != len( ps ): fails.append( "n=%s on a row listing %d paths" % ( at.get( "n" ), len( ps ) ) ) + if at.get( "run_unknown" ) != "1": fails.append( "a row without run_unknown=1: %s" % m.group( 0 ) ) + if len( ps ) < 2: fails.append( "a row of one: %s" % m.group( 0 ) ) + groups.append( at.get( "hops" ) ); out += ps + else: + if "run" in at: singles_run.append( at["p"] ) + out.append( at["p"] ) + return out, groups, singles_run +a_paths, a_groups, a_run = xml_paths( aff ) +g_paths, g_groups, g_run = xml_paths( tg ) +if not a_paths: fails.append( "--affected emitted no test row at all (fixture broken)" ) +for label, paths in ( ( "--affected", a_paths ), ( "--test-gate", g_paths ) ): + if sorted( paths ) != EXPECT: fails.append( "%s multiset %r != %r" % ( label, sorted( paths ), EXPECT ) ) +for label, groups in ( ( "--affected", a_groups ), ( "--test-gate", g_groups ) ): + if len( set( groups ) ) < 3 or None in groups: fails.append( "%s: expected >=3 rows at distinct hops=, got hops=%r" % ( label, groups ) ) +for label, run in ( ( "--affected", a_run ), ( "--test-gate", g_run ) ): + if run != [ "test/t_leaf_b.cpp" ]: fails.append( "%s: the run= row must be the single test/t_leaf_b.cpp, got %r" % ( label, run ) ) +m = re.search( r']*\btests="(\d+)"', tg ) +if not m or int( m.group( 1 ) ) != len( EXPECT ): fails.append( "--test-gate tests= must count FILES (%d), got %s" % ( len( EXPECT ), m and m.group( 1 ) ) ) +# JSON twin: "p" is a string on a single row and an ARRAY on a group row +# the list is sliced on bracket DEPTH: a group row's "p":[…] array sits inside it, so a lazy `\[.*?\]` would +# stop at the first inner `]` (the shape that made this arm read red while the document was valid JSON) +def balanced_list( doc, key ): + i = doc.find( key ) + if i < 0: return None + i = doc.find( "[", i ); depth = 0; instr = False + for k in range( i, len( doc ) ): + c = doc[k] + if instr: + if c == "\\": continue + if c == '"': instr = False + continue + if c == '"': instr = True + elif c == "[": depth += 1 + elif c == "]": + depth -= 1 + if depth == 0: return doc[i:k+1] + return None +jl = balanced_list( tgj, '"tests_to_run":' ) +try: + rows = json.loads( jl ) if jl else [] +except Exception as e: + rows = []; fails.append( "--test-gate --json tests_to_run is not JSON: %s" % e ) +j_paths = [] +for r in rows: + p = r.get( "p" ) + if isinstance( p, list ): + j_paths += p + if r.get( "n" ) != len( p ) or r.get( "run_unknown" ) is not True: fails.append( "JSON group row without n=/run_unknown: %r" % r ) + else: + j_paths.append( p ) + if "run" not in r and r.get( "run_unknown" ) is not True: fails.append( "JSON single row carries neither: %r" % r ) +if sorted( j_paths ) != EXPECT: fails.append( "--test-gate --json multiset %r != %r" % ( sorted( j_paths ), EXPECT ) ) +# --situ text: ` path [hops=N] (run: …)` singles and ` [hops=N] (n): a, b, c (run: not derivable)` groups +sec = situ.split( "tests to run", 1 )[1].split( "\n [3]", 1 )[0] if "tests to run" in situ else "" +s_paths, s_groups = [], 0 +for line in sec.split( "\n" ): + if not line.startswith( " " ) or line.startswith( " (" ): continue + body = line[8:] + gm = re.match( r'(\[[^\]]*\] )?\((\d+)\): (.*?) \(run: not derivable\)$', body ) + if gm: + ps = gm.group( 3 ).split( ", " ); s_groups += 1 + if int( gm.group( 2 ) ) != len( ps ): fails.append( "situ group count (%s) != %d paths" % ( gm.group( 2 ), len( ps ) ) ) + s_paths += ps + else: + if " (run: " not in body: fails.append( "situ line carries no run recipe/disclosure: %r" % line ) + s_paths.append( body.split( " ", 1 )[0] ) +if sorted( s_paths ) != EXPECT: fails.append( "--situ text multiset %r != %r" % ( sorted( s_paths ), EXPECT ) ) +if s_groups < 3: fails.append( "--situ text: expected >=3 group lines, got %d" % s_groups ) +if fails: + print( "\n".join( fails ) ); sys.exit( 1 ) +print( "OK %d paths, %d rows on --affected" % ( len( EXPECT ), len( a_groups ) ) ) +PY12 +r12=$? +[ "$r12" -eq 0 ] \ + && ok "(12) E1: grouping keeps the path multiset in every dialect (7 paths, >=3 hop groups, the run= row single, tests= counts files)" \ + || no "(12) E1: the grouped rows do not carry the same paths as the single rows did (details above)" + [ "$fail" = 0 ] && echo "ALL PASS" || echo "FAILURES ABOVE" exit "$fail" diff --git a/test/w3fixlegendcheck.sh b/test/w3fixlegendcheck.sh index e0699b659..7a0c14189 100755 --- a/test/w3fixlegendcheck.sh +++ b/test/w3fixlegendcheck.sh @@ -304,7 +304,9 @@ printf 'int coreFn(){ return 7; }\n' >"$SITSB/core.cpp" i=1; while [ $i -le 30 ]; do printf 'int coreFn();\nint t%02d_main(){ return coreFn(); }\n' "$i" >"$SITSB/test/t$i.cpp"; i=$(( i + 1 )); done "$BIN" "$SITSB" --no-cache --situ=core.cpp >"$TMP/situ30" 2>&1 S2LINE="$( grep -E '^ \[2\]' "$TMP/situ30" || true )" -S2ROWS="$( sed -n '/\[2\]/,/\[3\]/p' "$TMP/situ30" | grep -c 'test/t[0-9]*\.cpp' || true )" +# E1 (2026-09-12): runner-less rows sharing their evidence ride ONE `[hops=N] (n): a, b, …` line, so the +# count is of PATHS (occurrences), not lines — `grep -c` would count the 30 tests as 1. +S2ROWS="$( sed -n '/\[2\]/,/\[3\]/p' "$TMP/situ30" | grep -o 'test/t[0-9]*\.cpp' | wc -l | tr -d ' ' || true )" case "$S2LINE" in *"showing "*" of "*) no "situ [2] still discloses a cut on its ANSWER rows (the 25-row cap was retired): $S2LINE";; *"(30)"*) ok "situ [2]: '(30)' with no cut disclosed — answer rows never page";; From 7ab0956a1db0cc63f46acbe53537f6c3d819f058 Mon Sep 17 00:00:00 2001 From: joyful-ii-V-I Date: Sun, 13 Sep 2026 05:56:19 -0400 Subject: [PATCH 02/13] fix(prcontext,tests): the run clause rode a pr-context bundle that could never carry a test row; a gate counted rows as tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two rows CI found on #214 at 100ed43d (7 jobs), both reproduced locally. (1) test/defaultceilingcheck.sh (5): est_tokens 8,025 > the 8,000 default budget on its 120-file fixture. The E1 commit spliced testmap.h's run=/run_unknown=/ clause into --pr-context's legend unconditionally (+330 B), and that fixture has no test file at all — the bundle paid for a rule about rows it can never emit (measured: old binary 7,989, unconditional 8,025). The legend is written and PRICED before the files render (the budget ladder fits est_tokens= to the envelope), so it cannot gate on the rows the way --affected does; it now gates on the one pre-render fact that decides whether a / row is possible: the corpus holds a test file (prLegendText gains corpusHasTests; its single caller passes it). Gated: 7,989 again. The ceiling is untouched. Red first: test/prcontextcheck.sh gains two arms — a corpus WITH a test file defines run_unknown= and in its legend; a corpus with none carries no clause — the second RED on the pre-fix binary ("a corpus with NO test file still pays for the run=/run_unknown=/ clause"), GREEN after. (2) test/testgatepagecheck.sh (d): "shown_tests=2 rows=0 tests=2" — the arm counted ' row. The arm counts FILES: the single rows plus every row's n=; its honesty assertion (shown_tests= equals the files named, tests_capped= derived from it) and its control (a claimed tests_capped="0" at 2 of 99 is rejected) are unchanged. --quality-delta gating="0" after acking, via the binary, the api-surface row (prLegendText's one-caller parameter is the gating fact) and the short-horizon-churn rows (prLegendText/writePrContext rewritten by this PR's own first commit and again here: one change, two commits). ASan: prcontextcheck and defaultceilingcheck under asan/ripwire, 0 reports. Full suite, python3 test/pargates.py . ./build/ripwire -j 6: "ALL PASS", exit 0, 0 FAIL rows (SKIPPED (ran, but proved nothing — not counted as passing): argvdiffcheck.sh argvdiffcheck: SKIP — no RIPWIRE_BASE reference binary editchecknotecheck.sh SKIP (c) byte-identity vs pre-change binary (set RIPWIRE_BASE_BIN=) ). Co-Authored-By: Claude Fable 5.1 --- .ripwire_quality_acks | 5 +++-- CHANGELOG.md | 5 +++++ src/prcontext.h | 12 +++++++++--- test/prcontextcheck.sh | 23 +++++++++++++++++++++++ test/testgatepagecheck.sh | 4 +++- 5 files changed, 43 insertions(+), 6 deletions(-) diff --git a/.ripwire_quality_acks b/.ripwire_quality_acks index c6db8ec69..c4003e5cf 100644 --- a/.ripwire_quality_acks +++ b/.ripwire_quality_acks @@ -8,6 +8,7 @@ ack api-surface 1039e3c8e0fc3667 4 cid=4efcfe9cb7f739a2 M12 (capture-audit L9): ack api-surface 105c48e20c80c896 3 cid=720fab31ea99ebde A2: unmeasuredHintNote gained the AbsHintFrame parameter one commit after this lane introduced it (4db6fb3). It is a header-inline helper in namespace mcpedit with exactly one caller, resolveOneForEdit, in the same file; no consumer outside this lane ever saw the 2-arg form. The widening is what makes the never-parsed disclosure and the symbol scan agree about which files a hint names -- two copies of that rule is the defect this replaces. ack api-surface 1085f731a3dde7c8 7 cid=b012ca29106914d1 capture-audit 2026-09-04 wave-1 close: symbols two lanes each grew past the other's acked magnitude — runDoctor (L10 legend + blobs_floor=, L9 built_from=), writeEnsembleReport (L9 root=, L10 conditional unavailable=), runAffected/runVerify (L9 root-relative block, L4 gauge), writeTestGateReport (L4 gauge splice, L9 row-gated root=; the XML/JSON twins' duplication is the lockstep mcpclidiffcheck asserts, as L9 acked; graphGaugeAttrXml/Json are the same lockstep), writePanelReport (L10 conditional attrs, L4 counts_floor), printUsage/validateConfig (L1 H10 hoist + L5/L9/L10 help text). Re-acked at the merged magnitude; prior reasons kept ack api-surface 10f47dd5a3f35d86 5 fnbody-require lane: deliberate additive API widening (optional out-params, default nullptr, every existing caller unaffected) to disclose the lazy require/import distinction on --impact's importer tier; residual complexity/duplication is the twin-dialect emitter shape and the dual-mode importersOfFiles scan this feature requires, already extracted where a helper genuinely reduced it (scanImporterEdges, recordLazyPair); short-horizon-churn is this same commit's own edit history +ack api-surface 131068a6cedf0864 3 cid=6df1f8794b53f5a2 E1 follow-up (#214 CI): prLegendText gains the one pre-render fact that gates testmap.h's run=/run_unknown=/ clause (corpusHasTests) — its single caller writePrContext passes it; the unconditional clause put defaultceilingcheck's 120-file no-test bundle 25 tokens over its 8000 default budget ack api-surface 1520fa02411735c3 6 cid=69e2cb4c55a88771 C1 F-06/F-07/F-10 (the listing-paging round): three listing verbs learn to disclose and page their row listings, and every gating row is that one change. api-surface 14 = ONE trailing DEFAULTED parameter each (an int pageOffset, an McpPageArgs window, a SituPageArgs, or the next= invocation a header now carries) on the emitters that must be TOLD their window — writeFlags/writeGate, writeCappedRows/writeCappedList/writeFlip/writeFlipHeader/writeFlipLights, computeFlip, nearestGateNames (which gains its cap and its TOTAL, the disclosure itself), situShowingNote/writeSituation, and the three MCP twins flagsText/flipText/situationDiffJson; additive by construction, every pre-existing call site compiles unchanged, and the alternative — a second capped emitter per verb — is the drift this repo removes rather than adds, because two emitters that disagree about a window can drop the row that IS the answer. verbosity 3 = kDocDriftLegend +20 lines and writeDocDriftPage +10 are the in-band vocabulary a reader needs to read shown_failed=/failed_capped=/failed_total= where they meet it (the rationale and the next= scan were already hoisted OUT of the body into their own function and comment, which took the complexity row to zero and the LOC row from +48 to +10); dispatchMcpLine +12 is two pagedResult wrappers on a pre-existing 1376-line dispatcher this lane adds to rather than creates. complexity 1 = the same dispatcher, +9 on a base of 518. duplication 1 = flagsText | flipText at 110 tokens, down from 131 after the shared mcpRowCap fold; the residual is getIndex + compute + captureXml, the shape EVERY index-backed MCP twin in this file has, and merging two verbs that return different results behind one entry point would be worse code than the clone. short-horizon-churn 19 = this lane's own footprint across cli.h/docdrift.h/darkflags.h/flipimpact.h/situ.h/mcp*, plus cli.h symbols three other lanes touched the same day; none foreign, none thrash. ack api-surface 15754e3561a34f40 7 cid=e3721579f68947f6 deep-tail lane (docs/EVALS.md Deep-tail serving; gate test/deeptailcheck.sh): the rank fact + file-grain tail land on every lens serving path at once, so the serving emitters carry the lane's own diff. api-surface jsonSigRowHead 6->7 = the defaulted globalRank param (0 = key absent; every existing caller source-compatible). complexity/verbosity runForLens +4/+39 and emitForLensJson +13 = the four seams a charged section costs (render, ladder charge, est charge, emission) after the fit logic was already extracted to renderForFileTailXml/forLensJsonTailStanza; forTaskText +17 = the MCP twin's parity wiring. churn=self rows are this one lane's diff on the emitters it owns, not thrash. The tail/r fit logic itself lives in serialize.h free functions, gate-covered red-first vs d8e257d. ack api-surface 163c0a0eb3219fa9 5 cid=9e7d5dab8c14a887 R2: prEmptyRootTail gains the truncated= parameter it needs to carry budget-floor-exceeded — deliberate, 1 caller, incompatible=0 (--edit-check contract-change); prEmptyRootPrice is the new file-scope helper that decides the label and re-prices, keeping writePrContext's own complexity and LOC unchanged | prior: V1/R2+N4: --pr-context est_tokens now PRICES the emitted document at 2.50 B/tok. pickPrTrimLevel(2->4) and prEmptyRootTail(3->4) are the deliberate arity changes that carry the price in instead of letting the ladder and the empty root each model one; the three short-horizon-churn rows are this lane's own edits to prcontext.h. @@ -745,7 +746,7 @@ ack short-horizon-churn 111000c203e47c35 5 cid=12c85505363fa80e OPTREMARKS F3 (d ack short-horizon-churn 1251aefc21a466dc 6 cid=813f4fac69d7378d OPTREMARKS F3 (docs/OPTREMARKS.md §8b): the ~430 per-AST-node std::strcmp( t, "literal" ) sites in the five ingest walk sections become rw::kindIs (src/infra/nodekind.h) — an inline compare, because strcmp is an external symbol LTO cannot inline and on macOS costs two dyld stub hops before it starts. Measured: 10.6% of busy CPU in strcmp leaves on a cold llvm run, 6-12% on four other corpora; 0.23% after. Output byte-identical across 7 corpora x 5 verbs, argvdiffcheck 640/642 vectors identical (the 2 that differ are the +dirty build stamp in --version). WHAT THESE ROWS ARE. (a) 95 short-horizon-churn rows, churn=self: the mechanical rewrite touches essentially every function in ingest_{metrics,binds,sidecap,relations,names}.h, so every one of them shows this lane's own single edit. Not thrash — one commit. (b) 7 duplication rows and 1 new-clone-of-reused-helper. These are REAL new clone groups (--clones, uncapped: 407 groups before, 409 after; the rewrite adds 7 and removes 5) and they are IDIOM COLLISIONS, not copies. kindIs( t, "x" ) is shorter than std::strcmp( t, "x" ) == 0, so short predicate bodies that were previously above the clone threshold now match each other's normalized token stream. Six of the seven pair a node-kind || -chain with an unrelated || -chain over a DISJOINT literal set in a different subsystem — cc_isParamList (tree-sitter parameter-list kinds) against predicatePrefixed (English name prefixes is/has/can), against sliceIsJsPatternKind (JS destructuring kinds), rubyCallIsAssignmentTarget against slice.h's JS binding probes. The tool's own rule is that two ladders over the SAME enum are a copy; these share no non-keyword identifier and no domain, and merging any pair would need a helper parameterised on an unrelated literal table — a wrong abstraction to satisfy a lint. The seventh, kindIs | lexTokenEqualsLowered, is the same shape at 56 tokens with materially different contracts: lexTokenEqualsLowered takes an explicit length and case-folds one side, kindIs takes its length from the literal's type and compares the terminating NUL as an ordinary byte — and that NUL comparison is precisely the safety property kindIs depends on (test/nodekindcheck.sh arm B proves the absence of a read past it with an mprotect(PROT_NONE) guard page). Folding them together would erase the one property being gated. Gate: test/nodekindcheck.sh, 4 arms, 1,348,096 enumerated (candidate, literal) pairs against std::strcmp plus two mutation controls that each turn an arm red. ack short-horizon-churn 1273fad87a99a9f2 6 cid=e6e7ad831a784a6c M10 (capture-audit L9): at= anchor family added to --for/--situ/--naming-calibration/--merge-scout/--stray-content/--dmm/--handoff. forRootRelPathsLegendShort gained a 2nd bool param (default-valued, back-compat) to fold at= into the existing short root-rel comment under --for's byte ceiling; runForLens grew from splicing the stamp through the ceiling ladder's byte accounting; the coPairAttr clone pair is a coincidental 2-bool-dispatch shape collision (different domains, no real duplication); short-horizon-churn rows are every function this finding's fix touched this session. ack short-horizon-churn 129d3c8d5a763870 7 E1 answer grader + questions task source + claude-runner control isolation (F3). churn=self on run_one/main/build_prompt/_claude_metrics/_harness_metrics/_execute/prepare_codex_environment/prepare_opencode_environment is this one change's own edit window on bench/agentloop/run_agentloop.py, which the opencode round touched days ago: the three preparers now share ephemeral_run_home()+link_credential() and the three command builders share build_harness_command(), so every one of those symbols is edited by the same commit that introduces the claude preparer. run_one's complexity regression was FIXED rather than acked (25->under the bar, by extracting build_harness_command/question_timeout/prepare_environment) and the two dead-code rows were fixed by restoring explicit dispatch — a dict of callables had hidden the codex/opencode preparers from the resolver. The remaining duplication row (prepare_claude_environment | prepare_codex_environment, 101 tokens, down from 282) is DELIBERATE and not further factored: the residue is 'ephemeral home named by one env var + credentials symlinked + set the var', and collapsing it would need a nine-parameter helper that opencode still could not use (it derives every path from xdg-basedir at module load, so five dirs plus HOME must move). Each preparer is asserted independently by its own canary gate — agentloopclaudecheck/agentloopopencodecheck/agentloopcodexcheck — and keeping the three recipes separately readable is the point of those gates. grade_answers.py's new-symbol rows are the six protocol grader types plus the closed accept-rule clause grammar; apply_clauses is a flat per-clause-kind dispatch with no nesting, and transcript_answer_text's error-masking row is the deliberate 'schema drift degrades to nulls, never raises' contract the retained transcript makes safe. Gates: agentloopgradercheck (new, born red on origin/main at exit 2) and agentloopclaudecheck (new, born red at exit 1) both green; codex/opencode canaries and analyze.py --self-test unchanged and green. -ack short-horizon-churn 131068a6cedf0864 17 cid=a171fda5c18fa950 E1/A4-2: every tests_to_run emitter re-routed through testmap.h's one seam (testRowsRendered) so runner-less rows group by evidence; the 12 sites were rewritten in-window by M21(b) and F1, and touching that family once more is the item itself | prior: R2: short-horizon churn on the three --pr-context symbols this round has been editing (V1 repriced them yesterday, V3 labels them today) — not new debt; writePrContext's complexity and verbosity are unchanged by this commit +ack short-horizon-churn 131068a6cedf0864 18 cid=6df1f8794b53f5a2 E1 follow-up (#214 CI): prLegendText/writePrContext were rewritten by this PR's own first commit (the clause splice) and again here to gate it on the corpus holding a test file; one change, two commits | prior: E1/A4-2: every tests_to_run emitter re-routed through testmap.h's one seam (testRowsRendered) so runner-less rows group by evidence; the 12 sites were rewritten in-window by M21(b) and F1, and touching that family once more is the item itself ack short-horizon-churn 15061a69cb5b451f 25 cid=c11a1cfb11d0325d E1/A4-2: every tests_to_run emitter re-routed through testmap.h's one seam (testRowsRendered) so runner-less rows group by evidence; the 12 sites were rewritten in-window by M21(b) and F1, and touching that family once more is the item itself | prior: R-E CORRECTION lane (2026-08-19), the W2-E root-relative fix round. Every row here belongs to ONE change with one purpose: the 2026-08-17 R-E landing emitted root-relative p= on ~30 verbs, defined root= in NO legend, converted the CLI arm of exemplar/impact/uses/owners/cochange/mentions/find_symbol and not the MCP twin, appended root= AFTER at= (breaking the r26 at=-stays-LAST rule --owners own emitter comment states), and left three verbs (--exemplar, --lego, --expand) serving relative paths against a root they never named. Full suite green at the end: gates=429 pass=427 skip=2 fail=0, ASan+LSan clean, byte-deterministic x3, xmllint clean. api-surface: kRootRelPathsLegend/rootRelPathsLegend are the ONE shared definition of root=, hoisted rather than pasted into eighteen legends (the S B4 echo-site rule); connectEstTokens gains an extraBytes param (1 to 2) because the first landing put root= in the connect start tag and left the estimator alone, i.e. the exact under-report kConnectRootBytes own comment forbids, and both the trim-loop fit check and the printed est_tokens must read the SAME number so it is passed, never re-derived. complexity: exemplarText 14 to 18, usesText 23 to 27, runDefaultMap 191 to 197, serialize 190 to 191, runTargetedViews 37 to 39 — every point is the single-root-condition ternary the CLI arm already carries at twenty-plus sites, applied to the twin so the two surfaces cannot answer one question in two path dialects; no new nesting level and no new control flow beyond that one conditional. verbosity: the added lines are overwhelmingly the WHY comments this repo requires on a re-pin or a degrade path, plus the guarded root= clause; no new logic rides in them. short-horizon-churn: churn=self on every symbol this correction edited is this one edit window, the same shape the two --lint acks above record and for the same reason. No duplication, dead-code, error-masking, param or reuse-decline finding appears in this report. ack short-horizon-churn 1520fa02411735c3 4 cid=69e2cb4c55a88771 C1 F-06/F-07/F-10 (the listing-paging round): three listing verbs learn to disclose and page their row listings, and every gating row is that one change. api-surface 14 = ONE trailing DEFAULTED parameter each (an int pageOffset, an McpPageArgs window, a SituPageArgs, or the next= invocation a header now carries) on the emitters that must be TOLD their window — writeFlags/writeGate, writeCappedRows/writeCappedList/writeFlip/writeFlipHeader/writeFlipLights, computeFlip, nearestGateNames (which gains its cap and its TOTAL, the disclosure itself), situShowingNote/writeSituation, and the three MCP twins flagsText/flipText/situationDiffJson; additive by construction, every pre-existing call site compiles unchanged, and the alternative — a second capped emitter per verb — is the drift this repo removes rather than adds, because two emitters that disagree about a window can drop the row that IS the answer. verbosity 3 = kDocDriftLegend +20 lines and writeDocDriftPage +10 are the in-band vocabulary a reader needs to read shown_failed=/failed_capped=/failed_total= where they meet it (the rationale and the next= scan were already hoisted OUT of the body into their own function and comment, which took the complexity row to zero and the LOC row from +48 to +10); dispatchMcpLine +12 is two pagedResult wrappers on a pre-existing 1376-line dispatcher this lane adds to rather than creates. complexity 1 = the same dispatcher, +9 on a base of 518. duplication 1 = flagsText | flipText at 110 tokens, down from 131 after the shared mcpRowCap fold; the residual is getIndex + compute + captureXml, the shape EVERY index-backed MCP twin in this file has, and merging two verbs that return different results behind one entry point would be worse code than the clone. short-horizon-churn 19 = this lane's own footprint across cli.h/docdrift.h/darkflags.h/flipimpact.h/situ.h/mcp*, plus cli.h symbols three other lanes touched the same day; none foreign, none thrash. ack short-horizon-churn 15754e3561a34f40 39 cid=93c7392c7b677557 P7 (terminality round A, lane R): the flat rank-ordered lens — ambient short-horizon churn on the JSON collector and its row structs (fileSlot field, flat emission), on fromTraceBundleText's legend line ('rows in r= order, p=file'), and +2..4 LOC on trimSigLadder (rank-major step F comment), narrowLegoToRenderedSigs (row p= scan) and packSignaturesJson (flat emission loop) — the P7 change itself; gate test/forrankordercheck.sh; --eval-retrieval byte-identical | prior: deep-tail lane (docs/EVALS.md Deep-tail serving; gate test/deeptailcheck.sh): the rank fact + file-grain tail land on every lens serving path at once, so the serving emitters carry the lane's own diff. api-surface jsonSigRowHead 6->7 = the defaulted globalRank param (0 = key absent; every existing caller source-compatible). complexity/verbosity runForLens +4/+39 and emitForLensJson +13 = the four seams a charged section costs (render, ladder charge, est charge, emission) after the fit logic was already extracted to renderForFileTailXml/forLensJsonTailStanza; forTaskText +17 = the MCP twin's parity wiring. churn=self rows are this one lane's diff on the emitters it owns, not thrash. The tail/r fit logic itself lives in serialize.h free functions, gate-covered red-first vs d8e257d. @@ -872,7 +873,7 @@ ack short-horizon-churn 4f90e22ea95dea67 22 cid=fe301df9e2f3c6db by=src/* rung 3 ack short-horizon-churn 4fb850ad3a3bb9c3 5 cid=efacb2a5f9e0435a OPTREMARKS F3 (docs/OPTREMARKS.md §8b): the ~430 per-AST-node std::strcmp( t, "literal" ) sites in the five ingest walk sections become rw::kindIs (src/infra/nodekind.h) — an inline compare, because strcmp is an external symbol LTO cannot inline and on macOS costs two dyld stub hops before it starts. Measured: 10.6% of busy CPU in strcmp leaves on a cold llvm run, 6-12% on four other corpora; 0.23% after. Output byte-identical across 7 corpora x 5 verbs, argvdiffcheck 640/642 vectors identical (the 2 that differ are the +dirty build stamp in --version). WHAT THESE ROWS ARE. (a) 95 short-horizon-churn rows, churn=self: the mechanical rewrite touches essentially every function in ingest_{metrics,binds,sidecap,relations,names}.h, so every one of them shows this lane's own single edit. Not thrash — one commit. (b) 7 duplication rows and 1 new-clone-of-reused-helper. These are REAL new clone groups (--clones, uncapped: 407 groups before, 409 after; the rewrite adds 7 and removes 5) and they are IDIOM COLLISIONS, not copies. kindIs( t, "x" ) is shorter than std::strcmp( t, "x" ) == 0, so short predicate bodies that were previously above the clone threshold now match each other's normalized token stream. Six of the seven pair a node-kind || -chain with an unrelated || -chain over a DISJOINT literal set in a different subsystem — cc_isParamList (tree-sitter parameter-list kinds) against predicatePrefixed (English name prefixes is/has/can), against sliceIsJsPatternKind (JS destructuring kinds), rubyCallIsAssignmentTarget against slice.h's JS binding probes. The tool's own rule is that two ladders over the SAME enum are a copy; these share no non-keyword identifier and no domain, and merging any pair would need a helper parameterised on an unrelated literal table — a wrong abstraction to satisfy a lint. The seventh, kindIs | lexTokenEqualsLowered, is the same shape at 56 tokens with materially different contracts: lexTokenEqualsLowered takes an explicit length and case-folds one side, kindIs takes its length from the literal's type and compares the terminating NUL as an ordinary byte — and that NUL comparison is precisely the safety property kindIs depends on (test/nodekindcheck.sh arm B proves the absence of a read past it with an mprotect(PROT_NONE) guard page). Folding them together would erase the one property being gated. Gate: test/nodekindcheck.sh, 4 arms, 1,348,096 enumerated (candidate, literal) pairs against std::strcmp plus two mutation controls that each turn an arm red. ack short-horizon-churn 4fc8bad5056ad3f3 7 recall.h is the intended edit target for lane L4 (structure-aware doc truncation); truncateRecallBody and buildSectionGranularBody are the two functions the task names, and the file is a legitimately hot area (many prior feature rounds) — churn is inherent to the assignment, not a code-shape defect to fix ack short-horizon-churn 5274ab1a5e193cb4 7 cid=5cfc96d7c5424d21 OPTREMARKS F3 (docs/OPTREMARKS.md §8b): the ~430 per-AST-node std::strcmp( t, "literal" ) sites in the five ingest walk sections become rw::kindIs (src/infra/nodekind.h) — an inline compare, because strcmp is an external symbol LTO cannot inline and on macOS costs two dyld stub hops before it starts. Measured: 10.6% of busy CPU in strcmp leaves on a cold llvm run, 6-12% on four other corpora; 0.23% after. Output byte-identical across 7 corpora x 5 verbs, argvdiffcheck 640/642 vectors identical (the 2 that differ are the +dirty build stamp in --version). WHAT THESE ROWS ARE. (a) 95 short-horizon-churn rows, churn=self: the mechanical rewrite touches essentially every function in ingest_{metrics,binds,sidecap,relations,names}.h, so every one of them shows this lane's own single edit. Not thrash — one commit. (b) 7 duplication rows and 1 new-clone-of-reused-helper. These are REAL new clone groups (--clones, uncapped: 407 groups before, 409 after; the rewrite adds 7 and removes 5) and they are IDIOM COLLISIONS, not copies. kindIs( t, "x" ) is shorter than std::strcmp( t, "x" ) == 0, so short predicate bodies that were previously above the clone threshold now match each other's normalized token stream. Six of the seven pair a node-kind || -chain with an unrelated || -chain over a DISJOINT literal set in a different subsystem — cc_isParamList (tree-sitter parameter-list kinds) against predicatePrefixed (English name prefixes is/has/can), against sliceIsJsPatternKind (JS destructuring kinds), rubyCallIsAssignmentTarget against slice.h's JS binding probes. The tool's own rule is that two ladders over the SAME enum are a copy; these share no non-keyword identifier and no domain, and merging any pair would need a helper parameterised on an unrelated literal table — a wrong abstraction to satisfy a lint. The seventh, kindIs | lexTokenEqualsLowered, is the same shape at 56 tokens with materially different contracts: lexTokenEqualsLowered takes an explicit length and case-folds one side, kindIs takes its length from the literal's type and compares the terminating NUL as an ordinary byte — and that NUL comparison is precisely the safety property kindIs depends on (test/nodekindcheck.sh arm B proves the absence of a read past it with an mprotect(PROT_NONE) guard page). Folding them together would erase the one property being gated. Gate: test/nodekindcheck.sh, 4 arms, 1,348,096 enumerated (candidate, literal) pairs against std::strcmp plus two mutation controls that each turn an arm red. -ack short-horizon-churn 52c2e3e32753dd28 17 cid=9973e66bb068786d E1/A4-2: every tests_to_run emitter re-routed through testmap.h's one seam (testRowsRendered) so runner-less rows group by evidence; the 12 sites were rewritten in-window by M21(b) and F1, and touching that family once more is the item itself | prior: R2: short-horizon churn on the three --pr-context symbols this round has been editing (V1 repriced them yesterday, V3 labels them today) — not new debt; writePrContext's complexity and verbosity are unchanged by this commit +ack short-horizon-churn 52c2e3e32753dd28 18 cid=a8838d1470d8468a E1 follow-up (#214 CI): prLegendText/writePrContext were rewritten by this PR's own first commit (the clause splice) and again here to gate it on the corpus holding a test file; one change, two commits | prior: E1/A4-2: every tests_to_run emitter re-routed through testmap.h's one seam (testRowsRendered) so runner-less rows group by evidence; the 12 sites were rewritten in-window by M21(b) and F1, and touching that family once more is the item itself ack short-horizon-churn 52de3cd8a58763d1 7 cid=462928d2141114f5 OPTREMARKS F3 (docs/OPTREMARKS.md §8b): the ~430 per-AST-node std::strcmp( t, "literal" ) sites in the five ingest walk sections become rw::kindIs (src/infra/nodekind.h) — an inline compare, because strcmp is an external symbol LTO cannot inline and on macOS costs two dyld stub hops before it starts. Measured: 10.6% of busy CPU in strcmp leaves on a cold llvm run, 6-12% on four other corpora; 0.23% after. Output byte-identical across 7 corpora x 5 verbs, argvdiffcheck 640/642 vectors identical (the 2 that differ are the +dirty build stamp in --version). WHAT THESE ROWS ARE. (a) 95 short-horizon-churn rows, churn=self: the mechanical rewrite touches essentially every function in ingest_{metrics,binds,sidecap,relations,names}.h, so every one of them shows this lane's own single edit. Not thrash — one commit. (b) 7 duplication rows and 1 new-clone-of-reused-helper. These are REAL new clone groups (--clones, uncapped: 407 groups before, 409 after; the rewrite adds 7 and removes 5) and they are IDIOM COLLISIONS, not copies. kindIs( t, "x" ) is shorter than std::strcmp( t, "x" ) == 0, so short predicate bodies that were previously above the clone threshold now match each other's normalized token stream. Six of the seven pair a node-kind || -chain with an unrelated || -chain over a DISJOINT literal set in a different subsystem — cc_isParamList (tree-sitter parameter-list kinds) against predicatePrefixed (English name prefixes is/has/can), against sliceIsJsPatternKind (JS destructuring kinds), rubyCallIsAssignmentTarget against slice.h's JS binding probes. The tool's own rule is that two ladders over the SAME enum are a copy; these share no non-keyword identifier and no domain, and merging any pair would need a helper parameterised on an unrelated literal table — a wrong abstraction to satisfy a lint. The seventh, kindIs | lexTokenEqualsLowered, is the same shape at 56 tokens with materially different contracts: lexTokenEqualsLowered takes an explicit length and case-folds one side, kindIs takes its length from the literal's type and compares the terminating NUL as an ordinary byte — and that NUL comparison is precisely the safety property kindIs depends on (test/nodekindcheck.sh arm B proves the absence of a read past it with an mprotect(PROT_NONE) guard page). Folding them together would erase the one property being gated. Gate: test/nodekindcheck.sh, 4 arms, 1,348,096 enumerated (candidate, literal) pairs against std::strcmp plus two mutation controls that each turn an arm red. ack short-horizon-churn 5383f63ad718935c 8 cid=c7781d76aeed7d13 R8 printf-family pilot: std::printf/fprintf/snprintf -> std::print/std::format across every call site in verbs_lint.h, a file independently hot this round; converting a printf call necessarily rewrites the same recently-touched line, so self-thrash is inherent to the conversion, not new debt. Byte parity verified via test/printffmtparitycheck.sh (RED on an injected 1-byte mutation, GREEN on this pilot). ack short-horizon-churn 5391ffd9aa5765bf 44 W1-S2 dead-code top-level fix: isDeadCandidate param add + the edits to computeDelta/computeSnapshot are the fix itself diff --git a/CHANGELOG.md b/CHANGELOG.md index c608976f9..ccee4dc9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,11 @@ has a runner, so nothing groups and the only change is the legend that now defin `--test-gate` legend pin moves 2,720 → 2,900 B (measured 2,843) and the `ripwire.pack-task/v1` compact pin 820 → 880 B (measured 865), both because the compact dialect and every rows-bearing full legend now define `run_unknown=` and `` — a definition `--affected` and the compact dialect never carried. +The clause is rows-gated on `--affected`, `--exercises`, `--pack-task` and the partitioned bundle, and on +`--pr-context` — whose legend is priced before its files render — gated on the corpus holding a test file +at all (measured on `test/defaultceilingcheck.sh`'s 120-file, no-test fixture: unconditional, the default +bundle went 7,989 → 8,025 tokens over its 8,000 budget; gated, 7,989; `test/prcontextcheck.sh` pins both +sides, red first). ### Added — Elixir module and arity resolution (parser version 95) diff --git a/src/prcontext.h b/src/prcontext.h index 6451f67d1..48bfffca3 100644 --- a/src/prcontext.h +++ b/src/prcontext.h @@ -597,7 +597,12 @@ inline std::string prBudgetTail( std::size_t changedFiles, std::uint32_t skipped // this comment IS ~91% of that document. Same bytes in the same order; they are simply measured before // they are written, the way every other priced root measures itself (serialize.h §H7). File scope, beside // kPrEmptyDiffBody, so the emitter reads as the decisions it makes rather than as the prose it ships. -inline std::string prLegendText( const std::string& baseEscaped, bool hasUnindexed ) +// E1 (2026-09-12): `corpusHasTests` gates testmap.h's run=/run_unknown=/ clause. This legend is written +// and PRICED before the files render (the budget ladder fits est_tokens= to the envelope), so the clause +// cannot ride the rows the way --affected's does; it rides the one pre-render fact that decides whether a +// / row is possible at all — the corpus holds a test file. Measured on test/defaultceilingcheck.sh's +// 120-file, no-test fixture: unconditional, 7,989 -> 8,025 tokens, over the 8,000 default budget. +inline std::string prLegendText( const std::string& baseEscaped, bool hasUnindexed, bool corpusHasTests ) { return std::string( ""; } @@ -901,7 +906,8 @@ inline int writePrContext( std::FILE* out, const std::string& root, const Ingest } std::sort( changed.begin(), changed.end(), [ & ]( std::uint32_t a, std::uint32_t b ) { return ing.files[a] < ing.files[b]; } ); - const std::string legendText = prLegendText( escBase, g.unindexedFiles > 0 ); + const bool corpusHasTests = std::any_of( ing.files.begin(), ing.files.end(), []( const std::string& f ) { return rw::isTestPath( f ); } ); + const std::string legendText = prLegendText( escBase, g.unindexedFiles > 0, corpusHasTests ); std::fwrite( legendText.data(), 1, legendText.size(), out ); const std::string anchorNoteText = prAnchorNoteText( anchorAttr ); diff --git a/test/prcontextcheck.sh b/test/prcontextcheck.sh index 639406996..8e599e94e 100755 --- a/test/prcontextcheck.sh +++ b/test/prcontextcheck.sh @@ -169,6 +169,29 @@ COUT="$( "$BIN" "$CLEAN" --pr-context --no-cache 2>/dev/null )"; CRC=$? && ok "clean tree degrades (files=0, exit 0)" \ || no "clean-tree degrade wrong (rc=$CRC): $COUT" +# ── E1 (2026-09-12): the run=/run_unknown=/ clause rides only a document whose corpus CAN carry a test row ─ +# The legend is written and priced before the files are rendered (the budget ladder fits est_tokens= to +# the envelope), so this bundle cannot gate the clause on the rows it ends up emitting the way --affected +# does. It gates on the one fact known before rendering that decides whether a / row is possible +# at all: does the corpus hold a test file. A corpus without one paid 330 B for a rule about rows it can +# never emit — measured on test/defaultceilingcheck.sh's 120-file fixture: est_tokens 7,989 -> 8,025, +# over the 8,000-token default budget. Red on the pre-fix binary (the clause rode every bundle). +{ echo "$OUT" | grep -q 'run_unknown=' && echo "$OUT" | grep -q ' definition in its legend" \ + || no "run clause: the fixture has test/test_core.cpp yet the legend does not define run_unknown=/" +NT="$TMP/notests"; mkdir -p "$NT/src"; git -C "$NT" init -q +git -C "$NT" config user.email a@x.com; git -C "$NT" config user.name A +printf 'int g( int x ) { return x; }\n' >"$NT/src/a.cpp"; git -C "$NT" add -A; git -C "$NT" commit -qm init +printf 'int h( int x ) { return g( x ) + 1; }\n' >>"$NT/src/a.cpp" +NTOUT="$( "$BIN" "$NT" --pr-context --no-cache 2>/dev/null )" +if echo "$NTOUT" | grep -q 'files="1"'; then + echo "$NTOUT" | grep -q 'run_unknown=' \ + && no "run clause: a corpus with NO test file still pays for the run=/run_unknown=/ clause" \ + || ok "run clause: a corpus with no test file carries no run=/run_unknown=/ clause (nothing it can be a rule about)" +else + no "run clause: the no-test fixture did not produce a one-file bundle: $( echo "$NTOUT" | head -c 300 )" +fi + # ── §P11.7: files ordered by BLAST RADIUS, and a doc file's headings collapsed to a count ─────────── # # The finding: the flagship review bundle emitted its sections in PATH order, so on this repo diff --git a/test/testgatepagecheck.sh b/test/testgatepagecheck.sh index 867936a28..ff26b2db4 100755 --- a/test/testgatepagecheck.sh +++ b/test/testgatepagecheck.sh @@ -202,7 +202,9 @@ printf 'int lib0() { return 0; }\nint lib1() { return 1; }\nint lib2() { return printf '#include "../src/lib.cpp"\nint test_lib0() { return lib0(); }\n' > "$TG/test/lib0_test.cpp" printf '#include "../src/lib.cpp"\nint test_lib1() { return lib1(); }\n' > "$TG/test/lib1_test.cpp" D="$( run "$TG" --test-gate=src/lib.cpp )" -DTROWS="$( printf '%s' "$D" | grep -o ' row, so the emitted +# count is FILES: the single rows plus every row's n= — shown_tests= must still equal that number. +DTROWS="$( { printf '%s' "$D" | grep -o ']*/>' | grep -oE ' n="[0-9]+"' | grep -oE '[0-9]+'; } | awk '{ s += $1 } END { print s + 0 }' )" DSHOWN="$( attr "$D" shown_tests )"; DTOTAL="$( attr "$D" tests )"; DCAP="$( attr "$D" tests_capped )" DWANT=0; [ "${DSHOWN:-0}" -lt "${DTOTAL:-0}" ] && DWANT=1 { [ -n "$DSHOWN" ] && [ "$DSHOWN" = "$DTROWS" ] && [ "$DCAP" = "$DWANT" ]; } \ From 5d742562d5cf52f24d8e194435687a1716e86e8c Mon Sep 17 00:00:00 2001 From: joyful-ii-V-I Date: Sun, 13 Sep 2026 10:21:50 -0400 Subject: [PATCH 03/13] fix(testmap,prcontext): a group reordered the rows it replaced, a byte cap dropped two paths that each fit, and the run clause rode a document with no row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings from the review of #214, all reproduced against this branch's own tip (7ab0956a) before the fix and all three red in a gate first. (1) A GROUP REORDERED THE ROWS IT REPLACED. partitionTestRows scanned every later row for the same attrs and skipped the incompatible ones with `continue`, so a runner-BEARING row sitting between two runner-less rows of the same attrs did not stop the scan: A, B(run=), A, A came back as one group of the three A rows emitted where the FIRST A stood, with B after it. The rows the emitter promises are in evidence order (changed, partner, hops asc, path) then are not: a reader walking the list sees the third A before the B that precedes it. Measured on the arm's own fixture, the pre-fix binary answered `a, c, d, b` for `a, b(run), c, d`. The fix is to group a CONTIGUOUS run only — the inner scan `break`s at the first row that is not a runner-less row with the same attrs — which makes order preservation true by construction rather than by an argument about how the rows were sorted. It costs one extra `` row per interruption: on the 127-row list this branch measures, 7 group rows become 8 and the answer grows 39 B (XML). Every re-measured number in the CHANGELOG entry moves with it. (2) A BYTE CAP DROPPED TWO PATHS THAT EACH FIT ALONE. The same function's group-size cap (maxGroupBytes, nonzero only for --pack-task's byte-bounded section) was tested with `members.size() >= 2`, so the SECOND member joined a group without the cap being consulted at all. The section then rejected the oversized row whole and named NO test, on a budget where either path would have been served as a singleton. Guard is now applied before every join. Measured by sweeping --token-budget on a two-test corpus with no runner: the first budget at which a section appears is 1,260 and it serves one file as a single row; the pre-fix binary's first section is `` at 1,360 — 100 tokens later, and on this branch's RocksDB bundle shown= is unchanged at 54 of 109. (3) THE RUN CLAUSE RODE A DOCUMENT WITH NO ROW. The previous commit gated --pr-context's legend clause on `corpusHasTests`, scanned over ing.files — before the diff selection, before paging, before the trim ladder. A test file anywhere in the corpus therefore bought (and priced) the clause for a bundle whose selected range reaches no test, and for every trim level whose testCap is 0. The predicate is now the renderer's own input: prBodyHasTestRow reads the RENDERED body, the pricer charges runClauseBytes per candidate level from that level's body, and the writer picks the matching legend form after the level is chosen — so the priced legend and the delivered legend cannot disagree. writePrContext renders the body before the head at all three of its exits (prRenderLevel names the memstream render the budget ladder already ran inline). Two arms, both red on 7ab0956a: a corpus whose only test exercises a file outside the diff, and the existing fixture at --max-tokens=300, where the ladder lands on trim_level>=2 (testCap=0) with tests counted and no row rendered. Gates. test/testrowruncheck.sh arm 12 gains a fourth leaf test so the fixture is A, B(run=), A, A, and now asserts the emitted ORDER of the paths in all four dialects, not only the multiset — red on 7ab0956a with `--affected ORDER changed by grouping: [a, c, d, b]` and the same on --test-gate, its JSON and --situ's text. New arm 13 sweeps --token-budget 1000..1700 over a two-test runner-less corpus and requires the first section that fits to be one singleton with shown= monotone in the budget — red on 7ab0956a with `the first section to fit is a GROUP, not one singleton: `. test/prcontextcheck.sh gains arms (i) and (ii) above, both red on 7ab0956a. All four green after; both gates ALL PASS. --quality-delta gating="0" after acking four rows BY SYMBOL through the binary (prBodyHasTestRow's two-find predicate matching accessshape::chaseTypeCanPoint at 36 tokens; prRenderLevel against packtask.h's packTaskRenderToString, the seven-line memstream idiom this tree already spells nine more times in mcpverbs.h; writePrContext's third churn=self edit in one window). The ledger diff adds exactly three rows and re-keys one at its new magnitude. Co-Authored-By: Claude Opus 5 (1M context) --- .ripwire_quality_acks | 5 +- CHANGELOG.md | 13 +++-- src/prcontext.h | 110 ++++++++++++++++++++++++++-------------- src/testmap.h | 17 +++++-- test/prcontextcheck.sh | 41 +++++++++++++++ test/testrowruncheck.sh | 59 ++++++++++++++++++--- 6 files changed, 188 insertions(+), 57 deletions(-) diff --git a/.ripwire_quality_acks b/.ripwire_quality_acks index c4003e5cf..e1ef63bde 100644 --- a/.ripwire_quality_acks +++ b/.ripwire_quality_acks @@ -485,6 +485,7 @@ ack duplication 0097b5800ff95c8e 304 Chip-trio merge 2026-08-23, finding 1 of 4. ack duplication 00b0c885f543c639 50 by=test/macroreparsefix lane fix/cpp-macro-member-reparse-2026-09-11, member-macro re-parse fixtures (test/macroreparsefix): sumDraft (leak_plain.cpp) and tallyCargo (leak_anon.cpp, control/semi.cpp) are deliberately ordinary C loops around the member macros, and test/macroreparsecheck.sh pins them BY NAME as the functions a derailed parse loses, so their bytes are the measured input. Their token streams meet unrelated plain loops (abicheck KindCounts::sumWhere, vendored dynamic_map.hpp node_rank::lt/le, bench geometry perimeter, the cloneidiomfix and sliceflowsensfix fixtures) with no shared identifier or contract; rewording a fixture to dodge a token match would game the number. ack duplication 03945adfda18f84d 20 cache-pack round, reviewed each: fixture functions deliberately mirror common loop shapes (token-matches to unrelated helpers are the fixture doing its job); needsLoopContext is a 3-clause tag predicate shape-matching other tiny predicates; mergeCachePack parallels mergeAtomsPack by documented same-shape contract ack duplication 03a66775dbf27f15 78 OPTREMARKS F3 (docs/OPTREMARKS.md §8b): the ~430 per-AST-node std::strcmp( t, "literal" ) sites in the five ingest walk sections become rw::kindIs (src/infra/nodekind.h) — an inline compare, because strcmp is an external symbol LTO cannot inline and on macOS costs two dyld stub hops before it starts. Measured: 10.6% of busy CPU in strcmp leaves on a cold llvm run, 6-12% on four other corpora; 0.23% after. Output byte-identical across 7 corpora x 5 verbs, argvdiffcheck 640/642 vectors identical (the 2 that differ are the +dirty build stamp in --version). WHAT THESE ROWS ARE. (a) 95 short-horizon-churn rows, churn=self: the mechanical rewrite touches essentially every function in ingest_{metrics,binds,sidecap,relations,names}.h, so every one of them shows this lane's own single edit. Not thrash — one commit. (b) 7 duplication rows and 1 new-clone-of-reused-helper. These are REAL new clone groups (--clones, uncapped: 407 groups before, 409 after; the rewrite adds 7 and removes 5) and they are IDIOM COLLISIONS, not copies. kindIs( t, "x" ) is shorter than std::strcmp( t, "x" ) == 0, so short predicate bodies that were previously above the clone threshold now match each other's normalized token stream. Six of the seven pair a node-kind || -chain with an unrelated || -chain over a DISJOINT literal set in a different subsystem — cc_isParamList (tree-sitter parameter-list kinds) against predicatePrefixed (English name prefixes is/has/can), against sliceIsJsPatternKind (JS destructuring kinds), rubyCallIsAssignmentTarget against slice.h's JS binding probes. The tool's own rule is that two ladders over the SAME enum are a copy; these share no non-keyword identifier and no domain, and merging any pair would need a helper parameterised on an unrelated literal table — a wrong abstraction to satisfy a lint. The seventh, kindIs | lexTokenEqualsLowered, is the same shape at 56 tokens with materially different contracts: lexTokenEqualsLowered takes an explicit length and case-folds one side, kindIs takes its length from the literal's type and compares the terminating NUL as an ordinary byte — and that NUL comparison is precisely the safety property kindIs depends on (test/nodekindcheck.sh arm B proves the absence of a read past it with an mprotect(PROT_NONE) guard page). Folding them together would erase the one property being gated. Gate: test/nodekindcheck.sh, 4 arms, 1,348,096 enumerated (candidate, literal) pairs against std::strcmp plus two mutation controls that each turn an arm red. +ack duplication 07acd981dd8a9619 36 E1 follow-up (CodeRabbit on #214, threads 1-3): the pr-context legend must follow the RENDERED rows, so the chosen body is now rendered before the head is written. prRenderLevel NAMES the memstream dance pickPrTrimLevel already ran inline and the unbudgeted root now needs as well - two call sites, one copy - and the clone it matches is packtask.h's packTaskRenderToString, the same seven-line idiom this tree already spells nine more times in mcpverbs.h; hoisting one shared helper into serialize.h would collapse three DEGRADED_PATH_ALERT messages that deliberately say WHICH buffer failed, so the duplication is the cheaper honesty and is recorded here rather than papered over. prBodyHasTestRow is a two-find predicate (a test row opener or a group row opener); its 36-token match against accessshape::chaseTypeCanPoint is the shape find(x)!=npos || find(y)!=npos, not shared logic. writePrContext's churn=self is this lane's own third edit to that one function inside the window - the E1 landing, the CI budget fix, and this rows-gated legend - one change seen three times, none foreign. ack duplication 094dbeec3d6a6f8f 32 timsort vendoring: every row is the vendored src/infra/timsort.hpp (upstream v3.0.1 + the recorded workspace patch) plus the facade forwarder it needs. The complexity/verbosity/params/duplication rows are UPSTREAM's shape — mergeLo/mergeHi, gallopLeft/gallopRight and the timsort/timmerge overload pairs are twins in the release itself — and restructuring them would destroy the property that makes the file auditable: that it can be re-derived byte-for-byte from a public tag plus one described patch. The one preexisting-worse row, infra::sort::stable vs infra::sort::unstable at 22 tokens, is what a facade IS: each entry is a one-line forward to a DIFFERENT algorithm, and collapsing them into one algorithm-parameterised template would hide the explicit named choice the layer exists to make (G5). Nothing is routed to timsort; test/timsortcheck.sh is what keeps the file honest. ack duplication 0aba5feb0affb13a 43 by=test/macroreparsefix lane fix/cpp-macro-member-reparse-2026-09-11, member-macro re-parse fixtures (test/macroreparsefix): sumDraft (leak_plain.cpp) and tallyCargo (leak_anon.cpp, control/semi.cpp) are deliberately ordinary C loops around the member macros, and test/macroreparsecheck.sh pins them BY NAME as the functions a derailed parse loses, so their bytes are the measured input. Their token streams meet unrelated plain loops (abicheck KindCounts::sumWhere, vendored dynamic_map.hpp node_rank::lt/le, bench geometry perimeter, the cloneidiomfix and sliceflowsensfix fixtures) with no shared identifier or contract; rewording a fixture to dodge a token match would game the number. ack duplication 0cbdfdcac9c7a134 29 wave-3 close: re-grouping artifact — hasAnyExt/isValidSeverity/isMcpProtocolVersionSupported/isDecisionShaped/hasNode did not change; their clone group lost the isShellBuiltinName member and the remaining pre-existing pairs re-emerge as new pairs vs HEAD @@ -537,6 +538,7 @@ ack duplication 68b1751fdaff3631 19 lane fix/cpp-macro-member-reparse-2026-09-11 ack duplication 6a7680fbd8a5083d 20 by=src/* slice honesty round (lane H): token-shape clones between one-line predicate/name-table helpers in slice.h and unrelated domains (naminglens predicatePrefixed, pattern servedNames/resolvedNames, quality headSnapExclHex, model localsCountedLang) — no shared identifiers, different scopes; reviewed by the lane and the orchestrator and declined to merge across domains (the clone-idiom class) ack duplication 6c20f55a7fbeda1b 41 lane E close (terminality round A): suite fixture data (bench/agentloop/editsuite/fixture/geometry.cpp) shares distance's body with test/fixture/geometry.cpp; the fixture is byte-frozen measured data (baseline + post-fix tables) ack duplication 6df9a7992177fa30 30 by=src/extentsuspect.h lane fix/cpp-macro-member-reparse-2026-09-11, extent detector (src/extentsuspect.h): extent::inSet is the std::find( begin, end, v ) != end membership test over a constexpr std::array of enums (the SymKind and Lang rule tables, 9 call sites). Its partners spell the same one-liner over a span of string_view (ingest_relations.h namesNode), an initializer_list of string_view (naminglens::ncAnyOf) and a Scope pattern vector (quality::scopeUsesDiffToken), and notes::sortNotes is a bare std::stable_sort. An idiom collision at 30-36 tokens across four subsystems: no member can take another member argument type, and the comment on namesNode records this spelling as the house one. +ack duplication 73b08512b41c61f2 101 E1 follow-up (CodeRabbit on #214, threads 1-3): the pr-context legend must follow the RENDERED rows, so the chosen body is now rendered before the head is written. prRenderLevel NAMES the memstream dance pickPrTrimLevel already ran inline and the unbudgeted root now needs as well - two call sites, one copy - and the clone it matches is packtask.h's packTaskRenderToString, the same seven-line idiom this tree already spells nine more times in mcpverbs.h; hoisting one shared helper into serialize.h would collapse three DEGRADED_PATH_ALERT messages that deliberately say WHICH buffer failed, so the duplication is the cheaper honesty and is recorded here rather than papered over. prBodyHasTestRow is a two-find predicate (a test row opener or a group row opener); its 36-token match against accessshape::chaseTypeCanPoint is the shape find(x)!=npos || find(y)!=npos, not shared logic. writePrContext's churn=self is this lane's own third edit to that one function inside the window - the E1 landing, the CI budget fix, and this rows-gated legend - one change seen three times, none foreign. ack duplication 7589afcf46d892d1 38 by=src/* rung 3 flow-sensitive reaching definitions (docs/EVALS.md 'Flow-sensitive slice in the small', 2026-09-03, lane/n6-b). edgesOf: the contract change IS the point — the diff now reads the scan's reach table (scan, rowOfOcc, cap) so rows/flow/diff share one edge oracle. sliceEmitBody +4 cx: the rd= emission per use row (its formatting already factored into sliceAppendReachAttr). The three duplication rows are the idiomatic tree-sitter named-child loop (seq/hasStructureBelow vs ingest helpers) and a two-way family ternary — no shared logic to lift. short-horizon-churn = self-churn of the two files this lane owns. sliceLegendText +11 lines = the registered per-construct disclosures the band requires on the first screen. ack duplication 767f5200ffbdb095 54 E1 answer grader + questions task source + claude-runner control isolation (F3). churn=self on run_one/main/build_prompt/_claude_metrics/_harness_metrics/_execute/prepare_codex_environment/prepare_opencode_environment is this one change's own edit window on bench/agentloop/run_agentloop.py, which the opencode round touched days ago: the three preparers now share ephemeral_run_home()+link_credential() and the three command builders share build_harness_command(), so every one of those symbols is edited by the same commit that introduces the claude preparer. run_one's complexity regression was FIXED rather than acked (25->under the bar, by extracting build_harness_command/question_timeout/prepare_environment) and the two dead-code rows were fixed by restoring explicit dispatch — a dict of callables had hidden the codex/opencode preparers from the resolver. The remaining duplication row (prepare_claude_environment | prepare_codex_environment, 101 tokens, down from 282) is DELIBERATE and not further factored: the residue is 'ephemeral home named by one env var + credentials symlinked + set the var', and collapsing it would need a nine-parameter helper that opencode still could not use (it derives every path from xdg-basedir at module load, so five dirs plus HOME must move). Each preparer is asserted independently by its own canary gate — agentloopclaudecheck/agentloopopencodecheck/agentloopcodexcheck — and keeping the three recipes separately readable is the point of those gates. grade_answers.py's new-symbol rows are the six protocol grader types plus the closed accept-rule clause grammar; apply_clauses is a flat per-clause-kind dispatch with no nesting, and transcript_answer_text's error-masking row is the deliberate 'schema drift degrades to nulls, never raises' contract the retained transcript makes safe. Gates: agentloopgradercheck (new, born red on origin/main at exit 2) and agentloopclaudecheck (new, born red at exit 1) both green; codex/opencode canaries and analyze.py --self-test unchanged and green. ack duplication 77278c286d945470 22 timsort vendoring: every row is the vendored src/infra/timsort.hpp (upstream v3.0.1 + the recorded workspace patch) plus the facade forwarder it needs. The complexity/verbosity/params/duplication rows are UPSTREAM's shape — mergeLo/mergeHi, gallopLeft/gallopRight and the timsort/timmerge overload pairs are twins in the release itself — and restructuring them would destroy the property that makes the file auditable: that it can be re-derived byte-for-byte from a public tag plus one described patch. The one preexisting-worse row, infra::sort::stable vs infra::sort::unstable at 22 tokens, is what a facade IS: each entry is a one-line forward to a DIFFERENT algorithm, and collapsing them into one algorithm-parameterised template would hide the explicit named choice the layer exists to make (G5). Nothing is routed to timsort; test/timsortcheck.sh is what keeps the file honest. @@ -645,6 +647,7 @@ ack new-clone-of-reused-helper 675204169bc3d5d2 4 wave-3 close, H7 hosts (substr ack new-clone-of-reused-helper 689ab18d529fb67c 4 P3.2 plan-lint model correction (validated against the field-evidence source repo's own flagship plan): computePlanLint/writePlanLint grow in complexity/verbosity because status now resolves from the §Status ledger as well as the card body (the real house dialect), extracted into resolveLedgerStatus to keep the growth to the genuinely new logic; missingWhy clones gitmine::coPairAttr and testmap::affectedSeededBy — the same trivial-classifier-idiom false positive already acked for glyphName/shapeName (a 3-branch string-constant selector has almost no room to look structurally unlike another one) ack new-clone-of-reused-helper 6a7680fbd8a5083d 4 by=src/* slice honesty round (lane H): token-shape clones between one-line predicate/name-table helpers in slice.h and unrelated domains (naminglens predicatePrefixed, pattern servedNames/resolvedNames, quality headSnapExclHex, model localsCountedLang) — no shared identifiers, different scopes; reviewed by the lane and the orchestrator and declined to merge across domains (the clone-idiom class) ack new-clone-of-reused-helper 6df9a7992177fa30 4 by=src/extentsuspect.h lane fix/cpp-macro-member-reparse-2026-09-11, extent detector (src/extentsuspect.h): extent::inSet is the std::find( begin, end, v ) != end membership test over a constexpr std::array of enums (the SymKind and Lang rule tables, 9 call sites). Its partners spell the same one-liner over a span of string_view (ingest_relations.h namesNode), an initializer_list of string_view (naminglens::ncAnyOf) and a Scope pattern vector (quality::scopeUsesDiffToken), and notes::sortNotes is a bare std::stable_sort. An idiom collision at 30-36 tokens across four subsystems: no member can take another member argument type, and the comment on namesNode records this spelling as the house one. +ack new-clone-of-reused-helper 73b08512b41c61f2 4 E1 follow-up (CodeRabbit on #214, threads 1-3): the pr-context legend must follow the RENDERED rows, so the chosen body is now rendered before the head is written. prRenderLevel NAMES the memstream dance pickPrTrimLevel already ran inline and the unbudgeted root now needs as well - two call sites, one copy - and the clone it matches is packtask.h's packTaskRenderToString, the same seven-line idiom this tree already spells nine more times in mcpverbs.h; hoisting one shared helper into serialize.h would collapse three DEGRADED_PATH_ALERT messages that deliberately say WHICH buffer failed, so the duplication is the cheaper honesty and is recorded here rather than papered over. prBodyHasTestRow is a two-find predicate (a test row opener or a group row opener); its 36-token match against accessshape::chaseTypeCanPoint is the shape find(x)!=npos || find(y)!=npos, not shared logic. writePrContext's churn=self is this lane's own third edit to that one function inside the window - the E1 landing, the CI budget fix, and this rows-gated legend - one change seen three times, none foreign. ack new-clone-of-reused-helper 7981b72e3b2a8a8b 3 mcpIsGitOnlyVerb (finding #8, 2026-08-15 harvest) is a 6-line linear-scan-and-compare over the SAME small kMcpGitOnlyVerbs table mcpGitOnlyNotBatchServed already loops beside it (mcp.h:127-144); query.h::isKnownLayerWord is the same trivial 'is w one of these known short strings' idiom over an UNRELATED domain (the graph-query layer vocabulary). Per this repo's own precedent (V6/isKnownVerb, ROUTING_LEDGER.md's V6 lane), forcing a shared cross-file generic membership helper across two unrelated small closed vocabularies is the wrong abstraction for a token-shape match, not a real duplication to collapse ack new-clone-of-reused-helper 845088dc7642af45 4 V3 harvest 2026-08-15: two MCP gate harnesses. Every MCP gate in this suite is deliberately STANDALONE — the house rule in their own headers is 'does NOT edit regression.sh or any other existing test file', so a gate carries its own 3-4 line JSON-RPC transport wrapper (mcp_call in mcphandlecheck, mcpCall in mcpeditpresencecheck, the curl wrapper in mcpremotecheck). test/mcptoolprunecheck.sh needs BOTH transports (HTTP for the pinned-root arms A-E/G, stdio for arm F, and the pair is the point: pinning is exactly what makes the omission provable), so its http_call and stdio_call land as the 9th and 10th members of two families that already exist. Extracting a shared test/lib harness would couple every MCP gate to one file and is a suite-wide refactor, not this lane's; sharing one of the two existing spellings instead would make this gate fail whenever an unrelated gate edits its own helper. No production code involved ack new-clone-of-reused-helper 894d5d3469e376e9 3 M1: four MCP gates now share a call() helper that injects legend:"full" for the seventeen declaring verbs. The clone is DELIBERATE and this repo's gates say why in their own comments: two gates deriving the same fact through one shared helper fail together on a shared mistake, so gate helpers are copied, not factored. Each copy carries its own re-pin note explaining the posture that gate needs. @@ -873,7 +876,7 @@ ack short-horizon-churn 4f90e22ea95dea67 22 cid=fe301df9e2f3c6db by=src/* rung 3 ack short-horizon-churn 4fb850ad3a3bb9c3 5 cid=efacb2a5f9e0435a OPTREMARKS F3 (docs/OPTREMARKS.md §8b): the ~430 per-AST-node std::strcmp( t, "literal" ) sites in the five ingest walk sections become rw::kindIs (src/infra/nodekind.h) — an inline compare, because strcmp is an external symbol LTO cannot inline and on macOS costs two dyld stub hops before it starts. Measured: 10.6% of busy CPU in strcmp leaves on a cold llvm run, 6-12% on four other corpora; 0.23% after. Output byte-identical across 7 corpora x 5 verbs, argvdiffcheck 640/642 vectors identical (the 2 that differ are the +dirty build stamp in --version). WHAT THESE ROWS ARE. (a) 95 short-horizon-churn rows, churn=self: the mechanical rewrite touches essentially every function in ingest_{metrics,binds,sidecap,relations,names}.h, so every one of them shows this lane's own single edit. Not thrash — one commit. (b) 7 duplication rows and 1 new-clone-of-reused-helper. These are REAL new clone groups (--clones, uncapped: 407 groups before, 409 after; the rewrite adds 7 and removes 5) and they are IDIOM COLLISIONS, not copies. kindIs( t, "x" ) is shorter than std::strcmp( t, "x" ) == 0, so short predicate bodies that were previously above the clone threshold now match each other's normalized token stream. Six of the seven pair a node-kind || -chain with an unrelated || -chain over a DISJOINT literal set in a different subsystem — cc_isParamList (tree-sitter parameter-list kinds) against predicatePrefixed (English name prefixes is/has/can), against sliceIsJsPatternKind (JS destructuring kinds), rubyCallIsAssignmentTarget against slice.h's JS binding probes. The tool's own rule is that two ladders over the SAME enum are a copy; these share no non-keyword identifier and no domain, and merging any pair would need a helper parameterised on an unrelated literal table — a wrong abstraction to satisfy a lint. The seventh, kindIs | lexTokenEqualsLowered, is the same shape at 56 tokens with materially different contracts: lexTokenEqualsLowered takes an explicit length and case-folds one side, kindIs takes its length from the literal's type and compares the terminating NUL as an ordinary byte — and that NUL comparison is precisely the safety property kindIs depends on (test/nodekindcheck.sh arm B proves the absence of a read past it with an mprotect(PROT_NONE) guard page). Folding them together would erase the one property being gated. Gate: test/nodekindcheck.sh, 4 arms, 1,348,096 enumerated (candidate, literal) pairs against std::strcmp plus two mutation controls that each turn an arm red. ack short-horizon-churn 4fc8bad5056ad3f3 7 recall.h is the intended edit target for lane L4 (structure-aware doc truncation); truncateRecallBody and buildSectionGranularBody are the two functions the task names, and the file is a legitimately hot area (many prior feature rounds) — churn is inherent to the assignment, not a code-shape defect to fix ack short-horizon-churn 5274ab1a5e193cb4 7 cid=5cfc96d7c5424d21 OPTREMARKS F3 (docs/OPTREMARKS.md §8b): the ~430 per-AST-node std::strcmp( t, "literal" ) sites in the five ingest walk sections become rw::kindIs (src/infra/nodekind.h) — an inline compare, because strcmp is an external symbol LTO cannot inline and on macOS costs two dyld stub hops before it starts. Measured: 10.6% of busy CPU in strcmp leaves on a cold llvm run, 6-12% on four other corpora; 0.23% after. Output byte-identical across 7 corpora x 5 verbs, argvdiffcheck 640/642 vectors identical (the 2 that differ are the +dirty build stamp in --version). WHAT THESE ROWS ARE. (a) 95 short-horizon-churn rows, churn=self: the mechanical rewrite touches essentially every function in ingest_{metrics,binds,sidecap,relations,names}.h, so every one of them shows this lane's own single edit. Not thrash — one commit. (b) 7 duplication rows and 1 new-clone-of-reused-helper. These are REAL new clone groups (--clones, uncapped: 407 groups before, 409 after; the rewrite adds 7 and removes 5) and they are IDIOM COLLISIONS, not copies. kindIs( t, "x" ) is shorter than std::strcmp( t, "x" ) == 0, so short predicate bodies that were previously above the clone threshold now match each other's normalized token stream. Six of the seven pair a node-kind || -chain with an unrelated || -chain over a DISJOINT literal set in a different subsystem — cc_isParamList (tree-sitter parameter-list kinds) against predicatePrefixed (English name prefixes is/has/can), against sliceIsJsPatternKind (JS destructuring kinds), rubyCallIsAssignmentTarget against slice.h's JS binding probes. The tool's own rule is that two ladders over the SAME enum are a copy; these share no non-keyword identifier and no domain, and merging any pair would need a helper parameterised on an unrelated literal table — a wrong abstraction to satisfy a lint. The seventh, kindIs | lexTokenEqualsLowered, is the same shape at 56 tokens with materially different contracts: lexTokenEqualsLowered takes an explicit length and case-folds one side, kindIs takes its length from the literal's type and compares the terminating NUL as an ordinary byte — and that NUL comparison is precisely the safety property kindIs depends on (test/nodekindcheck.sh arm B proves the absence of a read past it with an mprotect(PROT_NONE) guard page). Folding them together would erase the one property being gated. Gate: test/nodekindcheck.sh, 4 arms, 1,348,096 enumerated (candidate, literal) pairs against std::strcmp plus two mutation controls that each turn an arm red. -ack short-horizon-churn 52c2e3e32753dd28 18 cid=a8838d1470d8468a E1 follow-up (#214 CI): prLegendText/writePrContext were rewritten by this PR's own first commit (the clause splice) and again here to gate it on the corpus holding a test file; one change, two commits | prior: E1/A4-2: every tests_to_run emitter re-routed through testmap.h's one seam (testRowsRendered) so runner-less rows group by evidence; the 12 sites were rewritten in-window by M21(b) and F1, and touching that family once more is the item itself +ack short-horizon-churn 52c2e3e32753dd28 19 cid=04f69cd0461e2311 E1 follow-up (CodeRabbit on #214, threads 1-3): the pr-context legend must follow the RENDERED rows, so the chosen body is now rendered before the head is written. prRenderLevel NAMES the memstream dance pickPrTrimLevel already ran inline and the unbudgeted root now needs as well - two call sites, one copy - and the clone it matches is packtask.h's packTaskRenderToString, the same seven-line idiom this tree already spells nine more times in mcpverbs.h; hoisting one shared helper into serialize.h would collapse three DEGRADED_PATH_ALERT messages that deliberately say WHICH buffer failed, so the duplication is the cheaper honesty and is recorded here rather than papered over. prBodyHasTestRow is a two-find predicate (a test row opener or a group row opener); its 36-token match against accessshape::chaseTypeCanPoint is the shape find(x)!=npos || find(y)!=npos, not shared logic. writePrContext's churn=self is this lane's own third edit to that one function inside the window - the E1 landing, the CI budget fix, and this rows-gated legend - one change seen three times, none foreign. | prior: E1 follow-up (#214 CI): prLegendText/writePrContext were rewritten by this PR's own first commit (the clause splice) and again here to gate it on the corpus holding a test file; one change, two commits ack short-horizon-churn 52de3cd8a58763d1 7 cid=462928d2141114f5 OPTREMARKS F3 (docs/OPTREMARKS.md §8b): the ~430 per-AST-node std::strcmp( t, "literal" ) sites in the five ingest walk sections become rw::kindIs (src/infra/nodekind.h) — an inline compare, because strcmp is an external symbol LTO cannot inline and on macOS costs two dyld stub hops before it starts. Measured: 10.6% of busy CPU in strcmp leaves on a cold llvm run, 6-12% on four other corpora; 0.23% after. Output byte-identical across 7 corpora x 5 verbs, argvdiffcheck 640/642 vectors identical (the 2 that differ are the +dirty build stamp in --version). WHAT THESE ROWS ARE. (a) 95 short-horizon-churn rows, churn=self: the mechanical rewrite touches essentially every function in ingest_{metrics,binds,sidecap,relations,names}.h, so every one of them shows this lane's own single edit. Not thrash — one commit. (b) 7 duplication rows and 1 new-clone-of-reused-helper. These are REAL new clone groups (--clones, uncapped: 407 groups before, 409 after; the rewrite adds 7 and removes 5) and they are IDIOM COLLISIONS, not copies. kindIs( t, "x" ) is shorter than std::strcmp( t, "x" ) == 0, so short predicate bodies that were previously above the clone threshold now match each other's normalized token stream. Six of the seven pair a node-kind || -chain with an unrelated || -chain over a DISJOINT literal set in a different subsystem — cc_isParamList (tree-sitter parameter-list kinds) against predicatePrefixed (English name prefixes is/has/can), against sliceIsJsPatternKind (JS destructuring kinds), rubyCallIsAssignmentTarget against slice.h's JS binding probes. The tool's own rule is that two ladders over the SAME enum are a copy; these share no non-keyword identifier and no domain, and merging any pair would need a helper parameterised on an unrelated literal table — a wrong abstraction to satisfy a lint. The seventh, kindIs | lexTokenEqualsLowered, is the same shape at 56 tokens with materially different contracts: lexTokenEqualsLowered takes an explicit length and case-folds one side, kindIs takes its length from the literal's type and compares the terminating NUL as an ordinary byte — and that NUL comparison is precisely the safety property kindIs depends on (test/nodekindcheck.sh arm B proves the absence of a read past it with an mprotect(PROT_NONE) guard page). Folding them together would erase the one property being gated. Gate: test/nodekindcheck.sh, 4 arms, 1,348,096 enumerated (candidate, literal) pairs against std::strcmp plus two mutation controls that each turn an arm red. ack short-horizon-churn 5383f63ad718935c 8 cid=c7781d76aeed7d13 R8 printf-family pilot: std::printf/fprintf/snprintf -> std::print/std::format across every call site in verbs_lint.h, a file independently hot this round; converting a printf call necessarily rewrites the same recently-touched line, so self-thrash is inherent to the conversion, not new debt. Byte parity verified via test/printffmtparitycheck.sh (RED on an injected 1-byte mutation, GREEN on this pilot). ack short-horizon-churn 5391ffd9aa5765bf 44 W1-S2 dead-code top-level fix: isDeadCandidate param add + the edits to computeDelta/computeSnapshot are the fix itself diff --git a/CHANGELOG.md b/CHANGELOG.md index ccee4dc9e..f48fd3bc5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,11 +33,14 @@ middle of one of them, in all three dialects (red on the previous binary). All t `--flags --flip`, `--pack-task` XML and JSON, the MCP `situational_awareness` twin and the edit receipt — render through one seam in `testmap.h`, and the M21(b) rule keeps its meaning: a `` or `` row carries `run=` or `run_unknown="1"`, never neither. Measured on RocksDB (`wc -c`, same cache, -same commit): `--affected=db/write_batch.cc` 10,668 → 6,839 B, `--test-gate=db/write_batch.cc` 13,242 → -9,594 B (its JSON 11,055 → 7,121 B), `--situ=db/write_batch.cc` 11,769 → 7,313 B; 7 `` rows replace -124 single rows and the residual spent on the disclosure is 144 B (XML) and 207 B (text) per list. -`--pack-task`'s byte-budgeted tests section caps a group at its own budget and counts `shown=`/`total=` -in files, so the same bundle now names 54 of 109 tests where it named 28. On this tree every harness +same commit): `--affected=db/write_batch.cc` 10,668 → 6,878 B, `--test-gate=db/write_batch.cc` 13,242 → +9,633 B (its JSON 11,055 → 7,163 B), `--situ=db/write_batch.cc` 11,769 → 7,357 B; 8 `` rows replace +124 single rows (a group covers a contiguous run only, so the one runner row inside the hops=2 tier splits +it in two — order is preserved by construction, `test/testrowruncheck.sh` arm 12 reads the paths back in +emitted order) and the residual spent on the disclosure is 160 B (XML) and 230 B (text) per list. +`--pack-task`'s byte-budgeted tests section caps a group at its own budget — applied before every join, so +two paths that each fit alone are never joined into one row the section then rejects (arm 13) — and counts +`shown=`/`total=` in files, so the same bundle now names 54 of 109 tests where it named 28. On this tree every harness has a runner, so nothing groups and the only change is the legend that now defines ``: the `--test-gate` legend pin moves 2,720 → 2,900 B (measured 2,843) and the `ripwire.pack-task/v1` compact pin 820 → 880 B (measured 865), both because the compact dialect and every rows-bearing full legend now diff --git a/src/prcontext.h b/src/prcontext.h index 48bfffca3..29916cd2e 100644 --- a/src/prcontext.h +++ b/src/prcontext.h @@ -533,6 +533,28 @@ struct PrTrimRender // The floor-exceeded suffix feeds back into the price (it lengthens truncated=, hence the root tag), so it // is applied and RE-PRICED: monotone, since adding bytes to a document already over budget cannot bring it // under, so one re-price is the fixpoint and the printed number is the document's real price either way. +// One level's body, rendered to a string — the ladder's probe and (E1) the unbudgeted root's single render, +// which needs the body before the head can be written. +template< typename EmitFn > +inline std::string prRenderLevel( const EmitFn& emitFiles, const PrTrim& trim ) +{ + char* buf = nullptr; + std::size_t sz = 0; + std::string rendered; + if( std::FILE* ms = open_memstream( &buf, &sz ) ) + { + emitFiles( ms, trim ); + std::fflush( ms ); + std::fclose( ms ); + if( buf ) + { + rendered.assign( buf, sz ); + } + } + std::free( buf ); + return rendered; +} + template< typename EmitFn, typename PriceFn > inline PrTrimRender pickPrTrimLevel( const EmitFn& emitFiles, std::size_t budgetTokens, const PriceFn& price, const std::string& windowAttrs ) @@ -541,24 +563,10 @@ inline PrTrimRender pickPrTrimLevel( const EmitFn& emitFiles, std::size_t budget PrTrimRender out; for( std::size_t li = 0; li < nLevels; ++li ) { - char* buf = nullptr; - std::size_t sz = 0; - std::string rendered; - if( std::FILE* ms = open_memstream( &buf, &sz ) ) - { - emitFiles( ms, kPrTrims[li] ); - std::fflush( ms ); - std::fclose( ms ); - if( buf ) - { - rendered.assign( buf, sz ); - } - } - std::free( buf ); out.level = li; out.truncated = li > 0 ? std::string( kPrTrims[li].dropped ) : std::string( "none" ); - out.body = std::move( rendered ); - out.estTokens = price( out.body.size(), li, out.truncated, windowAttrs ); + out.body = prRenderLevel( emitFiles, kPrTrims[li] ); + out.estTokens = price( out.body, li, out.truncated, windowAttrs ); if( out.estTokens <= budgetTokens ) { break; @@ -566,7 +574,7 @@ inline PrTrimRender pickPrTrimLevel( const EmitFn& emitFiles, std::size_t budget if( li + 1 == nLevels ) { out.truncated += ";budget-floor-exceeded"; // even the floor render is over budget - out.estTokens = price( out.body.size(), li, out.truncated, windowAttrs ); + out.estTokens = price( out.body, li, out.truncated, windowAttrs ); } } return out; @@ -597,12 +605,15 @@ inline std::string prBudgetTail( std::size_t changedFiles, std::uint32_t skipped // this comment IS ~91% of that document. Same bytes in the same order; they are simply measured before // they are written, the way every other priced root measures itself (serialize.h §H7). File scope, beside // kPrEmptyDiffBody, so the emitter reads as the decisions it makes rather than as the prose it ships. -// E1 (2026-09-12): `corpusHasTests` gates testmap.h's run=/run_unknown=/ clause. This legend is written -// and PRICED before the files render (the budget ladder fits est_tokens= to the envelope), so the clause -// cannot ride the rows the way --affected's does; it rides the one pre-render fact that decides whether a -// / row is possible at all — the corpus holds a test file. Measured on test/defaultceilingcheck.sh's -// 120-file, no-test fixture: unconditional, 7,989 -> 8,025 tokens, over the 8,000 default budget. -inline std::string prLegendText( const std::string& baseEscaped, bool hasUnindexed, bool corpusHasTests ) +// E1 (2026-09-12): `withRunClause` splices testmap.h's run=/run_unknown=/ clause. The clause is a rule about +// rows, so it rides only a document whose chosen body renders a / row (prBodyHasTestRow): the writer +// builds both forms, the pricer charges runClauseBytes per candidate level from that level's own body, and the +// form matching the chosen body is written — after the choice, since the legend precedes the root in the +// stream but not in the decision. A corpus-level predicate ("the corpus holds a test file") over-approximated +// (CodeRabbit on #214): a test elsewhere in the corpus, or a testCap=0 level, bought the clause for a document +// with no row. Measured on test/defaultceilingcheck.sh's 120-file, no-test fixture: unconditional, 7,989 -> +// 8,025 tokens, over the 8,000 default budget; gated, 7,989. +inline std::string prLegendText( const std::string& baseEscaped, bool hasUnindexed, bool withRunClause ) { return std::string( ""; } @@ -665,13 +676,13 @@ inline std::string prEmptyRootTail( std::uint32_t skippedModeOnly, std::size_t b template< typename PriceFn > inline std::pair prEmptyRootPrice( const PriceFn& price, std::size_t budgetTokens ) { - const std::size_t plain = price( kPrEmptyDiffBody.size(), 0, std::string( "none" ), std::string() ); + const std::size_t plain = price( kPrEmptyDiffBody, 0, std::string( "none" ), std::string() ); if( budgetTokens == 0 || plain <= budgetTokens ) { return { plain, std::string( "none" ) }; } const std::string labelled( "budget-floor-exceeded" ); - return { price( kPrEmptyDiffBody.size(), 0, labelled, std::string() ), labelled }; + return { price( kPrEmptyDiffBody, 0, labelled, std::string() ), labelled }; } // Open the root: the attributes EVERY form shares, this site's own tail, and the one remark row @@ -709,7 +720,8 @@ struct PrPriceCtx const PrContextMask* anchor = nullptr; const std::string* baseEscaped = nullptr; const std::string* atAttrs = nullptr; // gitstamp::atAttr, appended past every tail attribute - std::size_t envelopeBytes = 0; // legend + anchoring note + closing tag (never the root tag) + std::size_t envelopeBytes = 0; // legend (WITHOUT the run clause) + anchoring note + closing tag (never the root tag) + std::size_t runClauseBytes = 0; // E1: testmap.h's run=/run_unknown=/ clause, charged only for a body that renders a test row std::size_t changedFiles = 0; std::uint32_t skippedModeOnly = 0; std::size_t budgetTokens = 0; @@ -729,10 +741,20 @@ struct PrPriceCtx // // The attribute is part of the document it prices, so its own digits are converged in ≤4 passes exactly as // pricedRootAttr converges them. -inline std::size_t prPriceDocument( const PrPriceCtx& c, std::size_t bodyBytes, std::size_t level, +// E1 (CodeRabbit on #214): the ONE predicate that decides whether a rendered body carries the run clause's +// subject — a / row — read by the pricer for every candidate level and by the writer for the chosen +// one, so the priced legend and the delivered legend cannot disagree. Rows are the only place these two +// openers occur in this document. +inline bool prBodyHasTestRow( std::string_view body ) noexcept +{ + return body.find( " 0, corpusHasTests ); - std::fwrite( legendText.data(), 1, legendText.size(), out ); - - const std::string anchorNoteText = prAnchorNoteText( anchorAttr ); - std::fwrite( anchorNoteText.data(), 1, anchorNoteText.size(), out ); + // E1: both legend forms are built now and ONE is written later, once the body is known (prBodyHasTestRow); + // the envelope is priced without the clause and the pricer adds runClauseBytes for a rows-bearing body. + const std::string legendText = prLegendText( escBase, g.unindexedFiles > 0, false ); + const std::string legendWithClause = prLegendText( escBase, g.unindexedFiles > 0, true ); + const std::string anchorNoteText = prAnchorNoteText( anchorAttr ); + const auto writeHead = [ & ]( std::string_view body ) + { + const std::string& legend = prBodyHasTestRow( body ) ? legendWithClause : legendText; + std::fwrite( legend.data(), 1, legend.size(), out ); + std::fwrite( anchorNoteText.data(), 1, anchorNoteText.size(), out ); + }; // R2/N4: the fixed, non-body envelope of every root this emitter writes — the legend, the anchoring // note, and the closing tag. The root's OWN start tag varies with the tail it carries, so it is @@ -921,10 +948,11 @@ inline int writePrContext( std::FILE* out, const std::string& root, const Ingest // R2/N4: the price context (see prPriceDocument) — the envelope and every root attribute that does not // vary per candidate trim level, gathered once. const PrPriceCtx priceCtx{ .g = &g, .sharedAttrs = &sharedAttrs, .anchor = &anchor, .baseEscaped = &escBase, .atAttrs = &atAttrStr, - .envelopeBytes = envelopeBytes, .changedFiles = changed.size(), .skippedModeOnly = skippedModeOnly, + .envelopeBytes = envelopeBytes, .runClauseBytes = legendWithClause.size() - legendText.size(), + .changedFiles = changed.size(), .skippedModeOnly = skippedModeOnly, .budgetTokens = budgetTokens, .isDefaultBudget = budget.isDefault }; - const auto priceOf = [ & ]( std::size_t bodyBytes, std::size_t level, const std::string& truncatedRaw, const std::string& windowAttrs ) - { return prPriceDocument( priceCtx, bodyBytes, level, ex( truncatedRaw ), windowAttrs ); }; + const auto priceOf = [ & ]( std::string_view body, std::size_t level, const std::string& truncatedRaw, const std::string& windowAttrs ) + { return prPriceDocument( priceCtx, body, level, ex( truncatedRaw ), windowAttrs ); }; if( changed.empty() ) { @@ -932,6 +960,7 @@ inline int writePrContext( std::FILE* out, const std::string& root, const Ingest const std::string rootOpen = prRootOpenText( g, sharedAttrs, prEmptyRootTail( skippedModeOnly, budgetTokens, budget.isDefault, emptyEst, ex( emptyTruncated ) ) + atAttrStr, anchor, escBase ); + writeHead( kPrEmptyDiffBody ); std::fwrite( rootOpen.data(), 1, rootOpen.size(), out ); std::fwrite( kPrEmptyDiffBody.data(), 1, kPrEmptyDiffBody.size(), out ); std::fwrite( kPrCloseTag.data(), 1, kPrCloseTag.size(), out ); @@ -1184,8 +1213,10 @@ inline int writePrContext( std::FILE* out, const std::string& root, const Ingest if( budgetTokens == 0 ) { const std::string rootOpen = prRootOpenText( g, sharedAttrs, " files=\"" + std::to_string( changed.size() ) + "\" skipped_mode_only=\"" + std::to_string( skippedModeOnly ) + "\"" + atAttrStr, anchor, escBase ); + const std::string body = prRenderLevel( emitFiles, kPrTrims[0] ); // E1: rendered first, so the head can follow the body + writeHead( body ); std::fwrite( rootOpen.data(), 1, rootOpen.size(), out ); - emitFiles( out, kPrTrims[0] ); + std::fwrite( body.data(), 1, body.size(), out ); std::fwrite( kPrCloseTag.data(), 1, kPrCloseTag.size(), out ); return 0; } @@ -1227,6 +1258,7 @@ inline int writePrContext( std::FILE* out, const std::string& root, const Ingest prBudgetTail( changed.size(), skippedModeOnly, budgetTokens, chosen, ex( chosen.truncated ) ) + ( budget.isDefault ? " budget_default=\"1\"" : "" ) + windowAttrs + atAttrStr, anchor, escBase ); + writeHead( chosen.body ); // E1: the legend form the chosen body was priced with std::fwrite( rootOpen.data(), 1, rootOpen.size(), out ); std::fwrite( chosen.body.data(), 1, chosen.body.size(), out ); std::fwrite( kPrCloseTag.data(), 1, kPrCloseTag.size(), out ); diff --git a/src/testmap.h b/src/testmap.h index d5e413125..6170bf591 100644 --- a/src/testmap.h +++ b/src/testmap.h @@ -743,16 +743,25 @@ inline std::vector> partitionTestRows( const TestRunn groups.push_back( { i } ); continue; } - // every later runner-less row with the same attrs joins; the scan is O(rows²) on lists of a few hundred rows + // A group covers a CONTIGUOUS run only: the scan stops at the first row that is not a runner-less row + // with the same attrs. The rows arrive in evidence order, so equal-attribute runner-less rows are + // already adjacent and the only thing that can interrupt a run is a same-attribute row WITH a runner; + // hoisting the rows after it into a group in front of it would move them ahead of it (CodeRabbit on + // #214: A, B(run), A became G(A,A), B). Stopping instead costs one more per interruption and makes + // order-preservation true by construction — test/testrowruncheck.sh arm 12 reads the paths back in + // emitted order and asserts they are the single rows' order. Linear: every row is visited once. std::vector members{ i }; std::size_t bytes = rows[i].attrs.size() + 48 + rows[i].path.size(); for( std::uint32_t j = i + 1; j < rows.size(); ++j ) { - if( taken[j] || rows[j].attrs != rows[i].attrs || !idx.commandFor( rows[j].fileId ).empty() ) + if( rows[j].attrs != rows[i].attrs || !idx.commandFor( rows[j].fileId ).empty() ) { - continue; + break; } - if( maxGroupBytes != 0 && members.size() >= 2 && bytes + rows[j].path.size() + 1 > maxGroupBytes ) + // The cap is applied before EVERY join, the second member included: two paths that each fit as a + // singleton must never be joined into one row the byte-budgeted section then rejects whole + // (arm 13). A chunk closed at one member is rendered as a single row. + if( maxGroupBytes != 0 && bytes + rows[j].path.size() + 1 > maxGroupBytes ) { groups.push_back( std::move( members ) ); // this chunk is full: close it, the next member opens another at the same key members = {}; diff --git a/test/prcontextcheck.sh b/test/prcontextcheck.sh index 8e599e94e..2d80054c8 100755 --- a/test/prcontextcheck.sh +++ b/test/prcontextcheck.sh @@ -192,6 +192,47 @@ else no "run clause: the no-test fixture did not produce a one-file bundle: $( echo "$NTOUT" | head -c 300 )" fi +# ── E1 follow-up (CodeRabbit on #214, third thread): the clause follows the RENDERED rows, not the corpus ── +# "the corpus holds a test file" over-approximated: a test file elsewhere in the corpus, or a trim level whose +# testCap is 0, still bought the clause for a document that renders no test row. The legend is now built after +# the level is chosen and priced per candidate level from that level's own rendered body — the same predicate +# (a / row in the body) decides both. Two fixtures, both RED on 7ab0956a: +# (i) a test file OUTSIDE the selected range: test/t_other.cpp exercises src/b.cpp, and only src/a.cpp is +# in the diff — no changed file reaches a test, so no row renders, so no clause; +# (ii) the existing fixture (test_core.cpp IS reached) under a budget small enough that the ladder lands on a +# level with testCap=0 (L2+): no row renders, so no clause even though the corpus and the diff both have one. +OT="$TMP/othertest"; mkdir -p "$OT/src" "$OT/test"; git -C "$OT" init -q +git -C "$OT" config user.email a@x.com; git -C "$OT" config user.name A +printf 'int ga( int x ) { return x; }\n' >"$OT/src/a.cpp" +printf 'int gb( int x ) { return x * 2; }\n' >"$OT/src/b.cpp" +printf 'int gb( int x );\nint test_gb( void ) { return gb( 1 ); }\n' >"$OT/test/t_other.cpp" +git -C "$OT" add -A; git -C "$OT" commit -qm init +printf 'int ha( int x ) { return ga( x ) + 1; }\n' >>"$OT/src/a.cpp" +OTOUT="$( "$BIN" "$OT" --pr-context --no-cache 2>/dev/null )" +# rows live in the BODY, after the root's start tag — the legend's own `` definition must not read as a row +OTBODY="${OTOUT#* sections in PATH order, so on this repo diff --git a/test/testrowruncheck.sh b/test/testrowruncheck.sh index eb0522132..77afe6cdc 100755 --- a/test/testrowruncheck.sh +++ b/test/testrowruncheck.sh @@ -244,19 +244,23 @@ fi # ── ARM 12 — E1: grouping never changes the MULTISET of paths, in any dialect ───────────────────────── # A fixture with THREE hop groups (tests reaching the changed symbol at depth 1, 2 and 3) and a runner row -# in the MIDDLE of the depth-1 group (t_leaf_b.sh stem-matches t_leaf_b.cpp; path order a < b < c), so the -# arm sees: a runner-less group interrupted by a single run= row, groups at three distinct hops=, and the -# same seven paths in --affected, --test-gate (XML and JSON) and --situ's text. What it proves: every path +# in the MIDDLE of the depth-1 group (t_leaf_b.sh stem-matches t_leaf_b.cpp; path order a < b < c < d), so +# the arm sees: a runner-less run interrupted by a single run= row, groups at three distinct hops=, and the +# same eight paths in --affected, --test-gate (XML and JSON) and --situ's text. What it proves: every path # appears exactly once (verbatim — a reader's grep for a file name must still hit), the run= row stays a -# single row, at least three rows exist with distinct hops=, and the root's tests= count is the number -# of FILES, not rows. Red on the pre-E1 binary (no row at all). +# single row, at least three rows exist with distinct hops=, the root's tests= count is the number of +# FILES, not rows, and — CodeRabbit on #214, the A,B,A shape — the ORDER is preserved: the paths read off +# the rows in emitted order (a group contributing its members in place) are exactly the order the single +# rows had, so a group only ever covers a CONTIGUOUS run and a runner row never has a later sibling hoisted +# in front of it. Red on the pre-E1 binary (no row at all) and, for the order half, on 7ab0956a (which +# grouped a, c, d across b: a,c,d,b). command -v python3 >/dev/null 2>&1 || no "(12) python3 missing — the multiset arm cannot run" W2="$( mktemp -d )"; trap 'rm -rf "$WORK" "$W2"' EXIT mkdir -p "$W2/src" "$W2/test" printf 'int leaf( int x )\n{\n return x + 1;\n}\n' > "$W2/src/leaf.cpp" printf 'int leaf( int x );\nint mid( int x )\n{\n return leaf( x );\n}\n' > "$W2/src/mid.cpp" printf 'int mid( int x );\nint top( int x )\n{\n return mid( x );\n}\n' > "$W2/src/top.cpp" -for n in leaf_a leaf_b leaf_c; do printf 'int leaf( int x );\nint test_%s( void )\n{\n return leaf( 1 );\n}\n' "$n" > "$W2/test/t_$n.cpp"; done +for n in leaf_a leaf_b leaf_c leaf_d; do printf 'int leaf( int x );\nint test_%s( void )\n{\n return leaf( 1 );\n}\n' "$n" > "$W2/test/t_$n.cpp"; done for n in mid_a mid_b; do printf 'int mid( int x );\nint test_%s( void )\n{\n return mid( 1 );\n}\n' "$n" > "$W2/test/t_$n.cpp"; done for n in top_a top_b; do printf 'int top( int x );\nint test_%s( void )\n{\n return top( 1 );\n}\n' "$n" > "$W2/test/t_$n.cpp"; done printf '#!/usr/bin/env bash\necho leaf_b\n' > "$W2/test/t_leaf_b.sh"; chmod +x "$W2/test/t_leaf_b.sh" @@ -270,7 +274,8 @@ S12="$( rw2 --situ=src/leaf.cpp )" python3 - "$A12" "$G12" "$J12" "$S12" <<'PY12' import sys, re, json aff, tg, tgj, situ = sys.argv[1:5] -EXPECT = sorted( "test/t_%s.cpp" % n for n in ( "leaf_a", "leaf_b", "leaf_c", "mid_a", "mid_b", "top_a", "top_b" ) ) +ORDER = [ "test/t_%s.cpp" % n for n in ( "leaf_a", "leaf_b", "leaf_c", "leaf_d", "mid_a", "mid_b", "top_a", "top_b" ) ] # evidence order: hops asc, then path +EXPECT = sorted( ORDER ) fails = [] def xml_paths( doc ): out, groups, singles_run = [], [], [] @@ -292,6 +297,7 @@ g_paths, g_groups, g_run = xml_paths( tg ) if not a_paths: fails.append( "--affected emitted no test row at all (fixture broken)" ) for label, paths in ( ( "--affected", a_paths ), ( "--test-gate", g_paths ) ): if sorted( paths ) != EXPECT: fails.append( "%s multiset %r != %r" % ( label, sorted( paths ), EXPECT ) ) + if paths != ORDER: fails.append( "%s ORDER changed by grouping: %r != %r (a group must cover a contiguous run only)" % ( label, paths, ORDER ) ) for label, groups in ( ( "--affected", a_groups ), ( "--test-gate", g_groups ) ): if len( set( groups ) ) < 3 or None in groups: fails.append( "%s: expected >=3 rows at distinct hops=, got hops=%r" % ( label, groups ) ) for label, run in ( ( "--affected", a_run ), ( "--test-gate", g_run ) ): @@ -332,6 +338,7 @@ for r in rows: j_paths.append( p ) if "run" not in r and r.get( "run_unknown" ) is not True: fails.append( "JSON single row carries neither: %r" % r ) if sorted( j_paths ) != EXPECT: fails.append( "--test-gate --json multiset %r != %r" % ( sorted( j_paths ), EXPECT ) ) +if j_paths != ORDER: fails.append( "--test-gate --json ORDER changed by grouping: %r" % j_paths ) # --situ text: ` path [hops=N] (run: …)` singles and ` [hops=N] (n): a, b, c (run: not derivable)` groups sec = situ.split( "tests to run", 1 )[1].split( "\n [3]", 1 )[0] if "tests to run" in situ else "" s_paths, s_groups = [], 0 @@ -347,6 +354,7 @@ for line in sec.split( "\n" ): if " (run: " not in body: fails.append( "situ line carries no run recipe/disclosure: %r" % line ) s_paths.append( body.split( " ", 1 )[0] ) if sorted( s_paths ) != EXPECT: fails.append( "--situ text multiset %r != %r" % ( sorted( s_paths ), EXPECT ) ) +if s_paths != ORDER: fails.append( "--situ text ORDER changed by grouping: %r" % s_paths ) if s_groups < 3: fails.append( "--situ text: expected >=3 group lines, got %d" % s_groups ) if fails: print( "\n".join( fails ) ); sys.exit( 1 ) @@ -354,8 +362,43 @@ print( "OK %d paths, %d rows on --affected" % ( len( EXPECT ), len( a_groups PY12 r12=$? [ "$r12" -eq 0 ] \ - && ok "(12) E1: grouping keeps the path multiset in every dialect (7 paths, >=3 hop groups, the run= row single, tests= counts files)" \ + && ok "(12) E1: grouping keeps the path multiset AND order in every dialect (8 paths, >=3 hop groups, the run= row single in place, tests= counts files)" \ || no "(12) E1: the grouped rows do not carry the same paths as the single rows did (details above)" +# ── ARM 13 — E1: a byte cap never drops two paths that each fit as a singleton ─────────────────────── +# --pack-task's section is byte-budgeted per ROW, so a group is capped at the section's budget +# (testmap.h partitionTestRows maxGroupBytes). CodeRabbit on #214: the cap was applied only from the THIRD +# member on, so two runner-less paths that each fit alone were joined into one row the section then +# rejected whole — the bundle named NO test where it could have named one. The arm sweeps --token-budget +# upward on a two-test corpus with no runner: the FIRST budget at which a section appears must serve +# ONE file as a single row (shown="1", no ), and shown= must never decrease as the budget grows. +# Red on 7ab0956a: the first section to appear is `` shown="2". +W3="$( mktemp -d )"; trap 'rm -rf "$WORK" "$W2" "$W3"' EXIT +mkdir -p "$W3/src" "$W3/test" +printf 'int compute_value( int x )\n{\n return x + 1;\n}\n' > "$W3/src/core.cpp" +for n in alpha_long_name beta_long_name; do printf 'int compute_value( int x );\nint test_%s( void )\n{\n return compute_value( 1 );\n}\n' "$n" > "$W3/test/t_$n.cpp"; done +( cd "$W3" && git init -q && git config user.email t@t && git config user.name t && git add -A && git commit -qm init >/dev/null 2>&1 ) +first=""; firstrow=""; prev=0; mono=1; seen=0 +for b in $( seq 1000 20 1700 ); do + o="$( cd "$W3" && "$BIN" . --no-cache --pack-task="compute_value" --token-budget=$b 2>/dev/null )" + sh="$( printf '%s' "$o" | grep -oE '' | cut -c1-160 )"; fi + [ "$sh" -eq 2 ] && seen=1 +done +if [ -z "$first" ]; then + no "(13) no --token-budget in 1000..1700 produced a section — the sweep cannot bite" +elif [ "$seen" -ne 1 ]; then + no "(13) the sweep never reached shown=\"2\" — the fixture's two tests are not both served at 1700 tokens" +elif printf '%s' "$firstrow" | grep -q ' section to fit is a GROUP, not one singleton: $firstrow" +elif [ "$mono" -ne 1 ]; then + no "(13) shown= decreased as the budget grew" +else + ok "(13) E1: the byte cap admits one file before two (first at --token-budget=$first is shown=\"1\", a single row; shown= monotone)" +fi + [ "$fail" = 0 ] && echo "ALL PASS" || echo "FAILURES ABOVE" exit "$fail" From 6621370f02371a72c40968cbc93704be6ec4ff93 Mon Sep 17 00:00:00 2001 From: joyful-ii-V-I Date: Sun, 13 Sep 2026 13:14:35 -0400 Subject: [PATCH 04/13] fix(testmap,packtask,prcontext,partition): the tests section cut where the bytes were not, and six legends asked six different questions about rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ten findings from the second review of #214, all reproduced against ff8d77a1 before the fix and all the confirmed ones red in a gate first. THE SECTION CUT WHERE THE BYTES WERE NOT. --pack-task's tests section is byte-budgeted, and E1 had it GROUP first and hand the group rows to the generic list cutter under a per-row byte cap whose estimate was `attrs + 48 + Σ( path + 1 )` computed on UNESCAPED path bytes. A corpus whose test paths hold '&' or '<' renders wider than that admits; packTaskListSection breaks at the FIRST over-budget entry, so the whole tail of the section went with it — run= singles included. Measured on a matched pair of ten-test fixtures differing in exactly one byte per name ('&' against '_') at --token-budget=1440: the control named 5 files, the '&' fixture named NONE. The section now cuts over its own grouped, ESCAPED rendering (packTaskTestsSection): the largest PREFIX whose rendered body fits the budget, found by bisection, which is exact because the rendered size is monotone in the prefix length (extending the prefix appends a row or extends the last group by `,path`, and the two-member that replaces a one-member single is strictly wider). Chosen over the simpler cut-then-group — also safe, since grouping only shrinks — because cutting over the SINGLE rows' bytes then spends fewer of them: 2 files where grouping-first served 5. Over budgets 1440..1860 the new cut names 6..11 files against the old 5..11, and the '&' fixture never empties. RocksDB, --pack-task="change WriteBatch::Put" at the default 6,000-token budget: (11,993 -> 12,490 B), where the pre-E1 bundle named 28. maxGroupBytes, its 48-byte constant, PackTaskSection's keptUnits/totalUnits and packTaskListSection's unitsPerEntry are all GONE with the estimate that needed them — one entry, one test file, on both sides of the cut — and with them the groupCap==0 "never split" degenerate the review flagged as plausible. Gate: testrowruncheck arm 13. SIX LEGENDS ASKED SIX DIFFERENT QUESTIONS ABOUT ROWS. The run-hint clause is a rule ABOUT rows (~180 B) and eight legends splice it. Each asked its own question: "is the rendered string empty", "does the document contain ` CDATA quotes the literal text of the element — any source file that WRITES it does — charged the clause with zero rows (repro: a two-file corpus with no test at all whose one body prints ``, --pack-task="write_report" --partition=2). prcontext.h had the same mistake in its first fix, string-matching ` could evict a real row to pay for it. The seam that renders the rows is the only thing that KNOWS how many there are, so it returns the count with them (testmap.h JoinedTestRows) and all eight ask that one count through runHintClauseIfRows( testFilesRendered ). --pr-context carries it per trim level in PrTrimRender; packTaskBundleText reports its section's kept count to partition.h. Gates: testrowruncheck arms 14 and 15. --test-gate's clause additionally stopped riding an untested-only report, which is the same rule applied where it was already local. A SILENT EMPTY BODY. prRenderLevel returned "" on an open_memstream failure with NO alert, and the unbudgeted --pr-context path had just been routed through it: the document would have shipped legend, root and closing tag around an empty body claiming truncated="none". Every such render now goes through ONE seam (infra/emit.h rw::renderToString, the shape packtask.h already had) that reports the failure; packtask.h's own wrapper and mcpverbs.h's captureXml were folded into it in the same commit, and captureXml now alerts, which its copy never did. Both --pr-context exits fall back to streaming the level straight to `out` — complete, correct bytes, a modelled estimate, and a DEGRADED_PATH_ALERT saying which, which is serialize.h's ChargedSection degrade contract. , IS A PROMISE THE FORMAT CANNOT KEEP. A ',' inside a grouped path was spelled ,, and every XML parser undoes an entity BEFORE a consumer splits p= on the delimiter, so n= would disagree with what the reader counts; the text twin had no escape at all. A path containing ',' is now never grouped — it is served as a single row — which is right in all three dialects at once, and the legend says so instead of describing an escape. THE LEGEND AND THE EMITTER DISAGREED. counts test FILES, while the bundle legend said "shown=rows kept, total=rows that qualified" — observed shown="8" total="8" over 3 rendered rows. Said in the row-gated clause rather than the always-on bundle legend, which is charged against the ceiling it describes: unconditional it put packtaskcheck's 2,000-token arm 5,620 B over a 5,428 B ceiling (measured). The MCP twins got the same fact: situational_awareness and explore return bare JSON with no legend of any kind, so their tool descriptions now carry the row shape (one wording, spliced twice). NINE GATES, NINE READERS. Every gate that asserts over these rows had its own: `grep -oE '"tests_to_run":\[[^]]*\]'` stops at the first ']', which since E1 is the end of the FIRST group's path array — testrowruncheck arms 3, 5 and 9 were asserting over two and a half rows and passing vacuously; receiptpostcheck, rootrelemitcheck ARM 6, impactpartitioncheck and selectorchaincheck read the single rows only; rootrelemitcheck's text reader took $1 of a line that on a group line is "[hops=1]". They all want the same thing — the files named, in emitted order — so they now all ask test/testrowpaths.py, one reader for three dialects and both row shapes, which qualifies a row by run_unknown="1" so --flags' own gate row is never read as a test group. Two more gate defects fell out: arm 7 read ` 3,000 (measured 2,957): two facts a consumer of a row cannot do without, both in the row-gated clause, so a zero-row report still pays nothing. mcpmanifestcheck 42,384 -> 42,800 (measured 42,777): one 207-byte clause in two tool descriptions — NOT the L7 case that file declines, because that one described an ARGUMENT the schema already renders, and this describes a RESPONSE two legend-less JSON answers cannot state anywhere else. printf_parity.manifest: pack_task re-pinned (UPDATE_GOLDEN=1, "moved={pack_task}, 41 unchanged"). Two more table pins the change moved, both re-derived rather than bumped. fixedbufsweep's fixed-buffer census: packtask.h's `open` buffer row 2 -> 3 call sites, with the third site's own arithmetic written out (packTaskTestsSection's tag is the LITERAL 'tests', no %.*s at all, so the format is a fixed 35 B plus two %zu at 20 digits and one %d — worst case 76 B + NUL against 160, the widest margin of the three) and the first site's caller vocabulary corrected, since 'tests' no longer reaches packTaskListSection; EXPECTED calls/mentions/sites 218/322/218 -> 219/323/219. And the asan tree was rebuilt after the last src edit, so g1freshcheck stops reading a binary older than src/mcpverbs.h. Red first, against a build of ff8d77a1: testrowruncheck (13) "control names 5 file(s), the '&' fixture names NONE"; (14) "--handoff(0 rows, clause present) --flags --flip=FEATURE_ZETA(0 rows, clause present)"; (15) "the partitioned bundle charges the run-hint clause for a body that merely QUOTES ' --- .ripwire_quality_acks | 17 +- CHANGELOG.md | 75 ++++-- skills/ripwire-mcp/SKILL.md | 2 +- src/flipimpact.h | 25 +- src/handoff.h | 9 +- src/infra/emit.h | 47 ++++ src/mcp.h | 12 +- src/mcpverbs.h | 20 +- src/packtask.h | 191 ++++++++++----- src/partition.h | 21 +- src/prcontext.h | 129 ++++++---- src/situ.h | 2 +- src/testmap.h | 157 +++++++----- src/verbs_change.h | 12 +- test/affectedcheck.sh | 4 +- test/fixedbufsweep.sh | 4 +- test/impactpartitioncheck.sh | 12 +- test/mcpmanifestcheck.sh | 11 +- test/prcontextcheck.sh | 7 +- test/printf_parity.manifest | 2 +- test/receiptpostcheck.sh | 22 +- test/rootrelemitcheck.sh | 22 +- test/selectorchaincheck.sh | 4 +- test/testgatelegendbudgetcheck.sh | 28 ++- test/testrowpaths.py | 192 +++++++++++++++ test/testrowruncheck.sh | 384 ++++++++++++++++++++---------- 26 files changed, 1017 insertions(+), 394 deletions(-) create mode 100644 test/testrowpaths.py diff --git a/.ripwire_quality_acks b/.ripwire_quality_acks index 37736a64a..06ac7ac6f 100644 --- a/.ripwire_quality_acks +++ b/.ripwire_quality_acks @@ -36,7 +36,7 @@ ack api-surface 2e6026bd58111ad5 5 cid=3d6b010d178e2c8f by=src/* lane/n6-d, the ack api-surface 30dfe3580e3235a8 5 WAVE-2 close (2026-08-19), finding 3 of 3: the 76 remaining gating rows, ONE change. All of them are W2-E's root-relative p= landing (9beaa2c/fccea68/a271e6c/b3fe074 plus the f9108b7 correction), measured for the first time at WAVE granularity. The per-lane acks written during W2-E covered only the correction round's own diff (working-tree-vs-HEAD at that moment), so the original ~30-verb landing was never QD-acked; this ack closes that gap rather than re-accepting anything. Verified by reading the whole 20cdc04..860291c src/main.cpp diff line by line: 282 of 448 added lines match the root-relative predicate directly and every one of the remaining 127 is an existing std::printf rewritten from ing.files[...] to the root-relative rp local, plus four extracted emit helpers (computeDirModules, printJsonSymbolRows, writeOversizeRows, writeDropRows). No unrelated logic rides in. By kind: api-surface 28 = the +1 rootArg/rootPrefix parameter on the emitters that must now be TOLD their root (writeAbiCheck/Ref/Struct, emitColumnar*, packBodies/Deps/Lego/Outline/Signatures[Json], serialize[Json], writeLayout*, packConnect, buildD1Row and the report writers) - defaulted wherever a caller could stay unchanged. complexity 31 and verbosity 16 = the single-root-condition ternary and its guarded root= clause applied per emitter, with no new nesting level and no new control flow beyond that one conditional; the large absolute numbers (runStructureText 207->231, runLint 313->323, runMaintenanceViews 174->190, runCallHierarchy 71->80) are pre-existing dispatcher size the wave adds to, not creates - decomposing them is its own round and is recorded as a wave-2 follow-up. params 1 = writeNonLocalStateReport 4->6, the same contract. The three sibling lanes are individually clean: --quality-delta at f6ec56d..1732fd8 (W2-J), 1732fd8..9a41c74 (W2-K) and 9a41c74..20cdc04 (W2-F) each report gating=0. Full suite green at this head: gates=429 pass=427 skip=2 fail=0, ASan+LSan clean, byte-deterministic, xmllint clean. churn= is unavailable in ref-pair mode by construction (both trees materialized out of the repo), so short-horizon-churn is silent here and that silence is not evidence. ack api-surface 32e780668c108fa5 5 fnbody-require lane: deliberate additive API widening (optional out-params, default nullptr, every existing caller unaffected) to disclose the lazy require/import distinction on --impact's importer tier; residual complexity/duplication is the twin-dialect emitter shape and the dual-mode importersOfFiles scan this feature requires, already extracted where a helper genuinely reduced it (scanImporterEdges, recordLazyPair); short-horizon-churn is this same commit's own edit history ack api-surface 33d55f3b93bc79ea 4 WAVE-2 close (2026-08-19), finding 3 of 3: the 76 remaining gating rows, ONE change. All of them are W2-E's root-relative p= landing (9beaa2c/fccea68/a271e6c/b3fe074 plus the f9108b7 correction), measured for the first time at WAVE granularity. The per-lane acks written during W2-E covered only the correction round's own diff (working-tree-vs-HEAD at that moment), so the original ~30-verb landing was never QD-acked; this ack closes that gap rather than re-accepting anything. Verified by reading the whole 20cdc04..860291c src/main.cpp diff line by line: 282 of 448 added lines match the root-relative predicate directly and every one of the remaining 127 is an existing std::printf rewritten from ing.files[...] to the root-relative rp local, plus four extracted emit helpers (computeDirModules, printJsonSymbolRows, writeOversizeRows, writeDropRows). No unrelated logic rides in. By kind: api-surface 28 = the +1 rootArg/rootPrefix parameter on the emitters that must now be TOLD their root (writeAbiCheck/Ref/Struct, emitColumnar*, packBodies/Deps/Lego/Outline/Signatures[Json], serialize[Json], writeLayout*, packConnect, buildD1Row and the report writers) - defaulted wherever a caller could stay unchanged. complexity 31 and verbosity 16 = the single-root-condition ternary and its guarded root= clause applied per emitter, with no new nesting level and no new control flow beyond that one conditional; the large absolute numbers (runStructureText 207->231, runLint 313->323, runMaintenanceViews 174->190, runCallHierarchy 71->80) are pre-existing dispatcher size the wave adds to, not creates - decomposing them is its own round and is recorded as a wave-2 follow-up. params 1 = writeNonLocalStateReport 4->6, the same contract. The three sibling lanes are individually clean: --quality-delta at f6ec56d..1732fd8 (W2-J), 1732fd8..9a41c74 (W2-K) and 9a41c74..20cdc04 (W2-F) each report gating=0. Full suite green at this head: gates=429 pass=427 skip=2 fail=0, ASan+LSan clean, byte-deterministic, xmllint clean. churn= is unavailable in ref-pair mode by construction (both trees materialized out of the repo), so short-horizon-churn is silent here and that silence is not evidence. -ack api-surface 3478654139c90f0f 4 cid=789faaec74b2ce26 C1 F-06/F-07/F-10 (the listing-paging round): three listing verbs learn to disclose and page their row listings, and every gating row is that one change. api-surface 14 = ONE trailing DEFAULTED parameter each (an int pageOffset, an McpPageArgs window, a SituPageArgs, or the next= invocation a header now carries) on the emitters that must be TOLD their window — writeFlags/writeGate, writeCappedRows/writeCappedList/writeFlip/writeFlipHeader/writeFlipLights, computeFlip, nearestGateNames (which gains its cap and its TOTAL, the disclosure itself), situShowingNote/writeSituation, and the three MCP twins flagsText/flipText/situationDiffJson; additive by construction, every pre-existing call site compiles unchanged, and the alternative — a second capped emitter per verb — is the drift this repo removes rather than adds, because two emitters that disagree about a window can drop the row that IS the answer. verbosity 3 = kDocDriftLegend +20 lines and writeDocDriftPage +10 are the in-band vocabulary a reader needs to read shown_failed=/failed_capped=/failed_total= where they meet it (the rationale and the next= scan were already hoisted OUT of the body into their own function and comment, which took the complexity row to zero and the LOC row from +48 to +10); dispatchMcpLine +12 is two pagedResult wrappers on a pre-existing 1376-line dispatcher this lane adds to rather than creates. complexity 1 = the same dispatcher, +9 on a base of 518. duplication 1 = flagsText | flipText at 110 tokens, down from 131 after the shared mcpRowCap fold; the residual is getIndex + compute + captureXml, the shape EVERY index-backed MCP twin in this file has, and merging two verbs that return different results behind one entry point would be worse code than the clone. short-horizon-churn 19 = this lane's own footprint across cli.h/docdrift.h/darkflags.h/flipimpact.h/situ.h/mcp*, plus cli.h symbols three other lanes touched the same day; none foreign, none thrash. +ack api-surface 3478654139c90f0f 5 cid=fc086b8b559ee8c6 Review of #214, round 2. The section now cuts over its own grouped, escaped rendering and the run-hint clause is gated on the row COUNT the emitter reports, so the seams that carry those two facts changed shape together and every gating row is that one change. api-surface writeFlipHeader 4->5: ONE parameter, the count of test files the caller already rendered, so the header can gate its clause on the rows the document will carry - a flip with paid 180 B for a rule about rows it has none of, and the header cannot see the corpus to count them itself (one caller, updated in the same commit). duplication/new-clone renderToString | chargeSection: chargeSection is the est_tokens family's FAULT-INJECTABLE buffer (openChargeBuffer is constexpr-false in release and is what estchargecheck degrade arm switches on), so it cannot route through a plain open_memstream seam without deleting the only reachable degrade path the gate has; mcpverbs captureXml WAS folded into the seam in this same commit rather than acked. short-horizon-churn churn=self on writeFlip/writeFlipHeader/writeHandoffPacket/writePrContext/partitionTestRows/dispatchMcpLine is this lane own footprint across the two rounds of one item - the E1 landing, the CI budget fix, the first review round and this one - none foreign, none thrash. | prior: C1 F-06/F-07/F-10 (the listing-paging round): three listing verbs learn to disclose and page their row listings, and every gating row is that one change. api-surface 14 = ONE trailing DEFAULTED parameter each (an int pageOffset, an McpPageArgs window, a SituPageArgs, or the next= invocation a header now carries) on the emitters that must be TOLD their window — writeFlags/writeGate, writeCappedRows/writeCappedList/writeFlip/writeFlipHeader/writeFlipLights, computeFlip, nearestGateNames (which gains its cap and its TOTAL, the disclosure itself), situShowingNote/writeSituation, and the three MCP twins flagsText/flipText/situationDiffJson; additive by construction, every pre-existing call site compiles unchanged, and the alternative — a second capped emitter per verb — is the drift this repo removes rather than adds, because two emitters that disagree about a window can drop the row that IS the answer. verbosity 3 = kDocDriftLegend +20 lines and writeDocDriftPage +10 are the in-band vocabulary a reader needs to read shown_failed=/failed_capped=/failed_total= where they meet it (the rationale and the next= scan were already hoisted OUT of the body into their own function and comment, which took the complexity row to zero and the LOC row from +48 to +10); dispatchMcpLine +12 is two pagedResult wrappers on a pre-existing 1376-line dispatcher this lane adds to rather than creates. complexity 1 = the same dispatcher, +9 on a base of 518. duplication 1 = flagsText | flipText at 110 tokens, down from 131 after the shared mcpRowCap fold; the residual is getIndex + compute + captureXml, the shape EVERY index-backed MCP twin in this file has, and merging two verbs that return different results behind one entry point would be worse code than the clone. short-horizon-churn 19 = this lane's own footprint across cli.h/docdrift.h/darkflags.h/flipimpact.h/situ.h/mcp*, plus cli.h symbols three other lanes touched the same day; none foreign, none thrash. ack api-surface 3561d0281d324276 14 WAVE-2 close (2026-08-19), finding 3 of 3: the 76 remaining gating rows, ONE change. All of them are W2-E's root-relative p= landing (9beaa2c/fccea68/a271e6c/b3fe074 plus the f9108b7 correction), measured for the first time at WAVE granularity. The per-lane acks written during W2-E covered only the correction round's own diff (working-tree-vs-HEAD at that moment), so the original ~30-verb landing was never QD-acked; this ack closes that gap rather than re-accepting anything. Verified by reading the whole 20cdc04..860291c src/main.cpp diff line by line: 282 of 448 added lines match the root-relative predicate directly and every one of the remaining 127 is an existing std::printf rewritten from ing.files[...] to the root-relative rp local, plus four extracted emit helpers (computeDirModules, printJsonSymbolRows, writeOversizeRows, writeDropRows). No unrelated logic rides in. By kind: api-surface 28 = the +1 rootArg/rootPrefix parameter on the emitters that must now be TOLD their root (writeAbiCheck/Ref/Struct, emitColumnar*, packBodies/Deps/Lego/Outline/Signatures[Json], serialize[Json], writeLayout*, packConnect, buildD1Row and the report writers) - defaulted wherever a caller could stay unchanged. complexity 31 and verbosity 16 = the single-root-condition ternary and its guarded root= clause applied per emitter, with no new nesting level and no new control flow beyond that one conditional; the large absolute numbers (runStructureText 207->231, runLint 313->323, runMaintenanceViews 174->190, runCallHierarchy 71->80) are pre-existing dispatcher size the wave adds to, not creates - decomposing them is its own round and is recorded as a wave-2 follow-up. params 1 = writeNonLocalStateReport 4->6, the same contract. The three sibling lanes are individually clean: --quality-delta at f6ec56d..1732fd8 (W2-J), 1732fd8..9a41c74 (W2-K) and 9a41c74..20cdc04 (W2-F) each report gating=0. Full suite green at this head: gates=429 pass=427 skip=2 fail=0, ASan+LSan clean, byte-deterministic, xmllint clean. churn= is unavailable in ref-pair mode by construction (both trees materialized out of the repo), so short-horizon-churn is silent here and that silence is not evidence. ack api-surface 3703c22e2f2112bd 5 cid=e07b67ecf3068d1d by=src/* Phase 5 (docs/EVALS.md): the external-name veto (@external, externalnames.h tables, ExternalVeto predicate, import-name bindings at ingest) and the receiver MRO walk (rule1BaseWalk, SuperObj); every gating row is this one change — the four deliberate contract changes (serialize/serializeJson gain externalCalls, captureIncludes gains binds, methodOnTypeOrBases gains skipSelf/unionOnMulti), the ladder's two new steps in buildGraph, the census's tenth mechanism, kParserVer 77, and the two lexical clone false-positives (a one-line binary_search vs a counts total; a key-buffer probe vs a JSON string writer) ack api-surface 3877dd1e9b4ae997 4 R-E (2026-08-17 harvest): narrowLegoToRenderedSigs needs an explicit rootPrefix param because packSignatures' sigsRendered rows are already root-relative while the function's own escaped path comparison was still absolute -- every comparison silently failed, narrowing legoScoped to nothing on every --for run whose rendered sigs hit this path. The +1 param (defaulted, so every other caller is unaffected) and the small cx/LOC bump on narrowLegoToRenderedSigs and its one caller runForLens are the minimal fix; caught by legobundlecheck.sh going red for the wrong reason. churn=self is this lane's own edit window. @@ -393,7 +393,7 @@ ack complexity 4b8467d6559779e4 22 fnbody-require lane: deliberate additive API ack complexity 4c787f37a66f10cc 75 cid=c646b4cc29e9de1c P2.2 register-macro dead-code fix: additive params on computeDelta/isDeadCandidate, complexity/verbosity growth in computeDelta and runQualityViews (the --dead-code verb), and the kQSnapCacheScheme bump line sit inside the in-window churn threshold - all eight gating rows are this lane's own footprint, none foreign ack complexity 4d421276056367a9 18 cid=5a1a2f3caf33a19e timsort vendoring: every row is the vendored src/infra/timsort.hpp (upstream v3.0.1 + the recorded workspace patch) plus the facade forwarder it needs. The complexity/verbosity/params/duplication rows are UPSTREAM's shape — mergeLo/mergeHi, gallopLeft/gallopRight and the timsort/timmerge overload pairs are twins in the release itself — and restructuring them would destroy the property that makes the file auditable: that it can be re-derived byte-for-byte from a public tag plus one described patch. The one preexisting-worse row, infra::sort::stable vs infra::sort::unstable at 22 tokens, is what a facade IS: each entry is a one-line forward to a DIFFERENT algorithm, and collapsing them into one algorithm-parameterised template would hide the explicit named choice the layer exists to make (G5). Nothing is routed to timsort; test/timsortcheck.sh is what keeps the file honest. ack complexity 50771ea7eb2ce242 19 cid=c2388456093ea895 capture-audit 2026-09-04 wave-1 close, lane L5 (refusal population, lane-L5.md) + lane L0 H13: guard code and its reasoning, not accidental growth — H6 file-list refusal (writeSituation/dispatchMcpLine/runChangeViews), H7 empty-selection refusals (runCrossRef flags/stray-content, evalStray badRefs, writeWhereisPage line-seed + near-miss), M7 named-file inputs + M8 --since validated once before any verb (main), M9 edit-verb refusals (runCliEdit/nearestNames/resolveOneForEdit), M20 seed disclosure (serialize + MapAnnotations::SeedDisclosure, packLego defs=, packConnect terminal defs=), F10/F14 empty list items (runPath/packConnect). L5 left these un-acked on purpose (shared-ledger race, H10); acked at close against the lane's own ec5e3c3 measurement -ack complexity 52c2e3e32753dd28 154 cid=2783212e28944a4b round ec5e3c3..HEAD, lane L7 P4 (defaultceilingcheck, prbudgetcheck, treecheck, usescheck 5b) + close H7 hosts (substrfiltercheck): default ceilings — pr-context budgeted by default with a windowed file page, --around depth 1, --zoom levels_shown, --external-surface 100 rows + builtins_excluded=; runChangeViews also hosts the pr-context paging and the --plan/--abi no-match refusal branch | prior: WAVE-2 close (2026-08-19), finding 3 of 3: the 76 remaining gating rows, ONE change. All of them are W2-E's root-relative p= landing (9beaa2c/fccea68/a271e6c/b3fe074 plus the f9108b7 correction), measured for the first time at WAVE granularity. The per-lane acks written during W2-E covered only the correction round's own diff (working-tree-vs-HEAD at that moment), so the original ~30-verb landing was never QD-acked; this ack closes that gap rather than re-accepting anything. Verified by reading the whole 20cdc04..860291c src/main.cpp diff line by line: 282 of 448 added lines match the root-relative predicate directly and every one of the remaining 127 is an existing std::printf rewritten from ing.files[...] to the root-relative rp local, plus four extracted emit helpers (computeDirModules, printJsonSymbolRows, writeOversizeRows, writeDropRows). No unrelated logic rides in. By kind: api-surface 28 = the +1 rootArg/rootPrefix parameter on the emitters that must now be TOLD their root (writeAbiCheck/Ref/Struct, emitColumnar*, packBodies/Deps/Lego/Outline/Signatures[Json], serialize[Json], writeLayout*, packConnect, buildD1Row and the report writers) - defaulted wherever a caller could stay unchanged. complexity 31 and verbosity 16 = the single-root-condition ternary and its guarded root= clause applied per emitter, with no new nesting level and no new control flow beyond that one conditional; the large absolute numbers (runStructureText 207->231, runLint 313->323, runMaintenanceViews 174->190, runCallHierarchy 71->80) are pre-existing dispatcher size the wave adds to, not creates - decomposing them is its own round and is recorded as a wave-2 follow-up. params 1 = writeNonLocalStateReport 4->6, the same contract. The three sibling lanes are individually clean: --quality-delta at f6ec56d..1732fd8 (W2-J), 1732fd8..9a41c74 (W2-K) and 9a41c74..20cdc04 (W2-F) each report gating=0. Full suite green at this head: gates=429 pass=427 skip=2 fail=0, ASan+LSan clean, byte-deterministic, xmllint clean. churn= is unavailable in ref-pair mode by construction (both trees materialized out of the repo), so short-horizon-churn is silent here and that silence is not evidence. +ack complexity 52c2e3e32753dd28 161 cid=32fe671bf177bea8 Review of #214, round 2. The section now cuts over its own grouped, escaped rendering and the run-hint clause is gated on the row COUNT the emitter reports, so the seams that carry those two facts changed shape together and every gating row is that one change. api-surface writeFlipHeader 4->5: ONE parameter, the count of test files the caller already rendered, so the header can gate its clause on the rows the document will carry - a flip with paid 180 B for a rule about rows it has none of, and the header cannot see the corpus to count them itself (one caller, updated in the same commit). duplication/new-clone renderToString | chargeSection: chargeSection is the est_tokens family's FAULT-INJECTABLE buffer (openChargeBuffer is constexpr-false in release and is what estchargecheck degrade arm switches on), so it cannot route through a plain open_memstream seam without deleting the only reachable degrade path the gate has; mcpverbs captureXml WAS folded into the seam in this same commit rather than acked. short-horizon-churn churn=self on writeFlip/writeFlipHeader/writeHandoffPacket/writePrContext/partitionTestRows/dispatchMcpLine is this lane own footprint across the two rounds of one item - the E1 landing, the CI budget fix, the first review round and this one - none foreign, none thrash. | prior: round ec5e3c3..HEAD, lane L7 P4 (defaultceilingcheck, prbudgetcheck, treecheck, usescheck 5b) + close H7 hosts (substrfiltercheck): default ceilings — pr-context budgeted by default with a windowed file page, --around depth 1, --zoom levels_shown, --external-surface 100 rows + builtins_excluded=; runChangeViews also hosts the pr-context paging and the --plan/--abi no-match refusal branch ack complexity 5361e2bced6f1988 23 cid=b34695b5d2051f1d capture-audit 2026-09-04 wave-1 close: symbols two lanes each grew past the other's acked magnitude — runDoctor (L10 legend + blobs_floor=, L9 built_from=), writeEnsembleReport (L9 root=, L10 conditional unavailable=), runAffected/runVerify (L9 root-relative block, L4 gauge), writeTestGateReport (L4 gauge splice, L9 row-gated root=; the XML/JSON twins' duplication is the lockstep mcpclidiffcheck asserts, as L9 acked; graphGaugeAttrXml/Json are the same lockstep), writePanelReport (L10 conditional attrs, L4 counts_floor), printUsage/validateConfig (L1 H10 hoist + L5/L9/L10 help text). Re-acked at the merged magnitude; prior reasons kept | prior: M12 follow-up (capture-audit L9): --ensemble gained root=/root-relative p= — writeEnsembleReport's 3 new default-valued params (singleRoot/rootPrefix/rootAttr, back-compat) thread the caller's already-computed single-root spelling through; short-horizon-churn on the touched dispatcher. ack complexity 53a1214d5505fa41 433 cid=e155c3fb2ceeb9f1 lane V1 N4 (bd8d666): the no-baseline half of the M8 --since policy decided beside the shape/range validation, from SinceScope::baselineSha | prior: capture-audit 2026-09-04 wave-1 close, lane L5 (refusal population, lane-L5.md) + lane L0 H13: guard code and its reasoning, not accidental growth — H6 file-list refusal (writeSituation/dispatchMcpLine/runChangeViews), H7 empty-selection refusals (runCrossRef flags/stray-content, evalStray badRefs, writeWhereisPage line-seed + near-miss), M7 named-file inputs + M8 --since validated once before any verb (main), M9 edit-verb refusals (runCliEdit/nearestNames/resolveOneForEdit), M20 seed disclosure (serialize + MapAnnotations::SeedDisclosure, packLego defs=, packConnect terminal defs=), F10/F14 empty list items (runPath/packConnect). L5 left these un-acked on purpose (shared-ledger race, H10); acked at close against the lane's own ec5e3c3 measurement ack complexity 53b3f823688054b4 83 cid=137a04e79db61a58 by=src/* §N6-C .gitignore-by-default: the crawl gains an ignore mode. The two api-surface/params rows are ONE deliberate contract change — ingest()/collectSources() take a trailing defaulted respectGitignore, the only way a CLI flag can reach the crawl without a global; the three short-horizon-churn rows are this lane's own edits to the flag ledger, the crawl and the --skipped verb, which is what adding a flag with a disclosure IS; collectSources +3 ccx / +11 LOC is what remains after the probe, the mode and the prune fan-out were extracted into probeIgnoreSet/recordDirPrune (it was +15/+43 inline). @@ -608,6 +608,7 @@ ack duplication eb2e912436eacba2 49 by=test/macroreparsefix lane fix/cpp-macro-m ack duplication ec3f7b5523723637 110 C1 F-06/F-07/F-10 (the listing-paging round): three listing verbs learn to disclose and page their row listings, and every gating row is that one change. api-surface 14 = ONE trailing DEFAULTED parameter each (an int pageOffset, an McpPageArgs window, a SituPageArgs, or the next= invocation a header now carries) on the emitters that must be TOLD their window — writeFlags/writeGate, writeCappedRows/writeCappedList/writeFlip/writeFlipHeader/writeFlipLights, computeFlip, nearestGateNames (which gains its cap and its TOTAL, the disclosure itself), situShowingNote/writeSituation, and the three MCP twins flagsText/flipText/situationDiffJson; additive by construction, every pre-existing call site compiles unchanged, and the alternative — a second capped emitter per verb — is the drift this repo removes rather than adds, because two emitters that disagree about a window can drop the row that IS the answer. verbosity 3 = kDocDriftLegend +20 lines and writeDocDriftPage +10 are the in-band vocabulary a reader needs to read shown_failed=/failed_capped=/failed_total= where they meet it (the rationale and the next= scan were already hoisted OUT of the body into their own function and comment, which took the complexity row to zero and the LOC row from +48 to +10); dispatchMcpLine +12 is two pagedResult wrappers on a pre-existing 1376-line dispatcher this lane adds to rather than creates. complexity 1 = the same dispatcher, +9 on a base of 518. duplication 1 = flagsText | flipText at 110 tokens, down from 131 after the shared mcpRowCap fold; the residual is getIndex + compute + captureXml, the shape EVERY index-backed MCP twin in this file has, and merging two verbs that return different results behind one entry point would be worse code than the clone. short-horizon-churn 19 = this lane's own footprint across cli.h/docdrift.h/darkflags.h/flipimpact.h/situ.h/mcp*, plus cli.h symbols three other lanes touched the same day; none foreign, none thrash. ack duplication ef3d5272b705b505 107 ingest.cpp split 2026-08-29: pre-split clone pairs (buildNewlineOffsets vs the bench_newline_ab arms, acked lane-B3 at keys 98099b517e9d2fbb/a6f7de52f80c9f48) whose clone-group keys changed because buildNewlineOffsets moved VERBATIM into ingest_astquery.h — the disclosed clone-ack rename floor, same artifact as the main.cpp split's moved-clone row; argvdiffcheck vs c267a4b proves no body changed ack duplication ef69b96712422a31 36 by=src/extentsuspect.h lane fix/cpp-macro-member-reparse-2026-09-11, extent detector (src/extentsuspect.h): extent::inSet is the std::find( begin, end, v ) != end membership test over a constexpr std::array of enums (the SymKind and Lang rule tables, 9 call sites). Its partners spell the same one-liner over a span of string_view (ingest_relations.h namesNode), an initializer_list of string_view (naminglens::ncAnyOf) and a Scope pattern vector (quality::scopeUsesDiffToken), and notes::sortNotes is a bare std::stable_sort. An idiom collision at 30-36 tokens across four subsystems: no member can take another member argument type, and the comment on namesNode records this spelling as the house one. +ack duplication f10ce50bdc680d80 122 Review of #214, round 2. The section now cuts over its own grouped, escaped rendering and the run-hint clause is gated on the row COUNT the emitter reports, so the seams that carry those two facts changed shape together and every gating row is that one change. api-surface writeFlipHeader 4->5: ONE parameter, the count of test files the caller already rendered, so the header can gate its clause on the rows the document will carry - a flip with paid 180 B for a rule about rows it has none of, and the header cannot see the corpus to count them itself (one caller, updated in the same commit). duplication/new-clone renderToString | chargeSection: chargeSection is the est_tokens family's FAULT-INJECTABLE buffer (openChargeBuffer is constexpr-false in release and is what estchargecheck degrade arm switches on), so it cannot route through a plain open_memstream seam without deleting the only reachable degrade path the gate has; mcpverbs captureXml WAS folded into the seam in this same commit rather than acked. short-horizon-churn churn=self on writeFlip/writeFlipHeader/writeHandoffPacket/writePrContext/partitionTestRows/dispatchMcpLine is this lane own footprint across the two rounds of one item - the E1 landing, the CI budget fix, the first review round and this one - none foreign, none thrash. ack duplication f14cfce02be351ad 24 timsort vendoring: every row is the vendored src/infra/timsort.hpp (upstream v3.0.1 + the recorded workspace patch) plus the facade forwarder it needs. The complexity/verbosity/params/duplication rows are UPSTREAM's shape — mergeLo/mergeHi, gallopLeft/gallopRight and the timsort/timmerge overload pairs are twins in the release itself — and restructuring them would destroy the property that makes the file auditable: that it can be re-derived byte-for-byte from a public tag plus one described patch. The one preexisting-worse row, infra::sort::stable vs infra::sort::unstable at 22 tokens, is what a facade IS: each entry is a one-line forward to a DIFFERENT algorithm, and collapsing them into one algorithm-parameterised template would hide the explicit named choice the layer exists to make (G5). Nothing is routed to timsort; test/timsortcheck.sh is what keeps the file honest. ack duplication f3518ef93569f6ae 25 idiom-class clone false positive: a three-way ternary over string literals, 25 normalized tokens, sharing no domain identifier with macroRoleAttr and in an unrelated subsystem. Reading both confirms it. ack duplication f4f3fc233cdda6ba 307 test/verify_strkern.cpp keeps VERBATIM copies of escapeXml/appendCdataSafe/escapeInto (as *Ref, plus one deliberately mutated set) and a masksEqual predicate as the doctest ORACLES the kernels are proven against; a reference that shared the shipped code would move with it and prove nothing (the TU's header says so) @@ -669,6 +670,7 @@ ack new-clone-of-reused-helper de772ce9497e6cc2 7 test/verify_strkern.cpp keeps ack new-clone-of-reused-helper dec50094e5aef506 3 OPTREMARKS F3 (docs/OPTREMARKS.md §8b): the ~430 per-AST-node std::strcmp( t, "literal" ) sites in the five ingest walk sections become rw::kindIs (src/infra/nodekind.h) — an inline compare, because strcmp is an external symbol LTO cannot inline and on macOS costs two dyld stub hops before it starts. Measured: 10.6% of busy CPU in strcmp leaves on a cold llvm run, 6-12% on four other corpora; 0.23% after. Output byte-identical across 7 corpora x 5 verbs, argvdiffcheck 640/642 vectors identical (the 2 that differ are the +dirty build stamp in --version). WHAT THESE ROWS ARE. (a) 95 short-horizon-churn rows, churn=self: the mechanical rewrite touches essentially every function in ingest_{metrics,binds,sidecap,relations,names}.h, so every one of them shows this lane's own single edit. Not thrash — one commit. (b) 7 duplication rows and 1 new-clone-of-reused-helper. These are REAL new clone groups (--clones, uncapped: 407 groups before, 409 after; the rewrite adds 7 and removes 5) and they are IDIOM COLLISIONS, not copies. kindIs( t, "x" ) is shorter than std::strcmp( t, "x" ) == 0, so short predicate bodies that were previously above the clone threshold now match each other's normalized token stream. Six of the seven pair a node-kind || -chain with an unrelated || -chain over a DISJOINT literal set in a different subsystem — cc_isParamList (tree-sitter parameter-list kinds) against predicatePrefixed (English name prefixes is/has/can), against sliceIsJsPatternKind (JS destructuring kinds), rubyCallIsAssignmentTarget against slice.h's JS binding probes. The tool's own rule is that two ladders over the SAME enum are a copy; these share no non-keyword identifier and no domain, and merging any pair would need a helper parameterised on an unrelated literal table — a wrong abstraction to satisfy a lint. The seventh, kindIs | lexTokenEqualsLowered, is the same shape at 56 tokens with materially different contracts: lexTokenEqualsLowered takes an explicit length and case-folds one side, kindIs takes its length from the literal's type and compares the terminating NUL as an ordinary byte — and that NUL comparison is precisely the safety property kindIs depends on (test/nodekindcheck.sh arm B proves the absence of a read past it with an mprotect(PROT_NONE) guard page). Folding them together would erase the one property being gated. Gate: test/nodekindcheck.sh, 4 arms, 1,348,096 enumerated (candidate, literal) pairs against std::strcmp plus two mutation controls that each turn an arm red. ack new-clone-of-reused-helper e6ae1365cb6c154a 3 V3 harvest 2026-08-15: two MCP gate harnesses. Every MCP gate in this suite is deliberately STANDALONE — the house rule in their own headers is 'does NOT edit regression.sh or any other existing test file', so a gate carries its own 3-4 line JSON-RPC transport wrapper (mcp_call in mcphandlecheck, mcpCall in mcpeditpresencecheck, the curl wrapper in mcpremotecheck). test/mcptoolprunecheck.sh needs BOTH transports (HTTP for the pinned-root arms A-E/G, stdio for arm F, and the pair is the point: pinning is exactly what makes the omission provable), so its http_call and stdio_call land as the 9th and 10th members of two families that already exist. Extracting a shared test/lib harness would couple every MCP gate to one file and is a suite-wide refactor, not this lane's; sharing one of the two existing spellings instead would make this gate fail whenever an unrelated gate edits its own helper. No production code involved ack new-clone-of-reused-helper ef69b96712422a31 3 by=src/extentsuspect.h lane fix/cpp-macro-member-reparse-2026-09-11, extent detector (src/extentsuspect.h): extent::inSet is the std::find( begin, end, v ) != end membership test over a constexpr std::array of enums (the SymKind and Lang rule tables, 9 call sites). Its partners spell the same one-liner over a span of string_view (ingest_relations.h namesNode), an initializer_list of string_view (naminglens::ncAnyOf) and a Scope pattern vector (quality::scopeUsesDiffToken), and notes::sortNotes is a bare std::stable_sort. An idiom collision at 30-36 tokens across four subsystems: no member can take another member argument type, and the comment on namesNode records this spelling as the house one. +ack new-clone-of-reused-helper f10ce50bdc680d80 6 Review of #214, round 2. The section now cuts over its own grouped, escaped rendering and the run-hint clause is gated on the row COUNT the emitter reports, so the seams that carry those two facts changed shape together and every gating row is that one change. api-surface writeFlipHeader 4->5: ONE parameter, the count of test files the caller already rendered, so the header can gate its clause on the rows the document will carry - a flip with paid 180 B for a rule about rows it has none of, and the header cannot see the corpus to count them itself (one caller, updated in the same commit). duplication/new-clone renderToString | chargeSection: chargeSection is the est_tokens family's FAULT-INJECTABLE buffer (openChargeBuffer is constexpr-false in release and is what estchargecheck degrade arm switches on), so it cannot route through a plain open_memstream seam without deleting the only reachable degrade path the gate has; mcpverbs captureXml WAS folded into the seam in this same commit rather than acked. short-horizon-churn churn=self on writeFlip/writeFlipHeader/writeHandoffPacket/writePrContext/partitionTestRows/dispatchMcpLine is this lane own footprint across the two rounds of one item - the E1 landing, the CI budget fix, the first review round and this one - none foreign, none thrash. ack new-clone-of-reused-helper f5a96185fd557a83 3 R2 pattern lane: the six gating rows left after two real extractions are idiom and dispatcher footprint, not new debt. runLint complexity/verbosity: adding a FIFTH verb to a five-verb dispatcher grows it; the compile, refusal and disclosure assembly are already out in runPatternSearch (the split runMatchQuery established) and the legend is out in kPatternLegend, so what remains is the emission loop every sibling verb also has inline. The four lintCatalogFind clone rows: a std::find_if over a constexpr table returning pointer-or-null IS the idiomatic spelling, and wrapping std::find_if to make two three-line lookups tokenize differently is the 'do not game the number' case this verb's own legend names. ack new-clone-of-reused-helper ff14ff2b821304c8 4 by=src/* slice honesty round (lane H): token-shape clones between one-line predicate/name-table helpers in slice.h and unrelated domains (naminglens predicatePrefixed, pattern servedNames/resolvedNames, quality headSnapExclHex, model localsCountedLang) — no shared identifiers, different scopes; reviewed by the lane and the orchestrator and declined to merge across domains (the clone-idiom class) ack params 00864cc4801c708a 6 cid=88b8c4067d691df7 timsort vendoring: every row is the vendored src/infra/timsort.hpp (upstream v3.0.1 + the recorded workspace patch) plus the facade forwarder it needs. The complexity/verbosity/params/duplication rows are UPSTREAM's shape — mergeLo/mergeHi, gallopLeft/gallopRight and the timsort/timmerge overload pairs are twins in the release itself — and restructuring them would destroy the property that makes the file auditable: that it can be re-derived byte-for-byte from a public tag plus one described patch. The one preexisting-worse row, infra::sort::stable vs infra::sort::unstable at 22 tokens, is what a facade IS: each entry is a one-line forward to a DIFFERENT algorithm, and collapsing them into one algorithm-parameterised template would hide the explicit named choice the layer exists to make (G5). Nothing is routed to timsort; test/timsortcheck.sh is what keeps the file honest. @@ -817,7 +819,7 @@ ack short-horizon-churn 3298be65bf6058ef 100 cid=0d8a43a6b93b2f31 M1: self-churn ack short-horizon-churn 32d067796a0393b8 32 PHP + Lua language port (lane/lang-php-lua, 2026-08-21). All SEVEN remaining gating rows are the SAME class — short-horizon-churn with churn=self, i.e. 'this symbol was edited recently and you edited it again'. That is this change's own edit window, not a history signal, and every one of the seven is a site a language port CANNOT avoid touching: (1) src/model.h::Lang — the enum gains Php(18)/Lua(19); appending is the only safe move (inserting would renumber every on-disk cache key). (2) src/ingest.cpp::kLangTable — the extension->grammar rows for .php/.phtml/.lua, plus the extent 37->40 the compiler enforces. (3) src/main.cpp::computeLangCounts — its two tallies are sized on the LAST enum member, so a new member is a mechanical edit there by construction. (4) src/clones.h::kHashLineCommentLangMask — PHP joins (# IS a PHP line comment), Lua does not (its comment is --, and #t is the length operator). (5) src/lintrules.h::dependencyCapable — PHP true (namespace_use_declaration is captured), Lua false (require is an ordinary call, like Ruby). (6) cc_walk and (7) ev_noteNode — both call isDecisionType/cc_isNestingControl, which now take a Lang so Lua's do...end (a bare scope block, NOT a loop) stops being counted as a decision; every other language is byte-identical. The STRUCTURAL regressions this round did produce were FIXED, not acked: cc_walk +12 cx / +13 LOC from the inline boolean-operator test was extracted to cc_isBooleanJoin, and the duplication that extraction then created against cc_boolOp was removed by giving both ONE shared cc_operatorText. Gates: test/phpcheck.sh + test/luacheck.sh, both shown red (36 and 21 failing arms) against a cd30104-built binary. ack short-horizon-churn 32e780668c108fa5 33 fnbody-require lane: deliberate additive API widening (optional out-params, default nullptr, every existing caller unaffected) to disclose the lazy require/import distinction on --impact's importer tier; residual complexity/duplication is the twin-dialect emitter shape and the dual-mode importersOfFiles scan this feature requires, already extracted where a helper genuinely reduced it (scanImporterEdges, recordLazyPair); short-horizon-churn is this same commit's own edit history ack short-horizon-churn 3385856c74077f1a 22 cid=ef5ec129660c61f5 by=src/* rung 3 flow-sensitive reaching definitions (docs/EVALS.md 'Flow-sensitive slice in the small', 2026-09-03, lane/n6-b). edgesOf: the contract change IS the point — the diff now reads the scan's reach table (scan, rowOfOcc, cap) so rows/flow/diff share one edge oracle. sliceEmitBody +4 cx: the rd= emission per use row (its formatting already factored into sliceAppendReachAttr). The three duplication rows are the idiomatic tree-sitter named-child loop (seq/hasStructureBelow vs ingest helpers) and a two-way family ternary — no shared logic to lift. short-horizon-churn = self-churn of the two files this lane owns. sliceLegendText +11 lines = the registered per-construct disclosures the band requires on the first screen. -ack short-horizon-churn 3478654139c90f0f 4 cid=789faaec74b2ce26 C1 F-06/F-07/F-10 (the listing-paging round): three listing verbs learn to disclose and page their row listings, and every gating row is that one change. api-surface 14 = ONE trailing DEFAULTED parameter each (an int pageOffset, an McpPageArgs window, a SituPageArgs, or the next= invocation a header now carries) on the emitters that must be TOLD their window — writeFlags/writeGate, writeCappedRows/writeCappedList/writeFlip/writeFlipHeader/writeFlipLights, computeFlip, nearestGateNames (which gains its cap and its TOTAL, the disclosure itself), situShowingNote/writeSituation, and the three MCP twins flagsText/flipText/situationDiffJson; additive by construction, every pre-existing call site compiles unchanged, and the alternative — a second capped emitter per verb — is the drift this repo removes rather than adds, because two emitters that disagree about a window can drop the row that IS the answer. verbosity 3 = kDocDriftLegend +20 lines and writeDocDriftPage +10 are the in-band vocabulary a reader needs to read shown_failed=/failed_capped=/failed_total= where they meet it (the rationale and the next= scan were already hoisted OUT of the body into their own function and comment, which took the complexity row to zero and the LOC row from +48 to +10); dispatchMcpLine +12 is two pagedResult wrappers on a pre-existing 1376-line dispatcher this lane adds to rather than creates. complexity 1 = the same dispatcher, +9 on a base of 518. duplication 1 = flagsText | flipText at 110 tokens, down from 131 after the shared mcpRowCap fold; the residual is getIndex + compute + captureXml, the shape EVERY index-backed MCP twin in this file has, and merging two verbs that return different results behind one entry point would be worse code than the clone. short-horizon-churn 19 = this lane's own footprint across cli.h/docdrift.h/darkflags.h/flipimpact.h/situ.h/mcp*, plus cli.h symbols three other lanes touched the same day; none foreign, none thrash. +ack short-horizon-churn 3478654139c90f0f 7 cid=fc086b8b559ee8c6 Review of #214, round 2. The section now cuts over its own grouped, escaped rendering and the run-hint clause is gated on the row COUNT the emitter reports, so the seams that carry those two facts changed shape together and every gating row is that one change. api-surface writeFlipHeader 4->5: ONE parameter, the count of test files the caller already rendered, so the header can gate its clause on the rows the document will carry - a flip with paid 180 B for a rule about rows it has none of, and the header cannot see the corpus to count them itself (one caller, updated in the same commit). duplication/new-clone renderToString | chargeSection: chargeSection is the est_tokens family's FAULT-INJECTABLE buffer (openChargeBuffer is constexpr-false in release and is what estchargecheck degrade arm switches on), so it cannot route through a plain open_memstream seam without deleting the only reachable degrade path the gate has; mcpverbs captureXml WAS folded into the seam in this same commit rather than acked. short-horizon-churn churn=self on writeFlip/writeFlipHeader/writeHandoffPacket/writePrContext/partitionTestRows/dispatchMcpLine is this lane own footprint across the two rounds of one item - the E1 landing, the CI budget fix, the first review round and this one - none foreign, none thrash. | prior: C1 F-06/F-07/F-10 (the listing-paging round): three listing verbs learn to disclose and page their row listings, and every gating row is that one change. api-surface 14 = ONE trailing DEFAULTED parameter each (an int pageOffset, an McpPageArgs window, a SituPageArgs, or the next= invocation a header now carries) on the emitters that must be TOLD their window — writeFlags/writeGate, writeCappedRows/writeCappedList/writeFlip/writeFlipHeader/writeFlipLights, computeFlip, nearestGateNames (which gains its cap and its TOTAL, the disclosure itself), situShowingNote/writeSituation, and the three MCP twins flagsText/flipText/situationDiffJson; additive by construction, every pre-existing call site compiles unchanged, and the alternative — a second capped emitter per verb — is the drift this repo removes rather than adds, because two emitters that disagree about a window can drop the row that IS the answer. verbosity 3 = kDocDriftLegend +20 lines and writeDocDriftPage +10 are the in-band vocabulary a reader needs to read shown_failed=/failed_capped=/failed_total= where they meet it (the rationale and the next= scan were already hoisted OUT of the body into their own function and comment, which took the complexity row to zero and the LOC row from +48 to +10); dispatchMcpLine +12 is two pagedResult wrappers on a pre-existing 1376-line dispatcher this lane adds to rather than creates. complexity 1 = the same dispatcher, +9 on a base of 518. duplication 1 = flagsText | flipText at 110 tokens, down from 131 after the shared mcpRowCap fold; the residual is getIndex + compute + captureXml, the shape EVERY index-backed MCP twin in this file has, and merging two verbs that return different results behind one entry point would be worse code than the clone. short-horizon-churn 19 = this lane's own footprint across cli.h/docdrift.h/darkflags.h/flipimpact.h/situ.h/mcp*, plus cli.h symbols three other lanes touched the same day; none foreign, none thrash. ack short-horizon-churn 3561d0281d324276 29 2026-08-15 harvest wave-level pass (orchestrator): 12-lane wave measured as one delta vs origin/main 4b9386c per verifier finding 6. All 21 gating rows triaged individually: emitGrepReport/grepHitsJson/runCallHierarchy/runDefaultMap/collectSources/printUsage/Config/runMcpHttp = feature absorption by design (grouping+boolean+corpus disclosure, file-root, bodyless_defs+legend, estimator guard, new flags), each converged and gate-verified at lane level; short-horizon-churn rows = single-wave multi-lane edits of shared hubs, process artifact; sym=main rows are main.cpp::main growth mislabeled to analyze.py by the bare-name canonId collision (path-qualified keying fix d593de3 still unpushed). emitGrepReport cx 25->63 flagged as W2 split candidate in PLAN round record. ack short-horizon-churn 357ab167dccb9a4d 2 cid=ebe308d04e543d16 by=src/* member-variable round (card A3): kUsesLegendOpen gains the one-sentence pointer to the member form, buildDefSpanIndex zero-widths field spans so containment attribution is byte-identical, kParserVer 74->75 for the new SymKind::Field + member use-site capture — each a single deliberate edit on a symbol other rounds touched recently ack short-horizon-churn 3703c22e2f2112bd 7 cid=e07b67ecf3068d1d by=src/* Phase 5 (docs/EVALS.md): the external-name veto (@external, externalnames.h tables, ExternalVeto predicate, import-name bindings at ingest) and the receiver MRO walk (rule1BaseWalk, SuperObj); every gating row is this one change — the four deliberate contract changes (serialize/serializeJson gain externalCalls, captureIncludes gains binds, methodOnTypeOrBases gains skipSelf/unionOnMulti), the ladder's two new steps in buildGraph, the census's tenth mechanism, kParserVer 77, and the two lexical clone false-positives (a one-line binary_search vs a counts total; a key-buffer probe vs a JSON string writer) @@ -877,7 +879,7 @@ ack short-horizon-churn 4f90e22ea95dea67 22 cid=fe301df9e2f3c6db by=src/* rung 3 ack short-horizon-churn 4fb850ad3a3bb9c3 5 cid=efacb2a5f9e0435a OPTREMARKS F3 (docs/OPTREMARKS.md §8b): the ~430 per-AST-node std::strcmp( t, "literal" ) sites in the five ingest walk sections become rw::kindIs (src/infra/nodekind.h) — an inline compare, because strcmp is an external symbol LTO cannot inline and on macOS costs two dyld stub hops before it starts. Measured: 10.6% of busy CPU in strcmp leaves on a cold llvm run, 6-12% on four other corpora; 0.23% after. Output byte-identical across 7 corpora x 5 verbs, argvdiffcheck 640/642 vectors identical (the 2 that differ are the +dirty build stamp in --version). WHAT THESE ROWS ARE. (a) 95 short-horizon-churn rows, churn=self: the mechanical rewrite touches essentially every function in ingest_{metrics,binds,sidecap,relations,names}.h, so every one of them shows this lane's own single edit. Not thrash — one commit. (b) 7 duplication rows and 1 new-clone-of-reused-helper. These are REAL new clone groups (--clones, uncapped: 407 groups before, 409 after; the rewrite adds 7 and removes 5) and they are IDIOM COLLISIONS, not copies. kindIs( t, "x" ) is shorter than std::strcmp( t, "x" ) == 0, so short predicate bodies that were previously above the clone threshold now match each other's normalized token stream. Six of the seven pair a node-kind || -chain with an unrelated || -chain over a DISJOINT literal set in a different subsystem — cc_isParamList (tree-sitter parameter-list kinds) against predicatePrefixed (English name prefixes is/has/can), against sliceIsJsPatternKind (JS destructuring kinds), rubyCallIsAssignmentTarget against slice.h's JS binding probes. The tool's own rule is that two ladders over the SAME enum are a copy; these share no non-keyword identifier and no domain, and merging any pair would need a helper parameterised on an unrelated literal table — a wrong abstraction to satisfy a lint. The seventh, kindIs | lexTokenEqualsLowered, is the same shape at 56 tokens with materially different contracts: lexTokenEqualsLowered takes an explicit length and case-folds one side, kindIs takes its length from the literal's type and compares the terminating NUL as an ordinary byte — and that NUL comparison is precisely the safety property kindIs depends on (test/nodekindcheck.sh arm B proves the absence of a read past it with an mprotect(PROT_NONE) guard page). Folding them together would erase the one property being gated. Gate: test/nodekindcheck.sh, 4 arms, 1,348,096 enumerated (candidate, literal) pairs against std::strcmp plus two mutation controls that each turn an arm red. ack short-horizon-churn 4fc8bad5056ad3f3 7 recall.h is the intended edit target for lane L4 (structure-aware doc truncation); truncateRecallBody and buildSectionGranularBody are the two functions the task names, and the file is a legitimately hot area (many prior feature rounds) — churn is inherent to the assignment, not a code-shape defect to fix ack short-horizon-churn 5274ab1a5e193cb4 7 cid=5cfc96d7c5424d21 OPTREMARKS F3 (docs/OPTREMARKS.md §8b): the ~430 per-AST-node std::strcmp( t, "literal" ) sites in the five ingest walk sections become rw::kindIs (src/infra/nodekind.h) — an inline compare, because strcmp is an external symbol LTO cannot inline and on macOS costs two dyld stub hops before it starts. Measured: 10.6% of busy CPU in strcmp leaves on a cold llvm run, 6-12% on four other corpora; 0.23% after. Output byte-identical across 7 corpora x 5 verbs, argvdiffcheck 640/642 vectors identical (the 2 that differ are the +dirty build stamp in --version). WHAT THESE ROWS ARE. (a) 95 short-horizon-churn rows, churn=self: the mechanical rewrite touches essentially every function in ingest_{metrics,binds,sidecap,relations,names}.h, so every one of them shows this lane's own single edit. Not thrash — one commit. (b) 7 duplication rows and 1 new-clone-of-reused-helper. These are REAL new clone groups (--clones, uncapped: 407 groups before, 409 after; the rewrite adds 7 and removes 5) and they are IDIOM COLLISIONS, not copies. kindIs( t, "x" ) is shorter than std::strcmp( t, "x" ) == 0, so short predicate bodies that were previously above the clone threshold now match each other's normalized token stream. Six of the seven pair a node-kind || -chain with an unrelated || -chain over a DISJOINT literal set in a different subsystem — cc_isParamList (tree-sitter parameter-list kinds) against predicatePrefixed (English name prefixes is/has/can), against sliceIsJsPatternKind (JS destructuring kinds), rubyCallIsAssignmentTarget against slice.h's JS binding probes. The tool's own rule is that two ladders over the SAME enum are a copy; these share no non-keyword identifier and no domain, and merging any pair would need a helper parameterised on an unrelated literal table — a wrong abstraction to satisfy a lint. The seventh, kindIs | lexTokenEqualsLowered, is the same shape at 56 tokens with materially different contracts: lexTokenEqualsLowered takes an explicit length and case-folds one side, kindIs takes its length from the literal's type and compares the terminating NUL as an ordinary byte — and that NUL comparison is precisely the safety property kindIs depends on (test/nodekindcheck.sh arm B proves the absence of a read past it with an mprotect(PROT_NONE) guard page). Folding them together would erase the one property being gated. Gate: test/nodekindcheck.sh, 4 arms, 1,348,096 enumerated (candidate, literal) pairs against std::strcmp plus two mutation controls that each turn an arm red. -ack short-horizon-churn 52c2e3e32753dd28 19 cid=04f69cd0461e2311 E1 follow-up (CodeRabbit on #214, threads 1-3): the pr-context legend must follow the RENDERED rows, so the chosen body is now rendered before the head is written. prRenderLevel NAMES the memstream dance pickPrTrimLevel already ran inline and the unbudgeted root now needs as well - two call sites, one copy - and the clone it matches is packtask.h's packTaskRenderToString, the same seven-line idiom this tree already spells nine more times in mcpverbs.h; hoisting one shared helper into serialize.h would collapse three DEGRADED_PATH_ALERT messages that deliberately say WHICH buffer failed, so the duplication is the cheaper honesty and is recorded here rather than papered over. prBodyHasTestRow is a two-find predicate (a test row opener or a group row opener); its 36-token match against accessshape::chaseTypeCanPoint is the shape find(x)!=npos || find(y)!=npos, not shared logic. writePrContext's churn=self is this lane's own third edit to that one function inside the window - the E1 landing, the CI budget fix, and this rows-gated legend - one change seen three times, none foreign. | prior: E1 follow-up (#214 CI): prLegendText/writePrContext were rewritten by this PR's own first commit (the clause splice) and again here to gate it on the corpus holding a test file; one change, two commits +ack short-horizon-churn 52c2e3e32753dd28 20 cid=32fe671bf177bea8 Review of #214, round 2. The section now cuts over its own grouped, escaped rendering and the run-hint clause is gated on the row COUNT the emitter reports, so the seams that carry those two facts changed shape together and every gating row is that one change. api-surface writeFlipHeader 4->5: ONE parameter, the count of test files the caller already rendered, so the header can gate its clause on the rows the document will carry - a flip with paid 180 B for a rule about rows it has none of, and the header cannot see the corpus to count them itself (one caller, updated in the same commit). duplication/new-clone renderToString | chargeSection: chargeSection is the est_tokens family's FAULT-INJECTABLE buffer (openChargeBuffer is constexpr-false in release and is what estchargecheck degrade arm switches on), so it cannot route through a plain open_memstream seam without deleting the only reachable degrade path the gate has; mcpverbs captureXml WAS folded into the seam in this same commit rather than acked. short-horizon-churn churn=self on writeFlip/writeFlipHeader/writeHandoffPacket/writePrContext/partitionTestRows/dispatchMcpLine is this lane own footprint across the two rounds of one item - the E1 landing, the CI budget fix, the first review round and this one - none foreign, none thrash. | prior: E1 follow-up (CodeRabbit on #214, threads 1-3): the pr-context legend must follow the RENDERED rows, so the chosen body is now rendered before the head is written. prRenderLevel NAMES the memstream dance pickPrTrimLevel already ran inline and the unbudgeted root now needs as well - two call sites, one copy - and the clone it matches is packtask.h's packTaskRenderToString, the same seven-line idiom this tree already spells nine more times in mcpverbs.h; hoisting one shared helper into serialize.h would collapse three DEGRADED_PATH_ALERT messages that deliberately say WHICH buffer failed, so the duplication is the cheaper honesty and is recorded here rather than papered over. prBodyHasTestRow is a two-find predicate (a test row opener or a group row opener); its 36-token match against accessshape::chaseTypeCanPoint is the shape find(x)!=npos || find(y)!=npos, not shared logic. writePrContext's churn=self is this lane's own third edit to that one function inside the window - the E1 landing, the CI budget fix, and this rows-gated legend - one change seen three times, none foreign. ack short-horizon-churn 52de3cd8a58763d1 7 cid=462928d2141114f5 OPTREMARKS F3 (docs/OPTREMARKS.md §8b): the ~430 per-AST-node std::strcmp( t, "literal" ) sites in the five ingest walk sections become rw::kindIs (src/infra/nodekind.h) — an inline compare, because strcmp is an external symbol LTO cannot inline and on macOS costs two dyld stub hops before it starts. Measured: 10.6% of busy CPU in strcmp leaves on a cold llvm run, 6-12% on four other corpora; 0.23% after. Output byte-identical across 7 corpora x 5 verbs, argvdiffcheck 640/642 vectors identical (the 2 that differ are the +dirty build stamp in --version). WHAT THESE ROWS ARE. (a) 95 short-horizon-churn rows, churn=self: the mechanical rewrite touches essentially every function in ingest_{metrics,binds,sidecap,relations,names}.h, so every one of them shows this lane's own single edit. Not thrash — one commit. (b) 7 duplication rows and 1 new-clone-of-reused-helper. These are REAL new clone groups (--clones, uncapped: 407 groups before, 409 after; the rewrite adds 7 and removes 5) and they are IDIOM COLLISIONS, not copies. kindIs( t, "x" ) is shorter than std::strcmp( t, "x" ) == 0, so short predicate bodies that were previously above the clone threshold now match each other's normalized token stream. Six of the seven pair a node-kind || -chain with an unrelated || -chain over a DISJOINT literal set in a different subsystem — cc_isParamList (tree-sitter parameter-list kinds) against predicatePrefixed (English name prefixes is/has/can), against sliceIsJsPatternKind (JS destructuring kinds), rubyCallIsAssignmentTarget against slice.h's JS binding probes. The tool's own rule is that two ladders over the SAME enum are a copy; these share no non-keyword identifier and no domain, and merging any pair would need a helper parameterised on an unrelated literal table — a wrong abstraction to satisfy a lint. The seventh, kindIs | lexTokenEqualsLowered, is the same shape at 56 tokens with materially different contracts: lexTokenEqualsLowered takes an explicit length and case-folds one side, kindIs takes its length from the literal's type and compares the terminating NUL as an ordinary byte — and that NUL comparison is precisely the safety property kindIs depends on (test/nodekindcheck.sh arm B proves the absence of a read past it with an mprotect(PROT_NONE) guard page). Folding them together would erase the one property being gated. Gate: test/nodekindcheck.sh, 4 arms, 1,348,096 enumerated (candidate, literal) pairs against std::strcmp plus two mutation controls that each turn an arm red. ack short-horizon-churn 5383f63ad718935c 8 cid=c7781d76aeed7d13 R8 printf-family pilot: std::printf/fprintf/snprintf -> std::print/std::format across every call site in verbs_lint.h, a file independently hot this round; converting a printf call necessarily rewrites the same recently-touched line, so self-thrash is inherent to the conversion, not new debt. Byte parity verified via test/printffmtparitycheck.sh (RED on an injected 1-byte mutation, GREEN on this pilot). ack short-horizon-churn 5391ffd9aa5765bf 44 W1-S2 dead-code top-level fix: isDeadCandidate param add + the edits to computeDelta/computeSnapshot are the fix itself @@ -907,7 +909,7 @@ ack short-horizon-churn 61d6cde8defa73ad 7 cid=5d0a21a4321d8300 OPTREMARKS F3 (d ack short-horizon-churn 61e5df9e1e40ff70 13 cid=e2155dd6082b880a E2 (terminality round A, lane E): +1 defaulted out-param: the receipt's ONE next= is read off the fold it renders (callers 2, incompatible 0) ack short-horizon-churn 623e9c51c095e307 3 S2b sweep-escalation lane: hooks/ripwire-nudge.sh was rewritten twice in 24h by the S2 meter lane and again here, so every meter_* function trips short-horizon-churn on any edit at all. The churn is the file's recent history, not a property of this change (the legend calls this kind preexisting by construction); the verbosity growth it came with WAS fixed, by splitting meter_classify_git and meter_classify_other out of meter_classify_bash. ack short-horizon-churn 624a465290b8a040 3 cid=7b2469337b9ad141 lane E close (terminality round A): run_editsuite.py: the ripwire half split into classify_ripwire_call; remaining rows are churn on the change's home -ack short-horizon-churn 6302e2e27e23bcde 6 cid=5f0d97b6b89f7781 E1/A4-2: every tests_to_run emitter re-routed through testmap.h's one seam (testRowsRendered) so runner-less rows group by evidence; the 12 sites were rewritten in-window by M21(b) and F1, and touching that family once more is the item itself | prior: C1 F-06/F-07/F-10 (the listing-paging round): three listing verbs learn to disclose and page their row listings, and every gating row is that one change. api-surface 14 = ONE trailing DEFAULTED parameter each (an int pageOffset, an McpPageArgs window, a SituPageArgs, or the next= invocation a header now carries) on the emitters that must be TOLD their window — writeFlags/writeGate, writeCappedRows/writeCappedList/writeFlip/writeFlipHeader/writeFlipLights, computeFlip, nearestGateNames (which gains its cap and its TOTAL, the disclosure itself), situShowingNote/writeSituation, and the three MCP twins flagsText/flipText/situationDiffJson; additive by construction, every pre-existing call site compiles unchanged, and the alternative — a second capped emitter per verb — is the drift this repo removes rather than adds, because two emitters that disagree about a window can drop the row that IS the answer. verbosity 3 = kDocDriftLegend +20 lines and writeDocDriftPage +10 are the in-band vocabulary a reader needs to read shown_failed=/failed_capped=/failed_total= where they meet it (the rationale and the next= scan were already hoisted OUT of the body into their own function and comment, which took the complexity row to zero and the LOC row from +48 to +10); dispatchMcpLine +12 is two pagedResult wrappers on a pre-existing 1376-line dispatcher this lane adds to rather than creates. complexity 1 = the same dispatcher, +9 on a base of 518. duplication 1 = flagsText | flipText at 110 tokens, down from 131 after the shared mcpRowCap fold; the residual is getIndex + compute + captureXml, the shape EVERY index-backed MCP twin in this file has, and merging two verbs that return different results behind one entry point would be worse code than the clone. short-horizon-churn 19 = this lane's own footprint across cli.h/docdrift.h/darkflags.h/flipimpact.h/situ.h/mcp*, plus cli.h symbols three other lanes touched the same day; none foreign, none thrash. +ack short-horizon-churn 6302e2e27e23bcde 7 cid=30a2424bb2bbbdf5 Review of #214, round 2. The section now cuts over its own grouped, escaped rendering and the run-hint clause is gated on the row COUNT the emitter reports, so the seams that carry those two facts changed shape together and every gating row is that one change. api-surface writeFlipHeader 4->5: ONE parameter, the count of test files the caller already rendered, so the header can gate its clause on the rows the document will carry - a flip with paid 180 B for a rule about rows it has none of, and the header cannot see the corpus to count them itself (one caller, updated in the same commit). duplication/new-clone renderToString | chargeSection: chargeSection is the est_tokens family's FAULT-INJECTABLE buffer (openChargeBuffer is constexpr-false in release and is what estchargecheck degrade arm switches on), so it cannot route through a plain open_memstream seam without deleting the only reachable degrade path the gate has; mcpverbs captureXml WAS folded into the seam in this same commit rather than acked. short-horizon-churn churn=self on writeFlip/writeFlipHeader/writeHandoffPacket/writePrContext/partitionTestRows/dispatchMcpLine is this lane own footprint across the two rounds of one item - the E1 landing, the CI budget fix, the first review round and this one - none foreign, none thrash. | prior: E1/A4-2: every tests_to_run emitter re-routed through testmap.h's one seam (testRowsRendered) so runner-less rows group by evidence; the 12 sites were rewritten in-window by M21(b) and F1, and touching that family once more is the item itself ack short-horizon-churn 639de1c3670999f9 30 cid=ffcb81de788c329a C1 F-06/F-07/F-10 (the listing-paging round): three listing verbs learn to disclose and page their row listings, and every gating row is that one change. api-surface 14 = ONE trailing DEFAULTED parameter each (an int pageOffset, an McpPageArgs window, a SituPageArgs, or the next= invocation a header now carries) on the emitters that must be TOLD their window — writeFlags/writeGate, writeCappedRows/writeCappedList/writeFlip/writeFlipHeader/writeFlipLights, computeFlip, nearestGateNames (which gains its cap and its TOTAL, the disclosure itself), situShowingNote/writeSituation, and the three MCP twins flagsText/flipText/situationDiffJson; additive by construction, every pre-existing call site compiles unchanged, and the alternative — a second capped emitter per verb — is the drift this repo removes rather than adds, because two emitters that disagree about a window can drop the row that IS the answer. verbosity 3 = kDocDriftLegend +20 lines and writeDocDriftPage +10 are the in-band vocabulary a reader needs to read shown_failed=/failed_capped=/failed_total= where they meet it (the rationale and the next= scan were already hoisted OUT of the body into their own function and comment, which took the complexity row to zero and the LOC row from +48 to +10); dispatchMcpLine +12 is two pagedResult wrappers on a pre-existing 1376-line dispatcher this lane adds to rather than creates. complexity 1 = the same dispatcher, +9 on a base of 518. duplication 1 = flagsText | flipText at 110 tokens, down from 131 after the shared mcpRowCap fold; the residual is getIndex + compute + captureXml, the shape EVERY index-backed MCP twin in this file has, and merging two verbs that return different results behind one entry point would be worse code than the clone. short-horizon-churn 19 = this lane's own footprint across cli.h/docdrift.h/darkflags.h/flipimpact.h/situ.h/mcp*, plus cli.h symbols three other lanes touched the same day; none foreign, none thrash. | prior: wave-3 close, H7 hosts: runCrossRef hosts the --plan and --stray-content refusal sites the fix routes through the shared sentence (verify-wave2 lanes edited it days earlier — the churn is the fix's, self) ack short-horizon-churn 649ed79cefcd6fc6 6 cid=a3636823a3a67d9f by=src/* lane/n6-d, the registered offset-table retry of docs/EVALS.md 'The auto-cache key ignores --exclude' (bands (6)-(8)). All seven gating rows are this lane's own footprint on the two cache seams; the three rows that were REAL are FIXED rather than acked (below). (1) api-surface contract-change loadCache 4->5 and runParsePool 7->8. loadCache's old fourth parameter was 'long long& blobWriteNsOut'; it is replaced by the crawled-file list plus a CacheLoadStats out-struct, because the whole point of v15 is that a load deserialises ONLY the records for the files THIS crawl asked for, and a load that is not told the crawl cannot do that. runParsePool takes that same struct through so the RIPWIRE_CACHE_STATS line can report cached_records=/blob_entries= — the two numbers that make band (2) an executable fact instead of a wall-clock claim (test/cacheoffsetcheck.sh check (e)). Both are internal to ingest.cpp's single TU, one call site each, updated in the same commit; no consumer outside the TU ever saw either signature. (2) five short-horizon-churn churn=self rows on kCacheVersion, kIngestCacheVersionMirror, loadCache, saveCache and runParsePool: the footprint of editing exactly the symbols a format bump must edit, in a window that also holds the gate commit. Not thrash — a version constant and its gated mirror must move together in one commit by construction (qextractionkeycheck). WHAT WAS FIXED INSTEAD OF ACKED, because it was real: saveCache's complexity 94->125 and verbosity 285->408 are gone (zero regression) after the seven per-file fact-grouping loops moved to buildCacheFileIndexes, the path/order prologue to buildCachePathKeys, and the plan/carry/trailer work to buildCacheWritePlan/appendCarryRecord/finishCacheBlob; and the duplication row against ingest_sidecap.h TreeGuard::operator= is gone because ReadFd dropped its move-assignment for an openOnce() that fills an empty guard, the only mutation the type needs. Verification at this head: test/cacheoffsetcheck.sh ALL PASS (written RED first at 8411f7e), the whole cache family green, ASan+UBSan+LSan clean on cold store, warm load, subset load and carry-over save on both the fixture and this repo, three-run byte determinism, warm==--no-cache, xmllint clean. ack short-horizon-churn 6652d5114718eb63 7 cid=497a477af2561c0a OPTREMARKS F3 (docs/OPTREMARKS.md §8b): the ~430 per-AST-node std::strcmp( t, "literal" ) sites in the five ingest walk sections become rw::kindIs (src/infra/nodekind.h) — an inline compare, because strcmp is an external symbol LTO cannot inline and on macOS costs two dyld stub hops before it starts. Measured: 10.6% of busy CPU in strcmp leaves on a cold llvm run, 6-12% on four other corpora; 0.23% after. Output byte-identical across 7 corpora x 5 verbs, argvdiffcheck 640/642 vectors identical (the 2 that differ are the +dirty build stamp in --version). WHAT THESE ROWS ARE. (a) 95 short-horizon-churn rows, churn=self: the mechanical rewrite touches essentially every function in ingest_{metrics,binds,sidecap,relations,names}.h, so every one of them shows this lane's own single edit. Not thrash — one commit. (b) 7 duplication rows and 1 new-clone-of-reused-helper. These are REAL new clone groups (--clones, uncapped: 407 groups before, 409 after; the rewrite adds 7 and removes 5) and they are IDIOM COLLISIONS, not copies. kindIs( t, "x" ) is shorter than std::strcmp( t, "x" ) == 0, so short predicate bodies that were previously above the clone threshold now match each other's normalized token stream. Six of the seven pair a node-kind || -chain with an unrelated || -chain over a DISJOINT literal set in a different subsystem — cc_isParamList (tree-sitter parameter-list kinds) against predicatePrefixed (English name prefixes is/has/can), against sliceIsJsPatternKind (JS destructuring kinds), rubyCallIsAssignmentTarget against slice.h's JS binding probes. The tool's own rule is that two ladders over the SAME enum are a copy; these share no non-keyword identifier and no domain, and merging any pair would need a helper parameterised on an unrelated literal table — a wrong abstraction to satisfy a lint. The seventh, kindIs | lexTokenEqualsLowered, is the same shape at 56 tokens with materially different contracts: lexTokenEqualsLowered takes an explicit length and case-folds one side, kindIs takes its length from the literal's type and compares the terminating NUL as an ordinary byte — and that NUL comparison is precisely the safety property kindIs depends on (test/nodekindcheck.sh arm B proves the absence of a read past it with an mprotect(PROT_NONE) guard page). Folding them together would erase the one property being gated. Gate: test/nodekindcheck.sh, 4 arms, 1,348,096 enumerated (candidate, literal) pairs against std::strcmp plus two mutation controls that each turn an arm red. @@ -1047,6 +1049,7 @@ ack short-horizon-churn b792d6faac289d2e 154 cid=89cca6bb691a6095 C1 F-06/F-07/F ack short-horizon-churn b7da91c05624ab1b 6 cid=12ba71ea2a2cbf3d OPTREMARKS F3 (docs/OPTREMARKS.md §8b): the ~430 per-AST-node std::strcmp( t, "literal" ) sites in the five ingest walk sections become rw::kindIs (src/infra/nodekind.h) — an inline compare, because strcmp is an external symbol LTO cannot inline and on macOS costs two dyld stub hops before it starts. Measured: 10.6% of busy CPU in strcmp leaves on a cold llvm run, 6-12% on four other corpora; 0.23% after. Output byte-identical across 7 corpora x 5 verbs, argvdiffcheck 640/642 vectors identical (the 2 that differ are the +dirty build stamp in --version). WHAT THESE ROWS ARE. (a) 95 short-horizon-churn rows, churn=self: the mechanical rewrite touches essentially every function in ingest_{metrics,binds,sidecap,relations,names}.h, so every one of them shows this lane's own single edit. Not thrash — one commit. (b) 7 duplication rows and 1 new-clone-of-reused-helper. These are REAL new clone groups (--clones, uncapped: 407 groups before, 409 after; the rewrite adds 7 and removes 5) and they are IDIOM COLLISIONS, not copies. kindIs( t, "x" ) is shorter than std::strcmp( t, "x" ) == 0, so short predicate bodies that were previously above the clone threshold now match each other's normalized token stream. Six of the seven pair a node-kind || -chain with an unrelated || -chain over a DISJOINT literal set in a different subsystem — cc_isParamList (tree-sitter parameter-list kinds) against predicatePrefixed (English name prefixes is/has/can), against sliceIsJsPatternKind (JS destructuring kinds), rubyCallIsAssignmentTarget against slice.h's JS binding probes. The tool's own rule is that two ladders over the SAME enum are a copy; these share no non-keyword identifier and no domain, and merging any pair would need a helper parameterised on an unrelated literal table — a wrong abstraction to satisfy a lint. The seventh, kindIs | lexTokenEqualsLowered, is the same shape at 56 tokens with materially different contracts: lexTokenEqualsLowered takes an explicit length and case-folds one side, kindIs takes its length from the literal's type and compares the terminating NUL as an ordinary byte — and that NUL comparison is precisely the safety property kindIs depends on (test/nodekindcheck.sh arm B proves the absence of a read past it with an mprotect(PROT_NONE) guard page). Folding them together would erase the one property being gated. Gate: test/nodekindcheck.sh, 4 arms, 1,348,096 enumerated (candidate, literal) pairs against std::strcmp plus two mutation controls that each turn an arm red. ack short-horizon-churn b7e9e25ba704623a 2 cid=7a87da721dc19be4 by=src/* rung 3 flow-sensitive reaching definitions (docs/EVALS.md 'Flow-sensitive slice in the small', 2026-09-03, lane/n6-b). edgesOf: the contract change IS the point — the diff now reads the scan's reach table (scan, rowOfOcc, cap) so rows/flow/diff share one edge oracle. sliceEmitBody +4 cx: the rd= emission per use row (its formatting already factored into sliceAppendReachAttr). The three duplication rows are the idiomatic tree-sitter named-child loop (seq/hasStructureBelow vs ingest helpers) and a two-way family ternary — no shared logic to lift. short-horizon-churn = self-churn of the two files this lane owns. sliceLegendText +11 lines = the registered per-construct disclosures the band requires on the first screen. ack short-horizon-churn b8c5550b5e3dc150 6 cid=200408e3ce35c6b8 lane T 2026-09-05: install.sh --hook banner re-worded to disclose the v3 capture (Edit/Write targets, MCP symbol/file arguments); the matcher rewrite is the fix for MCP rows being invisible (hookcheck section 14) +ack short-horizon-churn b8fb362316309156 13 cid=ced21816b0a38c38 Review of #214, round 2. The section now cuts over its own grouped, escaped rendering and the run-hint clause is gated on the row COUNT the emitter reports, so the seams that carry those two facts changed shape together and every gating row is that one change. api-surface writeFlipHeader 4->5: ONE parameter, the count of test files the caller already rendered, so the header can gate its clause on the rows the document will carry - a flip with paid 180 B for a rule about rows it has none of, and the header cannot see the corpus to count them itself (one caller, updated in the same commit). duplication/new-clone renderToString | chargeSection: chargeSection is the est_tokens family's FAULT-INJECTABLE buffer (openChargeBuffer is constexpr-false in release and is what estchargecheck degrade arm switches on), so it cannot route through a plain open_memstream seam without deleting the only reachable degrade path the gate has; mcpverbs captureXml WAS folded into the seam in this same commit rather than acked. short-horizon-churn churn=self on writeFlip/writeFlipHeader/writeHandoffPacket/writePrContext/partitionTestRows/dispatchMcpLine is this lane own footprint across the two rounds of one item - the E1 landing, the CI budget fix, the first review round and this one - none foreign, none thrash. ack short-horizon-churn b9a3c1188021d834 11 §L3: short-horizon-churn is an unavoidable byproduct of adding the grammars=/eligible_files=/of_files= disclosure to --match's emission point, which lives inside runLint — the function that already serves both --match and --lint. No way to add this output without editing it. ack short-horizon-churn ba2bfbd5e821ec8b 8 verifier F1/F2/F4/F5 round: short-horizon-churn=self on printUsage / formatRecallHeader / serialize / collectSources / emitRunLintSarif / kSkippedLegend / runLint / runSkipped is THIS change's own edit window and nothing else. Every finding of every other kind this round opened was FIXED rather than acked: emitLintSarif's complexity 11->20, verbosity 44->69, params 5->6 and api-surface 5->6 all went back to baseline by extracting writeSarifRuleDecl / writeSarifResult / writeSarifRunProperties and folding the run-level facts into one SarifRunProperties struct instead of a parameter per disclosure; emitRunLintSarif's params 8->12 went back to 8 by taking the MainDispatch every other handler already takes; formatRecallHeader's verbosity 55->67 went back by moving the rationale above the function. What is left is structural: a disclosure gap cannot be closed without editing the verb that has it. pruned_dirs= requires collectSources (the crawl branch that prunes) and runSkipped+kSkippedLegend (the verb and legend that report it) and printUsage (the flag's own help text); the SARIF selection/applicability crossover requires emitRunLintSarif and its runLint call site; the recall lines= clause requires formatRecallHeader. Same shape as the §L1 skip-taxonomy ack already in this file, which records exactly this set for exactly this reason. Gates: test/skipreasoncheck.sh arms 8+9 and test/sarifcheck.sh arms 8-10 both RED against a pristine HEAD binary and green after; pargates 415 green (412 pass, 3 skip, 0 fail); determinism + xmllint + SARIF JSON-validity all clean. ack short-horizon-churn baaadc036e16abed 39 cid=edac6f040440c34a recall CLI/MCP scorer unification: the one gating row is short-horizon-churn churn=self on mcpverbs.h::recallText (was=0 now=39), which is this lane's own edit to that symbol and not new debt - recallText SHRANK from a captureXml lambda over writeRecall to a single recallFor call, and the churn number counts the lines this commit moved through it. The two remaining new-symbol rows are recall.h::recallFor itself: api-surface new-symbol (it IS the new shared seam both front doors call, which is the point of the change) and params 0->8, the union of what buildRecall (8 params) and lexicalScores need to rank-then-build - no argument is new information, they are the arguments the two call sites already passed separately, moved to the one place neither door can now diverge from. The two ambient churn rows on serialize.h packBodies/packSignatures are comment-only edits repointing a stale writeRecall reference to buildRecall. CLI --recall output byte-identical to a pre-change binary on three corpora; 34 targeted gates green including the new test/recallparitycheck.sh (red on 8 of 12 arms pre-fix). @@ -1086,7 +1089,7 @@ ack short-horizon-churn d3afca728392d688 6 cid=5765fc3aca7af273 OPTREMARKS F3 (d ack short-horizon-churn d42c85b67bd0956f 33 cid=30fecf6174abfa33 E1/A4-2: every tests_to_run emitter re-routed through testmap.h's one seam (testRowsRendered) so runner-less rows group by evidence; the 12 sites were rewritten in-window by M21(b) and F1, and touching that family once more is the item itself | prior: C1 F-06/F-07/F-10 (the listing-paging round): three listing verbs learn to disclose and page their row listings, and every gating row is that one change. api-surface 14 = ONE trailing DEFAULTED parameter each (an int pageOffset, an McpPageArgs window, a SituPageArgs, or the next= invocation a header now carries) on the emitters that must be TOLD their window — writeFlags/writeGate, writeCappedRows/writeCappedList/writeFlip/writeFlipHeader/writeFlipLights, computeFlip, nearestGateNames (which gains its cap and its TOTAL, the disclosure itself), situShowingNote/writeSituation, and the three MCP twins flagsText/flipText/situationDiffJson; additive by construction, every pre-existing call site compiles unchanged, and the alternative — a second capped emitter per verb — is the drift this repo removes rather than adds, because two emitters that disagree about a window can drop the row that IS the answer. verbosity 3 = kDocDriftLegend +20 lines and writeDocDriftPage +10 are the in-band vocabulary a reader needs to read shown_failed=/failed_capped=/failed_total= where they meet it (the rationale and the next= scan were already hoisted OUT of the body into their own function and comment, which took the complexity row to zero and the LOC row from +48 to +10); dispatchMcpLine +12 is two pagedResult wrappers on a pre-existing 1376-line dispatcher this lane adds to rather than creates. complexity 1 = the same dispatcher, +9 on a base of 518. duplication 1 = flagsText | flipText at 110 tokens, down from 131 after the shared mcpRowCap fold; the residual is getIndex + compute + captureXml, the shape EVERY index-backed MCP twin in this file has, and merging two verbs that return different results behind one entry point would be worse code than the clone. short-horizon-churn 19 = this lane's own footprint across cli.h/docdrift.h/darkflags.h/flipimpact.h/situ.h/mcp*, plus cli.h symbols three other lanes touched the same day; none foreign, none thrash. ack short-horizon-churn d44595768a7cf3af 2 cid=21539523996cfb13 rich-ingest promotion for the eval verbs: --eval-retrieval/--eval-mined/--eval-skills now request captureValueUses so lexicalScoresTiered takes its persisted-stats path instead of re-tokenizing the corpus per query (94% of eval user time). short-horizon-churn rows are this session's repeated edits to those functions, not instability; verbosity on dispatchMain is one added comment. Scores byte-identical, postingscheck ALL PASS. ack short-horizon-churn d557a0077677ebd4 39 cid=7e6e8b041d54e368 P7 (terminality round A, lane R): the flat rank-ordered lens — ambient short-horizon churn on the JSON collector and its row structs (fileSlot field, flat emission), on fromTraceBundleText's legend line ('rows in r= order, p=file'), and +2..4 LOC on trimSigLadder (rank-major step F comment), narrowLegoToRenderedSigs (row p= scan) and packSignaturesJson (flat emission loop) — the P7 change itself; gate test/forrankordercheck.sh; --eval-retrieval byte-identical -ack short-horizon-churn d63db6944aa504a7 43 cid=70fc957435aa6310 lane for-widen (L-W): the file page, coverage= and the thin next= land in the three --for dispatch/emit bodies the 0.6.1 lanes touched this week — this lane's own churn, not thrash | prior: lane/helptask-precision 2026-09-10, MCP no_route (audit F-R1-07): all eight gating rows are this one change and nothing rides with it. api-surface forTaskText 4->5 and packTaskText 5->6 params: ONE DEFAULTED bool each (noRoute), so every pre-existing call site compiles unchanged and was verified to; the alternative — a second overload per verb — is the clone seam this repo removes rather than adds. complexity +4 on each of the same two: the four !noRoute gates are a MIRROR of verbs_for.h's own four (cfg.noRoute gates the shape demotion, the mention anchor, the co-change prior and the route note), and collapsing them would be the MCP dialect deciding for itself what --no-route means — the exact drift mcpforparitycheck exists to prevent. Both were already far over the ccx bar (55/29) before this change; decomposing forTaskText is its own round. short-horizon-churn churn=self on dispatchMcpLine, kMcpVerbFields, forTaskText and packTaskText is the footprint of having edited four symbols this window already touched. FIXED rather than acked in the same pass: the verbosity row on dispatchMcpLine (1376 -> 1387) is gone — the second hand-rolled five-line boolean accumulate was replaced by ONE guarded boolArg reader that post_check now shares, the rule intArg already states for the numeric fields, netting the dispatcher SMALLER than before. Manifest re-anchored 41300 -> 41650 for obliged schema bytes only: descriptions are BYTE-IDENTICAL at 19632 B +ack short-horizon-churn d63db6944aa504a7 44 cid=feca94020cbd1e3a Review of #214, round 2. The section now cuts over its own grouped, escaped rendering and the run-hint clause is gated on the row COUNT the emitter reports, so the seams that carry those two facts changed shape together and every gating row is that one change. api-surface writeFlipHeader 4->5: ONE parameter, the count of test files the caller already rendered, so the header can gate its clause on the rows the document will carry - a flip with paid 180 B for a rule about rows it has none of, and the header cannot see the corpus to count them itself (one caller, updated in the same commit). duplication/new-clone renderToString | chargeSection: chargeSection is the est_tokens family's FAULT-INJECTABLE buffer (openChargeBuffer is constexpr-false in release and is what estchargecheck degrade arm switches on), so it cannot route through a plain open_memstream seam without deleting the only reachable degrade path the gate has; mcpverbs captureXml WAS folded into the seam in this same commit rather than acked. short-horizon-churn churn=self on writeFlip/writeFlipHeader/writeHandoffPacket/writePrContext/partitionTestRows/dispatchMcpLine is this lane own footprint across the two rounds of one item - the E1 landing, the CI budget fix, the first review round and this one - none foreign, none thrash. | prior: lane for-widen (L-W): the file page, coverage= and the thin next= land in the three --for dispatch/emit bodies the 0.6.1 lanes touched this week — this lane's own churn, not thrash ack short-horizon-churn d6e55b78e3a5fdda 7 cid=31161fe2ed1af5a5 OPTREMARKS F3 (docs/OPTREMARKS.md §8b): the ~430 per-AST-node std::strcmp( t, "literal" ) sites in the five ingest walk sections become rw::kindIs (src/infra/nodekind.h) — an inline compare, because strcmp is an external symbol LTO cannot inline and on macOS costs two dyld stub hops before it starts. Measured: 10.6% of busy CPU in strcmp leaves on a cold llvm run, 6-12% on four other corpora; 0.23% after. Output byte-identical across 7 corpora x 5 verbs, argvdiffcheck 640/642 vectors identical (the 2 that differ are the +dirty build stamp in --version). WHAT THESE ROWS ARE. (a) 95 short-horizon-churn rows, churn=self: the mechanical rewrite touches essentially every function in ingest_{metrics,binds,sidecap,relations,names}.h, so every one of them shows this lane's own single edit. Not thrash — one commit. (b) 7 duplication rows and 1 new-clone-of-reused-helper. These are REAL new clone groups (--clones, uncapped: 407 groups before, 409 after; the rewrite adds 7 and removes 5) and they are IDIOM COLLISIONS, not copies. kindIs( t, "x" ) is shorter than std::strcmp( t, "x" ) == 0, so short predicate bodies that were previously above the clone threshold now match each other's normalized token stream. Six of the seven pair a node-kind || -chain with an unrelated || -chain over a DISJOINT literal set in a different subsystem — cc_isParamList (tree-sitter parameter-list kinds) against predicatePrefixed (English name prefixes is/has/can), against sliceIsJsPatternKind (JS destructuring kinds), rubyCallIsAssignmentTarget against slice.h's JS binding probes. The tool's own rule is that two ladders over the SAME enum are a copy; these share no non-keyword identifier and no domain, and merging any pair would need a helper parameterised on an unrelated literal table — a wrong abstraction to satisfy a lint. The seventh, kindIs | lexTokenEqualsLowered, is the same shape at 56 tokens with materially different contracts: lexTokenEqualsLowered takes an explicit length and case-folds one side, kindIs takes its length from the literal's type and compares the terminating NUL as an ordinary byte — and that NUL comparison is precisely the safety property kindIs depends on (test/nodekindcheck.sh arm B proves the absence of a read past it with an mprotect(PROT_NONE) guard page). Folding them together would erase the one property being gated. Gate: test/nodekindcheck.sh, 4 arms, 1,348,096 enumerated (candidate, literal) pairs against std::strcmp plus two mutation controls that each turn an arm red. ack short-horizon-churn d8aaf90801200a68 13 cid=d059a10443da1f80 OPTREMARKS F3 (docs/OPTREMARKS.md §8b): the ~430 per-AST-node std::strcmp( t, "literal" ) sites in the five ingest walk sections become rw::kindIs (src/infra/nodekind.h) — an inline compare, because strcmp is an external symbol LTO cannot inline and on macOS costs two dyld stub hops before it starts. Measured: 10.6% of busy CPU in strcmp leaves on a cold llvm run, 6-12% on four other corpora; 0.23% after. Output byte-identical across 7 corpora x 5 verbs, argvdiffcheck 640/642 vectors identical (the 2 that differ are the +dirty build stamp in --version). WHAT THESE ROWS ARE. (a) 95 short-horizon-churn rows, churn=self: the mechanical rewrite touches essentially every function in ingest_{metrics,binds,sidecap,relations,names}.h, so every one of them shows this lane's own single edit. Not thrash — one commit. (b) 7 duplication rows and 1 new-clone-of-reused-helper. These are REAL new clone groups (--clones, uncapped: 407 groups before, 409 after; the rewrite adds 7 and removes 5) and they are IDIOM COLLISIONS, not copies. kindIs( t, "x" ) is shorter than std::strcmp( t, "x" ) == 0, so short predicate bodies that were previously above the clone threshold now match each other's normalized token stream. Six of the seven pair a node-kind || -chain with an unrelated || -chain over a DISJOINT literal set in a different subsystem — cc_isParamList (tree-sitter parameter-list kinds) against predicatePrefixed (English name prefixes is/has/can), against sliceIsJsPatternKind (JS destructuring kinds), rubyCallIsAssignmentTarget against slice.h's JS binding probes. The tool's own rule is that two ladders over the SAME enum are a copy; these share no non-keyword identifier and no domain, and merging any pair would need a helper parameterised on an unrelated literal table — a wrong abstraction to satisfy a lint. The seventh, kindIs | lexTokenEqualsLowered, is the same shape at 56 tokens with materially different contracts: lexTokenEqualsLowered takes an explicit length and case-folds one side, kindIs takes its length from the literal's type and compares the terminating NUL as an ordinary byte — and that NUL comparison is precisely the safety property kindIs depends on (test/nodekindcheck.sh arm B proves the absence of a read past it with an mprotect(PROT_NONE) guard page). Folding them together would erase the one property being gated. Gate: test/nodekindcheck.sh, 4 arms, 1,348,096 enumerated (candidate, literal) pairs against std::strcmp plus two mutation controls that each turn an arm red. ack short-horizon-churn d9990dc7492a8bb2 20 cid=4646606f648e387e by=src/* member-variable round (card A3): kUsesLegendOpen gains the one-sentence pointer to the member form, buildDefSpanIndex zero-widths field spans so containment attribution is byte-identical, kParserVer 74->75 for the new SymKind::Field + member use-site capture — each a single deliberate edit on a symbol other rounds touched recently @@ -1105,7 +1108,7 @@ ack short-horizon-churn dfc9aa3f1867944b 7 cid=4e20d921b9294272 OPTREMARKS F3 (d ack short-horizon-churn dfdf15c302800e6f 39 cid=c3a68b23304447a1 P7 (terminality round A, lane R): short-horizon churn on the lens legend clauses (kForFileTailLegend/Compact, kPackTaskBundleLegendBody: 'rows in r= order, p= the file') and on the two packers this lane rewrote (packSignatures, sigRowHead) — the P7 shape change itself, not drift; gate test/forrankordercheck.sh ack short-horizon-churn dfe94683f484669b 12 T3 disclosure-gap fix 2026-08-22: verbosity/churn on the two emitters + harness trace persistence are the registered disclosure's own bytes and comments; the gate-helper clone follows the self-contained-MCP-gate convention (every mcp gate carries its own mcp_call) ack short-horizon-churn e0a00f3a5f72168d 22 cid=8df40da9967f24e3 by=src/* rung 3 flow-sensitive reaching definitions (docs/EVALS.md 'Flow-sensitive slice in the small', 2026-09-03, lane/n6-b). edgesOf: the contract change IS the point — the diff now reads the scan's reach table (scan, rowOfOcc, cap) so rows/flow/diff share one edge oracle. sliceEmitBody +4 cx: the rd= emission per use row (its formatting already factored into sliceAppendReachAttr). The three duplication rows are the idiomatic tree-sitter named-child loop (seq/hasStructureBelow vs ingest helpers) and a two-way family ternary — no shared logic to lift. short-horizon-churn = self-churn of the two files this lane owns. sliceLegendText +11 lines = the registered per-construct disclosures the band requires on the first screen. -ack short-horizon-churn e14feca13c7ae680 12 cid=89b4fab9e89a8924 E1/A4-2: every tests_to_run emitter re-routed through testmap.h's one seam (testRowsRendered) so runner-less rows group by evidence; the 12 sites were rewritten in-window by M21(b) and F1, and touching that family once more is the item itself | prior: cap-disclosure lane (2026-09-10), --from-trace + --handoff: every row is this one change's own footprint. The gating churn=self row is renderTraceBlock, whose two emitTo format strings are exactly where the name_ladder_capped= attribute has to be written - the file is hot this week, so modifying its emitter at all reads as in-window thrash; nothing was rewritten, two attribute slots were added. partitionTraceFrames 14->16 is the ladder-total plumb (one local + one push into the parallel unresolvedLadderTotal vector). The api-surface rows are the new conditional-disclosure seams (NameLadder, TraceNameBinding, nameLadderAttr/hasNameLadderCut/ladderLegendOf, VerifiedFileRow/verifiedFileRow, kNameLadderLegend, kHandoffSymsCapClause) - all header-inline, all with their own call sites in the same commit. writeHandoffPacket got SMALLER: extracting verifiedFileRow removed its inner symbol loop, so its pre-existing complexity 57 and 240 LOC both fell below baseline instead of growing. Gate: test/tracehandoffcapcheck.sh, red-first against the parent binary. +ack short-horizon-churn e14feca13c7ae680 13 cid=3f37ac1bf2682450 Review of #214, round 2. The section now cuts over its own grouped, escaped rendering and the run-hint clause is gated on the row COUNT the emitter reports, so the seams that carry those two facts changed shape together and every gating row is that one change. api-surface writeFlipHeader 4->5: ONE parameter, the count of test files the caller already rendered, so the header can gate its clause on the rows the document will carry - a flip with paid 180 B for a rule about rows it has none of, and the header cannot see the corpus to count them itself (one caller, updated in the same commit). duplication/new-clone renderToString | chargeSection: chargeSection is the est_tokens family's FAULT-INJECTABLE buffer (openChargeBuffer is constexpr-false in release and is what estchargecheck degrade arm switches on), so it cannot route through a plain open_memstream seam without deleting the only reachable degrade path the gate has; mcpverbs captureXml WAS folded into the seam in this same commit rather than acked. short-horizon-churn churn=self on writeFlip/writeFlipHeader/writeHandoffPacket/writePrContext/partitionTestRows/dispatchMcpLine is this lane own footprint across the two rounds of one item - the E1 landing, the CI budget fix, the first review round and this one - none foreign, none thrash. | prior: E1/A4-2: every tests_to_run emitter re-routed through testmap.h's one seam (testRowsRendered) so runner-less rows group by evidence; the 12 sites were rewritten in-window by M21(b) and F1, and touching that family once more is the item itself ack short-horizon-churn e17ff0a2142942c4 4 PHP + Lua language port (lane/lang-php-lua, 2026-08-21). All SEVEN remaining gating rows are the SAME class — short-horizon-churn with churn=self, i.e. 'this symbol was edited recently and you edited it again'. That is this change's own edit window, not a history signal, and every one of the seven is a site a language port CANNOT avoid touching: (1) src/model.h::Lang — the enum gains Php(18)/Lua(19); appending is the only safe move (inserting would renumber every on-disk cache key). (2) src/ingest.cpp::kLangTable — the extension->grammar rows for .php/.phtml/.lua, plus the extent 37->40 the compiler enforces. (3) src/main.cpp::computeLangCounts — its two tallies are sized on the LAST enum member, so a new member is a mechanical edit there by construction. (4) src/clones.h::kHashLineCommentLangMask — PHP joins (# IS a PHP line comment), Lua does not (its comment is --, and #t is the length operator). (5) src/lintrules.h::dependencyCapable — PHP true (namespace_use_declaration is captured), Lua false (require is an ordinary call, like Ruby). (6) cc_walk and (7) ev_noteNode — both call isDecisionType/cc_isNestingControl, which now take a Lang so Lua's do...end (a bare scope block, NOT a loop) stops being counted as a decision; every other language is byte-identical. The STRUCTURAL regressions this round did produce were FIXED, not acked: cc_walk +12 cx / +13 LOC from the inline boolean-operator test was extracted to cc_isBooleanJoin, and the duplication that extraction then created against cc_boolOp was removed by giving both ONE shared cc_operatorText. Gates: test/phpcheck.sh + test/luacheck.sh, both shown red (36 and 21 failing arms) against a cd30104-built binary. ack short-horizon-churn e1f67e3817c9c097 7 cid=ff9ddf76360520be OPTREMARKS F3 (docs/OPTREMARKS.md §8b): the ~430 per-AST-node std::strcmp( t, "literal" ) sites in the five ingest walk sections become rw::kindIs (src/infra/nodekind.h) — an inline compare, because strcmp is an external symbol LTO cannot inline and on macOS costs two dyld stub hops before it starts. Measured: 10.6% of busy CPU in strcmp leaves on a cold llvm run, 6-12% on four other corpora; 0.23% after. Output byte-identical across 7 corpora x 5 verbs, argvdiffcheck 640/642 vectors identical (the 2 that differ are the +dirty build stamp in --version). WHAT THESE ROWS ARE. (a) 95 short-horizon-churn rows, churn=self: the mechanical rewrite touches essentially every function in ingest_{metrics,binds,sidecap,relations,names}.h, so every one of them shows this lane's own single edit. Not thrash — one commit. (b) 7 duplication rows and 1 new-clone-of-reused-helper. These are REAL new clone groups (--clones, uncapped: 407 groups before, 409 after; the rewrite adds 7 and removes 5) and they are IDIOM COLLISIONS, not copies. kindIs( t, "x" ) is shorter than std::strcmp( t, "x" ) == 0, so short predicate bodies that were previously above the clone threshold now match each other's normalized token stream. Six of the seven pair a node-kind || -chain with an unrelated || -chain over a DISJOINT literal set in a different subsystem — cc_isParamList (tree-sitter parameter-list kinds) against predicatePrefixed (English name prefixes is/has/can), against sliceIsJsPatternKind (JS destructuring kinds), rubyCallIsAssignmentTarget against slice.h's JS binding probes. The tool's own rule is that two ladders over the SAME enum are a copy; these share no non-keyword identifier and no domain, and merging any pair would need a helper parameterised on an unrelated literal table — a wrong abstraction to satisfy a lint. The seventh, kindIs | lexTokenEqualsLowered, is the same shape at 56 tokens with materially different contracts: lexTokenEqualsLowered takes an explicit length and case-folds one side, kindIs takes its length from the literal's type and compares the terminating NUL as an ordinary byte — and that NUL comparison is precisely the safety property kindIs depends on (test/nodekindcheck.sh arm B proves the absence of a read past it with an mprotect(PROT_NONE) guard page). Folding them together would erase the one property being gated. Gate: test/nodekindcheck.sh, 4 arms, 1,348,096 enumerated (candidate, literal) pairs against std::strcmp plus two mutation controls that each turn an arm red. ack short-horizon-churn e3c54d39d366fbaa 33 cid=c62e78b54758b435 E1/A4-2: every tests_to_run emitter re-routed through testmap.h's one seam (testRowsRendered) so runner-less rows group by evidence; the 12 sites were rewritten in-window by M21(b) and F1, and touching that family once more is the item itself | prior: M12 (lane L9, capture-audit-2026-09-04): the deliberate cost of one root-relative path spelling across --affected/--test-gate/edit receipts/fetch_body plus the in_id= legend trim. runAffected grows the same mvSingleRoot/mvRootPrefix/mvRootAttr block verbs_report.h's dispatcher already threads (complexity 13->18, verbosity +17, mostly the comment naming the finding); writeTestGateReport/Json's duplication is the XML/JSON twin pair staying in lockstep, which is the property mcpclidiffcheck asserts; every short-horizon-churn row is this lane editing its own targets three times in one afternoon. diff --git a/CHANGELOG.md b/CHANGELOG.md index e0f024419..20244f6e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,27 +25,36 @@ no harness has a runner that was the same 16 or 23 bytes repeated once per row: path), so runner-less rows whose per-row attributes are byte-equal are now served as one row, `` (JSON: `"p"` — or `"test"` — becomes an array beside `"n"`; text: `[hops=2] (17): a, b, c (run: not derivable)`), emitted where its first member stood. Rows -with a runner stay single, a group of one stays a `` row, a comma inside an XML path is `,`, and -every path is kept verbatim — the multiset of paths before and after is identical and so is their ORDER, -which is what `test/testrowruncheck.sh` arm 12 proves on a fixture with three hop groups and a runner row +with a runner stay single, a group of one stays a `` row, a path that contains a comma is never grouped +at all (`p=` is a comma-separated list and every XML parser undoes an entity before a consumer splits on the +delimiter, so an escaped comma would reappear as a separator and `n=` would disagree with what the reader +counts — the text twin had no escape to undo), and every path is kept verbatim — the multiset of paths +before and after is identical and so is their ORDER, which is what `test/testrowruncheck.sh` arm 12 proves +on a fixture with three hop groups and a runner row in the middle of one of them, in all three dialects (red on the previous binary). All twelve emitters — `--affected`, `--exercises`, `--test-gate` XML and JSON, `--situ`, `--pr-context`, `--handoff`, `--flags --flip`, `--pack-task` XML and JSON, the MCP `situational_awareness` twin and the edit receipt — render through one seam in `testmap.h`, and the M21(b) rule keeps its meaning: a `` or `` row carries `run=` or `run_unknown="1"`, never neither. Measured on RocksDB (`wc -c`, same cache, -same commit): `--affected=db/write_batch.cc` 10,668 → 6,878 B, `--test-gate=db/write_batch.cc` 13,242 → -9,633 B (its JSON 11,055 → 7,163 B), `--situ=db/write_batch.cc` 11,769 → 7,357 B; 8 `` rows replace -124 single rows (a group covers a contiguous run only, so the one runner row inside the hops=2 tier splits -it in two — order is preserved by construction, `test/testrowruncheck.sh` arm 12 reads the paths back in -emitted order) and the residual spent on the disclosure is 160 B (XML) and 230 B (text) per list. -`--pack-task`'s byte-budgeted tests section caps a group at its own budget — applied before every join, so -two paths that each fit alone are never joined into one row the section then rejects whole (arm 13 sweeps -the budget and requires the first section that fits to be one singleton) — and counts `shown=`/`total=` in -files, so the same bundle now names 54 of 109 tests where it named 28. On this tree every harness -has a runner, so nothing groups and the only change is the legend that now defines ``: the -`--test-gate` legend pin moves 2,720 → 2,900 B (measured 2,843) and the `ripwire.pack-task/v1` compact -pin 820 → 880 B (measured 865), both because the compact dialect and every rows-bearing full legend now -define `run_unknown=` and `` — a definition `--affected` and the compact dialect never carried. +same commit): `--affected=db/write_batch.cc` 10,668 → 6,992 B, `--test-gate=db/write_batch.cc` 13,242 → +9,747 B (its JSON 11,055 → 7,163 B), `--situ=db/write_batch.cc` 11,769 → 7,357 B, and in the compact +dialect 9,312 → 5,313 B and 11,223 → 7,596 B; 8 `` rows replace 124 single rows (a group covers a +contiguous run only, so the one runner row inside the hops=2 tier splits it in two — order is preserved by +construction, `test/testrowruncheck.sh` arm 12 reads the paths back in emitted order) and the residual +spent on the disclosure is 160 B (XML, 10 `run_unknown="1"`) and 230 B (text) per list. +`--pack-task`'s tests section is byte-budgeted, so it CUTS over its own grouped, escaped rendering: it takes +the largest prefix of the row list whose rendered `` body fits the section budget, found by bisection +(the rendered size is monotone in the prefix length, so the bisection is exact), and counts `shown=`/`total=` +in test files. Measured on RocksDB with `--pack-task="change WriteBatch::Put"` at the default 6,000-token +budget, `wc -c`, same cache, same commit: `` where the pre-E1 bundle named 28, +the whole bundle 11,993 → 12,490 B. On this tree every harness has a runner, so nothing groups and the only +change is the legend that now defines ``: the `--test-gate` legend pin moves 2,720 → 3,000 B (measured +2,957) and the `ripwire.pack-task/v1` compact pin 820 → 880 B (measured 865), both because the compact +dialect and every rows-bearing full legend now define `run_unknown=` and `` — a definition +`--affected` and the compact dialect never carried. The MCP manifest ceiling moves 42,384 → 42,800 B +(measured 42,777) for one 207-byte clause spliced into the two tool descriptions that serve these rows as +JSON: `situational_awareness` and `explore` return bare JSON with no legend of any kind, so a caller that +reads `p` as a string has nowhere else to learn that it can be an array. The clause is rows-gated everywhere it is spliced — `--affected`, `--exercises`, `--pack-task`, the partitioned bundle, and `--pr-context`, whose legend precedes its files in the STREAM but is now decided after them: the chosen body is rendered first, the pricer charges the clause per candidate trim level from @@ -54,7 +63,39 @@ legend and the delivered legend cannot disagree. A corpus-level predicate over-a file outside the selected range, or a trim level whose `testCap` is 0, bought the clause for a document with no row — and both now pay nothing (measured on `test/defaultceilingcheck.sh`'s 120-file, no-test fixture: unconditional, the default bundle went 7,989 → 8,025 tokens over its 8,000 budget; gated, 7,989; -`test/prcontextcheck.sh` pins all four sides, red first). +`test/prcontextcheck.sh` pins all four sides, red first). The clause is gated on a COUNT the emitter +reports with its rows, never on a search of the rendered bytes: `--pr-context` charges the trim level's own +row count and the partitioned bundle sums what each slice kept. Asking the bytes was wrong twice — a +`--pr-context` body and a `--pack-task` slice can both carry the literal text of the element inside CDATA, +and `--pack-task="write_report" --partition=2` over a two-file corpus with no test at all bought the outer +clause because one body prints `` (`test/testrowruncheck.sh` arm 15, red first). `--handoff` +and `--flags --flip` spliced the clause unconditionally and now ask the same count; `--handoff` is +byte-budgeted with heuristic rows dropped tail-first, so on a packet with no test row the 180 B it was +paying could evict a real row (arm 14, red first). + +Two byte-accounting rules changed with it. `--pack-task`'s tests section used to group FIRST and cut the +group rows with the generic list cutter under a per-row cap whose estimate was computed on UNESCAPED path +bytes, so a corpus whose test paths hold `&` or `<` rendered wider than the cap admitted; the cutter breaks +at the first over-budget entry, so the whole tail of the section went with it — `run=` singles included. +Measured on a matched pair of ten-test fixtures differing in one byte per name (`&` against `_`) at +`--token-budget=1440`: the control named 5 files and the `&` fixture named none. Cutting over the grouped, +escaped rendering fixes it and is strictly better than cutting the single rows and grouping afterwards, +which would have been safe but spends fewer of its bytes (2 files where grouping-first served 5); across +budgets 1440–1860 the new cut names 6–11 files against the old 5–11, and the `&` fixture never empties +(`test/testrowruncheck.sh` arm 13). And `--pr-context`, which must render a level to price it, rendered +through a helper that returned an empty string on an `open_memstream` failure with no alert at all — a +document could have shipped its legend, root and closing tag around an empty body claiming +`truncated="none"`. Every such render now goes through one seam in `infra/emit.h` (`rw::renderToString`, +which `packtask.h` already had in its own spelling) that reports the failure, and both `--pr-context` exits +fall back to streaming the level straight out: complete, correct bytes, a modelled estimate, and a +`DEGRADED_PATH_ALERT` saying which — serialize.h's own degrade contract. + +Nine gates assert something about these rows, and each had its own reader: since a row can now name several +files, `grep -oE '"tests_to_run":\[[^]]*\]'` stopped at the first `]` (the end of the first group's path +array, so three arms asserted over two and a half rows and passed vacuously), `sed`-based XML readers saw +only the single rows, and the text reader took `$1` of a line that on a group line is `[hops=1]`. They all +want the same thing — the files named, in emitted order — so they now all ask `test/testrowpaths.py`, one +reader for all three dialects and both row shapes. ### Added — --for pages its answer one file per row, and says when to widen diff --git a/skills/ripwire-mcp/SKILL.md b/skills/ripwire-mcp/SKILL.md index b234b55aa..6d4114a51 100644 --- a/skills/ripwire-mcp/SKILL.md +++ b/skills/ripwire-mcp/SKILL.md @@ -96,7 +96,7 @@ enclosing-chain report) and `@FILE:LINE` in any SYM selector; contract gate: `te | `grep` (`pattern`) | `--grep=STR` | parallel literal scan + enclosing symbol + matched line | | `cochange` (`file`) | `--cochange=FILE` | the lockstep git partners of one file | | `memory_recall` (`task`, `top_k` + `budget_tokens` optional) | `--recall=TASK [--top-k=N] [--max-tokens=N]` | full bodies of the few relevant docs/memory notes, bounded by the SAME default 8000-token body ceiling as the CLI (the header discloses `max_tokens=` and every cut). `budget_tokens` raises the ceiling explicitly when you want everything; `top_k` (default 8) shapes how many docs | -| `situational_awareness` (`diff`/`files` optional) | `--situ` | blast radius, tests_to_run, forgotten co-change partners (the Shotgun Surgery check), hotspot alert — as JSON; defaults to `git diff HEAD` | +| `situational_awareness` (`diff`/`files` optional) | `--situ` | blast radius, tests_to_run, forgotten co-change partners (the Shotgun Surgery check), hotspot alert — as JSON; defaults to `git diff HEAD`. In `tests_to_run` (here, in `explore` and on an edit receipt) a row's `p` is a path string OR an **array** of paths beside `n` — several runner-less tests sharing their attributes, served as one row — and every row carries `run` or `run_unknown: true` | | `mentions` (`symbol`) | `--mentions=SYM` | which markdown plans/designs discuss a symbol | | `owners` (`symbol` optional) | `--owners[=SYM]` | bus-factor: recency-weighted author ownership | | `lego` (`type`) | `--lego=TYPE` | an interface's method contract + every implementor (own-language) | diff --git a/src/flipimpact.h b/src/flipimpact.h index 528d5cab8..f89654858 100644 --- a/src/flipimpact.h +++ b/src/flipimpact.h @@ -1140,8 +1140,11 @@ inline constexpr const char* kFlipRowLegend = // The doc comment, the `` header attributes, and the four situational rows that qualify them // (already-lit / also / parent / capped) plus the family roll-up. +// `testFilesRendered` is the count testmap.h's seam returns for the listing this header introduces — +// review of #214: the run-hint clause was spliced unconditionally, so a flip with paid 180 B +// for a rule about rows it has none of. The caller renders the rows first and passes the count it got. inline void writeFlipHeader( std::FILE* out, const FlipResult& res, const XmlEscaper& ex, - const std::string& nextInvocation = std::string() ) + const std::string& nextInvocation, std::size_t testFilesRendered ) { rw::emitTo( out, "", - // M21(b): the run=/run_unknown= rule, from testmap.h's ONE constant. - std::string( rw::kRunHintLegendClause ).c_str(), kFlipRowLegend ); + // M21(b): the run=/run_unknown= rule, from testmap.h's ONE constant — rows-gated, through + // the ONE gate every other legend asks (runHintClauseIfRows). + std::string( rw::runHintClauseIfRows( testFilesRendered ) ).c_str(), kFlipRowLegend ); rw::emitTo( out, " listing is rendered HERE, before the header, so the header's run-hint clause + // can be gated on the rows this document will actually carry. TestRunnerIndex stays lazy — it reads a runner + // script only when asked about a file, and an empty res.tests asks about none. + const rw::TestRunnerIndex flipRunners( ing ); + const rw::JoinedTestRows flipTests = rw::testRowsList( flipRunners, rw::testRowsOutOf( res.tests, rel ), + rw::TestRowShape{ rw::RowDialect::Xml, "t" }, ex ); + + writeFlipHeader( out, res, ex, flipNextInvocation( res, maxRows, pageOffset ), flipTests.files ); writeFlipLights( out, res, ing, ex, maxRows, pageOffset ); for( const ValueBinding& b : res.bindings ) @@ -1262,10 +1273,10 @@ inline void writeFlip( std::FILE* out, const FlipResult& res, const IngestResult // own listing has never been windowed for exactly this reason; this listing is the same obligation // read from a different seed, so it is served whole on every page. SIZE_MAX, not maxRows. // E1: rows without a runner are grouped (testmap.h's seam), so the listing is rendered whole and wrapped here - // exactly as writeCappedList wraps an uncut list — `` with no cut attributes, n= the FILE count. - const rw::TestRunnerIndex flipRunners( ing ); + // exactly as writeCappedList wraps an uncut list — `` with no cut attributes, n= the FILE count, + // which is the same number the header's clause was gated on (flipTests.files, rendered above). rw::emitTo( out, "", res.tests.size() ); - rw::emitRaw( out, rw::testRowsJoined( flipRunners, rw::testRowsOutOf( res.tests, rel ), rw::TestRowShape{ rw::RowDialect::Xml, "t" }, ex ).c_str() ); + rw::emitRaw( out, flipTests.text.c_str() ); rw::emitRaw( out, "" ); writeCappedList( out, "untested", res.untested, maxRows, [ & ]( NodeId u ) { diff --git a/src/handoff.h b/src/handoff.h index 3cd88b908..77e9dc47e 100644 --- a/src/handoff.h +++ b/src/handoff.h @@ -279,7 +279,12 @@ inline int writeHandoffPacket( std::FILE* out, const std::string& root, const In // TestRunnerIndex is lazy: a packet with no test row reads no runner script. const rw::TestRunnerIndex hoRunners( ing ); const auto hoEsc = [ & ]( std::string_view t ) { return std::string( escapeXml( t, esc ) ); }; - v += rw::testRowsJoined( hoRunners, rw::testRowsOutOf( facts.tests, hoPathRel ), rw::TestRowShape{ rw::RowDialect::Xml, "t" }, hoEsc ); // E1: where no runner is derivable + // E1: where no runner is derivable. The seam returns the FILE count with the rows, and the legend + // below is gated on it — review of #214: this packet spliced the clause unconditionally, and it is + // BYTE-BUDGETED with heuristic rows dropped tail-first, so a packet with could evict a real + // row to pay 180 B for a rule about rows it has none of. + const rw::JoinedTestRows hoTests = rw::testRowsList( hoRunners, rw::testRowsOutOf( facts.tests, hoPathRel ), rw::TestRowShape{ rw::RowDialect::Xml, "t" }, hoEsc ); + v += hoTests.text; v += ""; // ── heuristic rows, priority order (dropped TAIL-FIRST under a budget) ─────────────────────────── @@ -381,7 +386,7 @@ inline int writeHandoffPacket( std::FILE* out, const std::string& root, const In const auto assemble = [ & ]( std::size_t keepRows, std::size_t withheld ) { std::string doc = kHandoffLegendHead; - doc += rw::kRunHintLegendClause; // M21(b): the ONE wording, spliced — never a seventh paraphrase + doc += rw::runHintClauseIfRows( hoTests.files ); // M21(b): the ONE wording through the ONE gate — never a seventh paraphrase if( anySymsCapped ) { doc += handoffSymsCapClause(); } // absent unless an row was cut doc += kHandoffLegendTail; doc += " +#include #include #include #include #include +#include #include #include #include @@ -151,4 +155,47 @@ template inline std::size_t formatTo( char* buf, std::size_t cap, st return static_cast( r.size ); } +// ── THE render-an-emitter-into-a-string seam ───────────────────────────────────────────────────────── +// An emitter writes to a FILE*. A caller that must MEASURE what it wrote (a budget ladder pricing its own +// document before it commits to a trim level) or REORDER it (a legend whose wording depends on the body that +// follows it in the stream) needs those bytes as a string first. That is one seven-line memstream dance, and +// it was hand-written at each such site. +// +// Review of #214: the copy in prcontext.h returned "" on failure with NO alert, and the unbudgeted +// --pr-context path had just been routed through it — so an open_memstream failure would have shipped a +// legend, a root tag and a closing tag around an EMPTY body, with truncated="none" saying nothing was cut. +// A degrade has to be visible and the caller has to be able to see it: `ok` is false exactly when the buffer +// could not be opened (and then `text` is empty and nothing was written), the alert names the site through +// the caller's own message — "which buffer failed" is the useful half — and the caller then takes its own +// documented path. Never a silent empty body. +struct Rendered +{ + std::string text; + bool ok = false; +}; + +template +inline Rendered renderToString( Emit&& emit, const char* degradeMsg ) +{ + Rendered out; + char* buf = nullptr; + std::size_t sz = 0; + std::FILE* m = open_memstream( &buf, &sz ); + if( !m ) + { + DEGRADED_PATH_ALERT( degradeMsg ); + return out; + } + emit( m ); + std::fflush( m ); + std::fclose( m ); + if( buf ) + { + out.text.assign( buf, sz ); + } + std::free( buf ); + out.ok = true; + return out; +} + } // namespace rw diff --git a/src/mcp.h b/src/mcp.h index 2ce96f295..acafbecb2 100644 --- a/src/mcp.h +++ b/src/mcp.h @@ -129,6 +129,14 @@ inline constexpr std::string_view kAtSeedShortClause = "@FILE:LINE line-seeds re // NAME, so a line-seed cannot narrow the scan — instead it REBINDS to the innermost enclosing definition // and the answer says so ('sym'), the one-step-smart-defaults posture: the call carries the answer, never // a pass-the-name-yourself retry. +// E1 / review of #214: the tests_to_run ROW SHAPE, for the three tools that serve those rows as JSON. The +// rows grew a second shape and these descriptions still promised the first — a caller reading `p` as a +// string breaks on the array, and `run_unknown` appeared nowhere in this file at all. ONE wording, spliced, +// never a fourth paraphrase; the manifest ceiling moves with it, measured, in the same commit. +inline constexpr std::string_view kTestRowJsonShapeClause = + "tests_to_run rows: p is a path STRING, or an ARRAY of paths beside n when several runner-less tests share " + "their attributes and are served as ONE row; every row carries run (the command) or run_unknown:true. "; + inline constexpr std::string_view kAtSeedRebindClause = "An @FILE:LINE line-seed rebinds to the innermost definition enclosing that line and answers for it, " "disclosing the rebound name as 'sym' (see find_symbol for the seed grammar)."; @@ -720,7 +728,7 @@ inline McpDispatchResult dispatchMcpLine( const std::string& line, int topK, boo // while the CLI --recall began honoring --top-k in this round's Wave 1. "{\"name\":\"memory_recall\",\"description\":\"Most relevant memory notes / docs for a task, full text — the few that matter, not the whole corpus. path = docs/memory dir; task = what you're working on; top_k = docs to return, 1..1000 (default 8), refused outside that band, never clamped; budget_tokens = the body ceiling in tokens (default 8000) — it SHAPES to fit, the CLI --recall's --max-tokens, not --token-budget's refuse-if-over GATE, and the header discloses max_tokens= and every cut.\"," + mcprefuse::toolMetadataFor( "memory_recall", pathIsRequired ) + "}," - "{\"name\":\"situational_awareness\",\"description\":\"The 5 things to know about a diff, as JSON: blast_radius, tests_to_run, forgotten (usual co-change partners missing from this diff), hotspot_alert, modules_touched. forgotten = the Shotgun Surgery check. diff/files optional — defaults to 'git diff HEAD'. files is a STRING of comma-separated paths (files=\\\"src/a.cpp,src/b.h\\\"), not an array; an array is refused rather than read as absent, which would answer about the working tree instead of the files you named. limit/offset page blast_radius and forgotten only; with no limit every row is served, as always.\"," + "{\"name\":\"situational_awareness\",\"description\":\"The 5 things to know about a diff, as JSON: blast_radius, tests_to_run, forgotten (usual co-change partners missing from this diff), hotspot_alert, modules_touched. forgotten = the Shotgun Surgery check. diff/files optional — defaults to 'git diff HEAD'. files is a STRING of comma-separated paths (files=\\\"src/a.cpp,src/b.h\\\"), not an array; an array is refused rather than read as absent, which would answer about the working tree instead of the files you named. limit/offset page blast_radius and forgotten only; with no limit every row is served, as always. " + std::string( kTestRowJsonShapeClause ) + "\"," + mcprefuse::toolMetadataFor( "situational_awareness", pathIsRequired ) + "}," "{\"name\":\"mentions\",\"description\":\"Docs (markdown plans/designs) that name a code symbol in a backtick. symbol = the code symbol name; limit/offset page the files. " + std::string( kAtSeedRebindClause ) + "\"," + mcprefuse::toolMetadataFor( "mentions", pathIsRequired ) + "}," @@ -764,7 +772,7 @@ inline McpDispatchResult dispatchMcpLine( const std::string& line, int topK, boo // L4 — the one-call orientation front door + B11 verb parity. `explore` is // the SAME handler as the CLI --pack-task; the older name `pack_task` still dispatches (tools/call // name=="pack_task" works) but is not separately advertised here — see mcp.h's kMcpVerbTable comment. - "{\"name\":\"explore\",\"description\":\"ONE-call task orientation: the routed+anchored ranking, full bodies of the top hits, their 1-hop callers, field notes, and tests_to_run — ALL under one deterministic byte budget, in a fixed section order (ranking > bodies > callers > notes > tests) that degrades gracefully and reports every truncation. Replaces the for -> fetch_body -> find_referencing_symbols -> memory_recall dance when you want the whole orientation at once; for JUST the ranked inventory use 'for'. Same handler as the CLI --pack-task. ALIAS: tools/call name='pack_task' answers this exact tool with these exact arguments (it gets no separate tools/list entry). task = the task in plain words; budget_tokens = optional (default 6000); partition = optional 2..16, refused outside that band — FANNING OUT to N agents on ONE task? Ask for it and get one shared core plus N minimally-overlapping slices carved along the call graph's own communities, budget_tokens then meaning ONE agent's budget. Read overlap_max/split before trusting the slices.\"," + "{\"name\":\"explore\",\"description\":\"ONE-call task orientation: the routed+anchored ranking, full bodies of the top hits, their 1-hop callers, field notes, and tests_to_run — ALL under one deterministic byte budget, in a fixed section order (ranking > bodies > callers > notes > tests) that degrades gracefully and reports every truncation. Replaces the for -> fetch_body -> find_referencing_symbols -> memory_recall dance when you want the whole orientation at once; for JUST the ranked inventory use 'for'. Same handler as the CLI --pack-task. ALIAS: tools/call name='pack_task' answers this exact tool with these exact arguments (it gets no separate tools/list entry). task = the task in plain words; budget_tokens = optional (default 6000); partition = optional 2..16, refused outside that band — FANNING OUT to N agents on ONE task? Ask for it and get one shared core plus N minimally-overlapping slices carved along the call graph's own communities, budget_tokens then meaning ONE agent's budget. Read overlap_max/split before trusting the slices. " + std::string( kTestRowJsonShapeClause ) + "\"," + mcprefuse::toolMetadataFor( "explore", pathIsRequired ) + "}," "{\"name\":\"from_trace\",\"description\":\"Paste a stack trace / sanitizer report / compiler error and get it mapped onto indexed symbols, ranked INNERMOST-first: the parsed frame map, the ranked suspects' signatures, and the innermost in-corpus symbol's FULL body. Out-of-corpus frames are listed and counted, never ranked, and the counters CLOSE (in_corpus = suspects + merged + unresolved). Each frame binds by its own NAME first, falling back to the def enclosing its line only when that name is absent or ambiguous — resolved_by= and any name-vs-line disagreement are disclosed, never silently rebound. Same handler as the CLI --from-trace. A failing-test trace also gets a test_hop block reaching the source symbols behind the assertion, labelled heuristic. trace = the raw trace TEXT (paste it, don't hand-translate it into a query); budget_tokens optional.\"," + mcprefuse::toolMetadataFor( "from_trace", pathIsRequired ) + "}," diff --git a/src/mcpverbs.h b/src/mcpverbs.h index 786ed073e..c30c74289 100644 --- a/src/mcpverbs.h +++ b/src/mcpverbs.h @@ -368,23 +368,13 @@ inline std::string mcpUnknownFieldRefusal( const std::string& scope, std::string return {}; } -// Capture one FILE*-writing renderer into a string. The three verbs below differ only in which writer they -// run, so the open_memstream boilerplate lives here once instead of three times. +// Capture one FILE*-writing renderer into a string — infra/emit.h's ONE renderToString seam with this +// surface's own degrade wording. It kept its own copy of the memstream dance until the review of #214 +// gave the tree a single seam for it; the contract is unchanged (an allocation failure is an empty string, +// never a NULL deref), and it now also ALERTS, which this copy never did. inline std::string captureXml( const std::function& render ) { - char* buf = nullptr; - std::size_t sz = 0; - std::FILE* mem = open_memstream( &buf, &sz ); - if( !mem ) - { - return {}; // alloc failure → empty, never deref NULL - } - render( mem ); - std::fflush( mem ); - std::fclose( mem ); - std::string out = buf ? std::string( buf, sz ) : std::string{}; - std::free( buf ); - return out; + return rw::renderToString( render, "mcp: open_memstream failed — this verb answers empty" ).text; } // full pipeline on a dir → XML captured into a string (captureXml, above). diff --git a/src/packtask.h b/src/packtask.h index 9e17894a1..b4539b5f0 100644 --- a/src/packtask.h +++ b/src/packtask.h @@ -148,10 +148,11 @@ inline constexpr int kPackTaskQuotaTestsPct = 10; // the cascaded remainder static_assert( kPackTaskQuotaRankingPct + kPackTaskQuotaBodiesPct + kPackTaskQuotaCallersPct + kPackTaskQuotaNotesPct + kPackTaskQuotaTestsPct == 100, "pack-task section quotas must sum to 100%" ); -// E1 (2026-09-12): a list entry may carry several UNITS (a test row is one entry of n= files). The cut is -// still per entry (the budget is bytes per entry), but shown=/total= — and the JSON tests_total/tests_kept — -// count units when the caller passes them, so "28 of 109 tests" stays 28 of 109 and never reads as "1 of 4". -struct PackTaskSection { std::string xml; std::size_t kept = 0; std::size_t keptUnits = 0; std::size_t totalUnits = 0; }; +// E1 / review of #214: one entry, one unit again. The section is CUT over single rows — one row per +// test file, whose rendered bytes are exactly what the cut measures — and only the KEPT prefix is grouped +// afterwards (see the tests section below), so shown=/total= count rows and test files at the same time and +// the per-entry `units` arithmetic this struct carried for one release is gone with the estimate that needed it. +struct PackTaskSection { std::string xml; std::size_t kept = 0; }; // W3FIX H2/M1 — the pieces the header comment is made of, so the header can be REBUILT in three shapes (as // built / task echo dropped / that plus route=) for serialize.h's climbCeilingLadder to price. A free function @@ -307,16 +308,9 @@ inline std::string packTaskHeaderText( const PackTaskHeaderParts& p, bool withRo // emitted _total/_kept for the same sections. Fixing the shared helper fixes , , // and in one place, which is why it is fixed here and not at four call sites. inline PackTaskSection packTaskListSection( std::string_view tag, std::string_view extraAttr, - const std::vector& entries, std::size_t budget, std::size_t wrapReserve, - const std::vector* unitsPerEntry = nullptr ) + const std::vector& entries, std::size_t budget, std::size_t wrapReserve ) { PackTaskSection out; - VERIFY( unitsPerEntry == nullptr || unitsPerEntry->size() == entries.size() ); - const auto unitsOf = [ & ]( std::size_t i ) -> std::size_t { return unitsPerEntry ? ( *unitsPerEntry )[i] : 1; }; - for( std::size_t i = 0; i < entries.size(); ++i ) - { - out.totalUnits += unitsOf( i ); - } if( entries.empty() || budget <= wrapReserve ) { return out; @@ -329,7 +323,6 @@ inline PackTaskSection packTaskListSection( std::string_view tag, std::string_vi break; } used += e.size(); - out.keptUnits += unitsOf( out.kept ); ++out.kept; } if( out.kept == 0 ) @@ -337,7 +330,7 @@ inline PackTaskSection packTaskListSection( std::string_view tag, std::string_vi return out; } char open[ 160 ]; - rw::formatTo( open, sizeof( open ), "<{}{} shown=\"{}\" total=\"{}\" capped=\"{}\">", std::string_view( tag.data(), tag.size() ), std::string_view( extraAttr.data(), extraAttr.size() ), out.keptUnits, out.totalUnits, + rw::formatTo( open, sizeof( open ), "<{}{} shown=\"{}\" total=\"{}\" capped=\"{}\">", std::string_view( tag.data(), tag.size() ), std::string_view( extraAttr.data(), extraAttr.size() ), out.kept, entries.size(), out.kept < entries.size() ? 1 : 0 ); out.xml = open; for( std::size_t i = 0; i < out.kept; ++i ) @@ -348,6 +341,88 @@ inline PackTaskSection packTaskListSection( std::string_view tag, std::string_vi return out; } +// ── THE SECTION — the one list whose entries are not independent ──────────────────────────────── +// Every other section here is a list of rows the budget can cut anywhere: entry i costs entry i's bytes, +// whatever its neighbours are. Test rows are not like that. E1 serves a run of runner-less rows with equal +// attributes as ONE row, so admitting one more FILE can cost a whole new row or just `,path` on the row +// already there — the cost of the k-th file depends on the k-1 before it, and packTaskListSection's +// "used += e.size()" has no way to say that. +// +// The first attempt grouped FIRST and handed the group rows to that helper under a per-row byte cap, with a +// cap estimate of `attrs + 48 + Σ( path + 1 )` computed on UNESCAPED paths. Review of #214: a corpus whose +// test paths hold '&' or '<' renders wider than the estimate admitted, the helper broke at the first +// over-budget entry, and the whole tail of the section went with it — `run=` singles included. Measured on a +// ten-test fixture at --token-budget=1440, `_`-named paths served 5 files and the same fixture with `&` in +// every name served NONE: the section vanished rather than shrank. +// +// So this section CUTS WHERE THE BYTES ARE. It asks the only question that matters — what is the largest +// PREFIX of the row list whose GROUPED, ESCAPED rendering fits the budget — and answers it by rendering +// candidate prefixes and measuring them. Cutting a prefix and grouping it afterwards would also have been +// safe (grouping only shrinks), but it cuts over the single rows' bytes and then spends fewer of them: on +// that same fixture it served 2 files where grouping-first served 5, which throws away the win E1 exists for. +// +// The search is a bisection, which is exact because `bodyOf` is monotone in k: extending the prefix by one +// row either appends a row or extends the last group by `,path` (a group of one is rendered as a single +// row, and the two-member that replaces it is strictly wider), so the rendered size never falls +// as k rises. ~log2(n) renders of an O(n) body, against a list that is a few hundred rows at most. +// +// shown=/total= therefore count test FILES — one qualifying file, one entry, on both sides of the cut — and +// a row carries N of them, which is exactly what the bundle legend now says. +template +inline PackTaskSection packTaskTestsSection( const rw::TestRunnerIndex& runners, std::span rows, + std::size_t budget, std::size_t wrapReserve, EscapeFn esc, + std::vector>* partitionOut ) +{ + PackTaskSection out; + if( partitionOut ) + { + partitionOut->clear(); + } + const auto bodyOf = [ & ]( std::size_t k, std::vector>* keepPart ) -> std::string + { + const std::span prefix( rows.data(), k ); + std::vector> part = rw::partitionTestRows( runners, prefix ); + std::string body; + for( const rw::RenderedTestRow& r : rw::testRowsRendered( runners, prefix, rw::TestRowShape{ rw::RowDialect::Xml, "test" }, esc, &part ) ) + { + body += r.text; + } + if( keepPart ) + { + *keepPart = std::move( part ); + } + return body; + }; + if( rows.empty() || budget <= wrapReserve ) + { + return out; + } + std::size_t lo = 0; // always fits (an empty body is wrapReserve alone) + std::size_t hi = rows.size(); // may not + while( lo < hi ) + { + const std::size_t mid = lo + ( hi - lo + 1 ) / 2; // lo < mid <= hi + if( wrapReserve + bodyOf( mid, nullptr ).size() <= budget ) + { + lo = mid; + } + else + { + hi = mid - 1; + } + } + out.kept = lo; + if( out.kept == 0 ) + { + return out; + } + const std::string body = bodyOf( out.kept, partitionOut ); + char open[ 160 ]; + rw::formatTo( open, sizeof( open ), "", out.kept, rows.size(), out.kept < rows.size() ? 1 : 0 ); + out.xml = std::string( open ) + body + ""; + return out; +} + // §H5, the direction the finding did not name: the XML names each over-budget skip in a // `` comment and the JSON dialect named none of them. Same set, same // reason, spelled for a parser. The key is OMITTED (not `[]`) when nothing was skipped, matching the @@ -761,18 +836,14 @@ inline std::vector buildMaskedRank( const IngestResult& ing, const std::v return masked; } -// a generic "render into a memstream, DEGRADED_PATH_ALERT + \"\" on failure" wrapper — shared by every -// packTaskBundleText section (and renderRankingWithFar below) so none of them hand-roll the memstream dance. +// every packTaskBundleText section (and renderRankingWithFar below) renders through infra/emit.h's ONE +// renderToString seam, with this file's own degrade wording. It keeps the name it had: a section that +// degrades is SKIPPED from the budget (empty string, the caller's documented path), so the `ok` flag the +// seam returns has no second reading here and the call sites stay one expression long. template inline std::string packTaskRenderToString( Emit&& emit ) { - char* buf = nullptr; std::size_t sz = 0; - std::FILE* m = open_memstream( &buf, &sz ); - if( !m ) { DEGRADED_PATH_ALERT( "pack-task: open_memstream failed — section skipped from the budget" ); return {}; } - emit( m ); - std::fflush( m ); std::fclose( m ); - std::string s; if( buf ) { s.assign( buf, sz ); std::free( buf ); } - return s; + return rw::renderToString( std::forward( emit ), "pack-task: open_memstream failed — section skipped from the budget" ).text; } // R2: section 1 as ONE cohesive unit — the distance-masked packSignatures call (eligibleIds only) PLUS the @@ -925,14 +996,14 @@ inline MonotoneRoll monotoneRoll( bool sectionCapped, std::size_t granted, std:: // first lap's conservative rule again, for the first lap's reason. inline std::size_t reflowListSection( PackTaskSection& section, std::string_view tag, std::string_view extraAttr, const std::vector& entries, std::size_t& budget, - std::size_t wrapReserve, std::size_t reflow, const std::vector* unitsPerEntry = nullptr ) + std::size_t wrapReserve, std::size_t reflow ) { if( reflow == 0 || section.kept >= entries.size() ) { return reflow; } budget += reflow; - section = packTaskListSection( tag, extraAttr, entries, budget, wrapReserve, unitsPerEntry ); + section = packTaskListSection( tag, extraAttr, entries, budget, wrapReserve ); return section.kept < entries.size() || budget <= section.xml.size() ? 0 : budget - section.xml.size(); } @@ -1144,7 +1215,8 @@ inline std::vector selectMonotoneBodySubset( const IngestResult& ing, co // PRE-budget-trim surface (a trimmed tail names slightly fewer) — an honest ceiling, documented as one. inline std::string packTaskBundleText( const IngestResult& ing, const Graph& g, const std::string& task, const LensRanking& lr, const PackTaskInputs& inArg, - std::string* jsonOut = nullptr, std::vector* surfaceOut = nullptr ) + std::string* jsonOut = nullptr, std::vector* surfaceOut = nullptr, + std::size_t* testsKeptOut = nullptr ) { // P2.4 — reuse-count self-supply. --pack-task's CLI/MCP call-sites only compute fan-in when --for or // --metrics was ALSO given, so the bundle used to print in="0" on every row while --for reported the real @@ -1385,11 +1457,10 @@ inline std::string packTaskBundleText( const IngestResult& ing, const Graph& g, remaining = remaining > notesRoll.charge ? remaining - notesRoll.charge : 0; // ── section 5 — tests_to_run for the top files (the --affected mining: tests that transitively reach) ─── - std::vector testRows; std::vector testFiles; // hoisted for the L2 --json tail below - std::vector> testPartition; // E1: the XML rows' single/group partition, reused by the JSON tail so both dialects serve the same rows - std::vector testUnits; // E1: files per row (n= on a row), so shown=/total= keep counting FILES - std::size_t testsBudget = sectionBudget( kPackTaskQuotaTestsPct, carry ); // E1: known before the rows, so a group row can be capped at it + std::vector ptRows; // the seam's row values — one per test FILE, the unit the section cuts in + std::vector> testPartition; // E1: the KEPT prefix's single/group partition, reused by the JSON tail so both dialects serve the same rows + std::size_t testsBudget = sectionBudget( kPackTaskQuotaTestsPct, carry ); { std::vector testSeeds; for( NodeId b : bodyIds ) @@ -1425,25 +1496,17 @@ inline std::string packTaskBundleText( const IngestResult& ing, const Graph& g, // inside the section's scope, because it is lazy — a bundle with no test row reads no runner script. // §B14 — std::string rows, not char[512]: a row carries TWO unbounded interpolands (the test path AND // the runner command), so it was the widest of the six breaching sites. - const rw::TestRunnerIndex runners( ing ); - const std::string ptPrefix = in.rootArg.empty() ? std::string() : rw::sarif::rootPrefixOf( in.rootArg ); - const std::vector ptRows = rw::testRowsOutOf( testFiles, [ & ]( std::uint32_t f ) -> std::string_view + const std::string ptPrefix = in.rootArg.empty() ? std::string() : rw::sarif::rootPrefixOf( in.rootArg ); + ptRows = rw::testRowsOutOf( testFiles, [ & ]( std::uint32_t f ) -> std::string_view { return in.rootArg.empty() ? std::string_view( ing.files[f] ) : rw::sarif::rootRelativeUri( ing.files[f], ptPrefix ); } ); - // E1: runner-less rows are grouped (testmap.h's seam). This section is byte-budgeted per ROW, so a group is - // capped at the section's own budget: rocksdb's 126 runner-less rows would otherwise be ONE ~3 KB row that - // a 10% tests quota cannot hold — measured shown="0" where the single rows had filled the section. - const std::size_t groupCap = testsBudget > kPackTaskWrapReserve ? testsBudget - kPackTaskWrapReserve : 0; - testPartition = rw::partitionTestRows( runners, ptRows, groupCap ); - for( rw::RenderedTestRow& row : rw::testRowsRendered( runners, ptRows, rw::TestRowShape{ rw::RowDialect::Xml, "test", {}, groupCap }, ex, &testPartition ) ) - { - testRows.emplace_back( std::move( row.text ) ); - testUnits.push_back( row.files ); - } } - PackTaskSection tests = packTaskListSection( "tests", "", testRows, testsBudget, kPackTaskWrapReserve, &testUnits ); - const std::size_t testsTotal = testRows.size(); + // E1 / review of #214: the section cuts over its own GROUPED, ESCAPED rendering — see packTaskTestsSection + // above for the defect that forced it and for why a bisection answers it exactly. + const rw::TestRunnerIndex runners( ing ); + PackTaskSection tests = packTaskTestsSection( runners, ptRows, testsBudget, kPackTaskWrapReserve, ex, &testPartition ); + const std::size_t testsTotal = ptRows.size(); const MonotoneRoll testsRoll = monotoneRoll( tests.kept < testsTotal, testsBudget, tests.xml.size() ); carry = testsRoll.carry; remaining = remaining > testsRoll.charge ? remaining - testsRoll.charge : 0; @@ -1515,7 +1578,14 @@ inline std::string packTaskBundleText( const IngestResult& ing, const Graph& g, } reflow = reflowListSection( callers, "callers", callersAttr, callerRows, callersBudget, kPackTaskWrapReserveWide, reflow ); reflow = reflowListSection( notes, "notes", "", noteEntries, notesBudget, kPackTaskWrapReserve, reflow ); - reflow = reflowListSection( tests, "tests", "", testRows, testsBudget, kPackTaskWrapReserve, reflow, &testUnits ); + // The tests section's own reflow lap — reflowListSection's rule (top up a section the first lap capped, + // once, and pass nothing further on) over packTaskTestsSection's cut. It is the LAST section to reflow, so + // its leftover goes nowhere and is not computed. + if( reflow > 0 && tests.kept < testsTotal ) + { + testsBudget += reflow; + tests = packTaskTestsSection( runners, ptRows, testsBudget, kPackTaskWrapReserve, ex, &testPartition ); + } const std::string& callersStr = callers.xml; const std::size_t callersKept = callers.kept; @@ -1658,19 +1728,20 @@ inline std::string packTaskBundleText( const IngestResult& ing, const Graph& g, } j += "]"; - // E1: tests_total/tests_kept count test FILES exactly as the XML section's total=/shown= do (a row is one - // entry of n= files); the JSON tail serves the SAME partition the XML section was cut over, so the two dialects - // list the same rows. - const std::size_t testsShown = std::min( testsKept, testRows.size() ); - { char b[ 96 ]; rw::formatTo( b, sizeof( b ), ",\"tests_total\":{},\"tests_kept\":{},\"tests_to_run\":[", tests.totalUnits, tests.keptUnits ); j += b; } + // E1: tests_total/tests_kept count test FILES exactly as the XML section's total=/shown= do — one entry + // per file on both sides of the cut. The JSON tail renders the SAME kept prefix through the SAME + // partition the XML body was grouped by, so the two dialects cannot list different rows. + { char b[ 96 ]; rw::formatTo( b, sizeof( b ), ",\"tests_total\":{},\"tests_kept\":{},\"tests_to_run\":[", testsTotal, testsKept ); j += b; } // §A9.5: the JSON sibling of the XML run= above — situ's tests_to_run already carries it, and one // computation path must not serialize two different obligations. const rw::TestRunnerIndex jsonRunners( ing ); const auto jrun = [ & ]( std::string_view s ) { return jsonStr( s ); }; - const std::vector jsonRows = rw::testRowsRendered( jsonRunners, rw::testRowsOutOf( testFiles, jPathRel ), rw::TestRowShape{ rw::RowDialect::Json, "p" }, jrun, &testPartition ); - for( std::size_t i = 0; i < testsShown && i < jsonRows.size(); ++i ) + const std::vector jKeptRows = rw::testRowsOutOf( std::span( testFiles ).first( std::min( testsKept, testFiles.size() ) ), jPathRel ); + bool jFirstTest = true; + for( const rw::RenderedTestRow& r : rw::testRowsRendered( jsonRunners, jKeptRows, rw::TestRowShape{ rw::RowDialect::Json, "p" }, jrun, &testPartition ) ) { - j += std::string( i == 0 ? "" : "," ) + jsonRows[i].text; + j += std::string( jFirstTest ? "" : "," ) + r.text; + jFirstTest = false; } j += "]"; @@ -1705,7 +1776,7 @@ inline std::string packTaskBundleText( const IngestResult& ing, const Graph& g, report += "bodies: " + listStatus( bodiesTotal, bodiesStr, bodiesKept ) + ( bodiesTotal > 0 && !bodiesStr.empty() && bodiesKept < bodiesTotal ? " (capped)" : "" ) + " | "; report += "callers: " + listStatus( callersTotal, callersStr, callersKept ) + " | "; report += "notes: " + listStatus( notesTotal, notesStr, notesKept ) + " | "; - report += "tests: " + listStatus( tests.totalUnits, testsStr, tests.keptUnits ); // E1: files, as the section's shown=/total= say + report += "tests: " + listStatus( testsTotal, testsStr, testsKept ); // E1: test files, as the section's shown=/total= say report += " | far: " + listStatus( farTotal, rankOut.farXml, farKept ); // R2: d2plus name-only tier (nested in ) // A2 (survey card, 2026-09-03) — the pack-task twin of --for's dropped_positive= root fact: how many // rank>0 eligibleIds the section-1 ladder cut. Emitted ONLY when nonzero (the pr_converged precedent, @@ -1739,7 +1810,7 @@ inline std::string packTaskBundleText( const IngestResult& ing, const Graph& g, const PackTaskHeaderParts headerParts{ task, rootOpenStr, taskNote, mentionNote, boostNote, docMentionNote, sibliftNote, expandNote, report, droppedPositiveAttr, in.rootArg, - tests.kept > 0 ? rw::kRunHintLegendClause : std::string_view() }; + rw::runHintClauseIfRows( tests.kept ) }; // the ONE gate: the section's own kept count const auto buildHeader = [ & ]( bool withRouteAttr, bool withTaskEcho, std::string_view extraNotes ) { if( in.innerBundle ) // P10 (L7): a partition slice — the outer legend speaks once for all of them @@ -1830,6 +1901,14 @@ inline std::string packTaskBundleText( const IngestResult& ing, const Graph& g, // §6 --partition: the bundle's own surface (see the contract above). topRanked already contains bodyIds // (bodies are the positive-score head of the SAME order), so the union is topRanked ∪ d2plus ∪ d1. + // E1 / review of #214: the number of test FILES this bundle's section actually kept. A caller + // that must gate the run-hint clause for SEVERAL bundles at once (partition.h's outer legend) needs the + // count, not a substring search over the rendered bytes — `clear(); diff --git a/src/partition.h b/src/partition.h index 47b1d5381..b074714c8 100644 --- a/src/partition.h +++ b/src/partition.h @@ -419,6 +419,7 @@ struct BundleOut std::vector surface; std::uint32_t assigned = 0; std::uint32_t modules = 0; + std::size_t testsKept = 0; // E1: test FILES this bundle's section kept — the outer legend's gate }; // the fixed context every bundle render shares — grouped (not individual params) so renderMaskedBundle stays @@ -449,7 +450,7 @@ inline BundleOut renderMaskedBundle( const BundleRenderCtx& ctx, const std::vect in.innerBundle = true; // P10 (L7): one outer legend for the whole document in.rankTopN = std::min( std::size_t( kPackTaskRankTopN ), keep.size() ); // never widen the window past the slice - out.xml = packTaskBundleText( *ctx.ing, *ctx.g, *ctx.task, masked, in, ctx.wantJson ? &out.json : nullptr, &out.surface ); + out.xml = packTaskBundleText( *ctx.ing, *ctx.g, *ctx.task, masked, in, ctx.wantJson ? &out.json : nullptr, &out.surface, &out.testsKept ); std::sort( out.surface.begin(), out.surface.end() ); out.surface.erase( std::unique( out.surface.begin(), out.surface.end() ), out.surface.end() ); return out; @@ -593,16 +594,22 @@ inline std::string packTaskPartitionText( const IngestResult& ing, const Graph& whole += partitionSummaryAttrs( plan, sum, ov ); whole += ">"; whole += kPartitionLegend; - // M21(b)/E1: the / row rule rides the outer legend ONCE for every slice, and only when some slice kept a - // test row (a section exists exactly then — packTaskListSection emits nothing at kept=0), the same - // rows-gating the single bundle applies to its own header; test/partitioncheck.sh P10 holds the two within 1.3x. - bool anySliceTests = core.xml.find( "/ row rule rides the outer legend ONCE for every slice, and only when some slice + // kept a test row — the same rows-gating the single bundle applies to its own header; test/partitioncheck.sh + // P10 holds the two within 1.3x. + // + // Review of #214: this used to ASK the rendered bytes (`xml.find( "/ row + bool rendered = true; // false ⇒ the measurement buffer failed; `body` is empty and means nothing }; // ONE estimator for every --pr-context root, never two counters (serialize.h's standing rule, the one @@ -533,26 +535,23 @@ struct PrTrimRender // The floor-exceeded suffix feeds back into the price (it lengthens truncated=, hence the root tag), so it // is applied and RE-PRICED: monotone, since adding bytes to a document already over budget cannot bring it // under, so one re-price is the fixpoint and the printed number is the document's real price either way. -// One level's body, rendered to a string — the ladder's probe and (E1) the unbudgeted root's single render, -// which needs the body before the head can be written. +// One level's body, plus the two facts a caller cannot recover from the bytes: how many test FILES it +// rendered (E1 — the run-hint clause is gated on that count, never on a string match over the body) and +// whether the measurement buffer opened at all. Through infra/emit.h's ONE renderToString seam, so a failure +// is ALERTED rather than returned as an indistinguishable empty body. template< typename EmitFn > -inline std::string prRenderLevel( const EmitFn& emitFiles, const PrTrim& trim ) +inline PrTrimRender prRenderLevel( const EmitFn& emitFiles, const PrTrim& trim ) { - char* buf = nullptr; - std::size_t sz = 0; - std::string rendered; - if( std::FILE* ms = open_memstream( &buf, &sz ) ) + PrTrimRender out; + const rw::Rendered r = rw::renderToString( [ & ]( std::FILE* ms ) { emitFiles( ms, trim, &out.testFiles ); }, + "pr-context: open_memstream failed — this level was not measured" ); + out.body = r.text; + out.rendered = r.ok; + if( !r.ok ) { - emitFiles( ms, trim ); - std::fflush( ms ); - std::fclose( ms ); - if( buf ) - { - rendered.assign( buf, sz ); - } + out.testFiles = 0; } - std::free( buf ); - return rendered; + return out; } template< typename EmitFn, typename PriceFn > @@ -563,10 +562,13 @@ inline PrTrimRender pickPrTrimLevel( const EmitFn& emitFiles, std::size_t budget PrTrimRender out; for( std::size_t li = 0; li < nLevels; ++li ) { + const PrTrimRender probe = prRenderLevel( emitFiles, kPrTrims[li] ); + out.body = probe.body; + out.testFiles = probe.testFiles; + out.rendered = probe.rendered; out.level = li; out.truncated = li > 0 ? std::string( kPrTrims[li].dropped ) : std::string( "none" ); - out.body = prRenderLevel( emitFiles, kPrTrims[li] ); - out.estTokens = price( out.body, li, out.truncated, windowAttrs ); + out.estTokens = price( out.body, out.testFiles, li, out.truncated, windowAttrs ); if( out.estTokens <= budgetTokens ) { break; @@ -574,7 +576,7 @@ inline PrTrimRender pickPrTrimLevel( const EmitFn& emitFiles, std::size_t budget if( li + 1 == nLevels ) { out.truncated += ";budget-floor-exceeded"; // even the floor render is over budget - out.estTokens = price( out.body, li, out.truncated, windowAttrs ); + out.estTokens = price( out.body, out.testFiles, li, out.truncated, windowAttrs ); } } return out; @@ -676,13 +678,13 @@ inline std::string prEmptyRootTail( std::uint32_t skippedModeOnly, std::size_t b template< typename PriceFn > inline std::pair prEmptyRootPrice( const PriceFn& price, std::size_t budgetTokens ) { - const std::size_t plain = price( kPrEmptyDiffBody, 0, std::string( "none" ), std::string() ); + const std::size_t plain = price( kPrEmptyDiffBody, 0, 0, std::string( "none" ), std::string() ); if( budgetTokens == 0 || plain <= budgetTokens ) { return { plain, std::string( "none" ) }; } const std::string labelled( "budget-floor-exceeded" ); - return { price( kPrEmptyDiffBody, 0, labelled, std::string() ), labelled }; + return { price( kPrEmptyDiffBody, 0, 0, labelled, std::string() ), labelled }; } // Open the root: the attributes EVERY form shares, this site's own tail, and the one remark row @@ -741,19 +743,15 @@ struct PrPriceCtx // // The attribute is part of the document it prices, so its own digits are converged in ≤4 passes exactly as // pricedRootAttr converges them. -// E1 (CodeRabbit on #214): the ONE predicate that decides whether a rendered body carries the run clause's -// subject — a / row — read by the pricer for every candidate level and by the writer for the chosen -// one, so the priced legend and the delivered legend cannot disagree. Rows are the only place these two -// openers occur in this document. -inline bool prBodyHasTestRow( std::string_view body ) noexcept -{ - return body.find( " 0 ? c.runClauseBytes : 0 ); // E1: the clause rides only a rows-bearing document std::size_t est = 0; for( int pass = 0; pass < 4; ++pass ) { @@ -930,12 +928,14 @@ inline int writePrContext( std::FILE* out, const std::string& root, const Ingest // E1: both legend forms are built now and ONE is written later, once the body is known (prBodyHasTestRow); // the envelope is priced without the clause and the pricer adds runClauseBytes for a rows-bearing body. - const std::string legendText = prLegendText( escBase, g.unindexedFiles > 0, false ); - const std::string legendWithClause = prLegendText( escBase, g.unindexedFiles > 0, true ); - const std::string anchorNoteText = prAnchorNoteText( anchorAttr ); - const auto writeHead = [ & ]( std::string_view body ) + const std::string legendText = prLegendText( escBase, g.unindexedFiles > 0, false ); + const std::string anchorNoteText = prAnchorNoteText( anchorAttr ); + // The clause-bearing form is built ONCE, and only if it is the form that gets written — the difference + // between the two is exactly kRunHintLegendClause (prLegendText splices that constant and nothing else), + // so the pricer reads the constant's size rather than a second rendering's. + const auto writeHead = [ & ]( std::size_t testFiles ) { - const std::string& legend = prBodyHasTestRow( body ) ? legendWithClause : legendText; + const std::string legend = testFiles > 0 ? prLegendText( escBase, g.unindexedFiles > 0, true ) : legendText; std::fwrite( legend.data(), 1, legend.size(), out ); std::fwrite( anchorNoteText.data(), 1, anchorNoteText.size(), out ); }; @@ -948,11 +948,11 @@ inline int writePrContext( std::FILE* out, const std::string& root, const Ingest // R2/N4: the price context (see prPriceDocument) — the envelope and every root attribute that does not // vary per candidate trim level, gathered once. const PrPriceCtx priceCtx{ .g = &g, .sharedAttrs = &sharedAttrs, .anchor = &anchor, .baseEscaped = &escBase, .atAttrs = &atAttrStr, - .envelopeBytes = envelopeBytes, .runClauseBytes = legendWithClause.size() - legendText.size(), + .envelopeBytes = envelopeBytes, .runClauseBytes = rw::kRunHintLegendClause.size(), .changedFiles = changed.size(), .skippedModeOnly = skippedModeOnly, .budgetTokens = budgetTokens, .isDefaultBudget = budget.isDefault }; - const auto priceOf = [ & ]( std::string_view body, std::size_t level, const std::string& truncatedRaw, const std::string& windowAttrs ) - { return prPriceDocument( priceCtx, body, level, ex( truncatedRaw ), windowAttrs ); }; + const auto priceOf = [ & ]( std::string_view body, std::size_t testFiles, std::size_t level, const std::string& truncatedRaw, const std::string& windowAttrs ) + { return prPriceDocument( priceCtx, body, testFiles, level, ex( truncatedRaw ), windowAttrs ); }; if( changed.empty() ) { @@ -960,7 +960,7 @@ inline int writePrContext( std::FILE* out, const std::string& root, const Ingest const std::string rootOpen = prRootOpenText( g, sharedAttrs, prEmptyRootTail( skippedModeOnly, budgetTokens, budget.isDefault, emptyEst, ex( emptyTruncated ) ) + atAttrStr, anchor, escBase ); - writeHead( kPrEmptyDiffBody ); + writeHead( 0 ); // the empty-diff body is a fixed comment: no changed file, so no test row std::fwrite( rootOpen.data(), 1, rootOpen.size(), out ); std::fwrite( kPrEmptyDiffBody.data(), 1, kPrEmptyDiffBody.size(), out ); std::fwrite( kPrCloseTag.data(), 1, kPrCloseTag.size(), out ); @@ -996,7 +996,10 @@ inline int writePrContext( std::FILE* out, const std::string& root, const Ingest // The per-file body emitter, parameterized by a trim level so the budget path can render it at several // depths into a memstream to measure, then re-render the chosen one to `out`. At the deepest trim it is // still one element PER changed file (counts intact) — files are never dropped, only detail is. - const auto emitFilesRange = [ & ]( std::FILE* o, const PrTrim& trim, std::size_t begin, std::size_t end ) + // `testFilesOut` (optional): the number of test FILES the rows below actually rendered, accumulated over + // the range — E1's ONE gate for the run-hint clause, reported BY the emitter instead of grepped back out + // of its bytes. nullptr on the streaming degrade path, which has no legend left to decide. + const auto emitFilesRange = [ & ]( std::FILE* o, const PrTrim& trim, std::size_t begin, std::size_t end, std::size_t* testFilesOut ) { for( std::size_t ci = begin; ci < end; ++ci ) { @@ -1081,9 +1084,12 @@ inline int writePrContext( std::FILE* out, const std::string& root, const Ingest if( trim.testCap > 0 ) { rw::emitTo( o, "", testFiles.size(), tSc.shown, tSc.capped ); - // §A9.5 / E1: the shown window, grouped where no runner is derivable (testmap.h's seam) - rw::emitRaw( o, testRowsJoined( prRunners, testRowsOutOf( std::span( testFiles ).first( tSc.shown ), prPathRel ), TestRowShape{ RowDialect::Xml, "test" }, ex ).c_str() ); + // §A9.5 / E1: the shown window, grouped where no runner is derivable (testmap.h's seam), which + // returns the FILE count with the rows — the number the legend's clause is gated on. + const JoinedTestRows tRows = testRowsList( prRunners, testRowsOutOf( std::span( testFiles ).first( tSc.shown ), prPathRel ), TestRowShape{ RowDialect::Xml, "test" }, ex ); + rw::emitRaw( o, tRows.text.c_str() ); rw::emitRaw( o, "" ); + if( testFilesOut ) { *testFilesOut += tRows.files; } } else { @@ -1207,16 +1213,28 @@ inline int writePrContext( std::FILE* out, const std::string& root, const Ingest // P4 (L7): the changed-file WINDOW — --limit/--offset when given, else every file (the budget may still cut it below) const PageWindow filePw = pageWindow( changed.size(), budget.pageLimit, budget.pageOffset ); std::size_t fileEnd = filePw.end; - const auto emitFiles = [ & ]( std::FILE* o, const PrTrim& trim ) { emitFilesRange( o, trim, filePw.begin, fileEnd ); }; + const auto emitFiles = [ & ]( std::FILE* o, const PrTrim& trim, std::size_t* testFilesOut ) { emitFilesRange( o, trim, filePw.begin, fileEnd, testFilesOut ); }; // NO budget at all (only a multi-root sub-bundle handed 0): level 0, no budget attributes. if( budgetTokens == 0 ) { const std::string rootOpen = prRootOpenText( g, sharedAttrs, " files=\"" + std::to_string( changed.size() ) + "\" skipped_mode_only=\"" + std::to_string( skippedModeOnly ) + "\"" + atAttrStr, anchor, escBase ); - const std::string body = prRenderLevel( emitFiles, kPrTrims[0] ); // E1: rendered first, so the head can follow the body - writeHead( body ); + // E1: the head carries a rule about rows, so it follows the body's DECISION even though it precedes + // the body in the stream — the level is rendered into a measurement buffer first and its row count + // decides the legend form. DEGRADE (infra/emit.h renderToString, which alerts): if that buffer cannot + // be opened there is no count, so the clause-less legend is written and the body is STREAMED straight + // to `out` exactly as it was before E1. Complete, correct bytes either way — never an empty body. + const PrTrimRender flat = prRenderLevel( emitFiles, kPrTrims[0] ); + writeHead( flat.testFiles ); std::fwrite( rootOpen.data(), 1, rootOpen.size(), out ); - std::fwrite( body.data(), 1, body.size(), out ); + if( flat.rendered ) + { + std::fwrite( flat.body.data(), 1, flat.body.size(), out ); + } + else + { + emitFiles( out, kPrTrims[0], nullptr ); + } std::fwrite( kPrCloseTag.data(), 1, kPrCloseTag.size(), out ); return 0; } @@ -1258,9 +1276,20 @@ inline int writePrContext( std::FILE* out, const std::string& root, const Ingest prBudgetTail( changed.size(), skippedModeOnly, budgetTokens, chosen, ex( chosen.truncated ) ) + ( budget.isDefault ? " budget_default=\"1\"" : "" ) + windowAttrs + atAttrStr, anchor, escBase ); - writeHead( chosen.body ); // E1: the legend form the chosen body was priced with + writeHead( chosen.testFiles ); // E1: the legend form the chosen body was priced with, from the same count std::fwrite( rootOpen.data(), 1, rootOpen.size(), out ); - std::fwrite( chosen.body.data(), 1, chosen.body.size(), out ); + if( chosen.rendered ) + { + std::fwrite( chosen.body.data(), 1, chosen.body.size(), out ); + } + else + { + // DEGRADE (renderToString alerted): no level could be measured, so est_tokens= is the modelled number + // for an empty body — but the document still owes its bytes. Stream the floor level straight out, the + // same contract serialize.h's ChargedSection degrade keeps: complete, correct bytes, a wrong estimate, + // and an alert saying which. Never an empty . + emitFiles( out, kPrTrims[0], nullptr ); + } std::fwrite( kPrCloseTag.data(), 1, kPrCloseTag.size(), out ); return 0; } diff --git a/src/situ.h b/src/situ.h index 0f369fb74..40bd1de63 100644 --- a/src/situ.h +++ b/src/situ.h @@ -1153,7 +1153,7 @@ inline void writeTestGateReport( std::FILE* out, const IngestResult& ing, const // H2H-Graft F1: the evidence clause (testmap.h's ONE wording) rides the rows-gated half, like the run= rule. rw::emitTo( out, "{}", kTestGateLegend, tgHasRows ? kTestGateRowLegend : "", std::string_view( kTestRowEvidenceLegend.data(), tgHasRows ? int( kTestRowEvidenceLegend.size() ) : 0 ), - tgHasRows ? kTestGateRunLegend : std::string_view{}, + runHintClauseIfRows( testRows ), // the ONE gate: this clause is about rows, so an untested-only report pays nothing rw::graphUnindexedLegend( g.unindexedFiles > 0 ), // #66: exactly when the root carries the attribute rw::rootRelPathsLegend( !tgRootAttr.empty() ) ); // §P11.4: this gate EXITS 4 on the obligation, so its rows carry the command that discharges it — where diff --git a/src/testmap.h b/src/testmap.h index 6170bf591..710576c7d 100644 --- a/src/testmap.h +++ b/src/testmap.h @@ -694,13 +694,24 @@ inline std::string runSuffixTextDisclosed( const TestRunnerIndex& idx, std::uint // * a group is emitted where its FIRST member stood, its members in list order; a single row with a // runner in the middle of a group stays where it was, so evidence order is preserved row for row; // * a group of ONE is a single row (the spelling is shorter and a consumer has one less shape); -// * a ',' inside an XML path is spelled , (columnar.h's precedent: ordinary entity decoding restores -// it and escapeXml never emits a bare ',' itself); JSON needs nothing — the array carries the paths; -// * `maxGroupBytes` (pack-task, whose section is byte-budgeted per ROW): a group is split into -// consecutive rows so no single row can starve the section (rocksdb's list would otherwise be ONE -// 3 KB row that the tests quota cannot hold — measured shown="0"). 0 = unbounded, every other verb. +// * a path containing a ',' is NEVER grouped — it is served as a single row. p= is a comma-separated list +// and every XML parser undoes an entity BEFORE a consumer splits on the delimiter, so an escaped comma +// (this seam spelled , until 2026-09-13) reappears as a separator and n= then disagrees with what +// the reader counts; the text twin had no escape at all. Refusing to group the row is the only spelling +// that is right in all three dialects at once, it costs one row on a path shape that is vanishingly +// rare, and the legend clause says so rather than describing an escape. // The ""-means-not-derivable test stays in runHint alone: the single rows below go through the Disclosed // wrappers, and a group exists only where commandFor is empty — one seam, one rule. +// +// The byte cap this seam used to carry (`maxGroupBytes`, a pre-escape estimate of a group's rendered size) +// is GONE, and with it its 48-byte overhead constant and pack-task's per-row units arithmetic. It was the +// wrong depth: the estimate counted UNESCAPED path bytes, so a list of paths holding '&' or '<' rendered +// wider than the cap admitted and packTaskListSection — which breaks at the first over-budget entry — then +// dropped the whole tail of the section, run= singles included. pack-task now CUTS first and GROUPS second +// (packtask.h): the section is cut over single rows, whose rendered bytes are exactly what it measures, and +// the kept prefix is grouped afterwards. Grouping a run of N≥2 rows is strictly smaller than the N single +// rows it replaces (it drops N-1 tag+attribute+disclosure repeats and adds only ` n="N"`), so it can never +// breach a cut that already held. struct TestRowOut { std::uint32_t fileId = 0; @@ -712,10 +723,9 @@ enum class RowDialect : std::uint8_t { Xml, Json, Text }; struct TestRowShape { - RowDialect dialect = RowDialect::Xml; - std::string_view tag = "t"; // XML element name ("t" | "test") or JSON key ("p" | "test") - std::string_view indent = ""; // text dialect: the line prefix - std::size_t maxGroupBytes = 0; // split a group so no row exceeds this (pre-escape estimate); 0 = never + RowDialect dialect = RowDialect::Xml; + std::string_view tag = "t"; // XML element name ("t" | "test") or JSON key ("p" | "test") + std::string_view indent = ""; // text dialect: the line prefix }; // One rendered row: the text, and how many test FILES it carries (1 for a single row, n for a group), so a @@ -727,51 +737,42 @@ struct RenderedTestRow }; // The partition: index lists into `rows`, a run of one for a single row, a run of ≥2 for a group. -inline std::vector> partitionTestRows( const TestRunnerIndex& idx, std::span rows, std::size_t maxGroupBytes ) +// +// A group covers a CONTIGUOUS run only: the scan stops at the first row that is not a groupable row with the +// same attrs. The rows arrive in evidence order, so equal-attribute groupable rows are already adjacent and +// the only things that can interrupt a run are a same-attribute row WITH a runner and a path carrying a ','; +// hoisting the rows after it into a group in FRONT of it would move them ahead of it (review of #214: +// A, B(run), A became G(A,A), B). Stopping instead costs one more per interruption and makes order +// preservation true by construction — test/testrowruncheck.sh arm 12 reads the paths back in emitted order +// and asserts they are the single rows' order. Linear: `i` advances to the end of the run it just closed, so +// every row is visited exactly once and no `taken` bookkeeping is needed to find the next unconsumed row. +inline std::vector> partitionTestRows( const TestRunnerIndex& idx, std::span rows ) { std::vector> groups; - std::vector taken( rows.size(), 0 ); - for( std::uint32_t i = 0; i < rows.size(); ++i ) + // The two disqualifications, in one place: a derivable runner (run= is per row) and a ',' in the path + // (p= is a comma-separated list — see the seam's header comment for why no escape can rescue it). + const auto groupable = [ & ]( std::uint32_t i ) noexcept { - if( taken[i] ) - { - continue; - } - taken[i] = 1; - if( !idx.commandFor( rows[i].fileId ).empty() ) - { - groups.push_back( { i } ); - continue; - } - // A group covers a CONTIGUOUS run only: the scan stops at the first row that is not a runner-less row - // with the same attrs. The rows arrive in evidence order, so equal-attribute runner-less rows are - // already adjacent and the only thing that can interrupt a run is a same-attribute row WITH a runner; - // hoisting the rows after it into a group in front of it would move them ahead of it (CodeRabbit on - // #214: A, B(run), A became G(A,A), B). Stopping instead costs one more per interruption and makes - // order-preservation true by construction — test/testrowruncheck.sh arm 12 reads the paths back in - // emitted order and asserts they are the single rows' order. Linear: every row is visited once. - std::vector members{ i }; - std::size_t bytes = rows[i].attrs.size() + 48 + rows[i].path.size(); - for( std::uint32_t j = i + 1; j < rows.size(); ++j ) + return idx.commandFor( rows[i].fileId ).empty() && rows[i].path.find( ',' ) == std::string::npos; + }; + for( std::uint32_t i = 0; i < rows.size(); ) + { + std::uint32_t end = i + 1; + if( groupable( i ) ) { - if( rows[j].attrs != rows[i].attrs || !idx.commandFor( rows[j].fileId ).empty() ) + while( end < rows.size() && groupable( end ) && rows[end].attrs == rows[i].attrs ) { - break; + ++end; } - // The cap is applied before EVERY join, the second member included: two paths that each fit as a - // singleton must never be joined into one row the byte-budgeted section then rejects whole - // (arm 13). A chunk closed at one member is rendered as a single row. - if( maxGroupBytes != 0 && bytes + rows[j].path.size() + 1 > maxGroupBytes ) - { - groups.push_back( std::move( members ) ); // this chunk is full: close it, the next member opens another at the same key - members = {}; - bytes = rows[i].attrs.size() + 48; - } - taken[j] = 1; - members.push_back( j ); - bytes += rows[j].path.size() + 1; + } + std::vector members; + members.reserve( end - i ); + for( std::uint32_t k = i; k < end; ++k ) + { + members.push_back( k ); } groups.push_back( std::move( members ) ); + i = end; } return groups; } @@ -811,10 +812,7 @@ inline std::string renderTestRowGroup( std::span rows, std::sp for( std::size_t k = 0; k < members.size(); ++k ) { if( k ) { s += ','; } - for( char c : esc( rows[ members[k] ].path ) ) - { - if( c == ',' ) { s += ","; } else { s += c; } - } + s += esc( rows[ members[k] ].path ); // no path here holds a ',' — partitionTestRows refuses to group one } s += "\" run_unknown=\"1\"/>"; break; @@ -833,9 +831,15 @@ inline std::string renderTestRowGroup( std::span rows, std::sp case RowDialect::Text: { s.append( shape.indent ); + // attrs are built with a LEADING space so every other dialect can append them straight after a + // tag name; this dialect STARTS a line with them, so that one space is dropped. A view, not a + // substr copy. (Deliberately not lintrules.h's ltrim: that header is the --lint verb's rule table + // and pulls ingest.h in with it — this shared emit seam must not depend on a verb.) if( !first.attrs.empty() ) { - s.append( first.attrs.substr( first.attrs.front() == ' ' ? 1 : 0 ) ); s += ' '; // " [hops=2]" -> "[hops=2] " + std::string_view a( first.attrs ); + if( a.front() == ' ' ) { a.remove_prefix( 1 ); } + s.append( a ); s += ' '; // " [hops=2]" -> "[hops=2] " } s += '('; s += std::to_string( members.size() ); s += "): "; for( std::size_t k = 0; k < members.size(); ++k ) @@ -884,7 +888,7 @@ template inline std::vector testRowsRendered( const TestRunnerIndex& idx, std::span rows, const TestRowShape& shape, EscapeFn esc, const std::vector>* partition = nullptr ) { - const std::vector> own = partition ? std::vector>{} : partitionTestRows( idx, rows, shape.maxGroupBytes ); + const std::vector> own = partition ? std::vector>{} : partitionTestRows( idx, rows ); const std::vector>& groups = partition ? *partition : own; std::vector out; out.reserve( groups.size() ); @@ -902,19 +906,42 @@ inline std::vector testRowsRendered( const TestRunnerIndex& idx return out; } -// The joined form, for the emitters that print the list in one go (`sep` between rows: "," for JSON, "" else). +// The joined form AND the number of test FILES it names, as ONE value. +// +// Review of #214: eight legends gate the run-hint clause below, and each one asked its own question — "is the +// rendered string empty", "does the document contain `` could evict a real +// row to pay for a rule about rows it has none of). The seam that renders the rows is the only thing that +// KNOWS how many there are, so it returns the count with them and every legend asks that one count. +// `files` is the number of test FILES (a row contributes N), and it is 0 exactly when `text` is. +struct JoinedTestRows +{ + std::string text; + std::size_t files = 0; +}; + template -inline std::string testRowsJoined( const TestRunnerIndex& idx, std::span rows, const TestRowShape& shape, EscapeFn esc, std::string_view sep = {} ) +inline JoinedTestRows testRowsList( const TestRunnerIndex& idx, std::span rows, const TestRowShape& shape, EscapeFn esc, std::string_view sep = {} ) { - std::string joined; - bool first = true; + JoinedTestRows out; + bool first = true; for( const RenderedTestRow& r : testRowsRendered( idx, rows, shape, esc ) ) { - if( !first ) { joined.append( sep ); } + if( !first ) { out.text.append( sep ); } first = false; - joined += r.text; + out.text += r.text; + out.files += r.files; } - return joined; + return out; +} + +// The joined form alone, for the emitters that print the list in one go and count their files elsewhere +// (`sep` between rows: "," for JSON, "" else). +template +inline std::string testRowsJoined( const TestRunnerIndex& idx, std::span rows, const TestRowShape& shape, EscapeFn esc, std::string_view sep = {} ) +{ + return testRowsList( idx, rows, shape, esc, sep ).text; } // The ONE sentence every legend that carries a tests_to_run row splices, so the seven cannot drift into @@ -925,13 +952,15 @@ inline constexpr std::string_view kRunHintLegendClause = "run= is the command that discharges a test row; run_unknown=\"1\" means none is derivable for that " "harness (a guess would be worse than none) — a or row carries one or the other, never neither. " " is 2+ runner-less rows with equal attributes served as ONE row: n= how many, p= their paths " - "in list order (, a comma in a path), every path verbatim. "; + "verbatim in list order — a path holding ',' is never grouped, so p= splits into exactly n= paths. A " + "shown=/total= over these rows counts test FILES: a row is n= of them. "; -// The clause is a rule about ROWS, so a legend splices it only when the rendered rows are non-empty — a -// tests="0" answer pays nothing for it (--affected/--exercises; --test-gate and --pack-task gate it the same way). -inline std::string_view runHintClauseIfRows( std::string_view rowsRendered ) noexcept +// The clause is a rule about ROWS, so a legend splices it only when the document actually renders one — a +// tests="0" answer pays nothing for it. THE gate, taking the count testRowsList returns (or, for a section +// that cut its own rows, that section's kept count): one rule, one spelling, asked by all eight sites. +inline std::string_view runHintClauseIfRows( std::size_t testFilesRendered ) noexcept { - return rowsRendered.empty() ? std::string_view() : kRunHintLegendClause; + return testFilesRendered == 0 ? std::string_view() : kRunHintLegendClause; } // ── P9 (capture-audit 2026-09-04) — the tests_to_run row set for ONE changed file ──────────────────── diff --git a/src/verbs_change.h b/src/verbs_change.h index 300fab290..ccd454762 100644 --- a/src/verbs_change.h +++ b/src/verbs_change.h @@ -137,7 +137,7 @@ std::optional runAffected( const MainDispatch& d ) row.changed = false; afRows.push_back( { f, std::string( afPathRel( f ) ), std::string( answer.isSeedTestFile[f] ? " seed_kind=\"test\"" : "" ) + rw::testRowEvidence( row, rw::EvDialect::Xml ) } ); } - const std::string afRowsXml = rw::testRowsJoined( runners, afRows, rw::TestRowShape{ rw::RowDialect::Xml, "test" }, ex ); + const rw::JoinedTestRows afRowsXml = rw::testRowsList( runners, afRows, rw::TestRowShape{ rw::RowDialect::Xml, "test" }, ex ); // seeded_by= is the honesty half of the file-first rule: the two readings answer DIFFERENT questions // over the same argument string and return different counts, so which one fired is a fact about the // measurement, not a detail. seeds= is the resolved seed-symbol count (1 for a lone function, ~84 @@ -150,7 +150,7 @@ std::optional runAffected( const MainDispatch& d ) "{}" // H2H-Graft F1: the evidence-order clause, testmap.h's ONE wording (changed= is spelled seed_kind="test" here: the argument matched it) "order=evidence says so on the root; partners= counts the partner rows. " "{}" // M21(b)/E1: the run=/run_unknown= rule and the group row, testmap.h's ONE wording — rows-gated - "{}{}-->{}", rw::kTestRowEvidenceLegend, rw::runHintClauseIfRows( afRowsXml ), + "{}{}-->{}", rw::kTestRowEvidenceLegend, rw::runHintClauseIfRows( afRowsXml.files ), // H1: the decl→def residue resolveAffectedSeeds summed over the symbol items. A file:name item whose // definitions were dropped seeded the walk with declarations alone, which reached the reader as a bare // tests="0" — on the verb whose answer is the list of tests to run. Exactly when the root carries it. @@ -164,7 +164,7 @@ std::optional runAffected( const MainDispatch& d ) rw::testRowPartnerCount( answer.rows ), // F1: how many rows stand on the name convention alone or as well afRootAttr.c_str(), // M12: root= says what every below is relative to rw::graphCountFloorAttrXml( g ).c_str() ); // H5/M15: gauge + marker; tests=/reached= are a transitive-caller walk over the name-based CSR - rw::emitRaw( stdout, afRowsXml.c_str() ); // E1: the rows rendered above — runner-less rows with equal evidence as ONE row, the multiset unchanged + rw::emitRaw( stdout, afRowsXml.text.c_str() ); // E1: the rows rendered above — runner-less rows with equal evidence as ONE row, the multiset unchanged rw::emitRaw( stdout, "" ); return 0; } @@ -246,13 +246,13 @@ std::optional runExercises( const MainDispatch& d ) return exSingleRoot ? rw::sarif::rootRelativeUri( ing.files[f], exRootPrefix ) : std::string_view( ing.files[f] ); }; const std::vector exRows = rw::testRowsOutOf( std::span( sel.testFiles ).first( shownSeed ), exPathRel ); - const std::string exRowsXml = rw::testRowsJoined( runners, exRows, rw::TestRowShape{ rw::RowDialect::Xml, "t" }, ex ); + const rw::JoinedTestRows exRowsXml = rw::testRowsList( runners, exRows, rw::TestRowShape{ rw::RowDialect::Xml, "t" }, ex ); rw::emitTo( stdout, "{}", rw::runHintClauseIfRows( exRowsXml ), rw::graphCountFloorBrief( g.unindexedFiles > 0 ).c_str(), rw::renderDisclosure( prD, rw::DiscloseAs::LegendClause ).c_str(), rw::rootRelPathsLegend( exSingleRoot ) ); + "{}{}-->{}", rw::runHintClauseIfRows( exRowsXml.files ), rw::graphCountFloorBrief( g.unindexedFiles > 0 ).c_str(), rw::renderDisclosure( prD, rw::DiscloseAs::LegendClause ).c_str(), rw::rootRelPathsLegend( exSingleRoot ) ); const std::string exRootAttr = exSingleRoot ? ( " root=\"" + ex( cfg.roots[0] ) + "\"" ) : std::string(); rw::emitTo( stdout, "", ex( cfg.exercisesFile ).c_str(), sel.testFiles.size(), shownSeed, @@ -261,7 +261,7 @@ std::optional runExercises( const MainDispatch& d ) + rw::renderDisclosure( prD, rw::DiscloseAs::XmlAttrs ) ).c_str(), exRootAttr.c_str(), rw::graphCountFloorAttrXml( g ).c_str() ); // H5/M15: gauge + marker; reaches= is a transitive-callee walk over the name-based CSR - rw::emitRaw( stdout, exRowsXml.c_str() ); // E1: the seed rows rendered above + rw::emitRaw( stdout, exRowsXml.text.c_str() ); // E1: the seed rows rendered above for( std::size_t i = epw.begin; i < epw.end; ++i ) { const Symbol& s = ing.symbols[ show[i] ]; diff --git a/test/affectedcheck.sh b/test/affectedcheck.sh index 21c759f16..5a36b920e 100755 --- a/test/affectedcheck.sh +++ b/test/affectedcheck.sh @@ -269,7 +269,9 @@ done # ── 7) H2H-Graft F1: rows in EVIDENCE order, stem partner first, hops= disclosed ────────────────────── # Ordered basenames (NOT sorted — the order IS the claim). -tord(){ printf '%s' "$1" | grep -oE ' row contributes its members in place) — through the shared +# reader, so this gate and the eight others that ask the same question cannot disagree about what a row is. +tord(){ printf '%s' "$1" | python3 "$ROOT/test/testrowpaths.py" paths xml | sed 's|.*/||' | tr '\n' ','; } D="$( run --affected=src/deep.cpp )" [ "$( tord "$D" )" = "deep_test.cpp,test_zdirect.cpp,test_afar.cpp," ] && [ "$( cnt "$D" )" = 3 ] \ && ok "(7a) --affected=src/deep.cpp: partner first, then hops asc — deep_test, test_zdirect(1), test_afar(2); tests=3" \ diff --git a/test/fixedbufsweep.sh b/test/fixedbufsweep.sh index 5398e43f3..f22c9a5a3 100755 --- a/test/fixedbufsweep.sh +++ b/test/fixedbufsweep.sh @@ -161,7 +161,7 @@ TABLE = { ( "src/mcpverbs.h", "nb" ): ( 7, "safe", "nb[160] x4: the CLI notes' MCP twins, byte-identical format. Plural '' / 's' only." ), ( "src/pageview.h", "buf + written" ): ( 1, "safe", "pageDisclosure's H8 floor marker (capture-audit L4): the %s is syn.floor, one of TWO fixed literals (' counts_floor=\"1\"' 17 B, or its JSON twin ',\"counts_floor\":true' 20 B), appended AFTER the paging snprintf into the SAME caller buffer with the remaining capacity (bufCap - written) as its size, guarded by written < bufCap. Every caller's buffer is sized against kPageDisclosureCap, which the floor literal is part of by construction; nothing user-supplied, nothing escaped." ), # ── src/packtask.h ─────────────────────────────────────────────────────────────────────────────────── - ( "src/packtask.h", "open" ): ( 2, "safe", "open[160] (`%.*s` x2, so INVISIBLE to the pre-wave-3 population, and it is an XML OPEN TAG — the shape §B14 is about): packTaskListSection's ''. Safe by ARITHMETIC, not by shape. 30 B of literal ('<' 1 + ' shown=\"' 8 + '\" total=\"' 9 + '\" capped=\"' 10 + '\">' 2). tag comes from the FOUR call sites (:448 'far', :610 'callers', :659 'notes', :698 'tests') ⇒ 7 B. extraAttr is farAttr[32]/callersAttr[32] or the empty literal, and those two are themselves ' of_top=\"%zu\"' snprintf'd into a char[32] ⇒ 31 B at most. Two %zu ⇒ 20 digits each, %d ⇒ 1. Worst case 30+7+31+20+20+1 = 109 B + NUL against 160: 50 B of margin. NOTE both `.*` precisions are int( v.size() ) — they print a string_view, they do not clamp it; the bound is the caller vocabulary and the char[32] feeding extraAttr. SECOND SITE (2026-08-28 serving-shape round, :903): restatePackTaskBodiesWrapper restates the bodies open tag into its own open[112] — two %zu at 20 digits, a fixed capped literal, and a %s that is the 13 B compress literal or empty, ~35 B of literal in total, worst case 88 B against 111 usable. All-numeric/fixed-vocab, same class as the first site." ), + ( "src/packtask.h", "open" ): ( 3, "safe", "open[160] (`%.*s` x2, so INVISIBLE to the pre-wave-3 population, and it is an XML OPEN TAG — the shape §B14 is about): packTaskListSection's ''. Safe by ARITHMETIC, not by shape. 30 B of literal ('<' 1 + ' shown=\"' 8 + '\" total=\"' 9 + '\" capped=\"' 10 + '\">' 2). tag comes from the THREE call sites (:901 'far', :1385 'callers', :1452 'notes' — 'tests' left this helper in the review of #214, see the third site below) ⇒ 7 B. extraAttr is farAttr[32]/callersAttr[32] or the empty literal, and those two are themselves ' of_top=\"%zu\"' snprintf'd into a char[32] ⇒ 31 B at most. Two %zu ⇒ 20 digits each, %d ⇒ 1. Worst case 30+7+31+20+20+1 = 109 B + NUL against 160: 50 B of margin. NOTE both `.*` precisions are int( v.size() ) — they print a string_view, they do not clamp it; the bound is the caller vocabulary and the char[32] feeding extraAttr. SECOND SITE (review of #214, :420): packTaskTestsSection writes the open tag itself, because that section cuts over its own GROUPED rendering rather than over independent entries. Its own open[160], and the narrowest of the three: the tag is the LITERAL 'tests' (no %.*s at all), so the format is a fixed 35 B ('' 2) plus two %zu at 20 digits and one %d at 1 — worst case 76 B + NUL against 160, 83 B of margin. All-numeric, no caller vocabulary to bound. THIRD SITE (2026-08-28 serving-shape round, :1091): restatePackTaskBodiesWrapper restates the bodies open tag into its own open[112] — two %zu at 20 digits, a fixed capped literal, and a %s that is the 13 B compress literal or empty, ~35 B of literal in total, worst case 88 B against 111 usable. All-numeric/fixed-vocab, same class as the first site." ), # ── src/partition.h ────────────────────────────────────────────────────────────────────────────────── ( "src/partition.h", "h" ): ( 2, "safe", "h[288] x2: ; role is the fixed 'core'/'slice' vocabulary, the rest %zu/%u/%d." ), ( "src/partition.h", "pb" ): ( 1, "safe", "pb[96]: the JSON part header; the %s is '' or ',' (the separator)." ), @@ -431,7 +431,7 @@ if not bad: # format to derive a class from — the same reason nestAttr and escAttr are rows. mentions is +2 because # the comment on that buffer names formatTo as well; arch.h's third mention, its emit.h include line, # predates this change. The code it replaced wrote through emitRaw/emitTo, which this gate does not count. -EXPECTED = { "mentions": 322, "calls": 218, "sites": 218, "rows": 92, "widthforms": 0 } +EXPECTED = { "mentions": 323, "calls": 219, "sites": 219, "rows": 92, "widthforms": 0 } # 2026-09-04 (capture-audit L6, H9): +1 call/+1 mention, sites/rows UNCHANGED — re-read, not # re-counted. packConnect gained ONE snprintf into a new `char connectCeiling[32]` for the # H9 ` max_tokens="%d"` ceiling disclosure: a single %d of a caller-supplied INTEGER, no %s, diff --git a/test/impactpartitioncheck.sh b/test/impactpartitioncheck.sh index 70858016b..fd5b99bfb 100644 --- a/test/impactpartitioncheck.sh +++ b/test/impactpartitioncheck.sh @@ -48,8 +48,10 @@ BIN="${1:-${RIPWIRE_BIN:-$ROOT/build/ripwire}}" cd "$ROOT" -python3 - "$BIN" <<'PY' -import re, subprocess, sys +ROOT="$ROOT" python3 - "$BIN" <<'PY' +import os, re, subprocess, sys +sys.path.insert(0, os.path.join(os.environ.get("ROOT", "."), "test")) +import testrowpaths # THE shared tests_to_run row reader BIN = sys.argv[1] fail = [0] @@ -106,7 +108,11 @@ if dup_count: print(" INFO --test-gate=%s: %d row(s) share a (name,file) key with another row (no line= on to disambiguate) — collapses identically on both sides" % (SAMPLE_FILE, dup_count)) ok("--test-gate=%s: parsed %d rows into %d distinct (name,file) keys (shown_untested=%s)" % (SAMPLE_FILE, len(testgate_untested_raw), len(testgate_untested), tg_attrs.get("shown_untested"))) -testgate_testfiles = set(norm_path(m.group(1)) for m in re.finditer(r'`), +# and this set was built from the single rows alone — on a corpus where the rows group, a grouped test file +# was missing from the set and its row was then counted as an untested one. Read through the shared +# reader (test/testrowpaths.py), which knows both shapes in every dialect. +testgate_testfiles = set(norm_path(p) for p in testrowpaths.xml_paths(tg)) # ── union every --impact=src/graph.h:SYM call's rows, keyed (name, normalized path) -> tested bool ───── impact_rows = {} # key -> tested (bool) diff --git a/test/mcpmanifestcheck.sh b/test/mcpmanifestcheck.sh index b3ac77d32..1f32b2102 100755 --- a/test/mcpmanifestcheck.sh +++ b/test/mcpmanifestcheck.sh @@ -209,7 +209,16 @@ tools = json.loads( line )[ "result" ][ "tools" ] # this twin. Its description gained NO prose: a first draft named coverage= and the page there (+109 B) and was # removed rather than re-anchored around, the L7 precedent above — the schema properties are where a client renders # an argument, and the answer's own legend defines coverage= and the page. Headroom after this line: 23 B. -CEILING = 42384 +# RE-ANCHORED 2026-09-13 (review of #214): 42,384 → 42,800 = +416 B, EXACTLY the one 207-byte clause spliced +# into the TWO tool descriptions that serve tests_to_run rows as JSON — `situational_awareness` and `explore` +# (measured against a build of ff8d77a1: 42,361 B here, 23 B under the old ceiling). It is NOT the L7 case the +# line above declines. That one removed prose describing an ARGUMENT, because the schema properties are where a +# client renders an argument and the answer's own legend defines the rest. This clause describes the RESPONSE: +# E1 made a tests_to_run row's `p` a path string OR an array of paths beside `n`, and these two answers carry +# no legend of any kind — `situational_awareness` returns bare JSON with no vocabulary block — so a caller that +# parses `p` as a string has nowhere else to learn otherwise before it breaks. ONE wording (mcp.h +# kTestRowJsonShapeClause), spliced twice, never a third paraphrase. Headroom after this line: 23 B. +CEILING = 42800 manifest = len( json.dumps( { "tools": tools }, separators = ( ",", ":" ) ) ) descBytes = sum( len( t[ "description" ] ) for t in tools ) schemaBytes = sum( len( json.dumps( t[ "inputSchema" ], separators = ( ",", ":" ) ) ) for t in tools ) diff --git a/test/prcontextcheck.sh b/test/prcontextcheck.sh index 2d80054c8..9bf089a6d 100755 --- a/test/prcontextcheck.sh +++ b/test/prcontextcheck.sh @@ -195,8 +195,11 @@ fi # ── E1 follow-up (CodeRabbit on #214, third thread): the clause follows the RENDERED rows, not the corpus ── # "the corpus holds a test file" over-approximated: a test file elsewhere in the corpus, or a trim level whose # testCap is 0, still bought the clause for a document that renders no test row. The legend is now built after -# the level is chosen and priced per candidate level from that level's own rendered body — the same predicate -# (a / row in the body) decides both. Two fixtures, both RED on 7ab0956a: +# the level is chosen and priced per candidate level from that level's OWN ROW COUNT — the emitter reports how +# many test files it wrote (PrTrimRender::testFiles) and both the pricer and the writer read that one number. +# A first fix grepped the rendered body for a `', aff): # F1: evidence attrs ride between p= and run= - want.append((m.group(1), m.group(2))) -got = [ (t["p"], t.get("run")) for t in rows ] +# E1 / review of #214: a row may name SEVERAL files, in either dialect — `` +# in the XML and a "p" ARRAY in the JSON. This comparison read the XML's single rows only and assumed the +# JSON's "p" was a string, so on a corpus where the rows group it compared a shorter list to a crashing +# one. Both sides are now read the same way, through the shared reader: the FILES each names, in order. +want = testrowpaths.xml_paths(aff) +got = testrowpaths.json_paths('{"tests_to_run":' + json.dumps(rows) + '}') assert got == want, "receipt tests_to_run %r != --affected rows %r" % (got, want) assert want, "the fixture reached no test file — the assertion would be vacuous" +# the run recipe still has to agree, per SINGLE row (a group row carries run_unknown by construction and +# has no per-path recipe to compare): key the XML singles by path and check the JSON's singles against them. +xrun = dict((m.group(1), m.group(2)) for m in re.finditer(r']*?(?: run="([^"]*)")?/>', aff)) +for t in rows: + if isinstance(t.get("p"), str) and t["p"] in xrun: + assert t.get("run") == xrun[t["p"]], "receipt run recipe for %s: %r != %r" % (t["p"], t.get("run"), xrun[t["p"]]) print("OK") PY [ $? -eq 0 ] \ diff --git a/test/rootrelemitcheck.sh b/test/rootrelemitcheck.sh index 84e60283a..b13f0d032 100755 --- a/test/rootrelemitcheck.sh +++ b/test/rootrelemitcheck.sh @@ -341,10 +341,12 @@ done # diffed with `sort | uniq`. The arms above catch each verb's leak in isolation; this one pins the CROSS-VERB # property directly, because that is the property an agent actually consumes. Run at BOTH root spellings: a # verb can be self-consistent and still disagree with its siblings on only one of the two. -tests_to_run_rows(){ # → one path per line +# E1 / review of #214: a tests_to_run row may name SEVERAL files (``), and +# this reader saw only the singles — on a corpus where the rows group it compared two EMPTY lists and passed. +# Every dialect is now read by test/testrowpaths.py, THE shared reader. +tests_to_run_rows(){ # → one path per line, emitted order local root="$1"; shift - "$BIN" "$root" "$@" 2>/dev/null \ - | tr '<' '\n' | sed -n 's/^test p="\([^"]*\)".*/\1/p; s/^t p="\([^"]*\)".*/\1/p' + "$BIN" "$root" "$@" 2>/dev/null | python3 "$ROOT/test/testrowpaths.py" paths xml } # This arm needs a corpus that HAS a test file. $SHORT/$DEEP do not: the fixture's one cross-dir caller is a # test only by virtue of the "test/fixture/…" path it lives at in THIS repo, and a copy of it elsewhere is @@ -368,15 +370,15 @@ for spelling in abs rel; do # this corpus, reaches only the cross-dir consumer — the symbol reading is the one that reaches the test. a_rows=$( cd "$CD" && tests_to_run_rows "$RT" --affected=distance ) g_rows=$( cd "$CD" && tests_to_run_rows "$RT" --test-gate=geometry.cpp ) - # the JSON twin carries "p" in BOTH arrays — narrow to tests_to_run so this compares like with like + # the JSON twin carries "p" in BOTH arrays — the shared reader slices tests_to_run by BRACKET DEPTH (a + # group row's "p" is itself an array, so the first ']' is not the end of the list) and unwraps both shapes j_rows=$( cd "$CD" && "$BIN" "$RT" --test-gate=geometry.cpp --json 2>/dev/null \ - | sed -n 's/.*"tests_to_run":\[\([^]]*\)\].*/\1/p' | tr ',' '\n' | sed -n 's/.*"p":"\([^"]*\)".*/\1/p' ) - # M21(b) re-pin (capture-audit 2026-09-04, lane L8): every --situ tests-to-run line now ends in a run - # recipe OR its "(run: not derivable)" disclosure, so the old "the line contains no '(' " extraction - # matched nothing and this arm read red while the SPELLING it exists to compare was correct. Re-pinned to - # the new contract: take the path FIELD off a row line, not the whole line. + | python3 "$ROOT/test/testrowpaths.py" paths json ) + # M21(b) re-pin (capture-audit 2026-09-04, lane L8): every --situ tests-to-run line ends in a run recipe + # OR its "(run: not derivable)" disclosure. E1 then made a line able to carry SEVERAL paths, and `$1` of + # such a line is `[hops=1]`, not a path — so the text dialect goes through the shared reader too. s_rows=$( cd "$CD" && "$BIN" "$RT" --situ=geometry.cpp 2>/dev/null \ - | sed -n '/tests to run/,/^ \[3\]/p' | awk '/^ [^ (]/ { print $1 }' ) + | python3 "$ROOT/test/testrowpaths.py" paths text ) if [ -z "$a_rows" ]; then no "ARM6/$spelling --affected emitted NO test row for distance — the arm would be a false green" continue diff --git a/test/selectorchaincheck.sh b/test/selectorchaincheck.sh index 14c93aa43..ca8e2d21a 100755 --- a/test/selectorchaincheck.sh +++ b/test/selectorchaincheck.sh @@ -120,7 +120,9 @@ esac # ── (d) --affected accepts a pasted `path:line` locator ─────────────────────────────────────────────── D_L="$( run --affected=src/graph.h:1148 2>/dev/null )" D_P="$( run --affected=src/graph.h 2>/dev/null )" -tset(){ printf '%s' "$1" | grep -oE '`), so the set of files +# is read by test/testrowpaths.py — THE shared reader — not by a grep for one row shape. +tset(){ printf '%s' "$1" | python3 "$ROOT/test/testrowpaths.py" paths xml | sort | tr '\n' ' '; } { [ -n "$( tset "$D_P" )" ] && [ "$( tset "$D_L" )" = "$( tset "$D_P" )" ]; } \ && ok "(d) --affected=src/graph.h:1148 ≡ --affected=src/graph.h (same test set)" \ || no "(d) --affected path:line ≠ path (line=[$( tset "$D_L" )] plain=[$( tset "$D_P" )])" diff --git a/test/testgatelegendbudgetcheck.sh b/test/testgatelegendbudgetcheck.sh index 066bb3bd9..a238d6f9a 100755 --- a/test/testgatelegendbudgetcheck.sh +++ b/test/testgatelegendbudgetcheck.sh @@ -93,15 +93,29 @@ EOF # RE-PINNED 2720 -> 2900 (2026-09-12, output-routing loop E1 / A4-2, owner call). ONE new FACT, in the SAME # row-gated clause (testmap.h kRunHintLegendClause, so the zero-row report still pays nothing): # +180 B the group row — 2+ runner-less rows with equal evidence attributes served as ONE row, n= how -# many, p= their paths in list order (, a comma), every path verbatim. It is what lets the -# not-derivable disclosure be said once per GROUP instead of once per row (rocksdb, 127 rows: 126 -# `run_unknown="1"` -> 9, test-gate 13,242 -> 9,594 B) and legendcoveragecheck wants n= defined +# many, p= their paths in list order, every path verbatim. It is what lets the not-derivable +# disclosure be said once per GROUP instead of once per row (rocksdb, 127 rows: 126 +# `run_unknown="1"` -> 10, test-gate 13,242 -> 9,633 B) and legendcoveragecheck wants n= defined # wherever a document carries it. -# Measured on this fixture: 2663 -> 2843 B; 2900 leaves ~57 B — the same posture as every pin above. -if [ "$legend" -le 2900 ]; then - ok "(a) --test-gate legend is $legend B (<= 2900 B budget; total=$total payload=$payload)" +# Measured on this fixture: 2663 -> 2843 B. +# RE-PINNED 2900 -> 3000 (2026-09-13, review of #214). TWO facts a consumer of a row cannot do without, +# both in the same row-gated clause, so a zero-row report still pays nothing: +# +65 B a path holding ',' is NEVER grouped, so p= splits into exactly n= paths. The seam used to spell +# such a path , and say so here; every XML parser undoes that entity BEFORE a consumer splits +# on the delimiter, so the escape was a promise the format could not keep. Refusing to group the +# row is the only spelling that is right in all three dialects, and this sentence is what makes +# `split( p, "," )` a safe thing for a reader to write. +# +49 B a section's shown=/total= over these rows count test FILES, so a row is N of them. Same +# finding from the other side: --pack-task prints above 30-odd +# RENDERED rows, and the bundle legend's own "shown=rows kept" sentence flatly contradicted it. +# Said HERE rather than in that always-on bundle legend, which is charged against the ceiling it +# describes: unconditional it put packtaskcheck's 2000-token arm 5620 B over a 5428 B ceiling +# (measured), and a bundle with no section has no use for it. +# Measured on this fixture: 2843 -> 2957 B; 3000 leaves ~43 B — the same posture as every pin above. +if [ "$legend" -le 3000 ]; then + ok "(a) --test-gate legend is $legend B (<= 3000 B budget; total=$total payload=$payload)" else - no "(a) --test-gate legend is $legend B (> 2900 B budget) — the essay re-inflated" + no "(a) --test-gate legend is $legend B (> 3000 B budget) — the essay re-inflated" fi # (b) the honesty vocabulary + the §B12.5 cross-verb UNIT-collision anchors (test/testgatecheck.sh arm (g) diff --git a/test/testrowpaths.py b/test/testrowpaths.py new file mode 100644 index 000000000..289bde810 --- /dev/null +++ b/test/testrowpaths.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +"""testrowpaths.py — THE tests_to_run row reader every gate shares. + +WHY THIS FILE EXISTS. Nine gates assert something about the tests_to_run row family, and each one had +grown its own reader: `grep -oE '` in XML, a `"p"` +ARRAY in JSON, `[hops=2] (3): a, b, c` in the text dialect. The private readers did not fail; they went +QUIET or, worse, wrong: + + * `grep -oE '"tests_to_run":\\[[^]]*\\]'` stops at the first `]`, which is now the end of the FIRST + group's path array — three gate arms asserted over two and a half rows and passed vacuously; + * `sed -n 's/^test p=//'` and friends saw the singles and silently skipped every group row; + * the text reader took `$1` of the line, which on a group line is `[hops=1]`, not a path. + +The invariant all nine actually want is THE FILES NAMED, in emitted order. That is one question, so it is +answered in one place, for every dialect, and a gate that adds a new assertion gets the group shapes for +free instead of re-deriving them. + + python3 test/testrowpaths.py paths xml|json|text [FILE] # one path per line, emitted order + python3 test/testrowpaths.py jsonlist [FILE] # the balanced "tests_to_run":[...] slice + +FILE defaults to stdin. Exit 0 even when nothing matches: "no rows" is an answer a gate may be asserting, +and an empty stdout says it. Exit 2 only on a malformed document the reader cannot parse at all. + +DIALECTS, and what counts as a row in each — the shapes testmap.h's seam emits, and nothing else: + xml and singles, groups. Comments are stripped FIRST, so + the legend's own `` definition is never read as a row. + json every object inside the balanced "tests_to_run":[…] list whose "p" (or "test") key is a string or + an array of strings. The list is sliced by bracket depth, not by the first `]`. + text --situ's ` path [hops=N] (run: …)` singles and its + ` [hops=N] (n): a, b, c (run: not derivable)` group lines. + +A path is emitted VERBATIM, exactly as the document spelled it, except that XML entity references are +decoded (`&` `<` `>` `"` `&#NN;`) — a gate compares paths against the file names it created, +which are unescaped. Note that a path containing ',' is never grouped (testmap.h refuses to), so splitting +a group's p= on ',' cannot split a path in half. +""" + +import json +import re +import sys + +_ENT = { "amp": "&", "lt": "<", "gt": ">", "quot": '"', "apos": "'" } + + +def xml_unescape( s ): + def one( m ): + body = m.group( 1 ) + if body.startswith( "#" ): + try: + return chr( int( body[2:], 16 ) if body[1:2].lower() == "x" else int( body[1:], 10 ) ) + except ValueError: + return m.group( 0 ) + return _ENT.get( body, m.group( 0 ) ) + return re.sub( r"&([#0-9A-Za-z]+);", one, s ) + + +def strip_comments( doc ): + """Drop every run. The legends define the row shapes they describe, so a reader that keeps + comments reads the DEFINITION of as a row with the paths 'a', 'b' and 'c'.""" + return re.sub( r"", "", doc, flags = re.S ) + + +def xml_paths( doc ): + """The test FILES an XML document names, in emitted order. + + `` + with the same opener. A tests_to_run group is qualified by run_unknown="1", which every group row carries + by construction (a group exists only where no runner is derivable), so that is what this reader matches — + the same qualification the compact legend uses to keep the two readings of `` apart.""" + out = [] + for m in re.finditer( r'<(?:t|test|g)\b[^>]*?\bp="([^"]*)"[^>]*/>', strip_comments( doc ) ): + row = m.group( 0 ) + raw = m.group( 1 ) + if row.startswith( " gate row, not a test group + # a group's p= is a comma-separated list; a path containing ',' is never grouped (testmap.h + # refuses to), so splitting on ',' cannot split a path in half. + out.extend( xml_unescape( p ) for p in raw.split( "," ) if p != "" ) + else: + out.append( xml_unescape( raw ) ) + return out + + +def json_list_slice( doc ): + """The "tests_to_run":[…] value, sliced by BRACKET DEPTH (a group row's "p" is itself an array, so the + first ']' is not the end of the list) and string-aware (a ']' inside a path is not a bracket).""" + i = doc.find( '"tests_to_run"' ) + if i < 0: + return None + i = doc.find( "[", i ) + if i < 0: + return None + depth = 0 + instr = False + esc = False + for k in range( i, len( doc ) ): + c = doc[k] + if instr: + if esc: + esc = False + elif c == "\\": + esc = True + elif c == '"': + instr = False + continue + if c == '"': + instr = True + elif c == "[": + depth += 1 + elif c == "]": + depth -= 1 + if depth == 0: + return doc[i:k + 1] + return None + + +def json_paths( doc ): + sl = json_list_slice( doc ) + if sl is None: + return [] + rows = json.loads( sl ) + out = [] + for r in rows: + if not isinstance( r, dict ): + continue + p = r.get( "p", r.get( "test" ) ) + if isinstance( p, list ): + out.extend( str( x ) for x in p ) + elif isinstance( p, str ): + out.append( p ) + return out + + +_TEXT_GROUP = re.compile( r"^\s*(?:\[[^\]]*\]\s*)*\((\d+)\):\s*(.*?)\s{2,}\(run: " ) +_TEXT_SINGLE = re.compile( r"^\s{4,}(\S+)\s.*?\(run: " ) + + +def text_paths( doc ): + out = [] + for line in doc.split( "\n" ): + if "(run: " not in line: + continue + g = _TEXT_GROUP.match( line ) + if g: + out.extend( p.strip() for p in g.group( 2 ).split( "," ) if p.strip() ) + continue + s = _TEXT_SINGLE.match( line ) + if s: + out.append( s.group( 1 ) ) + return out + + +def main( argv ): + if len( argv ) < 2: + sys.stderr.write( __doc__ ) + return 2 + mode = argv[1] + if mode == "jsonlist": + src = argv[2] if len( argv ) > 2 else None + doc = open( src ).read() if src else sys.stdin.read() + sl = json_list_slice( doc ) + sys.stdout.write( sl if sl else "" ) + return 0 + if mode != "paths" or len( argv ) < 3: + sys.stderr.write( __doc__ ) + return 2 + dialect = argv[2] + src = argv[3] if len( argv ) > 3 else None + doc = open( src ).read() if src else sys.stdin.read() + try: + if dialect == "xml": + paths = xml_paths( doc ) + elif dialect == "json": + paths = json_paths( doc ) + elif dialect == "text": + paths = text_paths( doc ) + else: + sys.stderr.write( "testrowpaths: unknown dialect %r (xml|json|text)\n" % dialect ) + return 2 + except Exception as e: # a document this reader cannot parse at all + sys.stderr.write( "testrowpaths: %s dialect unreadable: %s\n" % ( dialect, e ) ) + return 2 + for p in paths: + sys.stdout.write( p + "\n" ) + return 0 + + +if __name__ == "__main__": + sys.exit( main( sys.argv ) ) diff --git a/test/testrowruncheck.sh b/test/testrowruncheck.sh index 77afe6cdc..e8be07f7e 100755 --- a/test/testrowruncheck.sh +++ b/test/testrowruncheck.sh @@ -49,7 +49,7 @@ echo "testrowruncheck: BIN=$BIN" # Every site that prints a tests_to_run row. The list is the CONTRACT: a site added to src/ and not added # here fails, which is the only way a family gate stays a family gate. EXPECTED_SITES="src/verbs_change.h src/situ.h src/prcontext.h src/packtask.h src/handoff.h src/flipimpact.h src/mcpverbs.h src/mcpedit.h" -FOUND_SITES="$( cd "$ROOT" && grep -lE 'testRows(Rendered|Joined)\(|"<(t|test) p=\\"|\{\\"test\\":|\{\\"p\\":\\"%s\\"%s\}' src/*.h src/*.cpp 2>/dev/null \ +FOUND_SITES="$( cd "$ROOT" && grep -lE 'testRows(Rendered|Joined|List)\(|"<(t|test) p=\\"|\{\\"test\\":|\{\\"p\\":\\"%s\\"%s\}' src/*.h src/*.cpp 2>/dev/null \ | grep -vE 'src/(serialize|testmap)\.h' | sort | tr '\n' ' ' | sed 's/ $//' )" WANT_SITES="$( printf '%s\n' $EXPECTED_SITES | sort | tr '\n' ' ' | sed 's/ $//' )" [ "$FOUND_SITES" = "$WANT_SITES" ] \ @@ -113,6 +113,28 @@ cat > "$WORK/test/covered.sh" <<'EOF' echo covered EOF chmod +x "$WORK/test/covered.sh" +# a DARK gate whose guarded region sits in a file the tests reach, so --flags --flip has a report to make +# with rows in it (arm 7 skipped on every fixture for a release — it also read the wrong row element). +printf 'cmake_minimum_required( VERSION 3.20 )\nproject( trr )\noption( FEATURE_WRAP "the dark one" OFF )\n' > "$WORK/CMakeLists.txt" +# INSIDE wrapper's body, not beside it: a flip's tests are the tests that reach the gate's HOSTS, and a +# host that is itself dark is reached by nothing — the region has to sit in a function a test already calls. +cat > "$WORK/src/app.cpp" <<'APPEOF' +int compute( int x ) +{ + return x * 2; +} + +int wrapper( int x ) +{ +#ifdef FEATURE_WRAP + if( x > 100 ) + { + return compute( x + 1 ); + } +#endif + return compute( x ); +} +APPEOF ( cd "$WORK" && git init -q && git config user.email t@t && git config user.name t \ && git add -A && git commit -qm init >/dev/null 2>&1 ) # a second commit so the ref-taking verbs (--pr-context, --handoff) have a base to diff against, and a @@ -160,7 +182,15 @@ JHAS='"run":"' JUNK='"run_unknown":true' # the JSON dialects embed the tests_to_run LIST inside a document that also carries file rows keyed "p"; # slice the list first so the arm asks its question of the row family it is about and no other. -json_tests(){ printf '%s' "$1" | sed 's/\\"/"/g' | grep -oE '"tests_to_run":\[[^]]*\]'; } +# +# Review of #214: this slicer was `grep -oE '"tests_to_run":\[[^]]*\]'`, which stops at the first ']' — and +# since E1 the first ']' is the end of the FIRST GROUP's path array, not of the list. Arms 3, 5 and 9 were +# asserting over two and a half rows and passing vacuously. The slice is now taken by BRACKET DEPTH, in +# test/testrowpaths.py, which is also the reader every other gate in the tree uses for these rows. +ROWPATHS="$ROOT/test/testrowpaths.py" +json_tests(){ printf '%s' "$1" | sed 's/\\"/"/g' | python3 "$ROWPATHS" jsonlist; } +# the FILES a document names, in emitted order, in any dialect — singles and /array groups alike +row_paths(){ printf '%s' "$2" | python3 "$ROWPATHS" paths "$1"; } # ── ARM 1 — --affected (verbs_change.h) ──────────────────────────────────────────────────────────────── rows_disclosed "(1) --affected" "$( rw --affected=compute,wrapper )" "$XROW" "$XHAS" "$XUNK" @@ -178,7 +208,9 @@ rows_disclosed "(5) --pack-task json" "$( json_tests "$( rw --pack-task="change # ── ARM 6 — --handoff (handoff.h) — the row family lens 2 L2 found carrying NO run= at all ───────────── rows_disclosed "(6) --handoff" "$( rw --handoff )" "$XROW" "$XHAS" "$XUNK" # ── ARM 7 — --flags --flip (flipimpact.h) ────────────────────────────────────────────────────────────── -FLIPNAME="$( rw --flags | grep -oE '`; this arm read ` "$W2/test/t_$n.cpp"; done printf '#!/usr/bin/env bash\necho leaf_b\n' > "$W2/test/t_leaf_b.sh"; chmod +x "$W2/test/t_leaf_b.sh" ( cd "$W2" && git init -q && git config user.email t@t && git config user.name t && git add -A && git commit -qm init >/dev/null 2>&1 ) -printf 'int leaf2( int x ) { return x + 2; }\n' >> "$W2/src/leaf.cpp" +# a second commit and a dirty tree, so the DIFF-seeded verbs (--pr-context, --handoff) have a change set to +# answer about: without one they report files="0" and name no test at all, and arm 12's group-shape half +# (and arm 14's positive control) would be asserting over an empty document. +printf 'int leaf_extra( int x )\n{\n return x + 2;\n}\n' >> "$W2/src/leaf.cpp" +( cd "$W2" && git add -A && git commit -qm second >/dev/null 2>&1 ) +printf 'int leaf_extra2( int x )\n{\n return x + 3;\n}\n' >> "$W2/src/leaf.cpp" rw2(){ ( cd "$W2" && "$BIN" . "$@" --no-cache 2>/dev/null ); } A12="$( rw2 --affected=src/leaf.cpp )" G12="$( rw2 --test-gate=src/leaf.cpp )" J12="$( rw2 --test-gate=src/leaf.cpp --json )" S12="$( rw2 --situ=src/leaf.cpp )" -python3 - "$A12" "$G12" "$J12" "$S12" <<'PY12' -import sys, re, json -aff, tg, tgj, situ = sys.argv[1:5] +P12="$( rw2 --pr-context )" +H12="$( rw2 --handoff )" +K12="$( rw2 --pack-task="change leaf" )" +KJ12="$( rw2 --pack-task="change leaf" --json )" +ROOT="$ROOT" python3 - "$A12" "$G12" "$J12" "$S12" "$P12" "$H12" "$K12" "$KJ12" <<'PY12' +import sys, os, re +sys.path.insert( 0, os.path.join( os.environ[ "ROOT" ], "test" ) ) +import testrowpaths as trp # THE shared reader — singles and /array groups, any dialect +aff, tg, tgj, situ, prc, hoff, pt, ptj = sys.argv[1:9] ORDER = [ "test/t_%s.cpp" % n for n in ( "leaf_a", "leaf_b", "leaf_c", "leaf_d", "mid_a", "mid_b", "top_a", "top_b" ) ] # evidence order: hops asc, then path EXPECT = sorted( ORDER ) fails = [] -def xml_paths( doc ): - out, groups, singles_run = [], [], [] - for m in re.finditer( r'<(t|test|g)( [^>]*)/>', doc ): - tag, attrs = m.group( 1 ), m.group( 2 ) - at = dict( re.findall( r' ([a-z_]+)="([^"]*)"', attrs ) ) - if tag == "g": - ps = [ p.replace( ",", "," ) for p in at["p"].split( "," ) ] - if int( at.get( "n", "-1" ) ) != len( ps ): fails.append( "n=%s on a row listing %d paths" % ( at.get( "n" ), len( ps ) ) ) - if at.get( "run_unknown" ) != "1": fails.append( "a row without run_unknown=1: %s" % m.group( 0 ) ) - if len( ps ) < 2: fails.append( "a row of one: %s" % m.group( 0 ) ) - groups.append( at.get( "hops" ) ); out += ps - else: - if "run" in at: singles_run.append( at["p"] ) - out.append( at["p"] ) - return out, groups, singles_run -a_paths, a_groups, a_run = xml_paths( aff ) -g_paths, g_groups, g_run = xml_paths( tg ) -if not a_paths: fails.append( "--affected emitted no test row at all (fixture broken)" ) -for label, paths in ( ( "--affected", a_paths ), ( "--test-gate", g_paths ) ): - if sorted( paths ) != EXPECT: fails.append( "%s multiset %r != %r" % ( label, sorted( paths ), EXPECT ) ) - if paths != ORDER: fails.append( "%s ORDER changed by grouping: %r != %r (a group must cover a contiguous run only)" % ( label, paths, ORDER ) ) -for label, groups in ( ( "--affected", a_groups ), ( "--test-gate", g_groups ) ): - if len( set( groups ) ) < 3 or None in groups: fails.append( "%s: expected >=3 rows at distinct hops=, got hops=%r" % ( label, groups ) ) -for label, run in ( ( "--affected", a_run ), ( "--test-gate", g_run ) ): - if run != [ "test/t_leaf_b.cpp" ]: fails.append( "%s: the run= row must be the single test/t_leaf_b.cpp, got %r" % ( label, run ) ) -m = re.search( r']*\btests="(\d+)"', tg ) -if not m or int( m.group( 1 ) ) != len( EXPECT ): fails.append( "--test-gate tests= must count FILES (%d), got %s" % ( len( EXPECT ), m and m.group( 1 ) ) ) -# JSON twin: "p" is a string on a single row and an ARRAY on a group row -# the list is sliced on bracket DEPTH: a group row's "p":[…] array sits inside it, so a lazy `\[.*?\]` would -# stop at the first inner `]` (the shape that made this arm read red while the document was valid JSON) -def balanced_list( doc, key ): - i = doc.find( key ) - if i < 0: return None - i = doc.find( "[", i ); depth = 0; instr = False - for k in range( i, len( doc ) ): - c = doc[k] - if instr: - if c == "\\": continue - if c == '"': instr = False - continue - if c == '"': instr = True - elif c == "[": depth += 1 - elif c == "]": - depth -= 1 - if depth == 0: return doc[i:k+1] - return None -jl = balanced_list( tgj, '"tests_to_run":' ) -try: - rows = json.loads( jl ) if jl else [] -except Exception as e: - rows = []; fails.append( "--test-gate --json tests_to_run is not JSON: %s" % e ) -j_paths = [] -for r in rows: - p = r.get( "p" ) - if isinstance( p, list ): - j_paths += p - if r.get( "n" ) != len( p ) or r.get( "run_unknown" ) is not True: fails.append( "JSON group row without n=/run_unknown: %r" % r ) - else: - j_paths.append( p ) - if "run" not in r and r.get( "run_unknown" ) is not True: fails.append( "JSON single row carries neither: %r" % r ) -if sorted( j_paths ) != EXPECT: fails.append( "--test-gate --json multiset %r != %r" % ( sorted( j_paths ), EXPECT ) ) -if j_paths != ORDER: fails.append( "--test-gate --json ORDER changed by grouping: %r" % j_paths ) -# --situ text: ` path [hops=N] (run: …)` singles and ` [hops=N] (n): a, b, c (run: not derivable)` groups -sec = situ.split( "tests to run", 1 )[1].split( "\n [3]", 1 )[0] if "tests to run" in situ else "" -s_paths, s_groups = [], 0 -for line in sec.split( "\n" ): - if not line.startswith( " " ) or line.startswith( " (" ): continue - body = line[8:] - gm = re.match( r'(\[[^\]]*\] )?\((\d+)\): (.*?) \(run: not derivable\)$', body ) - if gm: - ps = gm.group( 3 ).split( ", " ); s_groups += 1 - if int( gm.group( 2 ) ) != len( ps ): fails.append( "situ group count (%s) != %d paths" % ( gm.group( 2 ), len( ps ) ) ) - s_paths += ps + +# (a) the four dialects that serve the WHOLE list: same paths, same order, through the shared reader. +for label, doc, dialect in ( ( "--affected", aff, "xml" ), ( "--test-gate", tg, "xml" ), + ( "--test-gate --json", tgj, "json" ), ( "--situ text", situ, "text" ) ): + paths = trp.xml_paths( doc ) if dialect == "xml" else ( trp.json_paths( doc ) if dialect == "json" else trp.text_paths( doc ) ) + if sorted( paths ) != EXPECT: + fails.append( "%s multiset %r != %r" % ( label, sorted( paths ), EXPECT ) ) + if paths != ORDER: + fails.append( "%s ORDER changed by grouping: %r != %r (a group must cover a contiguous run only)" % ( label, paths, ORDER ) ) + +# (b) the group shape itself EXISTS, at three distinct hops=, and the run= row stayed a single row. +for label, doc in ( ( "--affected", aff ), ( "--test-gate", tg ) ): + body = trp.strip_comments( doc ) + hops = re.findall( r']*?\bhops="(\d+)"', body ) + if len( set( hops ) ) < 3: + fails.append( "%s: expected >=3 rows at distinct hops=, got hops=%r" % ( label, hops ) ) + runs = [ m.group( 0 ) for m in re.finditer( r'<(?:t|test|g)\b[^>]*?\brun="[^"]*"[^>]*/>', body ) ] + if len( runs ) != 1 or runs[0].startswith( " shapes of the OTHER emitters were produced by no arm at all. This fixture +# forms a group, so every one of them must show one — and every group row must carry the disclosure. +for label, doc, dialect in ( ( "--pr-context", prc, "xml" ), ( "--handoff", hoff, "xml" ), + ( "--pack-task", pt, "xml" ), ( "--pack-task --json", ptj, "json" ) ): + if dialect == "xml": + body = trp.strip_comments( doc ) + groups = re.findall( r"]*/>", body ) + if not groups: + fails.append( "%s renders no group row on a fixture that forms one" % label ) + for grow in groups: + if 'run_unknown="1"' not in grow: + fails.append( "%s group row carries no disclosure: %r" % ( label, grow ) ) + if not re.search( r'\bn="\d+"', grow ): + fails.append( "%s group row carries no n=: %r" % ( label, grow ) ) else: - if " (run: " not in body: fails.append( "situ line carries no run recipe/disclosure: %r" % line ) - s_paths.append( body.split( " ", 1 )[0] ) -if sorted( s_paths ) != EXPECT: fails.append( "--situ text multiset %r != %r" % ( sorted( s_paths ), EXPECT ) ) -if s_paths != ORDER: fails.append( "--situ text ORDER changed by grouping: %r" % s_paths ) -if s_groups < 3: fails.append( "--situ text: expected >=3 group lines, got %d" % s_groups ) + import json as _json + sl = trp.json_list_slice( doc ) + rows = _json.loads( sl ) if sl else [] + arrays = [ r for r in rows if isinstance( r.get( "p", r.get( "test" ) ), list ) ] + if not arrays: + fails.append( "%s renders no array (group) row on a fixture that forms one: %r" % ( label, rows ) ) + for r in arrays: + if r.get( "run_unknown" ) is not True or "n" not in r: + fails.append( "%s group row missing n= or the disclosure: %r" % ( label, r ) ) + # and the files a group names are files this fixture has + got = trp.xml_paths( doc ) if dialect == "xml" else trp.json_paths( doc ) + if not got: + fails.append( "%s named no test file at all" % label ) + for g in got: + if not g.endswith( tuple( os.path.basename( e ) for e in EXPECT ) ): + fails.append( "%s named a path this fixture does not have: %r" % ( label, g ) ) + if fails: print( "\n".join( fails ) ); sys.exit( 1 ) -print( "OK %d paths, %d rows on --affected" % ( len( EXPECT ), len( a_groups ) ) ) +print( "OK %d paths in order, groups present in 8 dialect/verb combinations" % len( EXPECT ) ) PY12 r12=$? [ "$r12" -eq 0 ] \ - && ok "(12) E1: grouping keeps the path multiset AND order in every dialect (8 paths, >=3 hop groups, the run= row single in place, tests= counts files)" \ - || no "(12) E1: the grouped rows do not carry the same paths as the single rows did (details above)" + && ok "(12) E1: grouping keeps the path multiset AND order in every dialect, and every emitter renders the shape (8 paths, >=3 hop groups, the run= row single in place, tests= counts files)" \ + || no "(12) E1: the grouped rows do not carry the same paths, order or shape as the single rows did (details above)" + +# ── ARM 13 — E1: a path that ESCAPES WIDER than it reads never costs the section its rows ───────────── +# --pack-task's section is byte-budgeted. It used to GROUP first and hand the group rows to the +# generic list cutter under a per-row byte cap whose estimate was `attrs + 48 + Σ(path+1)` over UNESCAPED +# paths — so a corpus whose test paths hold '&' (or '<', or '"') rendered wider than the cap admitted, the +# cutter broke at the first over-budget entry, and the whole TAIL of the section went with it, run= singles +# included. The section now cuts over its own grouped, ESCAPED rendering (packTaskTestsSection). +# +# A true matched pair: the same ten test files, the same path LENGTHS, differing in exactly one byte per +# name — '&' in one fixture, '_' in the other — plus one harness with a runner so a run= single is in play. +# The property: at every budget the '&' fixture names at least one file whenever the control does (it may +# name fewer — escaped paths really are wider — but it must never collapse to nothing), and neither fixture +# ever goes backwards as the budget grows. Red on ff8d77a1: at --token-budget=1440 the control serves 5 +# files and the '&' fixture serves 0. +A13="$( mktemp -d )"; C13="$( mktemp -d )"; trap 'rm -rf "$WORK" "$W2" "$A13" "$C13"' EXIT +mk13(){ d="$1"; sep="$2"; mkdir -p "$d/src" "$d/test" + printf 'int compute_value( int x )\n{\n return x + 1;\n}\n' > "$d/src/core.cpp" + for n in a b c d e f x y z w; do printf 'int compute_value( int x );\nint test_%s( void )\n{\n return compute_value( 1 );\n}\n' "$n" > "$d/test/${n}${sep}t.cpp"; done + printf 'int compute_value( int x );\nint test_run( void )\n{\n return compute_value( 1 );\n}\n' > "$d/test/runme_t.cpp" + printf '#!/usr/bin/env bash\necho runme\n' > "$d/test/runme_t.sh"; chmod +x "$d/test/runme_t.sh" + ( cd "$d" && git init -q && git config user.email t@t && git config user.name t && git add -A && git commit -qm init >/dev/null 2>&1 ); } +mk13 "$A13" '&' +mk13 "$C13" '_' +named13(){ ( cd "$1" && "$BIN" . --no-cache --pack-task="compute_value" --token-budget="$2" 2>/dev/null ) | python3 "$ROWPATHS" paths xml | grep -c . ; } +bad13=""; prevA=0; prevC=0; sawA=0 +for b in 1440 1500 1560 1620 1680 1740 1800 1860; do + na="$( named13 "$A13" $b )"; nc="$( named13 "$C13" $b )" + [ "$na" -gt 0 ] && sawA=1 + [ "$nc" -gt 0 ] && [ "$na" -eq 0 ] && bad13="$bad13 budget=$b: control names $nc file(s), the '&' fixture names NONE" + [ "$na" -lt "$prevA" ] && bad13="$bad13 budget=$b: the '&' fixture went backwards ($prevA -> $na)" + [ "$nc" -lt "$prevC" ] && bad13="$bad13 budget=$b: the control went backwards ($prevC -> $nc)" + prevA=$na; prevC=$nc +done +if [ "$sawA" -ne 1 ] || [ "$prevC" -eq 0 ]; then + no "(13) neither fixture named a test file anywhere in 1440..1860 — the arm cannot bite" +elif [ -n "$bad13" ]; then + no "(13) a '&' in a test path costs the section its rows:$bad13" +else + ok "(13) E1: a path that escapes wider than it reads never drops the section (10 tests, '&' vs '_', budgets 1440..1860; both monotone, neither empty where the other is not)" +fi + +# ── ARM 14 — the run-hint clause is gated on ROWS at every site that splices it ─────────────────────── +# The clause is a rule ABOUT rows (~180 B). Review of #214: --handoff and --flags --flip spliced it +# unconditionally — and --handoff is BYTE-BUDGETED with heuristic rows dropped tail-first, so a packet with +# could evict a real row to pay for a rule about rows it has none of. Both now ask +# testmap.h's ONE gate (runHintClauseIfRows) with the count the seam returned. +# Fixture: a corpus with NO test file at all, so every verb below renders zero rows. Red on ff8d77a1 for +# --handoff; --flags --flip is asserted on the same corpus for the same reason. +N14="$( mktemp -d )"; trap 'rm -rf "$WORK" "$W2" "$A13" "$C13" "$N14"' EXIT +mkdir -p "$N14/src" +printf 'int alpha( int x )\n{\n return x + 1;\n}\n' > "$N14/src/a.cpp" +# a DARK preprocessor gate, so --flags --flip has something to report on this corpus too: its legend splices +# the same clause and, review of #214, spliced it unconditionally. +cat > "$N14/src/b.cpp" <<'B14' +int alpha( int x ); -# ── ARM 13 — E1: a byte cap never drops two paths that each fit as a singleton ─────────────────────── -# --pack-task's section is byte-budgeted per ROW, so a group is capped at the section's budget -# (testmap.h partitionTestRows maxGroupBytes). CodeRabbit on #214: the cap was applied only from the THIRD -# member on, so two runner-less paths that each fit alone were joined into one row the section then -# rejected whole — the bundle named NO test where it could have named one. The arm sweeps --token-budget -# upward on a two-test corpus with no runner: the FIRST budget at which a section appears must serve -# ONE file as a single row (shown="1", no ), and shown= must never decrease as the budget grows. -# Red on 7ab0956a: the first section to appear is `` shown="2". -W3="$( mktemp -d )"; trap 'rm -rf "$WORK" "$W2" "$W3"' EXIT -mkdir -p "$W3/src" "$W3/test" -printf 'int compute_value( int x )\n{\n return x + 1;\n}\n' > "$W3/src/core.cpp" -for n in alpha_long_name beta_long_name; do printf 'int compute_value( int x );\nint test_%s( void )\n{\n return compute_value( 1 );\n}\n' "$n" > "$W3/test/t_$n.cpp"; done -( cd "$W3" && git init -q && git config user.email t@t && git config user.name t && git add -A && git commit -qm init >/dev/null 2>&1 ) -first=""; firstrow=""; prev=0; mono=1; seen=0 -for b in $( seq 1000 20 1700 ); do - o="$( cd "$W3" && "$BIN" . --no-cache --pack-task="compute_value" --token-budget=$b 2>/dev/null )" - sh="$( printf '%s' "$o" | grep -oE '' | cut -c1-160 )"; fi - [ "$sh" -eq 2 ] && seen=1 +int beta( int x ) +{ + return alpha( x ) + 2; +} + +#ifdef FEATURE_ZETA +int zeta_only( int x ) +{ + return alpha( x ) * 3; +} +#endif +B14 +# the gate itself is a CMake option() — the shape --flags reports as kind="cmake" default="OFF" dark="1" +printf 'cmake_minimum_required( VERSION 3.20 )\nproject( n14 )\noption( FEATURE_ZETA "the dark one" OFF )\n' > "$N14/CMakeLists.txt" +( cd "$N14" && git init -q && git config user.email t@t && git config user.name t && git add -A && git commit -qm init >/dev/null 2>&1 ) +printf 'int gamma_fn( int x ) { return x - 1; }\n' >> "$N14/src/b.cpp" +CLAUSE='run= is the command that discharges a test row' +bad14="" +for v in --handoff --pr-context --test-gate --affected=src/a.cpp; do + o="$( cd "$N14" && "$BIN" . $v --no-cache 2>/dev/null )" + rows="$( printf '%s' "$o" | python3 "$ROWPATHS" paths xml | grep -c . )" + has="$( printf '%s' "$o" | grep -c "$CLAUSE" )" + [ "$rows" -eq 0 ] && [ "$has" -ne 0 ] && bad14="$bad14 $v(0 rows, clause present)" + [ "$rows" -gt 0 ] && [ "$has" -eq 0 ] && bad14="$bad14 $v($rows rows, clause MISSING)" done -if [ -z "$first" ]; then - no "(13) no --token-budget in 1000..1700 produced a section — the sweep cannot bite" -elif [ "$seen" -ne 1 ]; then - no "(13) the sweep never reached shown=\"2\" — the fixture's two tests are not both served at 1700 tokens" -elif printf '%s' "$firstrow" | grep -q ' section to fit is a GROUP, not one singleton: $firstrow" -elif [ "$mono" -ne 1 ]; then - no "(13) shown= decreased as the budget grew" +# --flags --flip on the same no-test corpus: zero rows, so no clause either +FN14="$( ( cd "$N14" && "$BIN" . --flags --no-cache 2>/dev/null ) | grep -oE '/dev/null )" + rows="$( printf '%s' "$o" | python3 "$ROWPATHS" paths xml | grep -c . )" + has="$( printf '%s' "$o" | grep -c "$CLAUSE" )" + [ "$rows" -eq 0 ] && [ "$has" -ne 0 ] && bad14="$bad14 --flags --flip=$FN14(0 rows, clause present)" + [ "$rows" -gt 0 ] && [ "$has" -eq 0 ] && bad14="$bad14 --flags --flip=$FN14($rows rows, clause MISSING)" else - ok "(13) E1: the byte cap admits one file before two (first at --token-budget=$first is shown=\"1\", a single row; shown= monotone)" + bad14="$bad14 --flags found no gate on the fixture (the flip half of this arm cannot bite)" fi +# the positive control: the grouping fixture from arm 12 DOES carry rows, so the same verbs must carry it +for v in --handoff --pr-context; do + o="$( rw2 $v )" + rows="$( printf '%s' "$o" | python3 "$ROWPATHS" paths xml | grep -c . )" + has="$( printf '%s' "$o" | grep -c "$CLAUSE" )" + [ "$rows" -gt 0 ] && [ "$has" -eq 0 ] && bad14="$bad14 control:$v($rows rows, clause MISSING)" + [ "$rows" -eq 0 ] && bad14="$bad14 control:$v named no row — the positive control cannot bite" +done +[ -z "$bad14" ] \ + && ok "(14) the run-hint clause rides exactly the documents that render a row (5 verbs on a no-test corpus, 2 positive controls)" \ + || no "(14) the run-hint clause is not rows-gated:$bad14" + +# ── ARM 15 — the partitioned bundle gates its outer clause on a COUNT, never on rendered bytes ──────── +# partition.h asked `xml.find( " CDATA +# quotes the literal text ``. Red on ff8d77a1. +Q15="$( mktemp -d )"; trap 'rm -rf "$WORK" "$W2" "$A13" "$C13" "$N14" "$Q15"' EXIT +mkdir -p "$Q15/src" +cat > "$Q15/src/emitter.cpp" <<'EOF' +#include + +void write_report( FILE* out, int n ) +{ + std::fprintf( out, "", n ); + std::fprintf( out, "" ); +} + +void caller_one( FILE* out ) +{ + write_report( out, 1 ); +} + +void caller_two( FILE* out ) +{ + write_report( out, 2 ); +} +EOF +printf 'void unrelated_helper( int x )\n{\n (void)x;\n}\n' > "$Q15/src/other.cpp" +( cd "$Q15" && git init -q && git config user.email t@t && git config user.name t && git add -A && git commit -qm init >/dev/null 2>&1 ) +O15="$( cd "$Q15" && "$BIN" . --no-cache --pack-task="write_report" --partition=2 2>/dev/null )" +lit15="$( printf '%s' "$O15" | grep -c ' section — it was meant to have no test file at all" +elif [ "$cls15" -ne 0 ]; then + no "(15) the partitioned bundle charges the run-hint clause for a body that merely QUOTES ' Date: Sun, 13 Sep 2026 15:30:25 -0400 Subject: [PATCH 05/13] fix(compactlegend,docs): the compact legend promised an escape the emitter deleted, and four comments named things that are not there MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ONE LEFTOVER FROM THE REVIEW OF 6621370f, and four doc nits in the same commit. THE COMPACT TERM DID NOT SAY WHAT THE FULL CLAUSE SAYS. 6621370f rewrote testmap.h's kRunHintLegendClause: a path holding ',' is never grouped (the escape is gone, because an XML parser undoes an entity BEFORE a consumer splits p= on the delimiter), and a shown=/total= over these rows counts test FILES. Its compact twin — compactlegend.h's term, the compact dialect's ONLY reading of — was left saying "every path verbatim (, a comma)" and never carried the counts-FILES rule at all. A reader holding only the compact legend was told to undo an entity that is not there, and on a comma-path corpus `--affected --legend=compact` contradicted the full legend about what the pair counts. The term now states both facts in the FULL CLAUSE'S OWN WORDS. WHY NOT ONE CONSTANT. The task asked for one constant if practical; it is not. kRunHintLegendClause is 350+ B of prose and the compact table is charged per verb — the compact dialect exists precisely to RE-SPELL rather than quote, which is the whole reason it is smaller. So the two are pinned against each other instead: test/compactlegendcheck.sh arm (R) reads the phrases it requires OUT OF kRunHintLegendClause and fails if either wording drops one, or promises , again, or if the compact term loses its `true, "g"` element qualifier and starts charging every single-row document. Add a fact to the full clause and the arm fails until the compact term carries it too. RED FIRST. Arm (R) reads source, not output, so its red is shown against 6621370f's src/: the parent's compact term states none of `a path holding ','`, `splits into exactly n=`, `counts test FILES`, and still promises `,`. Green on this tree. PINS: NONE MOVE — measured, not assumed. The term goes 99 -> 194 B, and it is present-only and qualified to , so it is charged only on a document that carries a run_unknown= row. On this tree and on every gate fixture every harness has a runner, so nothing groups and the term never emits. Verified by building 6621370f in a scratch worktree and running compactlegendcheck, testgatelegendbudgetcheck and packtaskcheck against BOTH binaries from this working tree: every byte number in all three is identical (testgate legend 2957 B <= 3000, pack-task compact 865 B <= 880), all three ALL PASS on both. The real cost is measured on a purpose-built fixture of six runner-less tests that does group: `--affected --legend=compact` 501 -> 596 B, the +95 being exactly this term. DOC NITS. * src/prcontext.h:611 and :929 named prBodyHasTestRow, the string-matching predicate 6621370f DELETED. They now name what actually decides it: the COUNT the level's own emitter reported (PrTrimRender::testFiles), which writeHead takes. * test/testrowpaths.py's docstring and the CHANGELOG said NINE gates had grown their own reader. Six read the PATHS and are converted (affectedcheck, impactpartitioncheck, receiptpostcheck, rootrelemitcheck, selectorchaincheck, testrowruncheck); both now name them. Two more gates read these rows and are NOT converted, and the docstring now says why: listingpagingcheck sums n= over the rows and w3fixlegendcheck counts path occurrences on a --situ line — neither asks for the paths, both were made group-aware in place, and routing a COUNT through a path reader would only add a dialect hop. * test/mcpmanifestcheck.sh's re-anchor comment read "+416 B, EXACTLY the one 207-byte clause spliced into the TWO tool descriptions" — 207 x 2 is 414. The missing 2 B are the two separator spaces: each description previously ended at '.' and now ends '. ' before the splice, so it is 2 x 208. The CHANGELOG said the same thing and is corrected with it. The ceiling itself (42,800) and the measured 42,777 are unchanged and were right. The ack ledger is untouched by this commit. Note on the ten acks 6621370f wrote: eight are keyed by symbol (cid=); the other two — `duplication f10ce50bdc680d80` and `new-clone-of-reused-helper f10ce50bdc680d80` — carry no cid because those two kinds key on the clone MEMBER-SET hash. They are group-scoped by that kind's design, not by an omission: a clone finding is a property of the group, so there is no single symbol to name. Gates: compactlegendcheck (ALL PASS, arm (R) red on 6621370f's src/), testgatelegendbudgetcheck (ALL PASS), packtaskcheck (ALL PASS), manifestcheck, docs/gatecount_build.py --check (613), docs/limits_build.py --check. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 28 ++++++++++++++++++++++------ src/compactlegend.h | 18 +++++++++++++++++- src/prcontext.h | 8 +++++--- test/compactlegendcheck.sh | 37 +++++++++++++++++++++++++++++++++++++ test/mcpmanifestcheck.sh | 5 +++-- test/testrowpaths.py | 12 ++++++++---- 6 files changed, 92 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 20244f6e1..d2f253e1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,9 +51,21 @@ the whole bundle 11,993 → 12,490 B. On this tree every harness has a runner, s change is the legend that now defines ``: the `--test-gate` legend pin moves 2,720 → 3,000 B (measured 2,957) and the `ripwire.pack-task/v1` compact pin 820 → 880 B (measured 865), both because the compact dialect and every rows-bearing full legend now define `run_unknown=` and `` — a definition -`--affected` and the compact dialect never carried. The MCP manifest ceiling moves 42,384 → 42,800 B -(measured 42,777) for one 207-byte clause spliced into the two tool descriptions that serve these rows as -JSON: `situational_awareness` and `explore` return bare JSON with no legend of any kind, so a caller that +`--affected` and the compact dialect never carried. That compact `` term now says what the full clause +says, in the full clause's own words: its first form promised "every path verbatim (`,` a comma)", an +escape `testmap.h` does not emit — a path holding `,` is not grouped at all — and it never carried the rule +that a `shown=`/`total=` over these rows counts test FILES, so a reader holding only the compact legend was +told to undo an entity that is not there and disagreed with the full legend about what the pair counts. The +term goes 99 → 194 B and is charged only on a document that carries a `` row (measured on a fixture of six +runner-less tests, `--affected --legend=compact` 501 → 596 B); no pin moves, on this tree or on any gate +fixture, because every harness here has a runner and nothing groups. The two wordings cannot be one constant +— the compact dialect exists to re-spell, not to quote — so `test/compactlegendcheck.sh` arm (R) pins them +against each other, reading the phrases it requires out of `kRunHintLegendClause` itself rather than +restating them, and fails the next release where either wording drops one or promises `,` again (red on +the parent commit's source). The MCP manifest ceiling moves 42,384 → 42,800 B +(measured 42,777) for one 207-byte clause, plus the one-space separator that joins it to the sentence before +it, spliced into each of the two tool descriptions that serve these rows as JSON (2 × 208 B): +`situational_awareness` and `explore` return bare JSON with no legend of any kind, so a caller that reads `p` as a string has nowhere else to learn that it can be an array. The clause is rows-gated everywhere it is spliced — `--affected`, `--exercises`, `--pack-task`, the partitioned bundle, and `--pr-context`, whose legend precedes its files in the STREAM but is now decided @@ -90,12 +102,16 @@ which `packtask.h` already had in its own spelling) that reports the failure, an fall back to streaming the level straight out: complete, correct bytes, a modelled estimate, and a `DEGRADED_PATH_ALERT` saying which — serialize.h's own degrade contract. -Nine gates assert something about these rows, and each had its own reader: since a row can now name several +Six gates read the PATHS out of these rows, and each had its own reader: since a row can now name several files, `grep -oE '"tests_to_run":\[[^]]*\]'` stopped at the first `]` (the end of the first group's path array, so three arms asserted over two and a half rows and passed vacuously), `sed`-based XML readers saw only the single rows, and the text reader took `$1` of a line that on a group line is `[hops=1]`. They all -want the same thing — the files named, in emitted order — so they now all ask `test/testrowpaths.py`, one -reader for all three dialects and both row shapes. +want the same thing — the files named, in emitted order — so `test/affectedcheck.sh`, +`test/impactpartitioncheck.sh`, `test/receiptpostcheck.sh`, `test/rootrelemitcheck.sh`, +`test/selectorchaincheck.sh` and `test/testrowruncheck.sh` now all ask `test/testrowpaths.py`, one reader for +all three dialects and both row shapes. Two more gates read these rows and keep their own readers, because +neither asks for the paths: `test/listingpagingcheck.sh` sums `n=` over the group rows to prove the family +never pages, and `test/w3fixlegendcheck.sh` counts path occurrences on a `--situ` line. ### Added — --for pages its answer one file per row, and says when to widen diff --git a/src/compactlegend.h b/src/compactlegend.h index c6a78bb59..4f57b4348 100644 --- a/src/compactlegend.h +++ b/src/compactlegend.h @@ -289,8 +289,24 @@ inline constexpr CompactCompletenessTerm kCompactCompletenessTerms[] = // once per group. Row-level (every dialect puts it on the row), present-only; the reading is qualified // to that element so a document of single rows never pays for it, and a --flags document's own never // triggers it (that element carries no run_unknown=). + // + // THE COMPACT TERM SAYS WHAT THE FULL CLAUSE SAYS (review of 6621370f). This row is the compact dialect's + // ONLY reading of , so a reader holding it and nothing else must be able to act on p=. It promised + // "every path verbatim (, a comma)" — an escape testmap.h no longer emits, because a path holding ',' + // is not grouped at all now — and it never carried the counts-FILES rule the full clause gained with it. + // So on a comma-path corpus `--affected --legend=compact` told its reader to undo an entity that is not + // there, and disagreed with the full legend about what a section's shown=/total= counts. + // The two facts a consumer cannot act without are now stated here in the FULL CLAUSE'S OWN WORDS: a + // path holding ',' is never grouped, so p= splits into exactly n=; and a shown=/total= over these rows + // counts test FILES. The compact dialect re-spells rather than quotes — that is what makes it compact, and + // kRunHintLegendClause is 350+ B against this table's per-verb charge — so the two cannot be ONE constant. + // test/compactlegendcheck.sh (R) pins them against each other instead: it reads the required phrases OUT + // OF kRunHintLegendClause and fails the next release where either wording drops one or promises , + // again. Cost of saying it: the term goes 99 -> 194 B, charged ONLY on a document that carries a row — + // measured on a six-runner-less-test fixture, `--affected --legend=compact` 501 -> 596 B, and no pinned + // legend on this tree or on any gate fixture moves at all, because every harness here has a runner. { "run_unknown", "run_unknown=1: no runner derivable (a guess would be worse)", true }, - { "run_unknown", ": n runner-less rows with equal attrs as ONE row, every path verbatim (, a comma)", true, "g" }, + { "run_unknown", ": n runner-less rows with equal attrs as ONE row, paths verbatim; a path holding ',' is never grouped, so p= splits into exactly n=; shown=/total= over these rows counts test FILES", true, "g" }, { "hits_capped", "hits_capped=1: hits= is a floor" }, // Both also ride the map header: est_tokens= alone there under order=stable (the root drops it), over_ceiling=1 there // under max-tokens. Same number, same reading, so one row reads both places. diff --git a/src/prcontext.h b/src/prcontext.h index 245e2830f..0337145ab 100644 --- a/src/prcontext.h +++ b/src/prcontext.h @@ -608,7 +608,8 @@ inline std::string prBudgetTail( std::size_t changedFiles, std::uint32_t skipped // they are written, the way every other priced root measures itself (serialize.h §H7). File scope, beside // kPrEmptyDiffBody, so the emitter reads as the decisions it makes rather than as the prose it ships. // E1 (2026-09-12): `withRunClause` splices testmap.h's run=/run_unknown=/ clause. The clause is a rule about -// rows, so it rides only a document whose chosen body renders a / row (prBodyHasTestRow): the writer +// rows, so it rides only a document whose chosen body renders a / row — decided by the COUNT that +// body's own emitter reported (PrTrimRender::testFiles), never by a search of the rendered bytes. The writer // builds both forms, the pricer charges runClauseBytes per candidate level from that level's own body, and the // form matching the chosen body is written — after the choice, since the legend precedes the root in the // stream but not in the decision. A corpus-level predicate ("the corpus holds a test file") over-approximated @@ -926,8 +927,9 @@ inline int writePrContext( std::FILE* out, const std::string& root, const Ingest } std::sort( changed.begin(), changed.end(), [ & ]( std::uint32_t a, std::uint32_t b ) { return ing.files[a] < ing.files[b]; } ); - // E1: both legend forms are built now and ONE is written later, once the body is known (prBodyHasTestRow); - // the envelope is priced without the clause and the pricer adds runClauseBytes for a rows-bearing body. + // E1: both legend forms are built now and ONE is written later, once the body is known — writeHead takes + // that body's own PrTrimRender::testFiles count. The envelope is priced without the clause and the pricer + // adds runClauseBytes for a rows-bearing body. const std::string legendText = prLegendText( escBase, g.unindexedFiles > 0, false ); const std::string anchorNoteText = prAnchorNoteText( anchorAttr ); // The clause-bearing form is built ONCE, and only if it is the form that gets written — the difference diff --git a/test/compactlegendcheck.sh b/test/compactlegendcheck.sh index 739ff2245..40ad86a27 100755 --- a/test/compactlegendcheck.sh +++ b/test/compactlegendcheck.sh @@ -1711,5 +1711,42 @@ while IFS='|' read -r kind name where <&3; do esac done 3<"$TMP/s.rows" +# ── (R) THE COMPACT TERM SAYS WHAT THE FULL CLAUSE SAYS ──────────────────────────────────────────── +# +# THE FINDING (review of 6621370f). testmap.h stopped escaping a comma inside a grouped path — a path holding +# ',' is not grouped at all now — and the FULL clause was rewritten to say so and to state that a shown=/total= +# over these rows counts test FILES. The COMPACT twin of the same rule, compactlegend.h's term, was not: +# it still promised "every path verbatim (, a comma)", an escape the body no longer emits, and it never +# carried the counts-FILES rule. A reader holding only the compact legend was told to undo an entity that is +# not there, and `--affected --legend=compact` on a comma-path corpus contradicted its own rows. +# +# WHY A GATE AND NOT ONE CONSTANT. Every other shared sentence in this tree is ONE constant spliced twice, and +# that is the right shape — but the compact dialect exists precisely to RE-SPELL, not to quote: kRunHintLegendClause +# is 350+ B and the compact term is a 194 B line in a table that is charged per verb. So the two are pinned +# against each other instead, and the REQUIRED FACTS are derived from the full constant rather than typed here: +# a fact is a distinctive phrase the full clause uses, and the compact term must use the same words for it. +# Add a fact to the full clause and this arm fails until the compact term carries it too. +RG_FULL="$( sed -n '/^inline constexpr std::string_view kRunHintLegendClause =/,/;$/p' "$ROOT/src/testmap.h" )" +RG_COMPACT="$( grep -F '"' "$ROOT/src/compactlegend.h" )" +if [ -z "$RG_FULL" ] || [ -z "$RG_COMPACT" ]; then + no "(R) could not read both wordings out of src/ (full=$( printf '%s' "$RG_FULL" | wc -c ) B, compact=$( printf '%s' "$RG_COMPACT" | wc -c ) B)" +else + rgbad="" + # the two facts a consumer cannot act without, in the FULL clause's own words + for fact in "verbatim" "a path holding ','" "splits into exactly n=" "counts test FILES"; do + printf '%s' "$RG_FULL" | grep -qF "$fact" || rgbad="$rgbad [full clause lost the fact: $fact]" + printf '%s' "$RG_COMPACT" | grep -qF "$fact" || rgbad="$rgbad [compact term does not state: $fact]" + done + # and the escape neither may promise again: an XML parser undoes it BEFORE a consumer splits p= on ',' + for w in "$RG_FULL" "$RG_COMPACT"; do + printf '%s' "$w" | grep -qF ',' && rgbad="$rgbad [a wording still promises the , escape testmap.h deleted]" + done + # the compact term stays qualified to and present-only, or --flags' own pays for it + printf '%s' "$RG_COMPACT" | grep -qF 'true, "g"' || rgbad="$rgbad [the compact term lost its element qualifier / present-only flag]" + [ -z "$rgbad" ] \ + && ok "(R) the compact term states every fact the full run-hint clause states, in the same words, and neither promises the deleted , escape" \ + || no "(R) the compact and full readings of have drifted:$rgbad" +fi + [ "$fail" -eq 0 ] && echo 'ALL PASS' || echo 'FAILURES ABOVE' exit "$fail" diff --git a/test/mcpmanifestcheck.sh b/test/mcpmanifestcheck.sh index 1f32b2102..a4e13a23c 100755 --- a/test/mcpmanifestcheck.sh +++ b/test/mcpmanifestcheck.sh @@ -209,8 +209,9 @@ tools = json.loads( line )[ "result" ][ "tools" ] # this twin. Its description gained NO prose: a first draft named coverage= and the page there (+109 B) and was # removed rather than re-anchored around, the L7 precedent above — the schema properties are where a client renders # an argument, and the answer's own legend defines coverage= and the page. Headroom after this line: 23 B. -# RE-ANCHORED 2026-09-13 (review of #214): 42,384 → 42,800 = +416 B, EXACTLY the one 207-byte clause spliced -# into the TWO tool descriptions that serve tests_to_run rows as JSON — `situational_awareness` and `explore` +# RE-ANCHORED 2026-09-13 (review of #214): 42,384 → 42,800 = +416 B, EXACTLY the one 207-byte clause plus the +# single space that separates it from the sentence before it (208 B), spliced into EACH of the TWO tool +# descriptions that serve tests_to_run rows as JSON — `situational_awareness` and `explore`; 2 × 208 = 416 # (measured against a build of ff8d77a1: 42,361 B here, 23 B under the old ceiling). It is NOT the L7 case the # line above declines. That one removed prose describing an ARGUMENT, because the schema properties are where a # client renders an argument and the answer's own legend defines the rest. This clause describes the RESPONSE: diff --git a/test/testrowpaths.py b/test/testrowpaths.py index 289bde810..c9d2be93e 100644 --- a/test/testrowpaths.py +++ b/test/testrowpaths.py @@ -1,8 +1,9 @@ #!/usr/bin/env python3 """testrowpaths.py — THE tests_to_run row reader every gate shares. -WHY THIS FILE EXISTS. Nine gates assert something about the tests_to_run row family, and each one had -grown its own reader: `grep -oE '` in XML, a `"p"` ARRAY in JSON, `[hops=2] (3): a, b, c` in the text dialect. The private readers did not fail; they went @@ -13,9 +14,12 @@ * `sed -n 's/^test p=//'` and friends saw the singles and silently skipped every group row; * the text reader took `$1` of the line, which on a group line is `[hops=1]`, not a path. -The invariant all nine actually want is THE FILES NAMED, in emitted order. That is one question, so it is +The invariant all six actually want is THE FILES NAMED, in emitted order. That is one question, so it is answered in one place, for every dialect, and a gate that adds a new assertion gets the group shapes for -free instead of re-deriving them. +free instead of re-deriving them. Two further gates read these rows and are NOT converted, because neither +asks for the paths: test/listingpagingcheck.sh sums `n=` over the rows to prove the family never pages, +and test/w3fixlegendcheck.sh counts path occurrences on a --situ line. Both were made group-aware in place +(E1) and stay that way — routing a COUNT through a path reader would only add a dialect hop. python3 test/testrowpaths.py paths xml|json|text [FILE] # one path per line, emitted order python3 test/testrowpaths.py jsonlist [FILE] # the balanced "tests_to_run":[...] slice From ccdb7c0513f3495d1625e79d8b1ad96290a8df78 Mon Sep 17 00:00:00 2001 From: joyful-ii-V-I Date: Sun, 13 Sep 2026 16:57:32 -0400 Subject: [PATCH 06/13] fix(emit,mcp,tests): a render seam that ignored its own failures, a clause naming a key two of its three producers do not emit, and two more silent readers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four findings from CodeRabbit's review of 6621370f (review 5191535624), all valid, all reproduced first. A RENDER SEAM THAT IGNORED ITS OWN FAILURES. rw::renderToString checked open_memstream and then discarded what fflush and fclose answered, setting ok=true regardless. Both can fail and either failure means the same thing: buf/sz are not the whole document. A memstream grows by realloc, so an allocation failure the per-row fwrites swallowed surfaces at the FLUSH; and it is fclose's final flush that publishes *buf and *sz at all, so a failure there leaves them stale or unset. Reading them anyway is exactly how a TRUNCATED document passes for a whole one — the same defect as the silent EMPTY body this seam was introduced to fix, one size smaller and harder to see. Both results are now checked, DEGRADED_PATH_ALERT fires on either, ok is false, and prRenderLevel therefore takes the streaming fallback it already documents. fclose runs whatever fflush said (the stream has to be closed) and exactly once; buf is freed once on every path. The two callers that read only .text (packtask.h's section renderer, mcpverbs.h's captureXml) now get "" plus an alert where they used to get silently short bytes. A CLAUSE THAT NAMED THE WRONG KEY. mcp.h's kTestRowJsonShapeClause said the path key is `p`. Only one of its three producers spells it that way: situational_awareness emits "test" (mcpverbs.h, TestRowShape{ Json, "test" }), explore (packtask.h) and the edit receipt (mcpedit.h) emit "p". The clause is spliced into situational_awareness and explore, so it told a situational_awareness caller to read a key its answer does not carry — worse than the silence it replaced, because a caller reads it as a contract. It now names both, per producer; the key is the ONLY thing that differs, so the string-or-array rule, the `n` beside an array and the run/run_unknown obligation are still stated once. The quotes are SINGLE, as kAtSeedRebindClause already spells a key: this string is spliced straight into the tools/list JSON and the first draft's double quotes made the manifest unparseable — mcpmanifestcheck caught it as a JSONDecodeError, not as a byte count. PIN MOVED, measured: test/mcpmanifestcheck.sh CEILING 42,800 -> 43,000 for a measured 42,973, the clause 207 -> 305 B in each of the same two descriptions (2 x 98 = +196). Headroom after: 27 B. TWO MORE SILENT READERS, IN THE READER WRITTEN TO END SILENT READERS. test/testrowpaths.py exists because eight private readers went quiet instead of failing; two of its own did the same. * json_list_slice returned None BOTH when the tests_to_run field is absent and when its array never closes, and json_paths turned None into [] at exit 0 — so a document cut mid-array asserted over zero rows and PASSED, vacuously. Those are different claims and now answer differently: no field is an ANSWER (0 paths, exit 0); a field whose list never closes raises TestRowParseError and exits 2 with a named reason, in `paths` and in `jsonlist` alike. * text_paths' single-row reader took `(\S+)`, which stops at the first space, so a test path holding one was reported TRUNCATED — a path that does not exist, produced silently. It now parses the renderer's own grammar instead of guessing: cut the run suffix, then the known attribute tail ([changed] [partner] [hops=N], emitted in that order and no other, testmap.h's Text arm of testRowEvidence), and keep everything between verbatim. A generic bracket matcher would have the mirror-image bug on a path holding '[...]', which is why this is pinned to the emitter. DISCLOSED, not papered over: the text dialect carries no escaping, so a path holding the literal three-space "(run: " opener still cannot be told from the suffix — XML and JSON are exact and a gate needing a path that adversarial should assert in those. Gate: testrowruncheck ARM 16, both halves, no binary needed. RED on the reader as it stood at 6621370f: (a) '{"tests_to_run":[{"p":["a","b"],"n":2},{"p":"c"}' -> 0 paths, rc=0 (want rc=2, no rows) (b) 'test/with space.cpp', 'test/two words.cpp', 'test/a b c.cpp' -> 'test/with', 'test/two', 'test/a' Green after, with a balanced-list positive control and a spaced group row beside them. Gates run: testrowruncheck, mcpmanifestcheck, prcontextcheck, packtaskcheck, estchargecheck (the fault-injectable est_tokens degrade path this seam sits beside), affectedcheck, impactpartitioncheck, receiptpostcheck, rootrelemitcheck, selectorchaincheck (the six that share the reader), listingpagingcheck and w3fixlegendcheck (the two that do not), mcpattrparitycheck, mcptranchecheck, manifestcheck, docs/gatecount_build.py --check (613), docs/limits_build.py --check (210 caps) — all ALL PASS. ASan+LSan clean on --pr-context, --pack-task, --affected --legend=compact and an MCP tools/list. --quality-delta gating="0". Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 24 +++++++++++++++- src/infra/emit.h | 30 +++++++++++++++----- src/mcp.h | 14 +++++++++- test/mcpmanifestcheck.sh | 11 +++++++- test/testrowpaths.py | 59 ++++++++++++++++++++++++++++++++++------ test/testrowruncheck.sh | 40 +++++++++++++++++++++++++++ 6 files changed, 159 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4baa26703..d190d77b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -111,7 +111,29 @@ want the same thing — the files named, in emitted order — so `test/affectedc `test/selectorchaincheck.sh` and `test/testrowruncheck.sh` now all ask `test/testrowpaths.py`, one reader for all three dialects and both row shapes. Two more gates read these rows and keep their own readers, because neither asks for the paths: `test/listingpagingcheck.sh` sums `n=` over the group rows to prove the family -never pages, and `test/w3fixlegendcheck.sh` counts path occurrences on a `--situ` line. +never pages, and `test/w3fixlegendcheck.sh` counts path occurrences on a `--situ` line. That shared reader +had two silences of its own, and both now fail loudly with a control in `test/testrowruncheck.sh` arm 16. Its +JSON slicer returned the same nothing for a document with no `tests_to_run` field and for one whose array +never closes, and the path reader turned that into an empty list at exit 0 — so a TRUNCATED document +asserted over zero rows and passed, which is the defect the file was written to end. The two are different +claims: no field is an answer (0 paths, exit 0), an unclosed list is exit 2 with a named reason. And the text +dialect's single-row reader took `(\S+)`, which stops at the first space, so a test path holding one was +reported truncated — a path that does not exist, produced silently. It now cuts the run suffix and the +renderer's own attribute tail (`[changed] [partner] [hops=N]`, in that order and no other) and keeps +everything between verbatim; what the text dialect still cannot resolve is a path holding the literal +three-space `(run: ` opener, because that dialect carries no escaping at all — XML and JSON are exact. + +Three more things the row work left half-said. `rw::renderToString` asked `open_memstream` and then ignored +what `fflush` and `fclose` answered, returning `ok=true` regardless: a memstream grows by `realloc`, so an +allocation failure the per-row writes swallowed surfaces at the flush, and it is the close that publishes the +buffer and its size at all. Reading them anyway is how a SHORT document passes for a whole one — the same +defect as the empty body one size smaller. Both results are now checked, the alert fires, and `--pr-context` +takes the streaming fallback it already documents. The MCP row-shape clause named the key `p`, and only one +of its three producers spells it that way: `situational_awareness` emits `test`, `explore` and the edit +receipt emit `p`. A clause naming the wrong key is worse than no clause, because a caller reads it as a +contract, so it names both per producer while the rules they share are still stated once; the manifest +ceiling moves 42,800 → 43,000 B for a measured 42,973 (the clause 207 → 305 B in each of the same two +descriptions, 2 × 98 B). ### Fixed — the reference guide said things the binary does not diff --git a/src/infra/emit.h b/src/infra/emit.h index aa8f29c2c..d651e38f1 100644 --- a/src/infra/emit.h +++ b/src/infra/emit.h @@ -164,10 +164,19 @@ template inline std::size_t formatTo( char* buf, std::size_t cap, st // Review of #214: the copy in prcontext.h returned "" on failure with NO alert, and the unbudgeted // --pr-context path had just been routed through it — so an open_memstream failure would have shipped a // legend, a root tag and a closing tag around an EMPTY body, with truncated="none" saying nothing was cut. -// A degrade has to be visible and the caller has to be able to see it: `ok` is false exactly when the buffer -// could not be opened (and then `text` is empty and nothing was written), the alert names the site through +// A degrade has to be visible and the caller has to be able to see it: `ok` is false exactly when the bytes +// returned are not the bytes the emitter wrote (and then `text` is empty), the alert names the site through // the caller's own message — "which buffer failed" is the useful half — and the caller then takes its own -// documented path. Never a silent empty body. +// documented path. Never a silent empty body, and never a silent SHORT one. +// +// CodeRabbit on #214: the first version asked open_memstream and then ignored what fflush and fclose +// answered, setting ok=true regardless. Both can fail, and either failure means the same thing: `buf`/`sz` +// are not the whole document. A memstream grows by realloc, so an allocation failure the per-row fwrites +// swallowed surfaces at the FLUSH; and it is fclose's final flush that publishes *buf and *sz at all, so a +// failure there leaves them stale or unset. Reading them anyway is exactly how a TRUNCATED document passes +// for a whole one — the same defect as the empty body above, one size smaller and harder to see. Both +// results are checked; fclose still runs whatever fflush said, because the stream has to be closed either +// way, and it runs exactly once. `buf` is freed once, on every path (free( nullptr ) is a no-op). struct Rendered { std::string text; @@ -187,14 +196,21 @@ inline Rendered renderToString( Emit&& emit, const char* degradeMsg ) return out; } emit( m ); - std::fflush( m ); - std::fclose( m ); - if( buf ) + // Order matters: fflush first (it reports the write error), then fclose UNCONDITIONALLY (it owns the + // stream, and skipping it on a flush failure would leak it). A null buf after a clean close is itself a + // failure — an emitter that wrote nothing still gets a zero-length, null-terminated buffer. + const bool flushed = std::fflush( m ) == 0; + const bool closed = std::fclose( m ) == 0; + out.ok = flushed && closed && buf != nullptr; + if( out.ok ) { out.text.assign( buf, sz ); } + else + { + DEGRADED_PATH_ALERT( degradeMsg ); + } std::free( buf ); - out.ok = true; return out; } diff --git a/src/mcp.h b/src/mcp.h index acafbecb2..c5c867bde 100644 --- a/src/mcp.h +++ b/src/mcp.h @@ -133,8 +133,20 @@ inline constexpr std::string_view kAtSeedShortClause = "@FILE:LINE line-seeds re // rows grew a second shape and these descriptions still promised the first — a caller reading `p` as a // string breaks on the array, and `run_unknown` appeared nowhere in this file at all. ONE wording, spliced, // never a fourth paraphrase; the manifest ceiling moves with it, measured, in the same commit. +// +// CodeRabbit on #214: the first wording named the key `p`, and only one of the three producers spells it +// that way. situational_awareness emits "test" (mcpverbs.h, TestRowShape{ Json, "test" }); explore +// (packtask.h) and the edit receipt (mcpedit.h) emit "p". So the clause told a situational_awareness caller +// to read a key its answer does not carry — worse than the silence it replaced, because it reads as a +// contract. Both spellings are named, per producer, in the SINGLE quotes kAtSeedRebindClause already uses +// for a key: this string is spliced straight into the tools/list JSON, so a double quote here has to survive +// a C++ literal AND JSON escaping to keep the manifest parseable — the first draft did not, and mcpmanifest- +// check caught it as a JSONDecodeError rather than a byte count. The key is the ONLY thing that differs: the +// string-or-array rule, the `n` beside an array, and the run/run_unknown obligation are one rule for all +// three, and are stated once. inline constexpr std::string_view kTestRowJsonShapeClause = - "tests_to_run rows: p is a path STRING, or an ARRAY of paths beside n when several runner-less tests share " + "tests_to_run rows: the path key is 'test' on situational_awareness and 'p' on explore and the edit " + "receipts; its value is a path STRING, or an ARRAY of paths beside n when several runner-less tests share " "their attributes and are served as ONE row; every row carries run (the command) or run_unknown:true. "; inline constexpr std::string_view kAtSeedRebindClause = diff --git a/test/mcpmanifestcheck.sh b/test/mcpmanifestcheck.sh index a4e13a23c..248141f43 100755 --- a/test/mcpmanifestcheck.sh +++ b/test/mcpmanifestcheck.sh @@ -219,7 +219,16 @@ tools = json.loads( line )[ "result" ][ "tools" ] # no legend of any kind — `situational_awareness` returns bare JSON with no vocabulary block — so a caller that # parses `p` as a string has nowhere else to learn otherwise before it breaks. ONE wording (mcp.h # kTestRowJsonShapeClause), spliced twice, never a third paraphrase. Headroom after this line: 23 B. -CEILING = 42800 +# RE-ANCHORED 2026-09-13 (CodeRabbit on #214): 42,800 -> 43,000 = +200 B for a MEASURED +196, the clause 207 +# -> 305 B plus its one-space separator, in each of the same two descriptions (2 x 98). The first wording +# named the key `p` and only one of the three producers spells it that way: situational_awareness emits +# `test` (mcpverbs.h), explore (packtask.h) and the edit receipt (mcpedit.h) emit `p`. A clause that names +# the wrong key is worse than no clause, because a caller reads it as a contract — so it now names both, per +# producer, and everything the three DO share (string-or-array, `n` beside an array, run/run_unknown) is +# still said once. The quotes around the two keys are SINGLE: this string is spliced straight into the +# tools/list JSON, and the first draft's double quotes made the manifest unparseable — which this gate +# caught as a JSONDecodeError, not as a byte count. Measured 42,973. Headroom after this line: 27 B. +CEILING = 43000 manifest = len( json.dumps( { "tools": tools }, separators = ( ",", ":" ) ) ) descBytes = sum( len( t[ "description" ] ) for t in tools ) schemaBytes = sum( len( json.dumps( t[ "inputSchema" ], separators = ( ",", ":" ) ) ) for t in tools ) diff --git a/test/testrowpaths.py b/test/testrowpaths.py index c9d2be93e..0bd9e4064 100644 --- a/test/testrowpaths.py +++ b/test/testrowpaths.py @@ -88,15 +88,30 @@ def xml_paths( doc ): return out +class TestRowParseError( Exception ): + """A document that NAMES a tests_to_run list this reader cannot read to its end. + + CodeRabbit on #214: this was not distinguished from the field being absent. Both returned None, json_paths + turned None into [], and the caller got an empty answer and exit 0 — so a TRUNCATED document (a gate that + captured a killed run, a byte cap that cut mid-array) asserted over zero rows and passed vacuously. That is + the very failure this file was written to end. The two cases are not the same claim: no `tests_to_run` field + is an ANSWER ("this document serves no such list", None -> []), while a field whose array never closes is a + document this reader cannot read at all, and the only honest thing to return is an error. Exit 2, as the + module header promises for exactly this.""" + + def json_list_slice( doc ): """The "tests_to_run":[…] value, sliced by BRACKET DEPTH (a group row's "p" is itself an array, so the - first ']' is not the end of the list) and string-aware (a ']' inside a path is not a bracket).""" + first ']' is not the end of the list) and string-aware (a ']' inside a path is not a bracket). + + Returns None when the document has no "tests_to_run" field at all. Raises TestRowParseError when it has + one that this reader cannot slice — see that class for why the two are not the same answer.""" i = doc.find( '"tests_to_run"' ) if i < 0: return None i = doc.find( "[", i ) if i < 0: - return None + raise TestRowParseError( '"tests_to_run" is present but is followed by no "[" — not a list at all' ) depth = 0 instr = False esc = False @@ -118,7 +133,8 @@ def json_list_slice( doc ): depth -= 1 if depth == 0: return doc[i:k + 1] - return None + raise TestRowParseError( '"tests_to_run" list is never closed: %d bracket(s) still open at end of document ' + '(%d bytes read from the field)' % ( depth, len( doc ) - i ) ) def json_paths( doc ): @@ -138,22 +154,43 @@ def json_paths( doc ): return out +# The text dialect's row grammar, spelled from the RENDERER rather than guessed at (testmap.h: the Text arm +# of testRowEvidence, and runSuffixTextDisclosed). A row is +# +# [ [changed]][ [partner]][ [hops=N]] (run: |not derivable) +# +# and a GROUP row replaces with " (n): a, b, c", its attrs leading. The three evidence +# attributes are optional, but they are emitted in THAT ORDER and no other, and the run suffix always opens +# with exactly three spaces. So the whole tail of a row is a closed, known shape. +# +# CodeRabbit on #214: the single-row reader took `(\S+)`, which stops at the first space — a test path +# holding a space was reported TRUNCATED, silently, as a path that does not exist. A generic bracket matcher +# would have the mirror-image bug (a path holding "[...]" would lose it), which is why this is pinned to the +# renderer's own sequence instead: everything before the known attribute tail is the path, spaces included. +# The one ambiguity left is the dialect's, not the reader's — text carries NO escaping, so a path containing +# the literal three-space "(run: " opener cannot be told from the suffix. XML and JSON are exact; a gate that +# needs a path that adversarial should assert in one of those. +_TEXT_RUN_OPEN = " (run: " +_TEXT_ATTR_TAIL = re.compile( r"(?: \[changed\])?(?: \[partner\])?(?: \[hops=\d+\])?$" ) _TEXT_GROUP = re.compile( r"^\s*(?:\[[^\]]*\]\s*)*\((\d+)\):\s*(.*?)\s{2,}\(run: " ) -_TEXT_SINGLE = re.compile( r"^\s{4,}(\S+)\s.*?\(run: " ) def text_paths( doc ): out = [] for line in doc.split( "\n" ): - if "(run: " not in line: + if _TEXT_RUN_OPEN not in line or not line.rstrip( "\r" ).endswith( ")" ): continue g = _TEXT_GROUP.match( line ) if g: out.extend( p.strip() for p in g.group( 2 ).split( "," ) if p.strip() ) continue - s = _TEXT_SINGLE.match( line ) - if s: - out.append( s.group( 1 ) ) + # a SINGLE row: cut the run suffix, then the known attribute tail; what is left of the indent is the + # path, verbatim — spaces and all. + head = line[ :line.index( _TEXT_RUN_OPEN ) ] + head = _TEXT_ATTR_TAIL.sub( "", head ) + path = head.strip() + if path: + out.append( path ) return out @@ -165,7 +202,11 @@ def main( argv ): if mode == "jsonlist": src = argv[2] if len( argv ) > 2 else None doc = open( src ).read() if src else sys.stdin.read() - sl = json_list_slice( doc ) + try: # an unbalanced list is exit 2 here too, not "" + sl = json_list_slice( doc ) + except TestRowParseError as e: + sys.stderr.write( "testrowpaths: json dialect unreadable: %s\n" % e ) + return 2 sys.stdout.write( sl if sl else "" ) return 0 if mode != "paths" or len( argv ) < 3: diff --git a/test/testrowruncheck.sh b/test/testrowruncheck.sh index e8be07f7e..223271360 100755 --- a/test/testrowruncheck.sh +++ b/test/testrowruncheck.sh @@ -534,5 +534,45 @@ else fi +# ── ARM 16 — THE SHARED READER'S OWN TWO SILENCES (CodeRabbit on #214) ──────────────────────────────── +# test/testrowpaths.py exists because eight private readers went quiet instead of failing. Two of its own +# did the same, and neither had a control until now. +# +# (a) A TRUNCATED JSON document. json_list_slice returned None both when the field is ABSENT and when its +# array never closes, and json_paths turned None into [] with exit 0 — so a document cut mid-array +# asserted over zero rows and PASSED. The two are different claims and now answer differently: no field +# is an answer (0 paths, exit 0); a field whose list never closes is exit 2 with a named reason. +# (b) A PATH WITH A SPACE in the text dialect. The single-row reader took `(\S+)`, which stops at the first +# space, so such a path was reported TRUNCATED — a path that does not exist, produced silently. The +# reader now cuts the run suffix and the renderer's own attribute tail ([changed] [partner] [hops=N], in +# that order) and keeps everything else verbatim. +# +# Both arms are RED on the reader as it stood at 6621370f (a: 0 paths, rc=0; b: "test/with" for +# "test/with space.cpp") and are pure reader tests — no binary, so they cost nothing. +r16bad="" +# (a) absent field: an answer. Truncated list: an error. Balanced list with a group: the rows. +a16="$( printf '%s' '{"blast_radius":[]}' | python3 "$ROWPATHS" paths json 2>/dev/null )"; a16rc=$? +[ "$a16rc" -eq 0 ] && [ -z "$a16" ] || r16bad="$r16bad [absent tests_to_run should be 0 paths at rc=0, got rc=$a16rc paths=$( printf '%s' "$a16" | tr '\n' ' ' )]" +t16="$( printf '%s' '{"tests_to_run":[{"p":["a","b"],"n":2},{"p":"c"}' | python3 "$ROWPATHS" paths json 2>/dev/null )"; t16rc=$? +[ "$t16rc" -eq 2 ] && [ -z "$t16" ] || r16bad="$r16bad [an unterminated tests_to_run array must be exit 2 with no rows, got rc=$t16rc paths=$( printf '%s' "$t16" | tr '\n' ' ' )]" +l16="$( printf '%s' '{"tests_to_run":[{"p":["a","b"],"n":2},{"p":"c"}]}' | python3 "$ROWPATHS" jsonlist 2>/dev/null )"; l16rc=$? +[ "$l16rc" -eq 0 ] && [ -n "$l16" ] || r16bad="$r16bad [the balanced control no longer slices: rc=$l16rc]" +j16="$( printf '%s' '{"tests_to_run":[{"p":["a","b"],"n":2},{"p":"c"}' | python3 "$ROWPATHS" jsonlist 2>/dev/null )"; j16rc=$? +[ "$j16rc" -eq 2 ] || r16bad="$r16bad [jsonlist swallows the same truncation: rc=$j16rc]" +# (b) the text dialect, in the renderer's own spelling (testmap.h: path, then [changed] [partner] [hops=N], +# then three spaces and the run suffix). Every path here holds a space; one holds all three attributes. +T16="$( printf '%s\n' \ + ' test/with space.cpp (run: not derivable)' \ + ' test/two words.cpp [hops=2] (run: ctest -R two)' \ + ' test/a b c.cpp [changed] [partner] [hops=1] (run: ctest -R abc)' \ + ' [hops=3] (2): test/g one.cpp, test/g two.cpp (run: not derivable)' )" +p16="$( printf '%s' "$T16" | python3 "$ROWPATHS" paths text 2>/dev/null | tr '\n' '|' )" +e16='test/with space.cpp|test/two words.cpp|test/a b c.cpp|test/g one.cpp|test/g two.cpp|' +[ "$p16" = "$e16" ] || r16bad="$r16bad [text single rows truncate a path at its first space: got '$p16' want '$e16']" +[ -z "$r16bad" ] \ + && ok "(16) the shared reader fails LOUDLY on a truncated tests_to_run array (exit 2, absent field still 0 rows) and keeps a text path's spaces" \ + || no "(16) the shared reader is still silent where it should fail:$r16bad" + + [ "$fail" = 0 ] && echo "ALL PASS" || echo "FAILURES ABOVE" exit "$fail" From 84e5da3be80c57434b293d881a489497dc1094f8 Mon Sep 17 00:00:00 2001 From: joyful-ii-V-I Date: Sun, 13 Sep 2026 17:32:40 -0400 Subject: [PATCH 07/13] docs(readme): "rows that share their evidence" named no precondition a reader could check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit on #214 (review body 5192131827, outside the diff so it carries no thread). The --test-gate section said runner-less rows "that share their evidence are served as one row, every path verbatim". Every word of that is true and none of it is checkable: a reader cannot tell from it whether two rows three apart can group, what "share their evidence" compares, or why a row they can see is still a . The preconditions are exact in partitionTestRows and were nowhere in the prose. Said now, in kRunHintLegendClause's own words so the document and the legend cannot drift: * a or row carries run= or run_unknown="1", never neither; * a row is TWO OR MORE CONTIGUOUS runner-less rows whose attributes are BYTE-IDENTICAL, served as one — n= how many, p= their paths verbatim in list order; * everything else stays its own row: a row with a run=, a row whose attributes differ from its neighbour's, and a path containing a comma, which is never grouped at all, so p= splits on ',' into exactly n= paths; * a shown=/total= over these rows counts test FILES: a row is n= of them. Checked clause by clause against the emitter, not against the old prose. partitionTestRows: `groupable` is `commandFor( fileId ).empty() && path.find( ',' ) == npos` (the two disqualifications), the run extends only while `rows[end].attrs == rows[i].attrs` (byte equality, contiguous by construction), and renderTestRowGroup opens with VERIFY( members.size() >= 2 ) — a group of one is a , which is why "two or more" is a precondition and not a description. No number moved and no behaviour changed: this is the prose catching up to the code. The / script_gates_unmodelled sentences after it are unchanged, re-wrapped only because the paragraph they followed grew. Gates: readmedriftcheck, readmeexamplecheck, versioncheck (rebuilt at this commit first, so built_from is the commit under test and not a +dirty stamp — the case versioncheck caught in the previous round), deckcheck and ripwirepubliccheck for the prose surfaces. Co-Authored-By: Claude Fable 5.1 --- README.md | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 458dc3698..b9dcaf700 100644 --- a/README.md +++ b/README.md @@ -1285,12 +1285,18 @@ $ ripwire . --test-gate # exit code: 4 A `run=` attribute appears only when a runner is derivable from real evidence — a test-dir script whose stem matches the harness, or whose text names it. A row with none says so — `run_unknown="1"`, -never a guessed suite command — and runner-less rows that share their evidence are served as one -`` row, every path verbatim, so the disclosure is paid -once per group rather than once per row. `script_gates_unmodelled="332"` is the same discipline: script-to-binary is not -a call edge, so those gates are invisible to this walk, and the number says so rather than letting -`tests="2"` read as complete. The `` rows are the untested blast radius: impacted symbols that no -test in the corpus reaches. +never a guessed suite command — and a `` or `` row carries one or the other, never neither. A +`` row is **two or more contiguous runner-less rows whose +attributes are byte-identical**, served as one: `n=` is how many, `p=` is their paths verbatim in list +order, and the disclosure is paid once per group rather than once per row. Everything else stays its own +row — a row with a `run=`, a row whose attributes differ from its neighbour's, and a path containing a +comma, which is never grouped at all, so `p=` splits on `,` into exactly `n=` paths. A `shown=`/`total=` +over these rows counts test FILES: a `` row is `n=` of them. + +`script_gates_unmodelled="332"` is the same discipline: script-to-binary is not a call edge, so those +gates are invisible to this walk, and the number says so rather than letting `tests="2"` read as +complete. The `` rows are the untested blast radius: impacted symbols that no test in the corpus +reaches. From 96098627daef63ed33a0389a50ca469be238ecd0 Mon Sep 17 00:00:00 2001 From: joyful-ii-V-I Date: Sun, 13 Sep 2026 18:39:13 -0400 Subject: [PATCH 08/13] fix(emit,readme): a throw from the emitter skipped every line of cleanup the seam owns, and two examples contradicted the contract above them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit review 5192490014 on 84e5da3b, two threads, both valid. A THROW FROM THE EMITTER TOOK THE WHOLE DOCUMENT. rw::renderToString called emit( m ) outside any handler. A throw from it — std::bad_alloc out of the std::format fallback is the reachable one, since the point of this seam is to buffer a document whose size is not known in advance — skipped the fclose, the free, the alert and the documented empty-result fallback in ONE jump: the memstream and its buffer leaked, and the caller got an exception where its contract says it gets ok == false. Measured on this tree with the fault injected, --pr-context aborted at rc=134 with ZERO bytes on stdout and `libc++abi: terminating due to uncaught exception of type std::bad_alloc`. Not the estimate lost — the document. The fix is this tree's own answer to "a throw crosses a seam that owns a resource": catch at the seam, release what it owns once, DISCLOSE, and hand back the degraded value the caller already reads (search.h's `catch( ... ) { out.degraded = true; return out; }`, ingest_astquery.h's per-file degrade). Callers need no new case: ok == false has always meant "these are not the bytes the emitter wrote", and --pr-context already streams the floor level straight out when it sees one. Same run now: exit 0, a complete 14,627-byte well-formed document. fclose runs before free, both once, on every path. THE ALERT NAMES THE CAUSE IT HAD. degradeMsg says the BUFFER failed; on this path it did not. Reusing it would have been a wrong reason attached to a right consequence, so the catch carries its own literal (the macro takes a const char*, and __PRETTY_FUNCTION__ already names the caller's Emit lambda by file and line). ONE FAULT-SWITCH READER, NOT TWO. The first draft copied serialize.h's isChargeBufferFaultInjected shape verbatim and --quality-delta called it: `duplication ... 53 tokens, gating="1"`. It was right. The parsing rule is the part worth sharing — EXACT "1" is the only ON value, because the contract is a switch — and it is a rule this tree already got wrong ONCE and fixed (CA4 w1fix2-verifier G4: `value[0] == '1'` made "=10" and "=1000000" inject the fault). Copying the shape would have meant the next switch could get it wrong again. Both now read through rw::faultSwitchOn( envName ) in infra/emit.h, which serialize.h already includes; each keeps its own named wrapper and its own once-per-process `static`, because the name is what a reader greps for and the static is what determinism needs. gating back to 0. Verified both directions still hold: RIPWIRE_FAULT_RENDER_EMIT_THROW=10 and RIPWIRE_FAULT_CHARGE_BUFFER=0 both inject nothing. Gate: test/prcontextcheck.sh arm (F), six rows, with estchargecheck #14's observability-probe idiom so it can never pass for lack of an alert it could not have seen (the switch is non-NDEBUG only, like DEGRADED_PATH_ALERT itself). RED on the parent commit: rc=134, 0 B of stdout, no alert. (F4) asserts the direction the degrade actually has, and got it backwards first — the degrade streams kPrTrims[0], the UNTRIMMED floor, so it carries at least as many rows as the budget-trimmed control and here carries 40 against 4. "Same count" was the wrong invariant; the arm failed on its own tree and is now written as the claim that matters: content is never lost, only the estimate. TWO README EXAMPLES CONTRADICTED THE CONTRACT DIRECTLY ABOVE THEM — the one this lane's previous commit had just written out. `` carried neither run= nor run_unknown="1", which is the "never neither" rule broken in the very block that states it (it is pre-M21(b) output, kept verbatim while the rule around it changed). And the example said n="7" beside three paths, where the contract says p= splits on ',' into exactly n= paths. Now run_unknown="1" and n="3". Gates: prcontextcheck (ALL PASS, arm F red-first), estchargecheck, readmedriftcheck, readmeexamplecheck, packtaskcheck, flagscheck, manifestcheck, gatecount_build --check (613), limits_build --check (210 caps). ASan+LSan with the fault injected: 0 reports, 0 leaks — the leak this commit exists to fix. --quality-delta gating="0". Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 16 +++++++++- README.md | 4 +-- src/infra/emit.h | 66 +++++++++++++++++++++++++++++++++++++++++- src/serialize.h | 17 ++++------- test/prcontextcheck.sh | 62 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 149 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d190d77b6..bbac4ece4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -133,7 +133,21 @@ of its three producers spells it that way: `situational_awareness` emits `test`, receipt emit `p`. A clause naming the wrong key is worse than no clause, because a caller reads it as a contract, so it names both per producer while the rules they share are still stated once; the manifest ceiling moves 42,800 → 43,000 B for a measured 42,973 (the clause 207 → 305 B in each of the same two -descriptions, 2 × 98 B). +descriptions, 2 × 98 B). And `renderToString` called the emitter outside any handler: a throw from it — +`std::bad_alloc` out of the `std::format` fallback is the reachable one, since the point of the seam is to +buffer a document whose size is not known in advance — skipped the `fclose`, the `free`, the alert and the +documented empty-result fallback in one jump, leaking the memstream and its buffer and handing the caller an +exception where its contract says `ok == false`. Measured on this tree with the fault injected: +`--pr-context` aborted at `rc=134` with **zero bytes** on stdout and `libc++abi: terminating due to uncaught +exception of type std::bad_alloc` — the whole document lost, not just its estimate. The seam now catches at +its own boundary, releases what it owns once, discloses, and returns the degraded value its callers already +read, so the same run exits 0 with a complete 14,627-byte well-formed document carrying the same 20 `` +rows as the undegraded control. The alert names the throw rather than borrowing the buffer's message, which +on that path would be a wrong cause attached to a right consequence. Because a throw path is otherwise +unreachable from a gate, it is driven by an in-source fault switch in `serialize.h`'s +`isChargeBufferFaultInjected` shape — non-NDEBUG only, read once per process, exact `"1"` the only ON value — +and `test/prcontextcheck.sh` arm (F) asserts the whole contract with its own observability probe, red on the +parent commit (`rc=134`, 0 B, no alert). ### Fixed — the reference guide said things the binary does not diff --git a/README.md b/README.md index b9dcaf700..8c1a5c491 100644 --- a/README.md +++ b/README.md @@ -1276,7 +1276,7 @@ $ ripwire . --test-gate # exit code: 4 - + … @@ -1286,7 +1286,7 @@ $ ripwire . --test-gate # exit code: 4 A `run=` attribute appears only when a runner is derivable from real evidence — a test-dir script whose stem matches the harness, or whose text names it. A row with none says so — `run_unknown="1"`, never a guessed suite command — and a `` or `` row carries one or the other, never neither. A -`` row is **two or more contiguous runner-less rows whose +`` row is **two or more contiguous runner-less rows whose attributes are byte-identical**, served as one: `n=` is how many, `p=` is their paths verbatim in list order, and the disclosure is paid once per group rather than once per row. Everything else stays its own row — a row with a `run=`, a row whose attributes differ from its neighbour's, and a path containing a diff --git a/src/infra/emit.h b/src/infra/emit.h index d651e38f1..c027b04e0 100644 --- a/src/infra/emit.h +++ b/src/infra/emit.h @@ -25,6 +25,7 @@ #include #include +#include // std::bad_alloc — renderToString's injected emitter-throw fault, below #include #include #include @@ -183,6 +184,49 @@ struct Rendered bool ok = false; }; +// ── THE EMITTER ITSELF MAY THROW, and that is a degrade, not a way out ─────────────────────────────── +// CodeRabbit on #214: `emit( m )` was called outside any handler. A throw from it — std::bad_alloc out of +// the std::format fallback is the reachable one, since the whole point of this seam is to buffer a document +// whose size is not known in advance — skipped the fclose, the free, the alert and the documented +// empty-result fallback in one jump: the memstream and its buffer leaked, and the caller got an exception +// where its contract says it gets `ok == false`. +// +// The house answer to "a throw crosses a seam that owns a resource" is this tree's own: catch at the seam, +// release what it owns, DISCLOSE, and hand back the degraded value the caller already knows how to read +// (search.h's `catch( ... ) { out.degraded = true; return out; }`, ingest_astquery.h's per-file degrade). +// It is the same answer this function already gives when open_memstream fails, and the callers need no new +// case: `ok == false` has always meant "these are not the bytes the emitter wrote", and --pr-context +// already streams the floor level straight out when it sees one. +// +// FAULT INJECTION, because a throw path is otherwise unreachable from a gate: the switch below is +// serialize.h's isChargeBufferFaultInjected idiom, verbatim in shape — non-NDEBUG only (so it is +// `constexpr false` and the getenv is deleted in release, G2/G3), read ONCE per process (so it cannot +// change mid-document and determinism holds), and EXACT "1" is the only ON value, because the contract is a +// switch and a prefix test would let "=10" and "=0" disagree with what they say. test/prcontextcheck.sh +// arm (F) drives it and asserts the whole contract: complete bytes, the alert, no leak under LSan. +// ONE reader for every such switch (serialize.h's charge-buffer fault is the other). The parsing rule is +// the part worth having in one place: EXACT "1" is the only ON value, because the contract is a switch and a +// prefix test would let "=10" and "=0" mean whatever the reader guessed — a defect this tree already fixed +// once, in the charge-buffer switch, and would otherwise have had to fix again here. Under NDEBUG it is +// `constexpr false`, so the branch and the getenv are both deleted (G2/G3: zero release cost). +#ifndef NDEBUG +inline bool faultSwitchOn( const char* envName ) noexcept +{ + const char* value = std::getenv( envName ); + return value != nullptr && std::strcmp( value, "1" ) == 0; +} +#else +inline constexpr bool faultSwitchOn( const char* ) noexcept { return false; } +#endif + +// Each switch keeps its own named wrapper and its own once-per-process read: the name is what a reader greps +// for, and the `static` is what keeps the answer from changing mid-document (determinism). +inline bool isRenderEmitThrowFaultInjected() noexcept +{ + static const bool isOn = faultSwitchOn( "RIPWIRE_FAULT_RENDER_EMIT_THROW" ); + return isOn; +} + template inline Rendered renderToString( Emit&& emit, const char* degradeMsg ) { @@ -195,7 +239,27 @@ inline Rendered renderToString( Emit&& emit, const char* degradeMsg ) DEGRADED_PATH_ALERT( degradeMsg ); return out; } - emit( m ); + try + { + // The injected fault stands exactly where a real std::bad_alloc would escape: the stream is open and + // nothing has been cleaned up yet, which is the state the catch below exists to unwind. + if( isRenderEmitThrowFaultInjected() ) { throw std::bad_alloc(); } + emit( m ); + } + catch( ... ) + { + // Everything this function owns, released once, in the order the non-throwing path releases it. The + // stream is closed rather than flushed first: there is no document to salvage, and fclose frees the + // FILE either way. `out` is still the default-constructed failure — empty text, ok == false. + std::fclose( m ); + std::free( buf ); + // NOT degradeMsg: that one says the BUFFER failed, and here it did not — the emitter did. The macro + // takes a const char*, so this is its own literal rather than a composed string; the caller is named + // anyway, because __PRETTY_FUNCTION__ carries the Emit lambda's own file and line. + DEGRADED_PATH_ALERT( "renderToString: the emitter THREW — nothing was measured, " + "the caller takes its documented fallback" ); + return out; + } // Order matters: fflush first (it reports the write error), then fclose UNCONDITIONALLY (it owns the // stream, and skipping it on a flush failure would leak it). A null buf after a clean close is itself a // failure — an emitter that wrote nothing still gets a zero-length, null-terminated buffer. diff --git a/src/serialize.h b/src/serialize.h index d15c483b8..37fcd7514 100644 --- a/src/serialize.h +++ b/src/serialize.h @@ -1205,22 +1205,15 @@ struct ChargedSection // and sidecar paths, which confounds the very assertion that matters ("the bytes are still complete and // correct"); and it fights the ASan runtime, which this gate must also run under. Scoping the fault to the // est_tokens family is what keeps the assertions clean, so the in-source switch wins on honesty, not effort. -#ifndef NDEBUG +// CA4 w1fix2-verifier G4: this once read `value[0] == '1'`, so `=10`, `=1x` and `=1000000` all injected the +// fault — a prefix test where the contract is a switch. That rule now lives in ONE place, rw::faultSwitchOn +// (infra/emit.h), which every fault switch reads through, so the next one cannot get it wrong again; the +// once-per-process `static` stays here, where determinism needs it. inline bool isChargeBufferFaultInjected() noexcept { - static const bool isOn = []() noexcept - { - // CA4 w1fix2-verifier G4: this read `value[0] == '1'`, so `=10`, `=1x` and `=1000000` all injected the - // fault — a prefix test where the contract is a switch. EXACT "1" is the only ON value; anything else, - // including "0", "true" and the empty string, is OFF. - const char* value = std::getenv( "RIPWIRE_FAULT_CHARGE_BUFFER" ); - return value != nullptr && std::strcmp( value, "1" ) == 0; - }(); + static const bool isOn = rw::faultSwitchOn( "RIPWIRE_FAULT_CHARGE_BUFFER" ); return isOn; } -#else -inline constexpr bool isChargeBufferFaultInjected() noexcept { return false; } -#endif // Drop-in for `open_memstream` at every est_tokens-family measurement buffer. nullptr ⇒ the caller takes its // own documented degrade path; this function never reports a failure it did not have. diff --git a/test/prcontextcheck.sh b/test/prcontextcheck.sh index 9bf089a6d..7cdf767d4 100755 --- a/test/prcontextcheck.sh +++ b/test/prcontextcheck.sh @@ -362,4 +362,66 @@ else ok "P10.4 invariant: dependents>0 always implies files>0 (files= is the reached total)" fi +# ── (F) THE EMITTER THROWS: renderToString releases what it owns, discloses, and the document still ships ── +# +# THE FINDING (CodeRabbit on #214, src/infra/emit.h). `emit( m )` was called outside any handler. A throw +# from it — std::bad_alloc out of the std::format fallback is the reachable one — skipped the fclose, the +# free, the alert and the documented empty-result fallback in one jump: the memstream and its buffer leaked +# and the caller got an exception where its contract says it gets ok == false. +# +# A throw path is unreachable from a gate by ordinary means, so this drives the in-source fault switch +# RIPWIRE_FAULT_RENDER_EMIT_THROW=1 — serialize.h's isChargeBufferFaultInjected idiom, and therefore living +# ONLY on the non-NDEBUG flavour, the same flavour DEGRADED_PATH_ALERT lives on. So, like estchargecheck #14, +# this arm establishes that flavour with its OWN probe rather than assuming it, and must never pass for lack +# of an alert it could not have seen. +PRC_FAULT_OUT="$TMP/f_dg.out"; PRC_FAULT_ERR="$TMP/f_dg.err" +RIPWIRE_FAULT_RENDER_EMIT_THROW=1 "$BIN" "$ROOT" --pr-context >"$PRC_FAULT_OUT" 2>"$PRC_FAULT_ERR" +prc_f_rc=$? +if ! grep -aq 'renderToString: the emitter THREW' "$PRC_FAULT_ERR"; then + # Either an NDEBUG build (unobservable BY DESIGN — the plain-flavour leg proves it) or a real regression. + if "$BIN" --version 2>/dev/null | grep -q 'release'; then + printf ' INFO (F) emitter-throw degrade is unobservable on this NDEBUG flavour (the plain build proves it)\n' + else + no "(F) RIPWIRE_FAULT_RENDER_EMIT_THROW=1 produced no DEGRADED_PATH_ALERT on a flavour that can see one — the seam regressed" + fi +else + ok "(F) observability probe: the emitter-throw fault switch is live and alerts on this flavour" + # (F0) the alert names the CAUSE IT HAD. degradeMsg says the BUFFER failed; on this path it did not, so + # reusing it would have been a wrong reason attached to a right consequence. + grep -aq 'open_memstream failed' "$PRC_FAULT_ERR" \ + && no "(F0) the emitter-throw alert blames open_memstream, which did not fail on this path" \ + || ok "(F0) the emitter-throw alert names the throw, not the buffer" + # (F1) THE DOCUMENT STILL SHIPS. The whole point of the degrade: the caller loses the ESTIMATE, never the + # content. prcontext.h streams the floor level straight out when no level could be measured. + [ "$prc_f_rc" -eq 0 ] \ + && ok "(F1) --pr-context still exits 0 with every render throwing" \ + || no "(F1) --pr-context exited $prc_f_rc with the emitter-throw fault injected — the throw escaped instead of degrading" + if grep -aq '' "$PRC_FAULT_OUT" && grep -aq ' root ($( wc -c <"$PRC_FAULT_OUT" | tr -d ' ' ) B)" + fi + if command -v xmllint >/dev/null 2>&1; then + xmllint --noout "$PRC_FAULT_OUT" 2>/dev/null \ + && ok "(F3) the degraded document is well-formed XML (G4 holds through the degrade)" \ + || no "(F3) the degraded --pr-context document does not parse — a degrade may not breach G4" + fi + # (F4) and it is not a stub. The direction here is the whole point and is easy to get backwards — this + # arm did, and caught itself: the degrade streams kPrTrims[0], the UNTRIMMED floor, while the + # undegraded run picks whichever level fits its token budget. So the degraded document carries at + # LEAST as many rows as the control and routinely more (40 against 4 on this tree). What the caller + # loses is the ESTIMATE; what it must never lose is content, and "same count" would assert the wrong + # invariant and fail on any tree whose control trims. Compared by ELEMENT COUNT, not bytes. + f_files="$( grep -ao '"$TMP/f_ctl.out" 2>/dev/null + c_files="$( grep -ao ' row — the degrade lost the content it exists to keep" + elif [ "$f_files" -ge "${c_files:-0}" ]; then + ok "(F4) the degraded document carries $f_files row(s) against the control's $c_files — content kept (the floor level is untrimmed), estimate lost" + else + no "(F4) the degraded document carries $f_files row(s), FEWER than the control's $c_files — the degrade lost content, not just the charge" + fi +fi + if [ "$fail" -eq 0 ]; then echo "ALL PASS"; exit 0; else echo "SOME CHECKS FAILED"; exit 1; fi From b016c8764550d85c2c252c67c7b9b37b5982f3cc Mon Sep 17 00:00:00 2001 From: joyful-ii-V-I Date: Sun, 13 Sep 2026 18:45:22 -0400 Subject: [PATCH 09/13] fix(prcontextcheck): the emitter-throw arm asked the working tree, so a clean checkout asked it nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Arm (F) drove `--pr-context` with no base ref, which reads `git diff HEAD`. On a CLEAN checkout the change set is empty, prcontext takes its empty-diff branch, no trim level is ever RENDERED, renderToString is never called — and the arm found no alert and blamed the seam. It was written against a dirty tree and passed there; the first clean run, right after merging origin/main, is what exposed it. That is test/CLAUDE.md's "the gate fixture is the live repo" trap, and the arm was one of its instances. Anchored to a committed range (HEAD~3) instead, which depends on nothing the working tree happens to hold, plus a precondition row that fails loudly if that range ever names no changed file — an arm that asserts nothing must say so rather than pass. The (F4) control moves to the same range, so both sides of the comparison are the same question. 50 rows against the budget-trimmed control's 10, same verdict. Found by running the targeted set on the merged tree rather than trusting the pre-merge run. Co-Authored-By: Claude Fable 5.1 --- test/prcontextcheck.sh | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/test/prcontextcheck.sh b/test/prcontextcheck.sh index 7cdf767d4..ec67e0eb3 100755 --- a/test/prcontextcheck.sh +++ b/test/prcontextcheck.sh @@ -374,9 +374,22 @@ fi # ONLY on the non-NDEBUG flavour, the same flavour DEGRADED_PATH_ALERT lives on. So, like estchargecheck #14, # this arm establishes that flavour with its OWN probe rather than assuming it, and must never pass for lack # of an alert it could not have seen. +# +# THE BASE REF IS NOT OPTIONAL. A bare `--pr-context` reads `git diff HEAD`, so on a CLEAN checkout the +# change set is empty, prcontext takes its empty-diff branch, no trim level is ever RENDERED, and +# renderToString is never called — the arm then finds no alert and blames the seam for a fixture that asked +# it nothing. (Written against a dirty tree, it passed; the first clean run after a merge is what exposed +# it — test/gatecheck's "the fixture is the live repo" trap.) A committed range is deterministic here and +# depends on nothing the working tree happens to hold. PRC_FAULT_OUT="$TMP/f_dg.out"; PRC_FAULT_ERR="$TMP/f_dg.err" -RIPWIRE_FAULT_RENDER_EMIT_THROW=1 "$BIN" "$ROOT" --pr-context >"$PRC_FAULT_OUT" 2>"$PRC_FAULT_ERR" +PRC_FAULT_BASE="HEAD~3" +RIPWIRE_FAULT_RENDER_EMIT_THROW=1 "$BIN" "$ROOT" --pr-context="$PRC_FAULT_BASE" >"$PRC_FAULT_OUT" 2>"$PRC_FAULT_ERR" prc_f_rc=$? +# and the range must actually name a file, or every assertion below is vacuous +if [ "$( grep -aoc '/dev/null || echo 0 )" = "0" ] && ! grep -aq 'THREW' "$PRC_FAULT_ERR"; then + "$BIN" "$ROOT" --pr-context="$PRC_FAULT_BASE" 2>/dev/null | grep -aq '/dev/null | grep -q 'release'; then @@ -394,7 +407,7 @@ else # (F1) THE DOCUMENT STILL SHIPS. The whole point of the degrade: the caller loses the ESTIMATE, never the # content. prcontext.h streams the floor level straight out when no level could be measured. [ "$prc_f_rc" -eq 0 ] \ - && ok "(F1) --pr-context still exits 0 with every render throwing" \ + && ok "(F1) --pr-context=$PRC_FAULT_BASE still exits 0 with every render throwing" \ || no "(F1) --pr-context exited $prc_f_rc with the emitter-throw fault injected — the throw escaped instead of degrading" if grep -aq '' "$PRC_FAULT_OUT" && grep -aq '"$TMP/f_ctl.out" 2>/dev/null + "$BIN" "$ROOT" --pr-context="$PRC_FAULT_BASE" >"$TMP/f_ctl.out" 2>/dev/null c_files="$( grep -ao ' row — the degrade lost the content it exists to keep" From 507cdb6d45043180d30e6b63498163dc5f0a0315 Mon Sep 17 00:00:00 2001 From: joyful-ii-V-I Date: Sun, 13 Sep 2026 19:12:40 -0400 Subject: [PATCH 10/13] fix(emit): the layer's fault switch named the host project, which is the one thing src/infra/ may not do MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit infraportcheck (C) on the merged tree: `emit.h:226: faultSwitchOn( "RIPWIRE_FAULT_RENDER_EMIT_THROW" )`. Everything under src/infra/ is built to TRAVEL to another repository, and a host-named switch would arrive there describing a program its reader has never run — which is precisely the breakage that gate exists to catch at the edit rather than on arrival, weeks later, in the other repo. The switch now carries the LAYER's prefix, INFRA_FAULT_RENDER_EMIT_THROW, and the comment says why the spelling differs from the host's own charge-buffer switch (that one lives in serialize.h, outside the layer, so it is host-named correctly). The same review pass trimmed the comment block: it had named five host files to explain the idiom, which is the same category of arrival-time nonsense one notch quieter. It now states the contract in terms of the seam itself. rw::faultSwitchOn stays where it is and stays generic — it takes the name, so a host that owns its own fault switches names them its own way and reads them through it. Found by the FULL suite on the merged tree, not by the targeted set: the layering gate is not one a pr-context change would think to run, which is the argument for running all 614 before every push. Gates: infraportcheck (ALL PASS, (C) green with its own deliberate-violation detector still firing), prcontextcheck (ALL PASS, arm F unchanged in substance), estchargecheck (ALL PASS — the sibling switch this one shares a reader with), gatecount_build --check (614), limits_build --check (210 caps), --quality-delta gating="0". Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 5 ++++- src/infra/emit.h | 44 ++++++++++++++++++++---------------------- test/prcontextcheck.sh | 8 +++++--- 3 files changed, 30 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 54e6be7fc..d5cc1864a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -147,7 +147,10 @@ on that path would be a wrong cause attached to a right consequence. Because a t unreachable from a gate, it is driven by an in-source fault switch in `serialize.h`'s `isChargeBufferFaultInjected` shape — non-NDEBUG only, read once per process, exact `"1"` the only ON value — and `test/prcontextcheck.sh` arm (F) asserts the whole contract with its own observability probe, red on the -parent commit (`rc=134`, 0 B, no alert). +parent commit (`rc=134`, 0 B, no alert). That switch carries the `INFRA_` prefix rather than this project's: +everything under `src/infra/` is built to travel to another repository, and `test/infraportcheck.sh` (C) +refuses a layer file that names the host — it caught the switch's first spelling, which is the gate doing +exactly what it exists for. ### Added — the task router knows the recency question, and every new shape is named where an agent reads diff --git a/src/infra/emit.h b/src/infra/emit.h index c027b04e0..19d8d9520 100644 --- a/src/infra/emit.h +++ b/src/infra/emit.h @@ -185,30 +185,28 @@ struct Rendered }; // ── THE EMITTER ITSELF MAY THROW, and that is a degrade, not a way out ─────────────────────────────── -// CodeRabbit on #214: `emit( m )` was called outside any handler. A throw from it — std::bad_alloc out of -// the std::format fallback is the reachable one, since the whole point of this seam is to buffer a document -// whose size is not known in advance — skipped the fclose, the free, the alert and the documented -// empty-result fallback in one jump: the memstream and its buffer leaked, and the caller got an exception -// where its contract says it gets `ok == false`. +// The emitter was called outside any handler. A throw from it — std::bad_alloc out of the std::format +// fallback is the reachable one, since the whole point of this seam is to buffer a document whose size is +// not known in advance — skipped the fclose, the free, the alert and the documented empty-result fallback in +// one jump: the memstream and its buffer leaked, and the caller got an exception where its contract says it +// gets `ok == false`. // -// The house answer to "a throw crosses a seam that owns a resource" is this tree's own: catch at the seam, -// release what it owns, DISCLOSE, and hand back the degraded value the caller already knows how to read -// (search.h's `catch( ... ) { out.degraded = true; return out; }`, ingest_astquery.h's per-file degrade). -// It is the same answer this function already gives when open_memstream fails, and the callers need no new -// case: `ok == false` has always meant "these are not the bytes the emitter wrote", and --pr-context -// already streams the floor level straight out when it sees one. +// The answer is the one this layer gives everywhere a throw crosses a seam that owns a resource: catch AT +// the seam, release what it owns, DISCLOSE, and hand back the degraded value the caller already reads. It is +// the same answer this function gives when open_memstream fails, so callers need no new case — `ok == false` +// has always meant "these are not the bytes the emitter wrote", and a caller that has a fallback for the +// empty buffer has one for this. // -// FAULT INJECTION, because a throw path is otherwise unreachable from a gate: the switch below is -// serialize.h's isChargeBufferFaultInjected idiom, verbatim in shape — non-NDEBUG only (so it is -// `constexpr false` and the getenv is deleted in release, G2/G3), read ONCE per process (so it cannot -// change mid-document and determinism holds), and EXACT "1" is the only ON value, because the contract is a -// switch and a prefix test would let "=10" and "=0" disagree with what they say. test/prcontextcheck.sh -// arm (F) drives it and asserts the whole contract: complete bytes, the alert, no leak under LSan. -// ONE reader for every such switch (serialize.h's charge-buffer fault is the other). The parsing rule is -// the part worth having in one place: EXACT "1" is the only ON value, because the contract is a switch and a -// prefix test would let "=10" and "=0" mean whatever the reader guessed — a defect this tree already fixed -// once, in the charge-buffer switch, and would otherwise have had to fix again here. Under NDEBUG it is -// `constexpr false`, so the branch and the getenv are both deleted (G2/G3: zero release cost). +// FAULT INJECTION, because a throw path is otherwise unreachable from a test: non-NDEBUG only (so it is +// `constexpr false` and the getenv is deleted in release: zero release cost), read ONCE per process (so it +// cannot change mid-document and determinism holds), and EXACT "1" is the only ON value, because the +// contract is a switch — a prefix test would let "=10" and "=0" mean whatever the reader guessed, which is a +// defect worth fixing once, here, rather than once per switch. +// +// THE ENV NAME CARRIES THIS LAYER'S PREFIX, NOT THE HOST'S. Everything under infra/ is built to travel to +// another repository; a host-named switch would arrive there describing a program its reader has never run, +// which is exactly what the layering gate refuses. A host that owns its own fault switches names them its +// own way and reads them through faultSwitchOn below. #ifndef NDEBUG inline bool faultSwitchOn( const char* envName ) noexcept { @@ -223,7 +221,7 @@ inline constexpr bool faultSwitchOn( const char* ) noexcept { return false; } // for, and the `static` is what keeps the answer from changing mid-document (determinism). inline bool isRenderEmitThrowFaultInjected() noexcept { - static const bool isOn = faultSwitchOn( "RIPWIRE_FAULT_RENDER_EMIT_THROW" ); + static const bool isOn = faultSwitchOn( "INFRA_FAULT_RENDER_EMIT_THROW" ); return isOn; } diff --git a/test/prcontextcheck.sh b/test/prcontextcheck.sh index ec67e0eb3..e478cc2ce 100755 --- a/test/prcontextcheck.sh +++ b/test/prcontextcheck.sh @@ -370,7 +370,9 @@ fi # and the caller got an exception where its contract says it gets ok == false. # # A throw path is unreachable from a gate by ordinary means, so this drives the in-source fault switch -# RIPWIRE_FAULT_RENDER_EMIT_THROW=1 — serialize.h's isChargeBufferFaultInjected idiom, and therefore living +# INFRA_FAULT_RENDER_EMIT_THROW=1 — serialize.h's isChargeBufferFaultInjected idiom (the INFRA_ prefix, not +# this project's, because src/infra/ is built to travel and test/infraportcheck.sh (C) refuses a layer file +# that names the host — it caught this switch's first spelling), and therefore living # ONLY on the non-NDEBUG flavour, the same flavour DEGRADED_PATH_ALERT lives on. So, like estchargecheck #14, # this arm establishes that flavour with its OWN probe rather than assuming it, and must never pass for lack # of an alert it could not have seen. @@ -383,7 +385,7 @@ fi # depends on nothing the working tree happens to hold. PRC_FAULT_OUT="$TMP/f_dg.out"; PRC_FAULT_ERR="$TMP/f_dg.err" PRC_FAULT_BASE="HEAD~3" -RIPWIRE_FAULT_RENDER_EMIT_THROW=1 "$BIN" "$ROOT" --pr-context="$PRC_FAULT_BASE" >"$PRC_FAULT_OUT" 2>"$PRC_FAULT_ERR" +INFRA_FAULT_RENDER_EMIT_THROW=1 "$BIN" "$ROOT" --pr-context="$PRC_FAULT_BASE" >"$PRC_FAULT_OUT" 2>"$PRC_FAULT_ERR" prc_f_rc=$? # and the range must actually name a file, or every assertion below is vacuous if [ "$( grep -aoc '/dev/null || echo 0 )" = "0" ] && ! grep -aq 'THREW' "$PRC_FAULT_ERR"; then @@ -395,7 +397,7 @@ if ! grep -aq 'renderToString: the emitter THREW' "$PRC_FAULT_ERR"; then if "$BIN" --version 2>/dev/null | grep -q 'release'; then printf ' INFO (F) emitter-throw degrade is unobservable on this NDEBUG flavour (the plain build proves it)\n' else - no "(F) RIPWIRE_FAULT_RENDER_EMIT_THROW=1 produced no DEGRADED_PATH_ALERT on a flavour that can see one — the seam regressed" + no "(F) INFRA_FAULT_RENDER_EMIT_THROW=1 produced no DEGRADED_PATH_ALERT on a flavour that can see one — the seam regressed" fi else ok "(F) observability probe: the emitter-throw fault switch is live and alerts on this flavour" From 7bc28ffbcfe99825ce1445485bf3be7808efe7db Mon Sep 17 00:00:00 2001 From: joyful-ii-V-I Date: Sun, 13 Sep 2026 19:59:15 -0400 Subject: [PATCH 11/13] fix(prcontextcheck): the flavour probe read a LABEL, so Release failed the seam for an alert it cannot emit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI red on exactly one job — release (macos-14, Release, appleclang, shard 4/4) — and on a property of the GATE, not of the code it guards. Arm (F) needs to know whether the binary under test can emit a DEGRADED_PATH_ALERT at all: both the fault switch and the macro exist only on the non-NDEBUG flavour, so on Release there is no alert to see and the silence must not read as a regression. It settled that question by grepping `--version` for "release". That is a LABEL, and its spelling is not this gate's to depend on: plain: ripwire 0.6.0 (dev, AppleClang …) -> no match, correct by accident Release: ripwire 0.6.0 (Release, AppleClang …) -> no match, because the R is CAPITAL So on macOS Release the probe answered "this flavour CAN see alerts", found none, and failed the seam. The grep never matched on either flavour; the plain leg passed only because it never reached the check. THE BINARY IS ASKED DIRECTLY NOW, with an alert it is KNOWN to emit: serialize.h's charge-buffer fault switch, whose alert is independent of everything this arm touches. If that one speaks, this binary can speak, and only then is the emitter-throw alert required of it. A label can be respelled; a fault that either fires or does not cannot. * ALERTING flavour: unchanged — the emitter-throw alert is required, must not blame open_memstream, and the degrade contract is asserted (exit 0, closed well-formed document, rows kept). * NO-ALERT flavour: the arm says so in an INFO and asserts what is still assertable — that a binary which cannot emit the charge-buffer alert does not emit the emitter-throw one either (they must agree about the flavour), and that the verb itself is intact: exit 0, a closed well-formed , rows present. The degrade proper is unobservable BY DESIGN there and the plain leg is what proves it (CLAUDE.md). Not a skip: four rows that can fail. Verified on BOTH flavours locally, same gate file, same commit: plain build ALL PASS (the alerting leg, six rows), and a Release tree built in a scratch worktree (cmake -B rel -DCMAKE_BUILD_TYPE=Release, never the dev tree) ALL PASS (the no-alert leg, four rows). Mutation-checked on Release so the new leg is not vacuous: forcing the throw-alert branch reds the consistency row, and pointing the arm at a base that names no file reds (F4). Gates: prcontextcheck on both flavours, g1freshcheck, versioncheck (rebuilt first, so built_from is the commit under test). Co-Authored-By: Claude Fable 5.1 --- test/prcontextcheck.sh | 49 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 43 insertions(+), 6 deletions(-) diff --git a/test/prcontextcheck.sh b/test/prcontextcheck.sh index e478cc2ce..8834b5e4e 100755 --- a/test/prcontextcheck.sh +++ b/test/prcontextcheck.sh @@ -385,6 +385,20 @@ fi # depends on nothing the working tree happens to hold. PRC_FAULT_OUT="$TMP/f_dg.out"; PRC_FAULT_ERR="$TMP/f_dg.err" PRC_FAULT_BASE="HEAD~3" +# +# WHICH FLAVOUR IS THIS BINARY? ASK IT, WITH AN ALERT IT IS KNOWN TO EMIT. +# Both the fault switch and DEGRADED_PATH_ALERT exist only on the non-NDEBUG flavour, so on a Release build +# there is no alert to see and this arm must not read that silence as a regression. The first version of the +# probe settled the question by grepping `--version` for "release" — a LABEL, whose spelling is not this +# gate's to depend on, and which the plain build spells "dev" and the Release build spells neither. It +# answered "this flavour can see alerts" on macOS Release and then failed the SEAM for the missing alert: +# CI red on one job, for a property of the gate, not of the code under test. The binary is asked directly +# now, with the SIBLING fault switch (serialize.h's charge buffer), whose alert is independent of everything +# this arm changes — if that one speaks, this binary can speak, and only then is the emitter-throw alert +# required of it. +INFRA_PROBE_ERR="$TMP/f_probe.err" +RIPWIRE_FAULT_CHARGE_BUFFER=1 "$BIN" "$ROOT/src" --top-k=5 --pack-signatures --no-cache >/dev/null 2>"$INFRA_PROBE_ERR" +if grep -aq 'open_memstream failed' "$INFRA_PROBE_ERR"; then PRC_ALERTS=1; else PRC_ALERTS=0; fi INFRA_FAULT_RENDER_EMIT_THROW=1 "$BIN" "$ROOT" --pr-context="$PRC_FAULT_BASE" >"$PRC_FAULT_OUT" 2>"$PRC_FAULT_ERR" prc_f_rc=$? # and the range must actually name a file, or every assertion below is vacuous @@ -392,15 +406,38 @@ if [ "$( grep -aoc '/dev/null || echo 0 )" = "0" ] && ! "$BIN" "$ROOT" --pr-context="$PRC_FAULT_BASE" 2>/dev/null | grep -aq '/dev/null | grep -q 'release'; then - printf ' INFO (F) emitter-throw degrade is unobservable on this NDEBUG flavour (the plain build proves it)\n' +if [ "$PRC_ALERTS" -eq 0 ]; then + # NO-ALERT FLAVOUR (NDEBUG). The fault switch is `constexpr false` here and the alert macro is compiled + # out, so this arm cannot exercise the degrade at all and must not pretend to: the PLAIN build is what + # proves it (CLAUDE.md). What is still assertable, and worth asserting, is that the verb this arm drives + # is not broken on this flavour — the same document the alerting leg demands, minus the degrade. + printf ' INFO (F) this binary emits no DEGRADED_PATH_ALERT (NDEBUG): the emitter-throw degrade is unobservable BY DESIGN here, and the plain-flavour leg is what proves it\n' + if grep -aq 'renderToString: the emitter THREW' "$PRC_FAULT_ERR"; then + no "(F) a binary that cannot emit the charge-buffer alert emitted the emitter-throw one — the two disagree about this flavour" + else + ok "(F) consistency: no alert on a flavour that compiles them out" + fi + [ "$prc_f_rc" -eq 0 ] \ + && ok "(F1) --pr-context=$PRC_FAULT_BASE exits 0 with the (compiled-out) fault requested" \ + || no "(F1) --pr-context exited $prc_f_rc on a flavour where the fault is not even compiled in" + if grep -aq '' "$PRC_FAULT_OUT" && grep -aq ' root ($( wc -c <"$PRC_FAULT_OUT" | tr -d ' ' ) B)" + fi + if command -v xmllint >/dev/null 2>&1; then + xmllint --noout "$PRC_FAULT_OUT" 2>/dev/null \ + && ok "(F3) the document is well-formed XML (G4 holds)" \ + || no "(F3) the --pr-context document does not parse" fi + f_rel="$( grep -ao ' row(s) — the verb is intact on this flavour" \ + || no "(F4) the document carries NO row over $PRC_FAULT_BASE" +elif ! grep -aq 'renderToString: the emitter THREW' "$PRC_FAULT_ERR"; then + no "(F) INFRA_FAULT_RENDER_EMIT_THROW=1 produced no DEGRADED_PATH_ALERT on a binary that PROVED it can emit one (the charge-buffer probe alerted) — the seam regressed" else - ok "(F) observability probe: the emitter-throw fault switch is live and alerts on this flavour" + ok "(F) observability probe: this binary emits alerts (the charge-buffer fault spoke) and the emitter-throw fault alerts too" # (F0) the alert names the CAUSE IT HAD. degradeMsg says the BUFFER failed; on this path it did not, so # reusing it would have been a wrong reason attached to a right consequence. grep -aq 'open_memstream failed' "$PRC_FAULT_ERR" \ From c9d6d4e84a552148b5f13ba20285c68c7cdc819d Mon Sep 17 00:00:00 2001 From: joyful-ii-V-I Date: Sun, 13 Sep 2026 20:28:00 -0400 Subject: [PATCH 12/13] fix(prcontextcheck): the emitter-throw arm anchored into the live history, so CI's merge ref asked it a different question MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same job red again — release (macos-14, Release, appleclang, 4/4) — new row: "(F4) the document carries NO row over HEAD~3". The precondition row did its job; the ANCHOR was the bug, for the second time. REPRODUCED EXACTLY, LOCALLY, BEFORE FIXING. Cloned the repo, fetched GitHub's own refs/pull/214/merge (the ref CI checks out, a merge commit whose FIRST parent is main's tip), built the scratch Release tree, and ran the gate as it stood: OLD gate + Release binary + merge ref -> rc=1, FAIL (F4) the document carries NO row over HEAD~3 The cause is not "three back names no file" — it names 54. It is that on the merge ref HEAD~3 walks the FIRST-PARENT chain into MAIN, so the range is enormous, --pr-context's budget trims the section away entirely, and the run reports no row at all. On the plain flavour the arm survived it by accident: the injected throw makes prcontext stream kPrTrims[0], the UNTRIMMED floor, which still had 82 rows. On Release the fault is compiled out, so the same command is the ordinary budget-trimmed document — zero rows, and the row fails. One anchor, two flavours, opposite answers. THE ARM BRINGS ITS OWN REPOSITORY NOW. A throwaway git fixture built in $TMP: three small C++ files, one commit, one edited file, a second commit, and --pr-context run over that base sha. One changed file, one row, nothing to trim. It is identical on a clean checkout, a dirty tree, a merge ref, a shallow clone and a rebase, because none of those are inputs to it. Every anchor into the live history is a property of the BRANCH, and counting commits differently would only have moved the trap a third time — this is the same "gate fixture is the live repo" trap that took the working-tree spelling two rounds ago. The charge-buffer flavour probe still runs against the live tree: it asks the BINARY a question and reads no git history at all. The precondition row stays exactly as it was — it is what caught this — and both fault-injection halves are unchanged. VERIFIED ON FOUR COMBINATIONS, same gate file: plain build ALL PASS and Release ALL PASS on this worktree; plain ALL PASS and Release ALL PASS on the fetched merge ref, which is the shape that was failing. The degraded and control runs now both report 1 row, on every one of them. Not touched: the pre-existing P10.4 summary check at the end of this file also spells HEAD~3 against the live repo. It cannot fail the way this arm did (an empty range simply does not match its grep), but it can pass vacuously on a merge ref, and it is not this commit's to change. Gates: prcontextcheck on both flavours and on the merge-ref clone, g1freshcheck, versioncheck (rebuilt first so built_from is the commit under test). Co-Authored-By: Claude Opus 5 --- test/prcontextcheck.sh | 83 +++++++++++++++++++++++++++++++++++------- 1 file changed, 70 insertions(+), 13 deletions(-) diff --git a/test/prcontextcheck.sh b/test/prcontextcheck.sh index 8834b5e4e..71382d981 100755 --- a/test/prcontextcheck.sh +++ b/test/prcontextcheck.sh @@ -377,14 +377,71 @@ fi # this arm establishes that flavour with its OWN probe rather than assuming it, and must never pass for lack # of an alert it could not have seen. # -# THE BASE REF IS NOT OPTIONAL. A bare `--pr-context` reads `git diff HEAD`, so on a CLEAN checkout the -# change set is empty, prcontext takes its empty-diff branch, no trim level is ever RENDERED, and -# renderToString is never called — the arm then finds no alert and blames the seam for a fixture that asked -# it nothing. (Written against a dirty tree, it passed; the first clean run after a merge is what exposed -# it — test/gatecheck's "the fixture is the live repo" trap.) A committed range is deterministic here and -# depends on nothing the working tree happens to hold. +# THE ARM BRINGS ITS OWN REPOSITORY, because every anchor into the live one is a property of the BRANCH. +# A bare `--pr-context` reads `git diff HEAD`, so on a clean checkout the change set is empty, no trim level +# is ever RENDERED, renderToString is never called, and the arm finds no alert and blames the seam for a +# fixture that asked it nothing. Anchoring at HEAD~3 fixed that locally and broke on CI, which checks out the +# PR's MERGE ref: there HEAD~1 is main's tip and three back is a different set of commits entirely, one that +# named no file this verb reports. Both spellings were the same mistake — test/gatecheck's "the gate fixture +# is the live repo" trap — and counting commits differently would only move it. +# +# So the fixture is BUILT here: a throwaway repo, two commits, one edited file. It is identical on a clean +# checkout, a dirty tree, a merge ref and a shallow clone, because none of those are inputs to it. (The live +# repo is still used for the charge-buffer probe below, which asks the BINARY a question and reads no git +# history at all.) PRC_FAULT_OUT="$TMP/f_dg.out"; PRC_FAULT_ERR="$TMP/f_dg.err" -PRC_FAULT_BASE="HEAD~3" +PRC_FIX="$TMP/f_repo" +mkdir -p "$PRC_FIX/src" +cat > "$PRC_FIX/src/core.cpp" <<'PRCEOF' +int coreHelper( int y ) +{ + return y + 1; +} + +int coreCompute( int x ) +{ + return coreHelper( x ) * 2; +} +PRCEOF +cat > "$PRC_FIX/src/caller.cpp" <<'PRCEOF' +int coreCompute( int x ); + +int callerEntry( int n ) +{ + return coreCompute( n ) + coreCompute( n + 1 ); +} +PRCEOF +cat > "$PRC_FIX/src/other.cpp" <<'PRCEOF' +int unrelatedLeaf( int z ) +{ + return z - 1; +} +PRCEOF +( + cd "$PRC_FIX" \ + && git init -q \ + && git config user.email gate@example.invalid \ + && git config user.name gate \ + && git add -A \ + && git commit -qm base +) >/dev/null 2>&1 +PRC_FAULT_BASE="$( cd "$PRC_FIX" && git rev-parse HEAD 2>/dev/null )" +# the second commit: ONE file changes, so the range names exactly the file whose row the rows below count +cat > "$PRC_FIX/src/core.cpp" <<'PRCEOF' +int coreHelper( int y ) +{ + return y + 2; +} + +int coreCompute( int x ) +{ + return coreHelper( x ) * 3; +} +PRCEOF +( cd "$PRC_FIX" && git add -A && git commit -qm edit ) >/dev/null 2>&1 +if [ -z "$PRC_FAULT_BASE" ]; then + no "(F) could not build the throwaway git fixture (no base sha) — the emitter-throw arm cannot run" +fi # # WHICH FLAVOUR IS THIS BINARY? ASK IT, WITH AN ALERT IT IS KNOWN TO EMIT. # Both the fault switch and DEGRADED_PATH_ALERT exist only on the non-NDEBUG flavour, so on a Release build @@ -399,12 +456,12 @@ PRC_FAULT_BASE="HEAD~3" INFRA_PROBE_ERR="$TMP/f_probe.err" RIPWIRE_FAULT_CHARGE_BUFFER=1 "$BIN" "$ROOT/src" --top-k=5 --pack-signatures --no-cache >/dev/null 2>"$INFRA_PROBE_ERR" if grep -aq 'open_memstream failed' "$INFRA_PROBE_ERR"; then PRC_ALERTS=1; else PRC_ALERTS=0; fi -INFRA_FAULT_RENDER_EMIT_THROW=1 "$BIN" "$ROOT" --pr-context="$PRC_FAULT_BASE" >"$PRC_FAULT_OUT" 2>"$PRC_FAULT_ERR" +INFRA_FAULT_RENDER_EMIT_THROW=1 "$BIN" "$PRC_FIX" --pr-context="$PRC_FAULT_BASE" >"$PRC_FAULT_OUT" 2>"$PRC_FAULT_ERR" prc_f_rc=$? # and the range must actually name a file, or every assertion below is vacuous if [ "$( grep -aoc '/dev/null || echo 0 )" = "0" ] && ! grep -aq 'THREW' "$PRC_FAULT_ERR"; then - "$BIN" "$ROOT" --pr-context="$PRC_FAULT_BASE" 2>/dev/null | grep -aq '/dev/null | grep -aq '' "$PRC_FAULT_OUT" && grep -aq '' "$PRC_FAULT_OUT" && grep -aq '"$TMP/f_ctl.out" 2>/dev/null + "$BIN" "$PRC_FIX" --pr-context="$PRC_FAULT_BASE" >"$TMP/f_ctl.out" 2>/dev/null c_files="$( grep -ao ' row — the degrade lost the content it exists to keep" From f2420915743f4f3c20ddf9d204284f0bc1500e60 Mon Sep 17 00:00:00 2001 From: joyful-ii-V-I Date: Mon, 14 Sep 2026 05:37:44 -0400 Subject: [PATCH 13/13] fix(prcontext,emit,testrows): an unmeasured est_tokens disclosed nothing, and the no-throw copy threw MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six findings from one review, every one of them a surface that was silently wrong rather than loudly broken. --pr-context PRINTED A WRONG est_tokens WITH NO DISCLOSURE. When a trim level's measurement render fails, prRenderLevel returns an EMPTY body; pickPrTrimLevel priced that empty body, the price fit, and the ladder broke at level 0 — while writePrContext correctly streamed the complete untrimmed floor through emitFiles( out, kPrTrims[0], nullptr ). The only signal was DEGRADED_PATH_ALERT, which src/infra/Diagnostics.h compiles to `do {} while (0)` under NDEBUG, so the binary a user installs printed a modelled number with nothing at all saying so (non-negotiable #3). The BYTES were never the defect and do not move: cutting answer rows because a measurement buffer failed would let a cap decide the content, which is the one thing a cap may never do. The fact goes where this class of fact already lives — truncated= now carries ";est-unmeasured", re-priced with the label in place (the label lengthens the root tag), and the legend defines it in budget-floor-exceeded's own voice. THE CHARGE IS READ OFF THE LABEL, not off a boolean beside it. prPriceDocument decides the clause from the truncated= value it is already handed, so ONE condition decides both the priced legend and the delivered legend and they cannot drift apart; and the two conditional clauses now arrive as a named PrLegendClauses{ runHint, estUnmeasured } rather than two bare bools, because `prLegendText( escBase, unindexed, true, false )` says nothing at its call site about which clause is which. Both readings came out of --quality-delta: threading a seventh parameter into prPriceDocument and a fourth into prLegendText took the range form to gating="2" (a params row from minor to major, and an api-surface contract change that invalidated a standing ack). The range form is gating="0" now with NO new ack — the findings are gone rather than suppressed. THE DEFINITION IS LABEL-GATED, which the gate found for me. Spliced unconditionally, the ~390 B clause cost test/defaultceilingcheck.sh's fixture its entire remaining headroom: that 120-file tree prices at 7,989 of the 8,000 default — 11 tokens spare, as E1 measured when it gated the run-hint clause for the same reason — and went to 8,037, over budget on a document with nothing unmeasured about it. So kPrEstUnmeasuredLegendClause rides exactly the document that carries the label, decided by the fact the ladder recorded (PrTrimRender::rendered) and never by a search of the rendered bytes; the pricer charges its size on the same fact, so the priced legend and the delivered legend cannot disagree. A healthy document is byte-identical to before (est_tokens 7,989, re-measured) and prcontextcheck (F-legend) holds it that way. THE LABEL CROSSED prBudgetTail's BUFFER. test/fixedbufsweep.sh had this buffer at 248 B of tail[256] — "SEVEN bytes of margin ... one more attribute crosses it" — and ';est-unmeasured' is 15 more and CAN ride beside ';budget-floor-exceeded' (a small --max-tokens puts even the unmeasured empty-body envelope over budget). Worst case 88 lit + 90 digits + 85 label = 263 B, so tail[320], 56 B of margin, and the sweep's row moves in this commit with the recomputed number. rw::formatTo was not what had been saving it: it truncates SILENTLY and its return is not read there, so an overrun would have dropped the closing quote of truncated=" and shipped a malformed root — a G4 breach with no diagnostic. renderToString's NO-THROW CONTRACT HAD A THROWING LAST STATEMENT. out.text.assign( buf, sz ) is the one allocation on the success path and it sat outside the handler, so a std::bad_alloc from it escaped a function documented to ALERT a failure and return ok == false, and jumped the std::free( buf ) two lines below on the way out — leaking the memstream buffer. Caught in its own handler rather than one around the whole body, because the two failures need different cleanup (the emitter's throw owns an OPEN stream; by this point only buf is left), with its own alert literal, and control falls THROUGH to the single free() so buf is released exactly once on every path. THE SHARED ROW READER'S MALFORMED-FIELD DETECTOR HAD A HOLE OF ITS OWN SPECIES. test/testrowpaths.py found "tests_to_run" and then scanned arbitrarily far forward for a '[', so {"tests_to_run":null,"other":[{"p":"ghost.cpp"}]} sliced the NEXT field's array and returned ghost.cpp at exit 0 — a foreign field's paths served as this field's answer, where the docstring already promised a TestRowParseError. The value is read adjacently now: past the key, a ':', optional whitespace, then '[' or raise. AND TWO PATH READERS HAD NEVER BEEN CONVERTED. The census over test/ for the four shapes the shared reader replaced found test/affectedcheck.sh's tset() — inside the very file the reader's docstring names among those it converted, so that claim was false — splitting EVERY row's p= on ',' including a single row's, which turns a comma-bearing path (never grouped, by testmap.h's refusal) into two names that name nothing; and test/testgatecheck.sh's tset() matching ` 320 (worst case 248 -> 263 B, margin 7 -> 56), and EXPECTED mentions 323 -> 324 — re-read from the diff, not accepted from the delta: the one added line is the COMMENT explaining that growth, which names formatTo. calls, sites, rows and widthforms are unchanged at 219/219/92/0. No legend or byte pin moved. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 49 +++++++++++++++++ bench/arb/run_arb.py | 29 ++++++++-- skills/ripwire-mcp/SKILL.md | 2 +- src/infra/emit.h | 35 +++++++++++- src/prcontext.h | 90 +++++++++++++++++++++++++++---- test/affectedcheck.sh | 11 ++-- test/fixedbufsweep.sh | 12 ++++- test/prcontextcheck.sh | 104 ++++++++++++++++++++++++++++++++++++ test/testgatecheck.sh | 10 +++- test/testrowpaths.py | 48 +++++++++++++---- test/testrowruncheck.sh | 40 ++++++++++++++ 11 files changed, 397 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d5cc1864a..2588dbfa5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -152,6 +152,55 @@ everything under `src/infra/` is built to travel to another repository, and `tes refuses a layer file that names the host — it caught the switch's first spelling, which is the gate doing exactly what it exists for. +### Fixed — an unmeasured `est_tokens` said nothing, a no-throw contract threw, and two test-row readers still went quiet + +Six defects from one review, each of them a surface that was silently wrong rather than loudly broken. +**`--pr-context` shipped a wrong `est_tokens` with no disclosure.** When a trim level's measurement render +fails, `prRenderLevel` returns an EMPTY body; the ladder priced that empty body, the price fit, and the root +printed it — while `writePrContext` correctly streamed the complete untrimmed floor. The only signal was +`DEGRADED_PATH_ALERT`, which `src/infra/Diagnostics.h` compiles to `do {} while (0)` under `NDEBUG`, so the +binary a user installs printed a modelled number with nothing at all saying so (non-negotiable #3). The bytes +were never the bug and are unchanged — a failed measurement may not decide what the answer contains — so the +fact goes where this class of fact already lives: `truncated=` now carries `;est-unmeasured`, re-priced with +the label in place, and the legend defines it in the same voice as `budget-floor-exceeded`. That label is 15 +bytes and can ride beside `budget-floor-exceeded`, which takes `prBudgetTail`'s worst case from 248 B to +263 B: `tail[256]` (SEVEN bytes of margin, as `test/fixedbufsweep.sh` had warned in terms) becomes +`tail[320]`, 56 B of margin, and the sweep's row moves with the measured recomputation. `rw::formatTo` was +not what had been saving it — it truncates silently and its return is not read there, so an overrun would +have dropped the closing quote of `truncated="` and shipped a malformed root with no diagnostic. +**`renderToString`'s no-throw contract had a throwing last statement**: `out.text.assign( buf, sz )` is the +one allocation on the success path and sat outside the handler, so a `std::bad_alloc` from it escaped a +function documented to return `ok == false`, and jumped the `std::free( buf )` two lines below on the way +out — leaking the memstream buffer. It is caught in its own handler (the two failures need different +cleanup: the emitter's throw owns an open stream, this one owns only the buffer) with its own alert literal, +and control falls through to the single `free()`, so the buffer is released exactly once on every path. +Proved by `INFRA_FAULT_RENDER_COPY_THROW`, the twin of the emitter switch, in `test/prcontextcheck.sh` arm +(G) — red on the parent commit, and honest in both flavours: the switch and the alert live only on the +non-`NDEBUG` build, so the plain-flavour leg proves the degrade and the `NDEBUG` leg asserts only that the +verb is intact and that no false disclosure appears. The `est-unmeasured` LEGEND definition is asserted on +every flavour, which is the point of moving the disclosure off the alert. **The shared test-row reader's +malformed-field detector had a hole of its own species**: `test/testrowpaths.py` found `"tests_to_run"` and +then scanned arbitrarily far forward for a `[`, so `{"tests_to_run":null,"other":[{"p":"ghost.cpp"}]}` +sliced the NEXT field's array and returned `ghost.cpp` at exit 0 — a foreign field's paths served as this +field's answer, where the docstring already promised a `TestRowParseError`. The value is now read +adjacently (past the key, a `:`, optional whitespace, then `[` or raise); `null`, a number, a string and an +object all take the raise, in both `paths` and `jsonlist`, with a well-formed array and JSON whitespace as +controls (`test/testrowruncheck.sh` arm 17). **And two path readers had never been converted.** A census of +`test/` over the four shapes the reader was written to replace found `test/affectedcheck.sh`'s `tset()` — +in the file the reader's own docstring names among those it converted, so that claim was false — splitting +EVERY row's `p=` on `,` including a single row's, which turns a comma-bearing path (never grouped, by +`testmap.h`'s refusal) into two names that name nothing; and `test/testgatecheck.sh`'s `tset()` matching +` row; split its p= and -# undo the , comma escape so every path is read verbatim, as it was on the single rows. +# E1 (2026-09-12): runner-less rows sharing their evidence ride ONE row, so a group's p= is +# split on ','. THE CONTRACT, as testmap.h states it: a path containing ',' is NEVER grouped — it is served +# as a single row — so splitting a group's p= cannot split a path in half, and there is no comma escape to +# undo. This adapter used to undo a `,` that the seam stopped spelling on 2026-09-13 while decoding NONE +# of the entities it does emit, so `src/a&b.cpp` was scored against a file name that does not exist. +# Both row shapes now go through one decode, and therefore have one path contract. GROUP_RE = re.compile(r']*?\bp="([^"]+)"') +# The five XML attribute entities plus numeric references — test/testrowpaths.py's xml_unescape, in a file +# that may not import from test/. Local and small on purpose: an XML parser is not a dependency this bench +# adapter is allowed to grow for five substitutions. +_ARB_ENT = {"amp": "&", "lt": "<", "gt": ">", "quot": '"', "apos": "'"} + + +def xml_unescape(s): + def one(m): + body = m.group(1) + if body.startswith("#"): + try: + return chr(int(body[2:], 16) if body[1:2].lower() == "x" else int(body[1:], 10)) + except ValueError: + return m.group(0) + return _ARB_ENT.get(body, m.group(0)) + return re.sub(r"&([#0-9A-Za-z]+);", one, s) + def affected_tests(bin_path, snap, changed_file): out = run_bin_or_none(bin_path, snap, ["--affected=%s" % changed_file]) if out is None: return [] - paths = TEST_RE.findall(out) + paths = [xml_unescape(p) for p in TEST_RE.findall(out)] for grp in GROUP_RE.findall(out): - paths += [p.replace(",", ",") for p in grp.split(",")] + paths += [xml_unescape(p) for p in grp.split(",") if p != ""] return [norm_path(m, snap) for m in paths] diff --git a/skills/ripwire-mcp/SKILL.md b/skills/ripwire-mcp/SKILL.md index 6d4114a51..32efce3f0 100644 --- a/skills/ripwire-mcp/SKILL.md +++ b/skills/ripwire-mcp/SKILL.md @@ -96,7 +96,7 @@ enclosing-chain report) and `@FILE:LINE` in any SYM selector; contract gate: `te | `grep` (`pattern`) | `--grep=STR` | parallel literal scan + enclosing symbol + matched line | | `cochange` (`file`) | `--cochange=FILE` | the lockstep git partners of one file | | `memory_recall` (`task`, `top_k` + `budget_tokens` optional) | `--recall=TASK [--top-k=N] [--max-tokens=N]` | full bodies of the few relevant docs/memory notes, bounded by the SAME default 8000-token body ceiling as the CLI (the header discloses `max_tokens=` and every cut). `budget_tokens` raises the ceiling explicitly when you want everything; `top_k` (default 8) shapes how many docs | -| `situational_awareness` (`diff`/`files` optional) | `--situ` | blast radius, tests_to_run, forgotten co-change partners (the Shotgun Surgery check), hotspot alert — as JSON; defaults to `git diff HEAD`. In `tests_to_run` (here, in `explore` and on an edit receipt) a row's `p` is a path string OR an **array** of paths beside `n` — several runner-less tests sharing their attributes, served as one row — and every row carries `run` or `run_unknown: true` | +| `situational_awareness` (`diff`/`files` optional) | `--situ` | blast radius, tests_to_run, forgotten co-change partners (the Shotgun Surgery check), hotspot alert — as JSON; defaults to `git diff HEAD`. In `tests_to_run`, `situational_awareness` uses `test`; `explore` and edit receipts use `p`. The field is a path string OR an **array** of paths beside `n` — several runner-less tests sharing their attributes, served as one row — and every row carries `run` or `run_unknown: true` | | `mentions` (`symbol`) | `--mentions=SYM` | which markdown plans/designs discuss a symbol | | `owners` (`symbol` optional) | `--owners[=SYM]` | bus-factor: recency-weighted author ownership | | `lego` (`type`) | `--lego=TYPE` | an interface's method contract + every implementor (own-language) | diff --git a/src/infra/emit.h b/src/infra/emit.h index 19d8d9520..f9fd1baef 100644 --- a/src/infra/emit.h +++ b/src/infra/emit.h @@ -225,6 +225,15 @@ inline bool isRenderEmitThrowFaultInjected() noexcept return isOn; } +// The SECOND throwing site, and its own switch for the same reason the first has one: the name is what a +// reader greps for. The emitter is not the only allocation here — the final copy out of the memstream buffer +// is one too, and it sat outside the handler that the emitter's throw takes. +inline bool isRenderCopyThrowFaultInjected() noexcept +{ + static const bool isOn = faultSwitchOn( "INFRA_FAULT_RENDER_COPY_THROW" ); + return isOn; +} + template inline Rendered renderToString( Emit&& emit, const char* degradeMsg ) { @@ -266,7 +275,31 @@ inline Rendered renderToString( Emit&& emit, const char* degradeMsg ) out.ok = flushed && closed && buf != nullptr; if( out.ok ) { - out.text.assign( buf, sz ); + // THE LAST ALLOCATION IS STILL AN ALLOCATION. This copy is the one throwing statement on the success + // path, and it used to stand outside every handler: a std::bad_alloc here escaped a function whose + // contract is that a failure is ALERTED and returned as ok == false, and it jumped the free() below + // on the way out, leaking the memstream buffer. Caught here rather than in one handler around the + // whole body, because the two failures need different cleanup: the emitter's throw owns an OPEN + // stream (fclose + free, in the catch above), while by this point the stream is already closed and + // only `buf` is left — and control falls THROUGH to the single free() below, so buf is released + // exactly once on every path, success and failure alike. + try + { + // The injected fault stands exactly where a real std::bad_alloc would: the buffer is complete + // and closed, and the copy of it is what fails. + if( isRenderCopyThrowFaultInjected() ) { throw std::bad_alloc(); } + out.text.assign( buf, sz ); + } + catch( ... ) + { + out.ok = false; + out.text.clear(); + // NOT degradeMsg, and not the emitter's literal either: the buffer did not fail and the emitter + // did not throw — the copy out of a complete buffer did. Same reasoning as the catch above, so + // the caller reads which of the three failures it actually hit. + DEGRADED_PATH_ALERT( "renderToString: the final COPY out of the buffer THREW — nothing was " + "measured, the caller takes its documented fallback" ); + } } else { diff --git a/src/prcontext.h b/src/prcontext.h index 0337145ab..285e59353 100644 --- a/src/prcontext.h +++ b/src/prcontext.h @@ -569,6 +569,23 @@ inline PrTrimRender pickPrTrimLevel( const EmitFn& emitFiles, std::size_t budget out.level = li; out.truncated = li > 0 ? std::string( kPrTrims[li].dropped ) : std::string( "none" ); out.estTokens = price( out.body, out.testFiles, li, out.truncated, windowAttrs ); + if( !probe.rendered ) + { + // THIS LEVEL WAS NOT MEASURED, AND THE DOCUMENT MUST SAY SO. A failed render leaves probe.body + // EMPTY, the price of an empty body fits any budget, and the ladder therefore breaks here with a + // root that prints est_tokens = the price of nothing while writePrContext streams the COMPLETE + // floor level. The bytes are the right answer — cutting rows because a measurement buffer failed + // would make a cap decide the content, which is the one thing a cap may never do — but the + // NUMBER is modelled, and until now its only signal was DEGRADED_PATH_ALERT, which Diagnostics.h + // compiles to `do {} while (0)` under NDEBUG. So the shipped binary printed a wrong est_tokens + // with no disclosure at all (review of #214). truncated= is the attribute that already carries + // exactly this class of fact, so the fact goes there and survives the flavour. + // + // Re-priced after the label is appended, for the same reason the budget-floor rung below + // re-prices: the label lengthens the root tag, so a number printed beside it must include it. + out.truncated += ";est-unmeasured"; + out.estTokens = price( out.body, out.testFiles, li, out.truncated, windowAttrs ); + } if( out.estTokens <= budgetTokens ) { break; @@ -587,7 +604,14 @@ inline PrTrimRender pickPrTrimLevel( const EmitFn& emitFiles, std::size_t budget inline std::string prBudgetTail( std::size_t changedFiles, std::uint32_t skippedModeOnly, std::size_t budgetTokens, const PrTrimRender& chosen, const std::string& truncatedEscaped ) { - char tail[ 256 ]; + // 320, not 256: test/fixedbufsweep.sh measured this buffer's worst case at 248 B of 256 — SEVEN bytes of + // margin — and warned that "one more attribute crosses it". ';est-unmeasured' is 15 more and CAN ride + // beside ';budget-floor-exceeded' (a small --max-tokens puts even the empty-body envelope over budget), + // so the worst case is 88 lit + 90 digits + 85 label = 263 B. formatTo is not what was saving it: it + // truncates SILENTLY and its return is not read here, so an overrun would have dropped the closing quote + // of truncated=" and shipped a malformed root — a G4 breach with no diagnostic. The sweep's row moves in + // the same commit with the recomputed number. + char tail[ 320 ]; rw::formatTo( tail, sizeof( tail ), " files=\"{}\" skipped_mode_only=\"{}\" budget_tokens=\"{}\" est_tokens=\"{}\" trim_level=\"{}\" truncated=\"{}\"", changedFiles, skippedModeOnly, budgetTokens, chosen.estTokens, chosen.level, truncatedEscaped.c_str() ); return tail; @@ -616,7 +640,35 @@ inline std::string prBudgetTail( std::size_t changedFiles, std::uint32_t skipped // (CodeRabbit on #214): a test elsewhere in the corpus, or a testCap=0 level, bought the clause for a document // with no row. Measured on test/defaultceilingcheck.sh's 120-file, no-test fixture: unconditional, 7,989 -> // 8,025 tokens, over the 8,000 default budget; gated, 7,989. -inline std::string prLegendText( const std::string& baseEscaped, bool hasUnindexed, bool withRunClause ) +// Review of #214: the est-unmeasured label's definition, and the ONE wording that defines it — the legend +// clause for the disclosure that replaces an alert the release build compiles out. +// +// CHARGED LIKE kRunHintLegendClause, AND FOR THE SAME REASON. This is a rule about a label, so it rides only +// a document whose chosen level actually carries that label — decided by the FACT the ladder recorded +// (PrTrimRender::rendered), never by a search of the rendered bytes. Unconditional, it cost the +// test/defaultceilingcheck.sh fixture its whole remaining headroom: that 120-file tree prices at 7,989 of +// the 8,000 default (11 tokens spare, as E1 measured when it gated the run clause for the same reason) and +// went to 8,037 — over budget, on a document with nothing unmeasured about it. Gated, it is 0 B there and +// the pricer charges it exactly on the level that states it, so the priced and the delivered legend cannot +// disagree. Defined-wherever-emitted is the rule prbudgetcheck (#10) already holds budget-floor-exceeded to; +// test/prcontextcheck.sh (F6) holds this one to it. +inline constexpr std::string_view kPrEstUnmeasuredLegendClause = + "truncated= carrying est-unmeasured means the chosen level could not be MEASURED (its measurement buffer, or the copy out of it, failed), so est_tokens= is a MODELLED " + "number and not this document's own price — recounting the delivered bytes will NOT reproduce it. The BYTES are unaffected: the complete untrimmed level is served, " + "because a failed measurement may not decide what the answer contains. "; + +// The two CONDITIONAL clauses of this legend, named instead of passed as a pair of bare bools: the call site +// `prLegendText( escBase, unindexed, true, false )` says nothing about which clause is which, and the two are +// decided by different facts — the chosen body's test-row COUNT, and whether that body could be measured at +// all. Both are gated for the same measured reason (see kPrEstUnmeasuredLegendClause): a clause that states a +// rule about something this document does not contain is bytes every reader pays for and no reader needs. +struct PrLegendClauses +{ + bool runHint = false; // M21(b)/E1: testmap.h's run=/run_unknown=/ rule — rides a rows-bearing body + bool estUnmeasured = false; // review of #214: the est-unmeasured label's definition — rides a document carrying the label +}; + +inline std::string prLegendText( const std::string& baseEscaped, bool hasUnindexed, const PrLegendClauses& clauses ) { return std::string( ""; } @@ -725,6 +778,7 @@ struct PrPriceCtx const std::string* atAttrs = nullptr; // gitstamp::atAttr, appended past every tail attribute std::size_t envelopeBytes = 0; // legend (WITHOUT the run clause) + anchoring note + closing tag (never the root tag) std::size_t runClauseBytes = 0; // E1: testmap.h's run=/run_unknown=/ clause, charged only for a body that renders a test row + std::size_t estUnmeasuredClauseBytes = 0; // review of #214: the est-unmeasured clause, charged only for a level that could not be measured std::size_t changedFiles = 0; std::uint32_t skippedModeOnly = 0; std::size_t budgetTokens = 0; @@ -752,7 +806,16 @@ struct PrPriceCtx inline std::size_t prPriceDocument( const PrPriceCtx& c, std::string_view body, std::size_t testFiles, std::size_t level, const std::string& truncatedEscaped, const std::string& windowAttrs ) { - const std::size_t bodyBytes = body.size() + ( testFiles > 0 ? c.runClauseBytes : 0 ); // E1: the clause rides only a rows-bearing document + // Review of #214: the est-unmeasured clause is charged off the LABEL this document will print, read from + // the truncated= value the caller already hands in — not off a parallel boolean beside it. The label is + // the fact (pickPrTrimLevel appends it from PrTrimRender::rendered before it prices, wherever it prices), + // so charging on its presence makes the priced legend and the delivered legend impossible to disagree: + // one condition decides both. This is NOT the E1 objection to grepping rendered bytes — truncated= is the + // ladder's own decision string, never emitter output, and the vocabulary that may appear in it is a + // closed const table plus these two labels. + const bool unmeasured = truncatedEscaped.find( "est-unmeasured" ) != std::string::npos; + const std::size_t bodyBytes = body.size() + ( testFiles > 0 ? c.runClauseBytes : 0 ) // E1: the clause rides only a rows-bearing document + + ( unmeasured ? c.estUnmeasuredClauseBytes : 0 ); // review of #214: and this one only a level that was not measured std::size_t est = 0; for( int pass = 0; pass < 4; ++pass ) { @@ -930,14 +993,20 @@ inline int writePrContext( std::FILE* out, const std::string& root, const Ingest // E1: both legend forms are built now and ONE is written later, once the body is known — writeHead takes // that body's own PrTrimRender::testFiles count. The envelope is priced without the clause and the pricer // adds runClauseBytes for a rows-bearing body. - const std::string legendText = prLegendText( escBase, g.unindexedFiles > 0, false ); + const std::string legendText = prLegendText( escBase, g.unindexedFiles > 0, PrLegendClauses{} ); const std::string anchorNoteText = prAnchorNoteText( anchorAttr ); // The clause-bearing form is built ONCE, and only if it is the form that gets written — the difference // between the two is exactly kRunHintLegendClause (prLegendText splices that constant and nothing else), // so the pricer reads the constant's size rather than a second rendering's. - const auto writeHead = [ & ]( std::size_t testFiles ) + // Review of #214: `unmeasured` is the SECOND rows-style gate — the est-unmeasured clause rides only the + // document whose chosen level could not be measured, and the pricer charged it on exactly that fact + // (PrTrimRender::rendered), so the written legend and the priced legend are the same bytes. + const auto writeHead = [ & ]( std::size_t testFiles, bool unmeasured ) { - const std::string legend = testFiles > 0 ? prLegendText( escBase, g.unindexedFiles > 0, true ) : legendText; + const std::string legend = ( testFiles > 0 || unmeasured ) + ? prLegendText( escBase, g.unindexedFiles > 0, + PrLegendClauses{ .runHint = testFiles > 0, .estUnmeasured = unmeasured } ) + : legendText; std::fwrite( legend.data(), 1, legend.size(), out ); std::fwrite( anchorNoteText.data(), 1, anchorNoteText.size(), out ); }; @@ -951,6 +1020,7 @@ inline int writePrContext( std::FILE* out, const std::string& root, const Ingest // vary per candidate trim level, gathered once. const PrPriceCtx priceCtx{ .g = &g, .sharedAttrs = &sharedAttrs, .anchor = &anchor, .baseEscaped = &escBase, .atAttrs = &atAttrStr, .envelopeBytes = envelopeBytes, .runClauseBytes = rw::kRunHintLegendClause.size(), + .estUnmeasuredClauseBytes = kPrEstUnmeasuredLegendClause.size(), .changedFiles = changed.size(), .skippedModeOnly = skippedModeOnly, .budgetTokens = budgetTokens, .isDefaultBudget = budget.isDefault }; const auto priceOf = [ & ]( std::string_view body, std::size_t testFiles, std::size_t level, const std::string& truncatedRaw, const std::string& windowAttrs ) @@ -962,7 +1032,7 @@ inline int writePrContext( std::FILE* out, const std::string& root, const Ingest const std::string rootOpen = prRootOpenText( g, sharedAttrs, prEmptyRootTail( skippedModeOnly, budgetTokens, budget.isDefault, emptyEst, ex( emptyTruncated ) ) + atAttrStr, anchor, escBase ); - writeHead( 0 ); // the empty-diff body is a fixed comment: no changed file, so no test row + writeHead( 0, false ); // the empty-diff body is a fixed comment: no changed file, so no test row and nothing to measure std::fwrite( rootOpen.data(), 1, rootOpen.size(), out ); std::fwrite( kPrEmptyDiffBody.data(), 1, kPrEmptyDiffBody.size(), out ); std::fwrite( kPrCloseTag.data(), 1, kPrCloseTag.size(), out ); @@ -1227,7 +1297,7 @@ inline int writePrContext( std::FILE* out, const std::string& root, const Ingest // be opened there is no count, so the clause-less legend is written and the body is STREAMED straight // to `out` exactly as it was before E1. Complete, correct bytes either way — never an empty body. const PrTrimRender flat = prRenderLevel( emitFiles, kPrTrims[0] ); - writeHead( flat.testFiles ); + writeHead( flat.testFiles, false ); // this root carries no est_tokens/truncated= at all, so it has no unmeasured price to disclose std::fwrite( rootOpen.data(), 1, rootOpen.size(), out ); if( flat.rendered ) { @@ -1278,7 +1348,7 @@ inline int writePrContext( std::FILE* out, const std::string& root, const Ingest prBudgetTail( changed.size(), skippedModeOnly, budgetTokens, chosen, ex( chosen.truncated ) ) + ( budget.isDefault ? " budget_default=\"1\"" : "" ) + windowAttrs + atAttrStr, anchor, escBase ); - writeHead( chosen.testFiles ); // E1: the legend form the chosen body was priced with, from the same count + writeHead( chosen.testFiles, !chosen.rendered ); // E1 / review of #214: the legend form the chosen body was priced with, from the same count and the same rendered fact std::fwrite( rootOpen.data(), 1, rootOpen.size(), out ); if( chosen.rendered ) { diff --git a/test/affectedcheck.sh b/test/affectedcheck.sh index 5a36b920e..039ad8f11 100755 --- a/test/affectedcheck.sh +++ b/test/affectedcheck.sh @@ -69,9 +69,14 @@ printf 'void unrelated_helper() { }\n' > "$R/test/de run(){ perl -e 'alarm 15; exec @ARGV' "$BIN" "$R" "$@" --no-cache 2>/dev/null; } runec(){ perl -e 'alarm 15; exec @ARGV' "$BIN" "$R" "$@" --no-cache >/dev/null 2>"$TMP/err.txt"; } -# extract the basenames of the emitted test rows, sorted. E1 (2026-09-12): a runner-less row may ride a -# GROUP row (testmap.h), so the p= value is split on ',' — every path is still verbatim. -tset(){ printf '%s' "$1" | grep -oE '<(test|g) [^>]*/>' | grep -oE ' p="[^"]*"' | sed 's/^ p="//; s/"$//' | tr ',' '\n' | sed 's|.*/||' | sort | tr '\n' ','; } +# The basenames of the emitted test rows, sorted — through test/testrowpaths.py, THE shared reader, like +# tord() below. This helper was the LAST private reader left in the file the shared reader's own docstring +# names among the six it converted (review of #214), so that claim was false while it stood. It split EVERY +# row's p= on ',' — including a single row's — and a path containing ',' is never grouped (testmap.h +# refuses to, so that p= is one path, not a list), which turned such a row into two names that name nothing. +# The shared reader splits only a QUALIFIED group row and decodes entities, so the two helpers in this file +# can no longer disagree about what a row is. +tset(){ printf '%s' "$1" | python3 "$ROOT/test/testrowpaths.py" paths xml | sed 's|.*/||' | sort | tr '\n' ','; } cnt(){ printf '%s' "$1" | grep -oE 'tests="[0-9]+"' | head -1 | grep -oE '[0-9]+'; } # ── 1) change core.cpp → exactly the two tests that reach its symbols ──────────────────────────────── diff --git a/test/fixedbufsweep.sh b/test/fixedbufsweep.sh index f22c9a5a3..09dafdd39 100755 --- a/test/fixedbufsweep.sh +++ b/test/fixedbufsweep.sh @@ -166,7 +166,7 @@ TABLE = { ( "src/partition.h", "h" ): ( 2, "safe", "h[288] x2: ; role is the fixed 'core'/'slice' vocabulary, the rest %zu/%u/%d." ), ( "src/partition.h", "pb" ): ( 1, "safe", "pb[96]: the JSON part header; the %s is '' or ',' (the separator)." ), # ── src/prcontext.h ────────────────────────────────────────────────────────────────────────────────── - ( "src/prcontext.h", "tail" ): ( 1, "latent", "tail[256]: truncated=\"%s\" is ESCAPE-THEN-SNPRINTF in shape, but the value is bounded — kPrTrims[].dropped is a const table (longest 48 B) plus ';budget-floor-exceeded' (22 B), none of which escapes. Worst case 88 lit + 90 digits + 70 = 248 B + NUL against 256: SEVEN bytes of margin. A fifth trim level or one more attribute crosses it." ), + ( "src/prcontext.h", "tail" ): ( 1, "latent", "tail[320]: truncated=\"%s\" is ESCAPE-THEN-SNPRINTF in shape, but the value is bounded — kPrTrims[].dropped is a const table (longest 48 B) plus ';budget-floor-exceeded' (22 B) plus ';est-unmeasured' (15 B), none of which escapes, and the last two CAN co-occur (a small --max-tokens puts even the unmeasured empty-body envelope over budget). Worst case 88 lit + 90 digits + 85 label = 263 B + NUL against 320: 56 B of margin. WAS tail[256] at 248 B — SEVEN bytes — and the review of #214 spent 15 of them on est-unmeasured, which is the 'one more attribute crosses it' this row used to warn about; the buffer moved in the same commit as the label. formatTo was never the thing saving it: it truncates silently and its return is not read here, so an overrun drops the closing quote of truncated=\" and ships a malformed root (a G4 breach with no diagnostic). A sixth trim level or one more label needs this recomputed again." ), # ── src/quality.h ──────────────────────────────────────────────────────────────────────────────────── ( "src/quality.h", "tail" ): ( 2, "not-markup", "tail[96] in shaKeyedCachePath: the qsnap/qheadsnap cache FILENAME; family + two hex digests + %016llx, all fixed-width. tail[64] in rootKeyedCachePath: the lean/rich + mcp cache FILENAME, a literal prefix + the 16-hex root key + a literal suffix — every part a compile-time or fixed-width constant. Neither is emitted." ), # ── src/serialize.h ────────────────────────────────────────────────────────────────────────────────── @@ -431,7 +431,15 @@ if not bad: # format to derive a class from — the same reason nestAttr and escAttr are rows. mentions is +2 because # the comment on that buffer names formatTo as well; arch.h's third mention, its emit.h include line, # predates this change. The code it replaced wrote through emitRaw/emitTo, which this gate does not count. -EXPECTED = { "mentions": 323, "calls": 219, "sites": 219, "rows": 92, "widthforms": 0 } +# 2026-09-14 (review of #214, --pr-context's est-unmeasured disclosure): mentions 323 -> 324, +# calls/sites/rows/widthforms ALL UNCHANGED at 219/219/92/0 — re-read from `git diff` and not +# accepted from the delta. The single added line is a COMMENT, not a call: prBudgetTail's buffer +# grew 256 -> 320 for the new ';est-unmeasured' label (its TABLE row above carries the recomputed +# worst case, 248 -> 263 B, margin 7 -> 56) and the comment explaining the growth names formatTo, +# because formatTo's SILENT truncation is precisely what was not saving the old bound — it writes +# at most cap-1 and its return is not read there, so an overrun would have dropped the closing +# quote of truncated=" and shipped a malformed root. Same buffer, same one call, same one row. +EXPECTED = { "mentions": 324, "calls": 219, "sites": 219, "rows": 92, "widthforms": 0 } # 2026-09-04 (capture-audit L6, H9): +1 call/+1 mention, sites/rows UNCHANGED — re-read, not # re-counted. packConnect gained ONE snprintf into a new `char connectCeiling[32]` for the # H9 ` max_tokens="%d"` ceiling disclosure: a single %d of a caller-supplied INTEGER, no %s, diff --git a/test/prcontextcheck.sh b/test/prcontextcheck.sh index 71382d981..feadde9ba 100755 --- a/test/prcontextcheck.sh +++ b/test/prcontextcheck.sh @@ -463,6 +463,26 @@ if [ "$( grep -aoc '/dev/null || echo 0 )" = "0" ] && ! "$BIN" "$PRC_FIX" --pr-context="$PRC_FAULT_BASE" 2>/dev/null | grep -aq '"$TMP/f_ctl_legend.out" 2>/dev/null +if grep -aq 'est-unmeasured' "$TMP/f_ctl_legend.out"; then + no "(F-legend) an undegraded --pr-context document carries est-unmeasured — either a false disclosure or a clause charged to every reader who does not need it" +else + ok "(F-legend) an undegraded document neither claims est-unmeasured nor pays for its definition (label-gated, like E1's run clause)" +fi + if [ "$PRC_ALERTS" -eq 0 ]; then # NO-ALERT FLAVOUR (NDEBUG). The fault switch is `constexpr false` here and the alert macro is compiled # out, so this arm cannot exercise the degrade at all and must not pretend to: the PLAIN build is what @@ -491,6 +511,12 @@ if [ "$PRC_ALERTS" -eq 0 ]; then [ "${f_rel:-0}" -gt 0 ] \ && ok "(F4) the document carries $f_rel row(s) — the verb is intact on this flavour" \ || no "(F4) the document carries NO row over $PRC_FAULT_BASE" + # (F5) NO FALSE DISCLOSURE. Nothing degraded here (the switch is `constexpr false`), so the est-unmeasured + # label must be ABSENT: a truncation notice on a document that measured its own price would be the + # mirror-image defect of the silence it was added to end. + grep -ao 'truncated="[^"]*"' "$PRC_FAULT_OUT" | grep -aq 'est-unmeasured' \ + && no "(F5) the document claims est-unmeasured on a flavour where the render fault is not compiled in — a disclosure with nothing behind it" \ + || ok "(F5) truncated= carries no est-unmeasured where nothing was left unmeasured" elif ! grep -aq 'renderToString: the emitter THREW' "$PRC_FAULT_ERR"; then no "(F) INFRA_FAULT_RENDER_EMIT_THROW=1 produced no DEGRADED_PATH_ALERT on a binary that PROVED it can emit one (the charge-buffer probe alerted) — the seam regressed" else @@ -531,6 +557,84 @@ else else no "(F4) the degraded document carries $f_files row(s), FEWER than the control's $c_files — the degrade lost content, not just the charge" fi + # (F5) AND THE WRONG NUMBER IS NOW LABELLED. est_tokens= here is the price of an EMPTY body (the ladder + # priced the failed probe and broke at level 0), while the bytes served are the untrimmed floor — + # so the number is modelled, not this document's price. Before the fix the ONLY signal was the alert + # above, which Release compiles out; truncated= is the channel that survives the flavour. + f_trunc="$( grep -ao 'truncated="[^"]*"' "$PRC_FAULT_OUT" | head -1 )" + if printf '%s' "$f_trunc" | grep -aq 'est-unmeasured'; then + ok "(F5) the degraded root DISCLOSES the unmeasured price in truncated= ($f_trunc) — the one channel a Release binary keeps" + else + no "(F5) the degraded root prints an est_tokens priced from an empty body with no est-unmeasured in truncated= ($f_trunc) — a wrong number, silently (non-negotiable #3)" + fi + # (F6) and the label is DEFINED on the document that carries it — prbudgetcheck #10's rule for + # budget-floor-exceeded, applied to the label that now rides beside it. + grep -aq 'est-unmeasured means' "$PRC_FAULT_OUT" \ + && ok "(F6) the degraded document ships the legend clause that defines est-unmeasured" \ + || no "(F6) the degraded document emits est-unmeasured but its own legend never defines the term" +fi + +# ── (G) THE FINAL COPY THROWS: renderToString's last statement was outside its own contract ─────────── +# +# THE FINDING (review of #214, src/infra/emit.h:269). `out.text.assign( buf, sz )` is the one allocation on +# the SUCCESS path, and it sat after the try/catch that arm (F) proves. A std::bad_alloc from it therefore +# escaped renderToString — whose whole documented contract is that a failure is ALERTED and returned as +# ok == false, never thrown — and, jumping over the `std::free( buf )` two lines below, LEAKED the memstream +# buffer on the way out. A no-throw contract with a throwing last statement. +# +# Driven by INFRA_FAULT_RENDER_COPY_THROW=1, the twin of arm (F)'s switch and injected immediately before the +# assign, where a real bad_alloc would land. Same flavour dependence as (F): the switch and the alert both +# live only on the non-NDEBUG build, so the PLAIN-flavour leg is what proves the degrade and the NDEBUG leg +# asserts only what is true there — exactly the structure (F) documents. +PRC_COPY_OUT="$TMP/g_copy.out"; PRC_COPY_ERR="$TMP/g_copy.err" +INFRA_FAULT_RENDER_COPY_THROW=1 "$BIN" "$PRC_FIX" --pr-context="$PRC_FAULT_BASE" >"$PRC_COPY_OUT" 2>"$PRC_COPY_ERR" +prc_g_rc=$? +if [ "$PRC_ALERTS" -eq 0 ]; then + printf ' INFO (G) this binary emits no DEGRADED_PATH_ALERT (NDEBUG): the copy-throw degrade is unobservable BY DESIGN here, and the plain-flavour leg is what proves it\n' + if grep -aq 'renderToString: the final COPY' "$PRC_COPY_ERR"; then + no "(G) a binary that cannot emit the charge-buffer alert emitted the copy-throw one — the two disagree about this flavour" + else + ok "(G) consistency: no alert on a flavour that compiles them out" + fi + [ "$prc_g_rc" -eq 0 ] \ + && ok "(G1) --pr-context exits 0 with the (compiled-out) copy fault requested" \ + || no "(G1) --pr-context exited $prc_g_rc on a flavour where the copy fault is not even compiled in" + g_rel="$( grep -ao ' row(s) — the verb is intact on this flavour" \ + || no "(G2) the document carries NO row over $PRC_FAULT_BASE" +elif ! grep -aq 'renderToString: the final COPY' "$PRC_COPY_ERR"; then + no "(G) INFRA_FAULT_RENDER_COPY_THROW=1 produced no DEGRADED_PATH_ALERT on a binary that PROVED it can emit one (the charge-buffer probe alerted) — the copy is still outside the no-throw contract" +else + ok "(G) the final copy's throw is CAUGHT: the alert speaks instead of the exception escaping" + # (G0) the alert names the cause it HAD. degradeMsg says the BUFFER failed and (F)'s literal says the + # EMITTER threw; here neither did — the copy out of a complete buffer did. + { grep -aq 'open_memstream failed' "$PRC_COPY_ERR" || grep -aq 'the emitter THREW' "$PRC_COPY_ERR"; } \ + && no "(G0) the copy-throw alert blames the buffer or the emitter, neither of which failed on this path" \ + || ok "(G0) the copy-throw alert names the copy, not the buffer and not the emitter" + [ "$prc_g_rc" -eq 0 ] \ + && ok "(G1) --pr-context still exits 0 with every final copy throwing — the throw degraded instead of escaping" \ + || no "(G1) --pr-context exited $prc_g_rc with the copy fault injected — the throw escaped renderToString's no-throw contract" + if grep -aq '' "$PRC_COPY_OUT" && grep -aq ' root ($( wc -c <"$PRC_COPY_OUT" | tr -d ' ' ) B)" + fi + if command -v xmllint >/dev/null 2>&1; then + xmllint --noout "$PRC_COPY_OUT" 2>/dev/null \ + && ok "(G3) the degraded document is well-formed XML (G4 holds through the degrade)" \ + || no "(G3) the degraded --pr-context document does not parse — a degrade may not breach G4" + fi + # (G4) the same content-kept/estimate-lost contract arm (F) asserts for its flavour of failure: a failed + # copy is a failed MEASUREMENT, so the floor level streams straight out and the price is disclosed. + g_files="$( grep -ao ' row(s) — content kept, estimate lost" \ + || no "(G4) the degraded document carries NO row — the degrade lost the content it exists to keep" + g_trunc="$( grep -ao 'truncated="[^"]*"' "$PRC_COPY_OUT" | head -1 )" + printf '%s' "$g_trunc" | grep -aq 'est-unmeasured' \ + && ok "(G5) a failed copy is disclosed in truncated= too ($g_trunc) — one label for every unmeasured level" \ + || no "(G5) the copy-degraded root prints an unmeasured est_tokens with no est-unmeasured in truncated= ($g_trunc)" fi if [ "$fail" -eq 0 ]; then echo "ALL PASS"; exit 0; else echo "SOME CHECKS FAILED"; exit 1; fi diff --git a/test/testgatecheck.sh b/test/testgatecheck.sh index de37e4e3d..179b64118 100755 --- a/test/testgatecheck.sh +++ b/test/testgatecheck.sh @@ -51,8 +51,14 @@ printf 'int user() { return uncovered(); }\n' > "$R/src/user.cpp" run(){ perl -e 'alarm 15; exec @ARGV' "$BIN" "$R" "$@" --no-cache 2>/dev/null; } rc(){ perl -e 'alarm 15; exec @ARGV' "$BIN" "$R" "$@" --no-cache >/dev/null 2>&1; echo $?; } attr(){ printf '%s' "$1" | grep -oE "$2=\"[0-9]+\"" | head -1 | grep -oE '[0-9]+'; } -# basenames of the emitted test rows, sorted -tset(){ printf '%s' "$1" | grep -oE ' +# group row (testmap.h's E1 shape, emitted whenever two runner-less tests share their evidence) was skipped +# without a word. Measured on a two-runner-less-test fixture: this helper returned the EMPTY set where the +# shared reader returned both paths. Only the one-test fixture below kept the arms honest, which is a +# property of the fixture and not of the reader. +tset(){ printf '%s' "$1" | python3 "$ROOT/test/testrowpaths.py" paths xml | sed 's|.*/||' | sort | tr '\n' ','; } # names of the emitted untested rows, sorted uset(){ printf '%s' "$1" | grep -oE '` in XML, a `"p"` ARRAY in JSON, `[hops=2] (3): a, b, c` in the text dialect. The private readers did not fail; they went @@ -16,10 +16,21 @@ The invariant all six actually want is THE FILES NAMED, in emitted order. That is one question, so it is answered in one place, for every dialect, and a gate that adds a new assertion gets the group shapes for -free instead of re-deriving them. Two further gates read these rows and are NOT converted, because neither +free instead of re-deriving them. Three further gates read these rows and are NOT converted, because none asks for the paths: test/listingpagingcheck.sh sums `n=` over the rows to prove the family never pages, -and test/w3fixlegendcheck.sh counts path occurrences on a --situ line. Both were made group-aware in place -(E1) and stay that way — routing a COUNT through a path reader would only add a dialect hop. +test/w3fixlegendcheck.sh counts path occurrences on a --situ line, and test/testgatepagecheck.sh adds the +singles to the groups' `n=` to check shown_tests=. All three were made group-aware in place (E1) and stay +that way — routing a COUNT through a path reader would only add a dialect hop. + +THE CLAIM ABOVE WAS ONCE HALF TRUE, WHICH IS WHY THE CENSUS IS RECORDED HERE. The review of #214 found TWO +path readers still private: affectedcheck's own tset() (in the file this docstring already named — it split +EVERY row's p= on ',', including a single row's, so a comma-bearing path became two names that name nothing) +and testgatecheck's tset() (matched ` pin, handoffcheck's +run= check). deeptailcheck's `= len( doc ) or doc[j] != ":": + raise TestRowParseError( '"tests_to_run" is present but is followed by no ":" — not a field at all' ) + j += 1 + while j < len( doc ) and doc[j].isspace(): + j += 1 + if j >= len( doc ) or doc[j] != "[": + got = doc[ j:j + 12 ] if j < len( doc ) else "end of document" + raise TestRowParseError( '"tests_to_run" is present but its value does not open with "[" — not a list ' + 'at all (value begins %r)' % got ) + i = j depth = 0 instr = False esc = False diff --git a/test/testrowruncheck.sh b/test/testrowruncheck.sh index 223271360..432c182e6 100755 --- a/test/testrowruncheck.sh +++ b/test/testrowruncheck.sh @@ -574,5 +574,45 @@ e16='test/with space.cpp|test/two words.cpp|test/a b c.cpp|test/g one.cpp|test/g || no "(16) the shared reader is still silent where it should fail:$r16bad" +# ── ARM 17 — THE THIRD SILENCE: "tests_to_run" PRESENT BUT NOT A LIST (review of #214) ──────────────── +# json_list_slice found the key and then ran `doc.find( "[", i )` — an UNBOUNDED forward scan. So a document +# spelling `"tests_to_run":null` was not read as "the field is not a list"; the scan walked PAST the value, +# found the NEXT '[' anywhere in the document, and sliced THAT. Measured on the reader as it stood at +# c9d6d4e8: `{"tests_to_run":null,"other":[{"p":"ghost.cpp"}]}` returned ghost.cpp at rc=0 — a foreign +# field's paths served as the tests_to_run answer, which is the same species of defect arm 16 closed and one +# step worse, because the caller is handed rows rather than silence. The docstring already promised the +# raise ("is followed by no '[' — not a list at all"); only the code disagreed. +# +# The fix reads the value ADJACENTLY: past the key, a ':', optional whitespace, and then the very next +# character must be '['. Every non-array value is exit 2; a well-formed array still parses, which is what +# the controls below hold. +r17bad="" +# (a) every non-array value raises — and the decoy '[' that used to be sliced names a path that would be +# served as a test row. null, a number, a string and an OBJECT all take this leg. +for d17 in '{"tests_to_run":null,"other":[]}' \ + '{"tests_to_run":null,"other":[{"p":"ghost.cpp"}]}' \ + '{"tests_to_run":7,"other":[{"p":"ghost.cpp"}]}' \ + '{"tests_to_run":"nope","other":[{"p":"ghost.cpp"}]}' \ + '{"tests_to_run":{"a":[{"p":"ghost.cpp"}]}}'; do + o17="$( printf '%s' "$d17" | python3 "$ROWPATHS" paths json 2>/dev/null )"; o17rc=$? + { [ "$o17rc" -eq 2 ] && [ -z "$o17" ]; } \ + || r17bad="$r17bad [$d17 -> rc=$o17rc paths='$( printf '%s' "$o17" | tr '\n' ' ' )', want rc=2 and no rows]" + # the jsonlist mode shares the slicer, so it owes the same answer + printf '%s' "$d17" | python3 "$ROWPATHS" jsonlist >/dev/null 2>&1 + [ $? -eq 2 ] || r17bad="$r17bad [jsonlist swallows the same non-list value: $d17]" +done +# (b) the CONTROLS: a well-formed array still parses, whitespace between ':' and '[' is legal JSON, and a +# group row's nested array is still sliced by depth rather than by the first ']'. +c17="$( printf '%s' '{"tests_to_run":[{"p":"ok.cpp"}]}' | python3 "$ROWPATHS" paths json 2>/dev/null )"; c17rc=$? +{ [ "$c17rc" -eq 0 ] && [ "$c17" = "ok.cpp" ]; } \ + || r17bad="$r17bad [the well-formed control no longer parses: rc=$c17rc paths='$c17']" +w17="$( printf '%s' '{"tests_to_run" : [{"p":["a","b"],"n":2},{"p":"c"}]}' | python3 "$ROWPATHS" paths json 2>/dev/null | tr '\n' '|' )"; w17rc=$? +{ [ "$w17rc" -eq 0 ] && [ "$w17" = "a|b|c|" ]; } \ + || r17bad="$r17bad [whitespace around the ':' must stay legal: rc=$w17rc paths='$w17']" +[ -z "$r17bad" ] \ + && ok "(17) a present-but-not-a-list tests_to_run is exit 2, never a slice of the NEXT field's array — the value is read adjacently" \ + || no "(17) the slicer still scans past its own field:$r17bad" + + [ "$fail" = 0 ] && echo "ALL PASS" || echo "FAILURES ABOVE" exit "$fail"