diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 2cbd9de56..68aa9459f 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -5,6 +5,8 @@ # - No re-review on every push: each fix commit used to start a new pass over a new diff. # - No automatic reply inside review threads: every in-thread answer used to wake another pass. # - Vendored parsers and generated/pinned files are excluded; a review of them cannot change them. +# - Gate fixture trees (test/*fix/) are excluded: they are inputs, and a train carrying many of them +# went over the 300-file review limit (the whole review was skipped). # - No docstring-coverage pre-merge check: the house style documents contracts where they bite, not every symbol. # CodeRabbit reads this file from the PR's head branch, so a fork PR picks it up once it merges main. reviews: @@ -17,6 +19,7 @@ reviews: - "!docs/captures/**" - "!test/*.hash" - "!test/printf_parity.manifest" + - "!test/*fix/**" # gate fixture trees (inputs a gate parses); keeps a train PR under the 300-file review limit path_instructions: - path: "src/**" instructions: >- diff --git a/.github/pargates-shard-weights.json b/.github/pargates-shard-weights.json index 46492fcb1..f16c8b459 100644 --- a/.github/pargates-shard-weights.json +++ b/.github/pargates-shard-weights.json @@ -93,6 +93,7 @@ "constcheck.sh": 2.7, "contextratiocheck.sh": 2.8, "coplintcheck.sh": 0.9, +"countfloorcheck.sh": 14.0, "cppbenchcheck.sh": 170.6, "cppoperatorcheck.sh": 5.5, "cppqualcheck.sh": 36.1, @@ -112,6 +113,7 @@ "deckclaimcheck.sh": 0.1, "deeptailcheck.sh": 1.0, "defaultceilingcheck.sh": 20.1, +"defectshapecheck.sh": 14.2, "defoverdeclcheck.sh": 1.4, "degradedhintcheck.sh": 2.5, "dependencypincheck.sh": 2.0, @@ -197,6 +199,7 @@ "forrankordercheck.sh": 77.9, "forrootlegendcheck.sh": 8.6, "forsectioncollapsecheck.sh": 5.2, +"forsigspancheck.sh": 6.0, "freshclonecheck.sh": 170.8, "freshnesscheck.sh": 4.4, "g1configcheck.sh": 0.8, @@ -209,6 +212,7 @@ "gitstampcheck.sh": 84.3, "goinstcheck.sh": 0.2, "gointerfacecheck.sh": 0.4, +"gokindcheck.sh": 0.6, "graphlegendbudgetcheck.sh": 2.5, "graphqueryrefusecheck.sh": 1.9, "grepandcheck.sh": 8.8, @@ -386,6 +390,7 @@ "pargatescheck.sh": 8.9, "parsehealthcheck.sh": 1.9, "partitioncheck.sh": 7.2, +"pathgapcheck.sh": 1.5, "patterncheck.sh": 5.0, "perfharnesscheck.sh": 8.6, "phpcheck.sh": 2.4, @@ -463,6 +468,7 @@ "recalltablecheck.sh": 0.2, "recalltotalcheck.sh": 24.7, "receiptpostcheck.sh": 20.4, +"receiverevidencecheck.sh": 36.0, "redactcheck.sh": 2.2, "redactfixcheck.sh": 0.3, "refusaltailcheck.sh": 3.4, diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2ea1ffb85..177f332dd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -723,6 +723,16 @@ jobs: - name: Sanitizer-only class — crashsweepcheck far-future timestamp and fixed-name shapes (test/crashsweepcheck.sh) run: RIPWIRE_BIN=asan/ripwire RIPWIRE_ASAN_BIN=asan/ripwire bash test/crashsweepcheck.sh + # A lifetime class only a sanitizer shows: one long-lived `--mcp` session rebuilt in place (a chmod, a new non-source + # file, an edit, a root switch, a memory release) and queried again each time. A cached ValueRefIndex once outlived + # the index it pointed into (heap-use-after-free) and no single-query gate could see it. The gate drives that session + # on this binary for 20 cycles and checks every answer against a one-shot CLI run; one binary plays both roles. + # LeakSanitizer is off in this step on BOTH legs (the gate sets detect_leaks=0 for its own processes; its subject is + # lifetime, not leaks, and the Linux leg has not yet shown a clean exit-time leak set for it). + # A failure leaves the gate's sanitizer reports in /tmp/asanlog-mcpreload.* for the upload step below. + - name: Sanitizer-only class — MCP index reloads under ASan (test/mcpreloadasancheck.sh) + run: RIPWIRE_BIN=asan/ripwire bash test/mcpreloadasancheck.sh + - name: det-gate — 2-run byte-identical diff (ASan binary) run: | ./asan/ripwire test/fixture --no-cache >/tmp/run_a.xml diff --git a/.ripwire_quality_acks b/.ripwire_quality_acks index fff1468da..e029c5a56 100644 --- a/.ripwire_quality_acks +++ b/.ripwire_quality_acks @@ -83,6 +83,7 @@ ack api-surface 5c2c4a2b311b8dba 4 cid=264993eba8c648bd C1 F-06/F-07/F-10 (the l ack api-surface 5e5cc30bcbc1fb63 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 5ec38fbd414fa4d4 7 cid=6f20993f1b475898 M13 paging/budget parity: each of these nine gained exactly ONE trailing DEFAULTED parameter (an McpPageArgs window, a token budget, or the legend posture) so its MCP twin can honor the flag its CLI twin already honors. Additive by construction - every pre-existing call site compiles unchanged and was verified to - and the alternative, a second overload per verb, is the clone seam this repo removes rather than adds. ack api-surface 5f7ae7ac9bbef3c5 5 cid=8662c6730ed46798 now=5 was=4 facet=contract-change p=src/darkflags.h:522 0.6.6 D5 review B1: forEachCodeByte gains the per-file TemplateStack (JS/TS template literals span lines); its one caller, harvestEnvReads, is updated in the same commit +ack api-surface 610cc771fd6f10c1 5 cid=86df6969a0d891fb now=5 was=4 facet=contract-change p=src/verbs_grep.h:105 lane count-floor-068 (4a1bbc82): emitGrepEncRows takes the page's enc rows and their floors (computed once by emitGrepReport before the legend, so the full dialect defines callers_floor= only when a row carries it) instead of the raw hits; its one caller, emitGrepReport in the same file, updated in the same commit ack api-surface 61e5df9e1e40ff70 5 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 api-surface 6302e2e27e23bcde 6 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 api-surface 6483016e50837e70 3 cid=4c53bf4b5b7d4a0c degrade disclosure: each read seam and the ref pool now take the DISCLOSE sink whose field the document reads @@ -396,13 +397,16 @@ ack complexity 1cdbcf525f9e7871 60 cid=4a92ba4004e888e5 lane/tc-sliceat: the --a ack complexity 1da01868deceb731 21 cid=bce36ef4a75ab3fb 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 complexity 1e9c684ddd70ed1f 27 cid=f3502fc85213b238 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 complexity 205bde7df6b15388 26 cid=a790a90cc086ddef 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 complexity 20b8113908960e36 40 cid=c93e456ce00ceaec 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 20b8113908960e36 52 cid=8333a4060e6ba0f6 now=52 was=48 p=src/verbs_navigate.h:2095 lane FE-B (receiver evidence, via="name"): the growth is the hedge's own surfaces — memberOnlyReceiver/receiverOf read the receiver chain (memberPath/memberCtor) every language's member call now records; emitColumnarSymbolRows, emitCalleeCallsBlock and MCP pathText emit via="name" and its legend sentence only when a row carries it; packBodies/packHops take the per-edge hedge bits and the legend-charged flag (one parameter each, every caller updated). Each is gate-pinned by test/receiverevidencecheck.sh (CLI/MCP parity arms); splitting the emitters is a joint map-rows lane after train 26b (owner 2026-10-07), which redesigns these rows | 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 218041bccb277b92 76 cid=69bc85c464d51fa1 lane B1 cap disclosure: the census IS the +3. The raw-token window moved out of the while condition so the scan reaches the end of the task text and counts every qualifying token, which is what makes mention_tokens_total= an exact number instead of a bare 'something was dropped'. The kept set is unchanged and the scan is bounded by one sentence of task text. ack complexity 231d5965c5d8503b 52 cid=0ecc0b97fecbcc72 P4.1 grep fast path — the mechanical footprint of threading two modes through one call chain, and nothing else. FOUR api-surface contract-change rows: spanTiersOfFiles (decl+def), grepApplySpanTiers and emitGrepReport each gained exactly ONE parameter, defaulted where it has more than one caller, so every existing call site is unchanged; RIPWIRE_BASE= argvdiffcheck reports 607/610 argv vectors byte-identical (the 3 diffs are the disclosed --version sha stamp and --run-trace duration_ms), and test/grepfastcheck.sh arm 6 byte-compares the whole --grep option matrix against the same base binary. TWO short-horizon-churn churn=self rows on the same two symbols: the footprint of having edited them in this window, not new debt. ONE complexity row, spanTiersOfFiles 49 to 52: the memo consult is a single branch inside the per-file worker, at the nesting that worker already had; extracting the whole worker body would be a refactor of the pre-existing tree-sitter parse path and was deliberately NOT bundled into a change whose claim is byte-identical output. What was FIXED rather than acked in this same pass: the duplication row (spanTierMemoPath now reuses quality.h's shaKeyedCachePath/headSnapRepoHex/exclConfigHex composition instead of a fourth hand-rolled name builder), main's complexity and verbosity rows (the prefetch launch/join moved into verbs_grep.h seams), and spanTiersOfFiles' verbosity row (prose moved out of the body onto the seams). ack complexity 240fa4dc89f6442d 23 cid=ca5968a9693b8433 churn=self on the three symbols that ARE the ack-ledger format. P1.4 adds one optional named token (by=) to that format, so these are exactly the sites a format change must touch. ack complexity 2434bf5574dc575b 16 cid=e3c081715b00762c now=16 was=0 p=src/quality.h:5813 lane/ack-provenance-backfill: the legacy-ack backfill (clone kinds reconstructed from the current tree, marked prov=recon), its refresh/unverified freshness rule, and qackconcurrencycheck arm 9 — plus this lane's own minor footprint on the ack reader/writer it extends +ack complexity 2570a290e1506158 33 cid=86af5bb8f2dd4ec2 now=33 was=0 p=src/receiverevidence.h:687 lane FE-B (receiver evidence, via="name"): the growth is the hedge's own surfaces — memberOnlyReceiver/receiverOf read the receiver chain (memberPath/memberCtor) every language's member call now records; emitColumnarSymbolRows, emitCalleeCallsBlock and MCP pathText emit via="name" and its legend sentence only when a row carries it; packBodies/packHops take the per-edge hedge bits and the legend-charged flag (one parameter each, every caller updated). Each is gate-pinned by test/receiverevidencecheck.sh (CLI/MCP parity arms); splitting the emitters is a joint map-rows lane after train 26b (owner 2026-10-07), which redesigns these rows ack complexity 2abd26fdd3d1e6df 66 cid=80501bdf3db6fba6 by=src/* lane/n2-i punch-list round (2026-09-02), F-04/F-07/F-09/F-14/F-16/F-17 fixes: computePlanLint +4cx/+15LOC is the stat()-before-open non-regular-file guard (F-09, a directory used to lint as a clean empty plan); runEditVerb +4cx/+20LOC is the CRLF-target payload-harmonization branch plus the replaced_bytes/file_eol/eol_normalized receipt fields (F-07/F-16); computeDocDrift +10LOC is the filter-matched-nothing early refusal (F-04, mirrors --scope/--dead-code's own filter refusal); runPlanLint churn=self is this same round's one-line message-specialization edit. Each is gate-covered red-first in test/docdriftcheck.sh §7, test/planlintcheck.sh §5b and test/mcpeditcheck.sh §1/§1b; no unrelated logic rides in. Full detail in LANE_REPORT.md. | prior: 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 complexity 2b0d39faec60fc08 20 R-R root-relative emission lane: threading the corpus root into 8 emitters is the change itself — +1 param each (contract-change), +3 cx from each pathRel lambda, and the verbosity of the relativization plus its comments. Reviewed row by row; none is avoidable without abandoning root-relative emission. Storage keys unmoved (baseline + ack ledger byte-identical across the cure). +ack complexity 2bea60141008e4d0 50 cid=0f6117d2b7b8058c now=50 was=0 p=src/receiverevidence.h:261 lane FE-B (receiver evidence, via="name"): the growth is the hedge's own surfaces — memberOnlyReceiver/receiverOf read the receiver chain (memberPath/memberCtor) every language's member call now records; emitColumnarSymbolRows, emitCalleeCallsBlock and MCP pathText emit via="name" and its legend sentence only when a row carries it; packBodies/packHops take the per-edge hedge bits and the legend-charged flag (one parameter each, every caller updated). Each is gate-pinned by test/receiverevidencecheck.sh (CLI/MCP parity arms); splitting the emitters is a joint map-rows lane after train 26b (owner 2026-10-07), which redesigns these rows +ack complexity 305daad7cbc4548c 24 cid=b80c4dd4359061a0 now=24 was=0 p=src/receiverevidence.h:176 lane FE-B (receiver evidence, via="name"): the growth is the hedge's own surfaces — memberOnlyReceiver/receiverOf read the receiver chain (memberPath/memberCtor) every language's member call now records; emitColumnarSymbolRows, emitCalleeCallsBlock and MCP pathText emit via="name" and its legend sentence only when a row carries it; packBodies/packHops take the per-edge hedge bits and the legend-charged flag (one parameter each, every caller updated). Each is gate-pinned by test/receiverevidencecheck.sh (CLI/MCP parity arms); splitting the emitters is a joint map-rows lane after train 26b (owner 2026-10-07), which redesigns these rows ack complexity 31105421c3ce88ae 47 cid=86626441189f2998 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: L10: --doctor legend + blobs_floor= disambiguation (finding 6) — DoctorCacheStats gains capHit, doctorCacheStats sets it, runDoctor emits blobs_floor= and the new legend comment; short-horizon-churn and the verbosity bump are the direct, deliberate cost of that ack complexity 3298be65bf6058ef 408 cid=dbfe71c5e9704f93 2026-09-06 stranger-audit rows 8-12: the two refusals (unreadable root, --cache=) are two guarded calls in dispatchMain; stripHomePair's churn is the two-segment envelope itself | prior: M1: self-churn on the four symbols this change edits — printUsage (the --legend help text now states the MCP default), kMcpValueFields (the legend field description, the one place every declaring tool's schema states the posture), packTaskBundleText (dropped_positive moved from the ledger prose to the ctx root) and dispatchMain (--batch --legend=compact now compacts its sub-answers). Short-horizon churn measures edit recency, and these are the edits. ack complexity 3561d0281d324276 99 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. @@ -414,9 +418,14 @@ ack complexity 3ba0d3f31d319672 24 cid=e5a559aa3504947e at-seed decision round ( ack complexity 3be1c13661e5a63c 20 pack-task budget round (verifier K1+K2, 2026-08-19): +4 ccx / +14 LOC in packTaskBundleText is the section REORDER (bodies allocated last, after the four fine-grained prefix sections) plus the one-shot reflow lap's ranking branch; the three list-section top-ups were factored into reflowListSection rather than inlined, which is why the delta is this small. selectMonotoneBodySubset +1 ccx / +8 LOC is the one early return that admits the top-ranked candidate at every pool. churn=self on both is this change's own edit window. Measured payoff on this repo, --pack-task=rank the call graph: callers 13/20 to 20/20 and fill 52.5% to 59.8% at the default 6000-token budget, 43.5% to 90.6% at 8000; the task-named body now survives every budget increase. Both properties gated red-first in test/packtaskmonotoncheck.sh ack complexity 3c07d993bfdbce53 77 cid=d0076087db5b1b9b 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 | prior: or-arise rung 2 (--slice-flow/--slice-depth): the ten gating rows are this lane's own flag-addition footprint and nothing foreign — printUsage/validateConfig grow the two new help entries and three refusal arms every modifier flag must add; sliceBundleText/sliceWalk grow the flow legend+rows and the all-occurrence output param (contract kept source-compatible via defaulted flowSpec); runSlice grows the seed-VAR refusal and flow wiring; per-symbol growth is the cost of the registered rung-2 contract in EVALS ack complexity 3d87404c1cdf50ec 27 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 3dfd850df2b251c4 42 cid=bcd12702a7b47ec5 now=42 was=41 p=src/nonlocalstate.h:559 lane FE-B (receiver evidence, via="name"): the growth is the hedge's own surfaces — memberOnlyReceiver/receiverOf read the receiver chain (memberPath/memberCtor) every language's member call now records; emitColumnarSymbolRows, emitCalleeCallsBlock and MCP pathText emit via="name" and its legend sentence only when a row carries it; packBodies/packHops take the per-edge hedge bits and the legend-charged flag (one parameter each, every caller updated). Each is gate-pinned by test/receiverevidencecheck.sh (CLI/MCP parity arms); splitting the emitters is a joint map-rows lane after train 26b (owner 2026-10-07), which redesigns these rows +ack complexity 426a1b33e60ac8f4 27 cid=f38b11013db6caeb now=27 was=0 p=src/receiverevidence.h:452 lane FE-B (receiver evidence, via="name"): the growth is the hedge's own surfaces — memberOnlyReceiver/receiverOf read the receiver chain (memberPath/memberCtor) every language's member call now records; emitColumnarSymbolRows, emitCalleeCallsBlock and MCP pathText emit via="name" and its legend sentence only when a row carries it; packBodies/packHops take the per-edge hedge bits and the legend-charged flag (one parameter each, every caller updated). Each is gate-pinned by test/receiverevidencecheck.sh (CLI/MCP parity arms); splitting the emitters is a joint map-rows lane after train 26b (owner 2026-10-07), which redesigns these rows ack complexity 44b41056b999b501 33 R-R root-relative emission lane: threading the corpus root into 8 emitters is the change itself — +1 param each (contract-change), +3 cx from each pathRel lambda, and the verbosity of the relativization plus its comments. Reviewed row by row; none is avoidable without abandoning root-relative emission. Storage keys unmoved (baseline + ack ledger byte-identical across the cure). ack complexity 44b4c7b78ee480d6 19 cid=6da24121cdbb52f4 capture-audit 2026-09-04 wave-1 close, lane L5 H7 (lane-L5.md §2): enumerateRefs counts DECLARED ref names matching the --stray-content filter before the HEAD-is-not-stray exclusion, so an empty SELECTION refuses while a real zero stays an answer — the +4 complexity is that count and its guard ack complexity 45b52ada32f63c11 195 cid=e9e4c8dd62e800e6 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 complexity 45bb9f7c5bc37916 33 cid=c8ed741b38b4c0a9 now=33 was=31 p=src/serialize.h:6655 lane FE-B (receiver evidence, via="name"): the growth is the hedge's own surfaces — memberOnlyReceiver/receiverOf read the receiver chain (memberPath/memberCtor) every language's member call now records; emitColumnarSymbolRows, emitCalleeCallsBlock and MCP pathText emit via="name" and its legend sentence only when a row carries it; packBodies/packHops take the per-edge hedge bits and the legend-charged flag (one parameter each, every caller updated). Each is gate-pinned by test/receiverevidencecheck.sh (CLI/MCP parity arms); splitting the emitters is a joint map-rows lane after train 26b (owner 2026-10-07), which redesigns these rows +ack complexity 47a961ce3b905fea 26 cid=02e812a08cd1c540 now=26 was=0 p=src/ingest_binds.h:127 lane FE-B (receiver evidence, via="name"): the growth is the hedge's own surfaces — memberOnlyReceiver/receiverOf read the receiver chain (memberPath/memberCtor) every language's member call now records; emitColumnarSymbolRows, emitCalleeCallsBlock and MCP pathText emit via="name" and its legend sentence only when a row carries it; packBodies/packHops take the per-edge hedge bits and the legend-charged flag (one parameter each, every caller updated). Each is gate-pinned by test/receiverevidencecheck.sh (CLI/MCP parity arms); splitting the emitters is a joint map-rows lane after train 26b (owner 2026-10-07), which redesigns these rows +ack complexity 48768a385100895f 46 cid=5cbec57476270c83 now=46 was=44 p=src/verbs_for.h:3595 lane FE-B (receiver evidence, via="name"): the growth is the hedge's own surfaces — memberOnlyReceiver/receiverOf read the receiver chain (memberPath/memberCtor) every language's member call now records; emitColumnarSymbolRows, emitCalleeCallsBlock and MCP pathText emit via="name" and its legend sentence only when a row carries it; packBodies/packHops take the per-edge hedge bits and the legend-charged flag (one parameter each, every caller updated). Each is gate-pinned by test/receiverevidencecheck.sh (CLI/MCP parity arms); splitting the emitters is a joint map-rows lane after train 26b (owner 2026-10-07), which redesigns these rows ack complexity 49ea9954a4e77a06 67 cid=638312475b7a6935 round ec5e3c3..HEAD, lane L7 P3/P8/P12 (nextverbcheck, qualitycheck/testgatecheck/dmmcheck bar= arms, grep re-pins) on top of wave-1/2 L4 M15 gauge + L9 root= + L8 receipts: every enumerated root carries next=, numeric quality rows carry bar=, test-gate ccx_bar=, grep's wrapper gone — each writer grew one clause per contract, measured and cut ack complexity 4b309450f25c2b44 722 markdown section tier (mdsectioncheck, kParserVer 63): extractMarkdown grew from a 1-line-heading line scanner into the tree-sitter section extractor (spans+hierarchy+links) — its cx/verbosity/params growth IS the feature, reviewed; ingest gains the md dispatch + the mdNestsTooDeep OOB guard (yaml posture); buildRecall gains the section-granular body path (disclosed [sections: note]); doctorProbeGrammars gains the parse-probe row for the no-tags.scm grammar (helper split out same commit); kLangTable/kParserVer/printUsage/quality.h-mirror churn=self is this tier's own edit window. 94-arm mdsectioncheck green both flavours, pargates 396 green, sequential regression 417 green, repo-wide ASan+LSan clean ack complexity 4b8467d6559779e4 22 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 @@ -427,19 +436,20 @@ ack complexity 52c2e3e32753dd28 161 cid=32fe671bf177bea8 Review of #214, round 2 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). +ack complexity 569699e57b2a3d87 16 cid=6c5914500a133567 now=16 was=13 p=src/ingest_binds.h:2370 lane FE-B (receiver evidence, via="name"): the growth is the hedge's own surfaces — memberOnlyReceiver/receiverOf read the receiver chain (memberPath/memberCtor) every language's member call now records; emitColumnarSymbolRows, emitCalleeCallsBlock and MCP pathText emit via="name" and its legend sentence only when a row carries it; packBodies/packHops take the per-edge hedge bits and the legend-charged flag (one parameter each, every caller updated). Each is gate-pinned by test/receiverevidencecheck.sh (CLI/MCP parity arms); splitting the emitters is a joint map-rows lane after train 26b (owner 2026-10-07), which redesigns these rows ack complexity 572dbeee3b452cbc 83 cid=4e4f42aee136bfe3 by=src/* 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 complexity 574641dcc1bdf0ec 110 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 59f050855874875f 28 cid=79ec141006215e25 by=src/* P9 the folded edit receipt: runEditVerb gains the postCheck opt-out parameter (defaulted true, so every existing call site is source-compatible) and the line-range + post-check splice; editplan's ensureStage and receipt each gain the root parameter they need to spell one identity (M12's root-relative rule, applied to the sibling it missed) and to run the per-op post-check. The +15 LOC in runEditVerb is the copy-out-before-the-index-rebuild discipline the fold requires — every reference into ing dangles once getIndex re-ingests, and that is stated in the code. | prior: M12 (lane L9): the minor half of the same change — runEditVerb/fetchBody each gain ONE single-root ternary plus the comment naming why the display path and the disk path may now differ, and testmap.h's ctor is ambient churn from the sibling edits in the same file. -ack complexity 5b224c7fe142bd56 322 cid=a785260560dbe199 by=src/* T14: deriveForConfidence gains homonymDecline (bool, no default — every call site updated in this same commit): confidence= must not claim high on a cut declined for a large name-exact homonym pool, and the flag has to reach both callers (CLI --for and MCP for) that already independently call this function with their own adaptiveCut result. No default was given deliberately, so a future call site cannot forget it silently. The one minor complexity row on runForLens (317->322) is the decline branch inline in the existing --adaptive if/else. | prior: round ec5e3c3..HEAD, the task lenses across V1 N1 (est_tokens on the root), L10b (route= trim, doc_mentions=), L6 (budget_tokens=), V2 F2/F5/F6 (over_ceiling on every rung, smallest-ceiling rule, route bracket) and L7 P3/P10 (r=1 next=, one outer partition legend): five lanes grew runForLens/packTaskBundleText/fromTraceBundleText/computeLensRanking past each other's acked magnitudes +ack complexity 5b224c7fe142bd56 328 cid=75447556b5ecaf1d by=src/* now=328 was=322 p=src/verbs_for.h:2249 lane FE-B (receiver evidence, via="name"): the growth is the hedge's own surfaces — memberOnlyReceiver/receiverOf read the receiver chain (memberPath/memberCtor) every language's member call now records; emitColumnarSymbolRows, emitCalleeCallsBlock and MCP pathText emit via="name" and its legend sentence only when a row carries it; packBodies/packHops take the per-edge hedge bits and the legend-charged flag (one parameter each, every caller updated). Each is gate-pinned by test/receiverevidencecheck.sh (CLI/MCP parity arms); splitting the emitters is a joint map-rows lane after train 26b (owner 2026-10-07), which redesigns these rows | prior: T14: deriveForConfidence gains homonymDecline (bool, no default — every call site updated in this same commit): confidence= must not claim high on a cut declined for a large name-exact homonym pool, and the flag has to reach both callers (CLI --for and MCP for) that already independently call this function with their own adaptiveCut result. No default was given deliberately, so a future call site cannot forget it silently. The one minor complexity row on runForLens (317->322) is the decline branch inline in the existing --adaptive if/else. ack complexity 5d8f5288f0df10f7 191 cid=f31f8078003db35b capture-audit 2026-09-04 lane L1 (flag-table universe), the seven gating rows of this lane's own five commits, each the documented cost of the guard/table it lands: parseArgs +4 cx is the --exclude= empty-value guard, one branch in the hand-written residue, the exact shape of its --and=/--not= siblings (M6); validateConfig +4 cx/+17 LOC is the bare --quality-ack pairing refusal plus the =REASON implication moved out of the parser arm (H10); validateModifierGuards +4 cx/+21 LOC is two pairing guards (--naming-locals refuse, --no-stable notice) in the helper every prior guard grew the same way (M16); kShapingVerbs 19->62 LOC is the ledger of every verb outside honorsPaging — 36 rows for 36 verbs, a table is its length, and test/shapingflagcheck.sh arm (F) is what stops it rotting (H3); main +11 LOC is the --json check moved ahead of the CLI edit bridge plus one call to refuseInertMainModifiers (H2/M16) — the two guards themselves and jsonUnsupportedVerb's allow-list walk live in their own helpers. Gates for all five written red first; battery green at this head. churn=self rows on jsonUnsupportedVerb/kViewFlags/writeAckRecords are this lane's own rewrites and are invisible in ref-pair mode by construction. | prior: R-H span tiers (2026-08-19 wave-3 lane, harvest R-H / experiment E5). The nine gating rows are ONE change, read line by line before acking. (1) api-surface grepHitsJson 3->4 params + verbosity: the MCP grep verb takes the span-tier MODE, because the escape hatch has to exist on the MCP surface too — an MCP-only agent that reads suppressed_comment= has no CLI to re-ask from; deliberate contract-change. WAVE-3 VERIFIER CORRECTION (P6-1): this reason originally read 'both callers updated in the same commit' and that was FALSE - src/mcpverbs.h's batch arm still took the defaulted GrepIn::Code and read no 'in' field at all, so the hatch was closed on the ONE surface that had no CLI fallback. Closed in the wave-3 fix lane: both callers now read the value through the same closed-value reader (mcpverbs.h::grepInModeFromArg), 'in' is a declared kBatchSubQueryFields member, and greptiercheck arms (9b)/(9c) pin the batch hatch and its refusal. (2) parseArgs +6 cx / +14 LOC and dispatchMcpLine +3 cx: one new closed-value flag arm (--grep-in=code|any) and its MCP twin, the same shape --grep-scope= added; a flag cannot be added to a hand-rolled parser without them. (3) churn=self on emitGrepReport / grepHitsJson / measure_set: this change's own edit window, not a history signal. (4) emitGrepReport +20 LOC / grepHitsJson +14 LOC: the filter call plus its wiring — the six conditional appends and the legend clause were already lifted into grepTierAttrs/grepTierLegend/grepTierKeys (the grepUnindexedAttrs/grepUnindexedKeys pattern), which is why the COMPLEXITY regressions on both are gone. Nothing here is a shortcut: the tier policy lives in search.h::grepApplySpanTiers and the parse in ingest.cpp::spanTiersOfFiles, both new symbols with their own gate (test/greptiercheck.sh - 30 arms at the wave-3 fix-lane head, 18 FAIL on the clean adb0831 pre-lane binary, 0 here; this text read '22 arms, 12 red', written against an earlier revision of the gate and never refreshed - WAVE-3 VERIFIER CORRECTION P6-7, and an ack's reason is the artifact a future reader trusts instead of re-deriving). ack complexity 5ec38fbd414fa4d4 56 cid=35883c203ba33e28 lane/tc-sliceat MCP half: dispatchMcpLine +9cx/+21LOC is the per-verb dispatch cost every advertised tool pays (the var/flow/depth arg reads, the depth band static_assert, and the one slice branch); the verb logic itself lives in mcpverbs.h sliceText (new-symbol, mirrors the CLI runSlice refusal-for-refusal with sliceBundleText as the one shared emitter). Gate-covered red-first in test/mcpslicecheck.sh ack complexity 5f7ae7ac9bbef3c5 33 cid=8662c6730ed46798 now=33 was=30 p=src/darkflags.h:522 0.6.6 D5 review B1: forEachCodeByte gains the per-file TemplateStack (JS/TS template literals span lines); its one caller, harvestEnvReads, is updated in the same commit ack complexity 61e5df9e1e40ff70 19 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 complexity 639de1c3670999f9 69 cid=bbf7c6fe9dbbe217 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) | 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 685ade09a168535e 30 T1 completeness claims (complete= on grep/whereis): the +1 on streamBlobs is the deliberate DEFAULTED StreamBlobStats* param (null-object sink inside, no per-site null test; every existing caller byte-identical) so whereis can prove its scan exhaustive before claiming; cx/LOC on streamBlobs/computeWhereis/writeWhereisPage/emitGrepReport is the claim computation plus its in-band legend (the honesty text IS the feature); churn=self on those plus grepCollect/dispatchMcpLine is this lane own edit window. Gated red-first by test/completecheck.sh (24 arms, 10 red pre-fix; mutation arms force cap/offset/budget/unreadable-file/regex-mode/oversized-blob and assert the attribute VANISHES); full plain suite green, 21 touched-family gates green under ASan+LSan, determinism x3, xmllint clean -ack complexity 6979fa794f2a5154 389 cid=9132efbcc6dfcbb7 now=389 was=386 p=src/ingest_sidecap.h:1706 fn-literal bodies: captureTagsFacts gains the defBodyNodeOf call that hands a literal-bound name its literal (the @name and Lang ride along) and reads params/cx from it: a +3 cx / +3 verbosity call site at the def-extraction seam | prior: ruby-attr class-DSL lane (review round; kParserVer 119->120): duplication/new-clone elixirArguments|elixirTarget is token-shape coincidence only - elixirTarget is the shared fieldIdentifierText primitive plus a null guard, elixirArguments reads a different field with no identifier gate; no shared logic to lift. captureTagsFacts +1cx/+4verb is the two-line gated call site of the inline-visibility lift. +ack complexity 6979fa794f2a5154 416 cid=027d9c38f3666ebb now=416 was=415 p=src/ingest_sidecap.h:1732 lane FE-B (receiver evidence, via="name"): the growth is the hedge's own surfaces — memberOnlyReceiver/receiverOf read the receiver chain (memberPath/memberCtor) every language's member call now records; emitColumnarSymbolRows, emitCalleeCallsBlock and MCP pathText emit via="name" and its legend sentence only when a row carries it; packBodies/packHops take the per-edge hedge bits and the legend-charged flag (one parameter each, every caller updated). Each is gate-pinned by test/receiverevidencecheck.sh (CLI/MCP parity arms); splitting the emitters is a joint map-rows lane after train 26b (owner 2026-10-07), which redesigns these rows | prior: fn-literal bodies: captureTagsFacts gains the defBodyNodeOf call that hands a literal-bound name its literal (the @name and Lang ride along) and reads params/cx from it: a +3 cx / +3 verbosity call site at the def-extraction seam ack complexity 6a38d70787d09e64 28 cid=f6a0c09dfc153ece Kotlin port (PR #126): the leading whitespace sample is walked by codepoint (jsonesc::utf8SeqLen) so invalid UTF-8 folds into errNodes/errBytes, deduplicated against the ERROR spans tree-sitter recovery already counted; without it a garbage byte run swallowed by recovery reads as a healthy file in --skipped. -ack complexity 6e58b0a307757079 85 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 6e58b0a307757079 106 cid=cd68c661a9c12758 now=106 was=100 p=src/serialize.h:8704 lane FE-B (receiver evidence, via="name"): the growth is the hedge's own surfaces — memberOnlyReceiver/receiverOf read the receiver chain (memberPath/memberCtor) every language's member call now records; emitColumnarSymbolRows, emitCalleeCallsBlock and MCP pathText emit via="name" and its legend sentence only when a row carries it; packBodies/packHops take the per-edge hedge bits and the legend-charged flag (one parameter each, every caller updated). Each is gate-pinned by test/receiverevidencecheck.sh (CLI/MCP parity arms); splitting the emitters is a joint map-rows lane after train 26b (owner 2026-10-07), which redesigns these rows | 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 7051b3950aaf4c14 35 cid=7919a3d486bba416 lane B1 cap disclosure: doc_mentions_capped= is contracted to be PROVABLE, never guessed, and that proof is the +6/+16. It is the in-loop test that a doc below its anchor's lift target was the one a cap turned away, plus docLiftWasRefused for the other half of the total cap, which ends the OUTER loop over anchors already consulted. A one-line flag would instead say 'there might be more', which is the fabricated fact in the other direction. ack complexity 75bf40d0284fc951 16 cid=9fe19a9429222b7f 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 complexity 763ff87870373241 23 cid=0f3e66f03f7be1f1 round ec5e3c3..HEAD, the MCP door across L6 (batch two-grammar), L8 P9 (post_check on the edit verbs), V2 F3/F7/F8/F9 (receipt keys, batch attribution, top-level array reader, legend present-but-empty) and L7 P1 (legend on 16 verbs): dispatchMcpLine/runBatchSub/runCliEdit are the one dispatch each of those lanes had to pass through | 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 @@ -452,34 +462,41 @@ ack complexity 7e26e5533d486c7a 39 cid=5220b8e1fc725dbe round ec5e3c3..HEAD, the ack complexity 7ed8ad2c213537a4 100 R-R root-relative emission lane: threading the corpus root into 8 emitters is the change itself — +1 param each (contract-change), +3 cx from each pathRel lambda, and the verbosity of the relativization plus its comments. Reviewed row by row; none is avoidable without abandoning root-relative emission. Storage keys unmoved (baseline + ack ledger byte-identical across the cure). ack complexity 81fbe59b4a35659b 89 cid=c5e9778e250e41f1 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 | 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 851e83b4505f10f6 68 cid=1730ab232c9a0037 by=src/* A2 (dropped_positive, 2026-09-03): collectJsonSigEntries gained the rank + positivesContentSkippedOut trailing params and the three-way positive/content-skip/budget split droppedPositiveCount needs — the minimum surface to feed the shared arithmetic; see docs/EVALS.md A2 registration. | 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 89b3fd14c192c899 779 cid=bf9296f0f9d8d2a3 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) | prior: wave merge 2026-08-20 (harvestexec): TWO seams in buildGraph, both from this wave, measured together at the merged head (cx 753->764, LOC 1327->1360). (a) The RefRole::Type namespace gate, from lane/resolver-precision: +8 cx / +23 LOC, ONE inserted block - a stable in-place filter over the assembled candidate set (12 lines of code) plus the 9-line derivation comment above it, plus a 3-line re-route of the isClassLike lambda through the same predicate. Not extracted to a helper on purpose, and the reason is the gate's own subject matter - on THIS loop the predicate is a provable no-op (the loop admits only role=Call, un-narrowed by resolve.h doctrine, and role=Macro, which retagMacroCallReferences already proves uniquely macro), so the block exists to be the ONE seam a future round edits, and hiding it behind a call would move the lines without removing them while making that edit invisible. test/nsfiltercheck.sh arm 2 is the executable form of the no-op claim and fails if the narrowing ever starts biting here. The measurable effect lives in contextratio.h's all-roles resolution, and the role that reaches it is RefRole::Extends, NOT RefRole::Type - collectFacts continues on Type 28 lines before it calls resolveCandidates, so a Type reference can never arrive there, and what keeps a type mention from spraying across same-named functions is that continue, not this predicate. What the predicate keeps from spraying is a BASE CLAUSE: on test/nsfilterfix, 'class Derived : public Handler' binds to both 'class Handler' and the free 'int Handler( int )' without it (ents 1->2, amb 0->1). Corrected 2026-08-20 by adversarial verification (V-5): the two source comments this ack leaned on named the wrong role, and test/nsfiltercheck.sh arm 5 now pins the real effect on the real role - the gate is red under full removal of the narrowing, which it was not before. Acked separately below. (b) The S5-E compose lang guard, from claude/kind-kepler-0c9b90: +3 cx / +10 LOC, one langCompatible branch plus its rationale comment, so HAS-A resolution applies the same language gate as every other admission site; before it a C++ member bound to a Python/TS same-named class and the duplicate defeated the (ownerSym,fieldName) dedup. Recorded red-first by test/composelangcheck.sh (mixed-lang fixture test/composelangfix, C<->C++ bridge control kept green). Cost is bounded and one-time for both: buildGraph does not grow again when a role or a language is added, only when a seam is. +ack complexity 89b3fd14c192c899 1203 cid=9fe16468f6ebb08e by=src/* now=1203 was=983 p=src/graph.h:5400 lane FE-B (receiver evidence, via="name"): the growth is the hedge's own surfaces — memberOnlyReceiver/receiverOf read the receiver chain (memberPath/memberCtor) every language's member call now records; emitColumnarSymbolRows, emitCalleeCallsBlock and MCP pathText emit via="name" and its legend sentence only when a row carries it; packBodies/packHops take the per-edge hedge bits and the legend-charged flag (one parameter each, every caller updated). Each is gate-pinned by test/receiverevidencecheck.sh (CLI/MCP parity arms); splitting the emitters is a joint map-rows lane after train 26b (owner 2026-10-07), which redesigns these rows | prior: 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 complexity 8a92173ded649e17 46 cid=a2c4a7904b16ec5b by=src/* 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. | 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 8d58de9bb922f582 43 T1 completeness claims (complete= on grep/whereis): the +1 on streamBlobs is the deliberate DEFAULTED StreamBlobStats* param (null-object sink inside, no per-site null test; every existing caller byte-identical) so whereis can prove its scan exhaustive before claiming; cx/LOC on streamBlobs/computeWhereis/writeWhereisPage/emitGrepReport is the claim computation plus its in-band legend (the honesty text IS the feature); churn=self on those plus grepCollect/dispatchMcpLine is this lane own edit window. Gated red-first by test/completecheck.sh (24 arms, 10 red pre-fix; mutation arms force cap/offset/budget/unreadable-file/regex-mode/oversized-blob and assert the attribute VANISHES); full plain suite green, 21 touched-family gates green under ASan+LSan, determinism x3, xmllint clean ack complexity 8dadfd869fbf7649 139 cid=2a94511e3d636398 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 90d3966814ee05fc 198 cid=2d17ccec98a6d4a0 capture-audit 2026-09-04 wave-1 close, lane L4 (floor + paging vocabulary, lane-L4.md): body growth of the emitters that gained M2's capped=1 => paging-quintet disclosure, M11's priced root (est_tokens=/budget_tokens=/over_ceiling=/withheld_rows= on pack-task/from-trace/handoff), finding 4's ladder (packSignatures), H5/M15 floor + gauge and rule-4 count_capped/any_of findings_capped (runDefaultMap/runLint; runLint also carries L10's compiled= mapping). Each is the disclosure plus its ceiling arithmetic, pinned by estchargecheck/truncvocabcheck/collectioncapcheck/floormarkcheck | 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 complexity 90d3966814ee05fc 296 cid=6321caf30ea6eef6 now=296 was=284 p=src/main.cpp:1629 lane FE-B (receiver evidence, via="name"): the growth is the hedge's own surfaces — memberOnlyReceiver/receiverOf read the receiver chain (memberPath/memberCtor) every language's member call now records; emitColumnarSymbolRows, emitCalleeCallsBlock and MCP pathText emit via="name" and its legend sentence only when a row carries it; packBodies/packHops take the per-edge hedge bits and the legend-charged flag (one parameter each, every caller updated). Each is gate-pinned by test/receiverevidencecheck.sh (CLI/MCP parity arms); splitting the emitters is a joint map-rows lane after train 26b (owner 2026-10-07), which redesigns these rows | prior: capture-audit 2026-09-04 wave-1 close, lane L4 (floor + paging vocabulary, lane-L4.md): body growth of the emitters that gained M2's capped=1 => paging-quintet disclosure, M11's priced root (est_tokens=/budget_tokens=/over_ceiling=/withheld_rows= on pack-task/from-trace/handoff), finding 4's ladder (packSignatures), H5/M15 floor + gauge and rule-4 count_capped/any_of findings_capped (runDefaultMap/runLint; runLint also carries L10's compiled= mapping). Each is the disclosure plus its ceiling arithmetic, pinned by estchargecheck/truncvocabcheck/collectioncapcheck/floormarkcheck ack complexity 92ac9caf38b8aab0 20 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 complexity 949f0a5b0ae79ca0 29 cid=b9354baf0556cc75 scopeless-fold lane: the five gating rows are the new mechanism itself, read one by one. (1) remapAckIdentity complexity 26->29: it resolves THREE rescue routes now (rename, content, and the 2026-08-25 key-scheme replay) where it resolved two; the growth is one route-selection ternary plus a third switch arm, and collapsing them would hide WHICH mechanism moved an ack, which is the disclosure this round exists to provide. A branchless array tally was tried and measured identical, so it bought nothing. (2)+(4) qualityKey pairs with resolve.h::canonicalIdRelTo: the similarity is deliberate and load-bearing. They take the same (ing, symbol, root) and differ ONLY in which identity question they answer - canonicalIdRelTo builds the ENTITY id (path::scope::name, which must keep folding the 21 extern-C tree_sitter_X declarations into the one C function they actually are), qualityKey builds the SOURCE key (pathQualifiedKey, which must not fold anything). Merging them would re-create the exact bug this lane fixed. (3)+(5) baselineHeaderIsForeign pairs with crossref::isNullSha at 32 tokens: a shape match between two unrelated three-line string predicates, not shared behaviour. It was extracted precisely BECAUSE the tool objected to the same logic inline in readBaseline; there is no third spelling that satisfies both. ack complexity 96fdcdff2f0ff0f7 28 cid=95538ee0cd9b8cae round ec5e3c3..HEAD, lane L7 P3/P8/P12 (nextverbcheck, qualitycheck/testgatecheck/dmmcheck bar= arms, grep re-pins) on top of wave-1/2 L4 M15 gauge + L9 root= + L8 receipts: every enumerated root carries next=, numeric quality rows carry bar=, test-gate ccx_bar=, grep's wrapper gone — each writer grew one clause per contract, measured and cut | prior: 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 complexity 97a23cbeae7feb30 39 wave merge 2026-08-20 (harvestexec): collectFacts +12 LOC / +2 cx, from lane/resolver-precision. The executable part is ONE guard — 'if role == RefRole::Type: continue' — and 8 of the 12 added lines are the comment that makes it honest (the rest are the if, its braces and the continue; 'the other eleven lines' was this ack's own overstatement, corrected 2026-08-20), because a silent skip here is indistinguishable from an oversight. Two reasons are recorded in it: a member declaration 'Shared m_a;' already contributes a site through its HAS-A compose reference over the SAME bytes, so admitting the type mention too would count one thing a reader must read twice; and the coupling/cohesion thresholds this lens feeds were calibrated over the pre-Type reference stream (docs/EVALS.md section 9), so widening its input from a lane registered to change the USE-SITE index would invalidate a calibrated instrument as a side effect. That makes the growth a scoping decision with its own follow-up round, not debt: deleting the comment would shrink the number and destroy the only record of why the lens holds still. ack complexity 97e844e27c205f75 73 cid=2ba2aaf4fee7426c M12 (lane L9): the minor half of the same change — runEditVerb/fetchBody each gain ONE single-root ternary plus the comment naming why the display path and the disk path may now differ, and testmap.h's ctor is ambient churn from the sibling edits in the same file. ack complexity 983814f2b5912a90 17 cid=1ea6676dedf249db 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 complexity 9ebbafddddd086b4 87 cid=8703e3bd00478ec4 F5 sibling: packConnect grows by the over_ceiling decision and its re-pricing — three statements beside the est_tokens it must agree with. Splitting them out would separate the label from the number it is about, which is the defect being fixed. | prior: H14/M13: symbolQueryJson replaced a one-def CSR walk with callhierarchy.h's real computation (defs union, tier order, test partition, paging), and dispatchMcpLine/forTaskText/runForLens grew the branches those disclosures need. The complexity IS the fix: the pre-fix shapes were simple because they answered less. Measured after, not asserted: no arm of any of the four was extractable without splitting one verb's answer across two functions. +ack complexity 9ebbafddddd086b4 97 cid=5538119bcafa2857 now=97 was=95 p=src/mcpverbs.h:3460 lane FE-B (receiver evidence, via="name"): the growth is the hedge's own surfaces — memberOnlyReceiver/receiverOf read the receiver chain (memberPath/memberCtor) every language's member call now records; emitColumnarSymbolRows, emitCalleeCallsBlock and MCP pathText emit via="name" and its legend sentence only when a row carries it; packBodies/packHops take the per-edge hedge bits and the legend-charged flag (one parameter each, every caller updated). Each is gate-pinned by test/receiverevidencecheck.sh (CLI/MCP parity arms); splitting the emitters is a joint map-rows lane after train 26b (owner 2026-10-07), which redesigns these rows | prior: F5 sibling: packConnect grows by the over_ceiling decision and its re-pricing — three statements beside the est_tokens it must agree with. Splitting them out would separate the label from the number it is about, which is the defect being fixed. ack complexity a3a317502ee383bc 18 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 complexity a4a8c133e40fe0ac 28 cid=0dfc15f8d6edef3d H14/M13: symbolQueryJson replaced a one-def CSR walk with callhierarchy.h's real computation (defs union, tier order, test partition, paging), and dispatchMcpLine/forTaskText/runForLens grew the branches those disclosures need. The complexity IS the fix: the pre-fix shapes were simple because they answered less. Measured after, not asserted: no arm of any of the four was extractable without splitting one verb's answer across two functions. ack complexity a5728903bed77b57 45 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 complexity a783c75feea9f253 35 cid=7345e4b8de2407c9 2026-09-06 stranger-audit rows 13-20: readBaseline/readAckRecords report what they skip (arity), wrapMcpJson/Opencode take the command token (arity), the pre-Q1 refusal in readBaseline, the notes date and the release workflow text — all deliberate; churn rows are this edit ack complexity a8b774025a21bdc6 112 cid=a577dfc85e2ff192 P17 slice+edit_check join MCP batch: runBatchSub gains two else-if arms (each calling the standalone builder, sliceText/editCheckText, so a batched answer cannot drift from the live one) plus three argument reads; dispatchMcpLine gains the post_check boolean read for the three edit verbs. Both are dispatch chains growing in kind. runBatchSub is NOT decomposed in this commit on purpose: lane L6 is changing this same function's sub-query GRAMMAR in this wave, and restructuring it here would guarantee a merge conflict over a concurrent lane's work — the decomposition is recorded as a follow-up, not skipped silently. ack complexity a9f76a08efdb3d50 21 cid=d951d606551bf2d0 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 (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 complexity b1e9f4168d9f11cc 25 cid=e55be8bafc0b0aaf now=25 was=23 p=src/verbs_navigate.h:2680 lane FE-B (receiver evidence, via="name"): the growth is the hedge's own surfaces — memberOnlyReceiver/receiverOf read the receiver chain (memberPath/memberCtor) every language's member call now records; emitColumnarSymbolRows, emitCalleeCallsBlock and MCP pathText emit via="name" and its legend sentence only when a row carries it; packBodies/packHops take the per-edge hedge bits and the legend-charged flag (one parameter each, every caller updated). Each is gate-pinned by test/receiverevidencecheck.sh (CLI/MCP parity arms); splitting the emitters is a joint map-rows lane after train 26b (owner 2026-10-07), which redesigns these rows +ack complexity b8aadfa58812b307 19 cid=ef705bbe14951c78 now=19 was=0 p=src/receiverevidence.h:607 lane FE-B (receiver evidence, via="name"): the growth is the hedge's own surfaces — memberOnlyReceiver/receiverOf read the receiver chain (memberPath/memberCtor) every language's member call now records; emitColumnarSymbolRows, emitCalleeCallsBlock and MCP pathText emit via="name" and its legend sentence only when a row carries it; packBodies/packHops take the per-edge hedge bits and the legend-charged flag (one parameter each, every caller updated). Each is gate-pinned by test/receiverevidencecheck.sh (CLI/MCP parity arms); splitting the emitters is a joint map-rows lane after train 26b (owner 2026-10-07), which redesigns these rows +ack complexity baa5f1e81f16d0f0 19 cid=24e92a420ed526db now=19 was=16 p=src/graph.h:4601 lane FE-B (receiver evidence, via="name"): the growth is the hedge's own surfaces — memberOnlyReceiver/receiverOf read the receiver chain (memberPath/memberCtor) every language's member call now records; emitColumnarSymbolRows, emitCalleeCallsBlock and MCP pathText emit via="name" and its legend sentence only when a row carries it; packBodies/packHops take the per-edge hedge bits and the legend-charged flag (one parameter each, every caller updated). Each is gate-pinned by test/receiverevidencecheck.sh (CLI/MCP parity arms); splitting the emitters is a joint map-rows lane after train 26b (owner 2026-10-07), which redesigns these rows ack complexity bb42e692522f57ff 30 cid=aa3e0713df809f6d 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 bcb3377087f2e034 66 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 c17db5239ed07cf7 63 cid=04f50efd091db7eb L10b finding 3: --recall --top-k=0 gets its own guard message (small, bounded verbosity growth in validateConfig's existing guard block) | prior: capture-audit 2026-09-04 lane L1 (flag-table universe), the seven gating rows of this lane's own five commits, each the documented cost of the guard/table it lands: parseArgs +4 cx is the --exclude= empty-value guard, one branch in the hand-written residue, the exact shape of its --and=/--not= siblings (M6); validateConfig +4 cx/+17 LOC is the bare --quality-ack pairing refusal plus the =REASON implication moved out of the parser arm (H10); validateModifierGuards +4 cx/+21 LOC is two pairing guards (--naming-locals refuse, --no-stable notice) in the helper every prior guard grew the same way (M16); kShapingVerbs 19->62 LOC is the ledger of every verb outside honorsPaging — 36 rows for 36 verbs, a table is its length, and test/shapingflagcheck.sh arm (F) is what stops it rotting (H3); main +11 LOC is the --json check moved ahead of the CLI edit bridge plus one call to refuseInertMainModifiers (H2/M16) — the two guards themselves and jsonUnsupportedVerb's allow-list walk live in their own helpers. Gates for all five written red first; battery green at this head. churn=self rows on jsonUnsupportedVerb/kViewFlags/writeAckRecords are this lane's own rewrites and are invisible in ref-pair mode by construction. -ack complexity c22ce1db6b79b087 22 cid=d71d453313b90a4d round ec5e3c3..HEAD, lane L7 P3/P8/P12 (nextverbcheck, qualitycheck/testgatecheck/dmmcheck bar= arms, grep re-pins) on top of wave-1/2 L4 M15 gauge + L9 root= + L8 receipts: every enumerated root carries next=, numeric quality rows carry bar=, test-gate ccx_bar=, grep's wrapper gone — each writer grew one clause per contract, measured and cut | prior: H14/M13: symbolQueryJson replaced a one-def CSR walk with callhierarchy.h's real computation (defs union, tier order, test partition, paging), and dispatchMcpLine/forTaskText/runForLens grew the branches those disclosures need. The complexity IS the fix: the pre-fix shapes were simple because they answered less. Measured after, not asserted: no arm of any of the four was extractable without splitting one verb's answer across two functions. +ack complexity c22ce1db6b79b087 31 cid=428e1247a5ef36e0 now=31 was=27 p=src/mcpverbs.h:691 lane FE-B (receiver evidence, via="name"): the growth is the hedge's own surfaces — memberOnlyReceiver/receiverOf read the receiver chain (memberPath/memberCtor) every language's member call now records; emitColumnarSymbolRows, emitCalleeCallsBlock and MCP pathText emit via="name" and its legend sentence only when a row carries it; packBodies/packHops take the per-edge hedge bits and the legend-charged flag (one parameter each, every caller updated). Each is gate-pinned by test/receiverevidencecheck.sh (CLI/MCP parity arms); splitting the emitters is a joint map-rows lane after train 26b (owner 2026-10-07), which redesigns these rows | prior: round ec5e3c3..HEAD, lane L7 P3/P8/P12 (nextverbcheck, qualitycheck/testgatecheck/dmmcheck bar= arms, grep re-pins) on top of wave-1/2 L4 M15 gauge + L9 root= + L8 receipts: every enumerated root carries next=, numeric quality rows carry bar=, test-gate ccx_bar=, grep's wrapper gone — each writer grew one clause per contract, measured and cut ack complexity c29edcedb6d64b02 157 cid=4d78247c50c7e7c8 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: lane V1 N3 (342493b, helper lifted a8e994d): the explicit-selector branch of the multi-root --situ loop — a selector sentence instead of the empty-diff zero ack complexity c4465160da273627 65 cid=a5da89d8d9da8562 L10 finding 9b: buildStructRow now copies layout.h's caveat kind(s) into a new AffStruct.why field, writeFieldAffinity emits why= on unmodeled rows — the complexity/verbosity growth is that copy+emit cost ack complexity c4f94cdc43457375 22 cid=040f24a59a23b677 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 complexity c5726c667bc4ffa4 77 r2 pkg-dir mention lift: applyMentionBoost ccx 74->77 is the new (c) branch, measured +1.6pp strict@10 with a dedicated gate (mentioncheck vi); score.py any10/strict10 are called inside sum() comprehensions — dead-code detector scope limitation, verified used -ack complexity ca97a4b6bf07887b 201 cid=b2c9107cb2d8ac03 H14/M13: symbolQueryJson replaced a one-def CSR walk with callhierarchy.h's real computation (defs union, tier order, test partition, paging), and dispatchMcpLine/forTaskText/runForLens grew the branches those disclosures need. The complexity IS the fix: the pre-fix shapes were simple because they answered less. Measured after, not asserted: no arm of any of the four was extractable without splitting one verb's answer across two functions. | 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 c79b36c44111aca3 30 cid=c0aaf33a6026048d now=30 was=0 p=src/ingest_binds.h:189 lane FE-B (receiver evidence, via="name"): the growth is the hedge's own surfaces — memberOnlyReceiver/receiverOf read the receiver chain (memberPath/memberCtor) every language's member call now records; emitColumnarSymbolRows, emitCalleeCallsBlock and MCP pathText emit via="name" and its legend sentence only when a row carries it; packBodies/packHops take the per-edge hedge bits and the legend-charged flag (one parameter each, every caller updated). Each is gate-pinned by test/receiverevidencecheck.sh (CLI/MCP parity arms); splitting the emitters is a joint map-rows lane after train 26b (owner 2026-10-07), which redesigns these rows +ack complexity c94040903501f1fe 26 cid=c0513ed9cc8bd60f now=26 was=11 p=src/ingest_binds.h:267 lane FE-B (receiver evidence, via="name"): the growth is the hedge's own surfaces — memberOnlyReceiver/receiverOf read the receiver chain (memberPath/memberCtor) every language's member call now records; emitColumnarSymbolRows, emitCalleeCallsBlock and MCP pathText emit via="name" and its legend sentence only when a row carries it; packBodies/packHops take the per-edge hedge bits and the legend-charged flag (one parameter each, every caller updated). Each is gate-pinned by test/receiverevidencecheck.sh (CLI/MCP parity arms); splitting the emitters is a joint map-rows lane after train 26b (owner 2026-10-07), which redesigns these rows +ack complexity ca97a4b6bf07887b 279 cid=cfe6534d0f63b7c4 now=279 was=271 p=src/serialize.h:2626 lane FE-B (receiver evidence, via="name"): the growth is the hedge's own surfaces — memberOnlyReceiver/receiverOf read the receiver chain (memberPath/memberCtor) every language's member call now records; emitColumnarSymbolRows, emitCalleeCallsBlock and MCP pathText emit via="name" and its legend sentence only when a row carries it; packBodies/packHops take the per-edge hedge bits and the legend-charged flag (one parameter each, every caller updated). Each is gate-pinned by test/receiverevidencecheck.sh (CLI/MCP parity arms); splitting the emitters is a joint map-rows lane after train 26b (owner 2026-10-07), which redesigns these rows | prior: H14/M13: symbolQueryJson replaced a one-def CSR walk with callhierarchy.h's real computation (defs union, tier order, test partition, paging), and dispatchMcpLine/forTaskText/runForLens grew the branches those disclosures need. The complexity IS the fix: the pre-fix shapes were simple because they answered less. Measured after, not asserted: no arm of any of the four was extractable without splitting one verb's answer across two functions. +ack complexity cab2f93db3992faf 131 cid=34e51af7ebbf56db now=131 was=0 p=src/ingest_binds.h:4183 lane FE-B (receiver evidence, via="name"): the growth is the hedge's own surfaces — memberOnlyReceiver/receiverOf read the receiver chain (memberPath/memberCtor) every language's member call now records; emitColumnarSymbolRows, emitCalleeCallsBlock and MCP pathText emit via="name" and its legend sentence only when a row carries it; packBodies/packHops take the per-edge hedge bits and the legend-charged flag (one parameter each, every caller updated). Each is gate-pinned by test/receiverevidencecheck.sh (CLI/MCP parity arms); splitting the emitters is a joint map-rows lane after train 26b (owner 2026-10-07), which redesigns these rows ack complexity cb7342964b38db9c 33 cid=3af54969638510bc by=src/* member-variable round (card A3): usesText grows the Owner.field member-selector branch (resolveFieldSelector + bare-name refusal listing spellings), the registered contract of this round | prior: R-R root-relative emission lane: threading the corpus root into 8 emitters is the change itself — +1 param each (contract-change), +3 cx from each pathRel lambda, and the verbosity of the relativization plus its comments. Reviewed row by row; none is avoidable without abandoning root-relative emission. Storage keys unmoved (baseline + ack ledger byte-identical across the cure). +ack complexity ce8ba13980fd838f 19 cid=16497434baf13239 now=19 was=0 p=src/ingest_binds.h:4104 lane FE-B (receiver evidence, via="name"): the growth is the hedge's own surfaces — memberOnlyReceiver/receiverOf read the receiver chain (memberPath/memberCtor) every language's member call now records; emitColumnarSymbolRows, emitCalleeCallsBlock and MCP pathText emit via="name" and its legend sentence only when a row carries it; packBodies/packHops take the per-edge hedge bits and the legend-charged flag (one parameter each, every caller updated). Each is gate-pinned by test/receiverevidencecheck.sh (CLI/MCP parity arms); splitting the emitters is a joint map-rows lane after train 26b (owner 2026-10-07), which redesigns these rows ack complexity d03215f48bec3886 17 cid=d622ffa6d33014f4 E1 (terminality round A, lane E): +1 branch: the redaction-marker refusal beside the NUL one (one ladder, one vocabulary) ack complexity d295ac7080c14dd5 18 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 complexity d42c85b67bd0956f 52 cid=346b0c28d573003f L10b finding 8: --situ distinguishes clean-tree from changed-but-symbol-free wording (small branch added) | 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 @@ -494,6 +511,7 @@ ack complexity e14feca13c7ae680 57 cid=29dde7810cd9e120 M4 (lane ca-L2): writeHa ack complexity e1b4964cea59171b 18 cid=8ba981522099e145 H14/M13: symbolQueryJson replaced a one-def CSR walk with callhierarchy.h's real computation (defs union, tier order, test partition, paging), and dispatchMcpLine/forTaskText/runForLens grew the branches those disclosures need. The complexity IS the fix: the pre-fix shapes were simple because they answered less. Measured after, not asserted: no arm of any of the four was extractable without splitting one verb's answer across two functions. ack complexity e514a69013d0934c 33 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 ack complexity e7f50422948f2c09 17 cid=6c7e771d42d81c16 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 e836c18ca4452097 19 cid=d0f46b9a11a3932c now=19 was=17 p=src/mcpverbs.h:475 lane FE-B (receiver evidence, via="name"): the growth is the hedge's own surfaces — memberOnlyReceiver/receiverOf read the receiver chain (memberPath/memberCtor) every language's member call now records; emitColumnarSymbolRows, emitCalleeCallsBlock and MCP pathText emit via="name" and its legend sentence only when a row carries it; packBodies/packHops take the per-edge hedge bits and the legend-charged flag (one parameter each, every caller updated). Each is gate-pinned by test/receiverevidencecheck.sh (CLI/MCP parity arms); splitting the emitters is a joint map-rows lane after train 26b (owner 2026-10-07), which redesigns these rows ack complexity ec2848a4801493a2 51 cid=ed0825d14daf74d4 H11 (lane ca-L2): validateModifierGuards grows one more modifier arm (--allow-dirty, refused alone) in the flat if-chain this function IS; every sibling arm is the same 6 lines and splitting one out would hide it from the guard roster | prior: capture-audit 2026-09-04 lane L1 (flag-table universe), the seven gating rows of this lane's own five commits, each the documented cost of the guard/table it lands: parseArgs +4 cx is the --exclude= empty-value guard, one branch in the hand-written residue, the exact shape of its --and=/--not= siblings (M6); validateConfig +4 cx/+17 LOC is the bare --quality-ack pairing refusal plus the =REASON implication moved out of the parser arm (H10); validateModifierGuards +4 cx/+21 LOC is two pairing guards (--naming-locals refuse, --no-stable notice) in the helper every prior guard grew the same way (M16); kShapingVerbs 19->62 LOC is the ledger of every verb outside honorsPaging — 36 rows for 36 verbs, a table is its length, and test/shapingflagcheck.sh arm (F) is what stops it rotting (H3); main +11 LOC is the --json check moved ahead of the CLI edit bridge plus one call to refuseInertMainModifiers (H2/M16) — the two guards themselves and jsonUnsupportedVerb's allow-list walk live in their own helpers. Gates for all five written red first; battery green at this head. churn=self rows on jsonUnsupportedVerb/kViewFlags/writeAckRecords are this lane's own rewrites and are invisible in ref-pair mode by construction. ack complexity ed05ee0357d016f1 49 --lint reads the corpus ONCE (audit lane B2, second half): astQueryGrouped gains an OPT-IN keptBytesOut, so the walk that already reads every file hands its bytes to the two symbol-level passes that ran after it instead of each re-opening the same ~900 files one at a time on the main thread. The +1 param on astQueryGrouped/namingLensChecks/appendNamingFindings is that deliberate contract and it is DEFAULTED — the --ensemble caller passes nothing and is byte-identical, verified against a pre-binary. Partial by construction and safe by construction: an empty slot (skipped file, or a genuinely empty one) falls through to the caller's own read, which returns the same bytes, so fast and slow paths cannot disagree; a size guard keeps a vector built for another corpus in bounds. Retention is a SINGLE point placed before the tree is built, so no exit can forget it and no branch can keep it twice; workers only ever write distinct pre-sized slots, verified under ASan+UBSan (-fno-sanitize-recover=all, LSan suppressions) clean on both corpora with output still identical. Cost measured honestly: peak RSS 182.5 -> 192.7 MB (+5.6%) for one corpus of text held across the lint block. churn=self on astQueryGrouped/builtInLintCaptures/runLint/lintSymbolLevelChecks/mergeNamingLens is lane B (one day earlier) plus this round own first commit. Frozen-corpus profile: mergeNamingLens 19.5 -> 6.3 ms (the naminglens getBytes row is GONE, 907 calls -> 0), lintSymbolLevelChecks 45.1 -> 37.7 ms, readFile 2335 -> 1168 calls; warm --lint 0.51-0.52s -> 0.42-0.43s. 20 gates green, determinism + xmllint clean ack complexity ed10de1685178c0e 21 cid=18c2d19fcd3157cf R1: +2 ccx on editplan::prepare and +1 on editpreview::run — ONE guarded early-return each (the redaction-marker gate moved to where the replaced bytes are known); both were already over the bar before this change and neither gained a nested branch @@ -502,7 +520,7 @@ ack complexity efe685bb50482275 58 cid=10ded7c311552cd1 L10 finding 9b: buildStr ack complexity f1aa10922e41f258 27 cid=6670a895f0520c6a E3 (terminality round A, lane E): run_editsuite.py: the --cost-arith subcommand (E3's arithmetic, per task) ack complexity f307304e87733112 17 cid=76db6c1cce778585 fix-round follow-on: the recall-ceiling MCP branch (+3 ccx on the dispatcher), the apostrophe word-boundary guards in firstQuotedLiteral (the refusal logic IS the fix), and re-touch churn on the flag table / RecallShape comment / installer timeout re-pin ack complexity f8d747d123b4143d 39 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 fce48dedc40bc8eb 16 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 fce48dedc40bc8eb 18 cid=4b3785b8a81e3973 now=18 was=15 p=src/mcpverbs.h:3166 lane FE-B (receiver evidence, via="name"): the growth is the hedge's own surfaces — memberOnlyReceiver/receiverOf read the receiver chain (memberPath/memberCtor) every language's member call now records; emitColumnarSymbolRows, emitCalleeCallsBlock and MCP pathText emit via="name" and its legend sentence only when a row carries it; packBodies/packHops take the per-edge hedge bits and the legend-charged flag (one parameter each, every caller updated). Each is gate-pinned by test/receiverevidencecheck.sh (CLI/MCP parity arms); splitting the emitters is a joint map-rows lane after train 26b (owner 2026-10-07), which redesigns these rows | 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 fd4cc5c6c0ad17d9 34 R-R root-relative emission lane: threading the corpus root into 8 emitters is the change itself — +1 param each (contract-change), +3 cx from each pathRel lambda, and the verbosity of the relativization plus its comments. Reviewed row by row; none is avoidable without abandoning root-relative emission. Storage keys unmoved (baseline + ack ledger byte-identical across the cure). ack dead-code:new-symbol 033294a4fc57eac8 0 cid=08917f8cd6a0890c 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 dead-code:new-symbol 0a892722cd821466 0 cid=e801e16ce70d1cd0 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. @@ -513,6 +531,7 @@ ack dead-code:new-symbol 86cc58659800f0fb 0 cid=2c493bc89aeb6a5f timsort vendori ack dead-code:new-symbol aba70b100ec42e27 0 cid=2c493bc89aeb6a5f 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 dead-code:new-symbol e9ba36b269911b50 0 cid=e45729c79053d829 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 dead-code:preexisting f50c3faf097e1b50 0 cid=5b5fb108c58d5881 load_samples is called indirectly via the RunSpec/build_runs loader reference (loader(args.data, name)) in main() — a first-class function value, not a literal call site, so the name-based call graph cannot see the edge; verified by grep and by running run_arb.py --help/--determinism-check +ack duplication 005bb1ea0ec27f8e 105 now=105 was=0 p=src/flipimpact.h:434 lane FE-B (receiver evidence): lexical clone pairs, not shared logic — the per-language predicates (implicitReceiverLang, classNameReceiverLang, memberNeverReachesFreeFunction, isReceiverEvidenceKind, callableKind, classLike, isThisRoot) are one-line OR-lists over different enum sets that share only the shape with quality.h/model.h/slice.h predicates asking other questions; keyOf/typeKey build different keys into a reused buffer; classAt (innermost class span by byte) vs flipimpact innermostAtLine (by line, other table); impactViaColumn vs lintrules dependencyCapableMask share a bitmask idiom; the JS fixture encode|stringify is a deliberate twin (test/receiverevidencefix: same-named near misses); pargates.py is unchanged since cd87e30e (a regrouped pre-existing pair). The one real clone, the enclosing-function walk, was merged into innermostEnclosingFn instead of acked ack duplication 0097b5800ff95c8e 304 now=304 was=0 prov=recon Chip-trio merge 2026-08-23, finding 1 of 4. The claude session parser (parse_claude_session_metrics) shares 304 normalized tokens with parse_opencode_ndjson_metrics, and the shape IS deliberately parallel: both walk a per-message JSONL stream, pick tool_use/tool-call blocks out of it, and split them into command_calls / ripwire evidence / native_read_calls through the SAME classify_native_read() rule so the three counts stay comparable within a harness. What is NOT shared is what the duplication metric cannot see: the two streams have different envelopes (claude nests under message.content with type=='assistant' gating; opencode is a flat NDJSON event stream with its own part kinds), different token/cost fields, and different notions of which record carries the model id. Factoring the walk into one generic reader would need a per-harness descriptor for every one of those, which is more coupling between two independently-versioned external formats than the ~40 shared lines cost — and a change to one CLI's stream shape would then be a change to the other harness's parser. The house rule this follows is the one already applied to _codex_metrics / _opencode_metrics / _claude_metrics, which are three sibling parsers by the same argument and were acked the same way. Revisit if a fourth harness lands: three parallel parsers is a pattern, four is a table. ack duplication 00b0c885f543c639 50 by=test/macroreparsefix now=50 was=0 prov=recon 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 now=20 was=0 prov=recon 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 @@ -531,6 +550,7 @@ ack duplication 0d8219d50310fcf9 44 by=test/macroreparsefix now=44 was=0 prov=re ack duplication 0fcefd71958f2838 44 DISCLOSE sinks: a reason-to-field switch is the shape every multi-reason sink shares; -Wswitch exhaustiveness over DisclosureWhy is the point (Diagnostics.h 4b) ack duplication 1030d8274cf87965 58 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 1096a473dedb29cf 128 now=128 was=0 prov=recon taskroute v1 landing: (1) the five clone/new-clone rows are taskroute.h re-implementing word-boundary find / comma join / enum-name helpers that exist only as OTHER modules private-namespace statics (darkflags/docdrift/mcprefusal/accessshape) — importing them would cross-couple unrelated modules; consolidation home is the REGISTERED infra refactor round (PLAN board, ranked second) which owns the infra:: helper extraction; (2) kTotalFlagArms churn=self fires on every flag addition by construction (one-shared-bump discipline) — the field-report churn-advisory-unless-combined backlog item is the real fix; (3) complexity/verbosity on main is the new verb dispatch arm growing main.cpp main by the minimum a flag costs, MISATTRIBUTED by path to bench/agentloop/analyze.py:280 by the known cross-file churn-keying bug (fix exists unpushed at d593de3); analyze.py is untouched in this diff +ack duplication 10e00e8b12aad57c 38 now=38 was=0 p=src/namingconsistency.h:136 lane FE-B (receiver evidence): lexical clone pairs, not shared logic — the per-language predicates (implicitReceiverLang, classNameReceiverLang, memberNeverReachesFreeFunction, isReceiverEvidenceKind, callableKind, classLike, isThisRoot) are one-line OR-lists over different enum sets that share only the shape with quality.h/model.h/slice.h predicates asking other questions; keyOf/typeKey build different keys into a reused buffer; classAt (innermost class span by byte) vs flipimpact innermostAtLine (by line, other table); impactViaColumn vs lintrules dependencyCapableMask share a bitmask idiom; the JS fixture encode|stringify is a deliberate twin (test/receiverevidencefix: same-named near misses); pargates.py is unchanged since cd87e30e (a regrouped pre-existing pair). The one real clone, the enclosing-function walk, was merged into innermostEnclosingFn instead of acked ack duplication 1104136ee19ceb5c 58 now=58 was=0 prov=recon octocode F3: matchNearestKindClause follows the established purpose-named optional-clause-builder idiom already used twice in this tree (mcprefusal.h::blankPayloadClause, selectorrefuse.h::selectorFaultClause) — the guard-empty/build-prefix/conditional-suffix shape recurs by that idiom's own design, not by accidental copy-paste; sharing a generic builder with AnchorList::clause (an unrelated --for routing internal) would couple two unrelated subsystems for a shape-only match ack duplication 110c4184a5bed17b 37 now=37 was=0 prov=recon incidental shape similarity of small JSON/string builders across unrelated protocols (#279 LSP wire helpers vs MCP/CLI/quality code); sharing a helper would couple the LSP wire format to them ack duplication 12a98f49622cc5c4 39 now=39 was=0 prov=recon main.cpp split 2026-08-29: NOT new duplication — the 39-token AnchorKey::operator< / model.h lessUnindexedExt near-miss pair existed verbatim at the base (operator< moved byte-identical from main.cpp to verbs_report.h); clone groups key on their member set, so the moved path minted a new group id for a pre-split pair. argvdiffcheck proves the bodies unchanged @@ -547,7 +567,9 @@ ack duplication 1cd9e6b6ec0829c1 75 now=75 was=0 prov=recon R2 pattern lane: the ack duplication 1d18dab8dc3fcc99 37 now=37 was=0 prov=recon incidental shape similarity of small JSON/string builders across unrelated protocols (#279 LSP wire helpers vs MCP/CLI/quality code); sharing a helper would couple the LSP wire format to them ack duplication 1f949c8839bb857e 19 now=19 was=0 prov=recon lane/diag-single-write: test/diagnotice_harness.cpp expectedDegraded is the test's own independent spelling of the degraded notice text, one return std::format( literal, four args ) — the 19-token pair with handoff.h handoffSymsCapClause is that one-statement shape and nothing else: a legend clause and a test expectation share no meaning, so merging them would name neither ack duplication 1fbb92d43e45d577 59 now=59 was=0 prov=recon Integration train 2b (PR #244, TS/JS literal receivers): isJsTsLitRecv (a five-case RecvKind membership test), jsLitCtorName (a five-case RecvKind -> constructor-name switch) and isJsTsBuiltinCtor (a five-string membership test) are each the smallest spelling of one fact about the Lit* enumerators appended in the same PR. Their token runs match refRoleTag, styleTag, votingStyle, langUsesHashComment, elixirTypedAttribute and a clone-idiom fixture by SHAPE (a short enum switch, an OR of equality tests), not by shared logic: no helper exists they could call, and folding unrelated enums into one table would couple them. Acked at the train that first measured them on the merged tree; the three stay adjacent in model.h beside the enum they describe. +ack duplication 202edf39c263e3af 37 now=37 was=0 p=src/lsp.h:379 honesty-small-068: lexical clone false positives on one-line helpers. isStructOrNamedType (a two-term kind test) matched routematch::methodsCompatible and localsCountedLang, and lspKindWord (a table read behind its EXPECTS) matched ConfigScopes::scope: each shares only the shape of a one-line boolean or indexed return, asks a different question of a different type, and a merge would name neither. Earlier rounds of this lane FIXED the real findings instead: pathText complexity/verbosity (one shared pathUnreachedXml clause), lspKindWord's switch near-clone of langTag (now a table), three sum/any helpers (sums carried). ack duplication 206efc5a490a0d83 27 now=27 was=0 prov=recon 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 duplication 20777160f7cd4594 26 now=26 was=0 p=src/graph.h:469 honesty-small-068: lexical clone false positives on one-line helpers. isStructOrNamedType (a two-term kind test) matched routematch::methodsCompatible and localsCountedLang, and lspKindWord (a table read behind its EXPECTS) matched ConfigScopes::scope: each shares only the shape of a one-line boolean or indexed return, asks a different question of a different type, and a merge would name neither. Earlier rounds of this lane FIXED the real findings instead: pathText complexity/verbosity (one shared pathUnreachedXml clause), lspKindWord's switch near-clone of langTag (now a table), three sum/any helpers (sums carried). ack duplication 22018148ed87cd0e 58 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 2374106e320946a5 50 now=50 was=0 prov=recon DISCLOSE sinks: a reason-to-field switch is the shape every multi-reason sink shares; -Wswitch exhaustiveness over DisclosureWhy is the point (Diagnostics.h 4b) ack duplication 23b67d4e4935395f 28 by=src/* now=28 was=0 prov=recon 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. @@ -555,6 +577,7 @@ ack duplication 24891af819b15ead 23 now=23 was=0 p=test/fnliteralfix/arrows.ts:1 ack duplication 274d9cad9a1f18de 31 now=31 was=0 prov=recon 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 ack duplication 296b8816e4cc2ce6 40 now=40 was=0 prov=recon facet=switch-name-table P3.2 plan-lint mechanical footprint: printUsage grows with the new help block; glyphName clones accessshape::shapeName (an enum-to-string switch idiom, the exact false-positive class P2.1's clone-idiom demotion targets, not yet landed); gitBlameLineSha clones quality.h::gitBlameRangeHasWindowCommit's blame-porcelain invocation shape — a shared-invocation extraction was tried and reverted as net-worse (it only moved the cost onto that untouched function's own churn), see planlint.h's own comment on the decision ack duplication 2d090ba54aef3c26 589 now=589 was=0 prov=recon WAVE-2 close (2026-08-19), finding 2 of 3 triaged separately. duplication emitCochangeGroups | emitCochangePairs (589 tokens, src/main.cpp:4317). These two cochange emitters were already structurally parallel before the wave; W2-E's root-relative p= landing added the SAME four-line preamble to each (xxSingleRoot / xxRootPrefix / xxRootAttr from rw::sarif, plus the rootRelPathsLegend call and the root=-before-at= placement), which is what carried the pair over the clone bar. The shared parts that COULD be hoisted already were: rootRelPathsLegend/kRootRelPathsLegend are one definition serving all eighteen legends (the S B4 echo-site rule, see ack api-surface 48efeb1d). What remains per-site is three locals computed from each emitter's OWN cfg/ing bindings in its own scope; folding those into a helper would hand every emitter a struct it must unpack, at ~30 call sites, to save three lines each. Accepted as the cost of the uniform root= contract, NOT as a claim that the preamble is optimal: the ~30-site preamble echo is recorded as a wave-2 follow-up for a dedicated round, where it can be measured and gated instead of refactored blind at integration close. +ack duplication 3058a1783a1178be 29 now=29 was=0 p=src/graph.h:533 lane FE-B (receiver evidence): lexical clone pairs, not shared logic — the per-language predicates (implicitReceiverLang, classNameReceiverLang, memberNeverReachesFreeFunction, isReceiverEvidenceKind, callableKind, classLike, isThisRoot) are one-line OR-lists over different enum sets that share only the shape with quality.h/model.h/slice.h predicates asking other questions; keyOf/typeKey build different keys into a reused buffer; classAt (innermost class span by byte) vs flipimpact innermostAtLine (by line, other table); impactViaColumn vs lintrules dependencyCapableMask share a bitmask idiom; the JS fixture encode|stringify is a deliberate twin (test/receiverevidencefix: same-named near misses); pargates.py is unchanged since cd87e30e (a regrouped pre-existing pair). The one real clone, the enclosing-function walk, was merged into innermostEnclosingFn instead of acked ack duplication 318844328dfb16c8 50 now=50 was=0 prov=recon DISCLOSE sinks: a reason-to-field switch is the shape every multi-reason sink shares; -Wswitch exhaustiveness over DisclosureWhy is the point (Diagnostics.h 4b) ack duplication 32420ebb102c4aaf 36 DISCLOSE sinks: a reason-to-field switch is the shape every multi-reason sink shares; -Wswitch exhaustiveness over DisclosureWhy is the point (Diagnostics.h 4b) ack duplication 33c5d907ab904cbe 43 by=src/* now=43 was=0 prov=recon 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) @@ -570,6 +593,7 @@ ack duplication 3caf742bad9a9545 56 now=56 was=0 prov=recon --quality-panel: the ack duplication 3ccdcc678c7957e9 44 now=44 was=0 prov=recon DISCLOSE sinks: a reason-to-field switch is the shape every multi-reason sink shares; -Wswitch exhaustiveness over DisclosureWhy is the point (Diagnostics.h 4b) ack duplication 3ed186c9821ed68e 50 now=50 was=0 prov=recon DISCLOSE sinks: a reason-to-field switch is the shape every multi-reason sink shares; -Wswitch exhaustiveness over DisclosureWhy is the point (Diagnostics.h 4b) ack duplication 41d9fbec47832262 37 now=37 was=0 prov=recon cachefix fixture functions deliberately mirror common loop shapes; token-matches to unrelated src helpers are the fixture doing its job +ack duplication 481141c5d4757226 74 now=74 was=0 p=src/quality.h:1216 lane FE-B (receiver evidence): lexical clone pairs, not shared logic — the per-language predicates (implicitReceiverLang, classNameReceiverLang, memberNeverReachesFreeFunction, isReceiverEvidenceKind, callableKind, classLike, isThisRoot) are one-line OR-lists over different enum sets that share only the shape with quality.h/model.h/slice.h predicates asking other questions; keyOf/typeKey build different keys into a reused buffer; classAt (innermost class span by byte) vs flipimpact innermostAtLine (by line, other table); impactViaColumn vs lintrules dependencyCapableMask share a bitmask idiom; the JS fixture encode|stringify is a deliberate twin (test/receiverevidencefix: same-named near misses); pargates.py is unchanged since cd87e30e (a regrouped pre-existing pair). The one real clone, the enclosing-function walk, was merged into innermostEnclosingFn instead of acked ack duplication 4892aee1410d832a 76 now=76 was=0 prov=recon 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 duplication 48e69eae0fc9e9db 34 now=34 was=0 p=src/tracelocus.h:962 token-shape duplication false positive from the F3 base-name hoist: immediateScope (afterLast then skipTemplateDisambiguator) shares no logic with traceDeliveredBytes (compactDeliveredBytes or a size fallback). The real clone (noteRead / immediateScope / baseNameOf) is gone. ack duplication 499d9f6f77638965 65 now=65 was=0 prov=recon Integration train 1b (PR #244 via train 2b): jsLitCtorName's pairings with accessshape shapeName, namingconsistency styleTag and planlint glyphName appear only on the merged tree; each side was acked on its own range (2b acked #244's Lit* helper rows) | prior: Integration train 2b (PR #244, TS/JS literal receivers): isJsTsLitRecv (a five-case RecvKind membership test), jsLitCtorName (a five-case RecvKind -> constructor-name switch) and isJsTsBuiltinCtor (a five-string membership test) are each the smallest spelling of one fact about the Lit* enumerators appended in the same PR. Their token runs match refRoleTag, styleTag, votingStyle, langUsesHashComment, elixirTypedAttribute and a clone-idiom fixture by SHAPE (a short enum switch, an OR of equality tests), not by shared logic: no helper exists they could call, and folding unrelated enums into one table would couple them. Acked at the train that first measured them on the merged tree; the three stay adjacent in model.h beside the enum they describe. @@ -592,8 +616,10 @@ ack duplication 5bfc60bf2eec4379 58 now=58 was=0 prov=recon readability-wave1 (c ack duplication 5c9174022e5a0734 44 DISCLOSE sinks: a reason-to-field switch is the shape every multi-reason sink shares; -Wswitch exhaustiveness over DisclosureWhy is the point (Diagnostics.h 4b) ack duplication 5e4956ffb34ce360 36 now=36 was=0 prov=recon M1: a 36-token shape coincidence, not duplication. isMcpProtocolVersionSupported walks a version list and mcpVerbDeclaresLegend walks a field list; both are the C++ spelling of 'is X in this list', over different tables, different types and different call sites. Folding them into one generic helper would couple the protocol handshake to the legend family for no shared meaning. ack duplication 5f01b66132b916bd 44 DISCLOSE sinks: a reason-to-field switch is the shape every multi-reason sink shares; -Wswitch exhaustiveness over DisclosureWhy is the point (Diagnostics.h 4b) +ack duplication 5fad9d85c2552890 32 now=32 was=0 p=src/receiverevidence.h:142 lane FE-B (receiver evidence): lexical clone pairs, not shared logic — the per-language predicates (implicitReceiverLang, classNameReceiverLang, memberNeverReachesFreeFunction, isReceiverEvidenceKind, callableKind, classLike, isThisRoot) are one-line OR-lists over different enum sets that share only the shape with quality.h/model.h/slice.h predicates asking other questions; keyOf/typeKey build different keys into a reused buffer; classAt (innermost class span by byte) vs flipimpact innermostAtLine (by line, other table); impactViaColumn vs lintrules dependencyCapableMask share a bitmask idiom; the JS fixture encode|stringify is a deliberate twin (test/receiverevidencefix: same-named near misses); pargates.py is unchanged since cd87e30e (a regrouped pre-existing pair). The one real clone, the enclosing-function walk, was merged into innermostEnclosingFn instead of acked ack duplication 5fc8d4b1ed98eaf3 36 idiom-class clone: a NAMED one-line std::find predicate. What matches is the std::find idiom itself — 36 normalized tokens, zero shared domain identifiers with ncAnyOf/namesNode, different value types, different subsystems. Inlining it at its two call sites to dodge the row would be metric-gaming; the name is the documentation. ack duplication 6464ab2300b93b7a 32 now=32 was=0 prov=recon scopeless-fold lane: the five gating rows are the new mechanism itself, read one by one. (1) remapAckIdentity complexity 26->29: it resolves THREE rescue routes now (rename, content, and the 2026-08-25 key-scheme replay) where it resolved two; the growth is one route-selection ternary plus a third switch arm, and collapsing them would hide WHICH mechanism moved an ack, which is the disclosure this round exists to provide. A branchless array tally was tried and measured identical, so it bought nothing. (2)+(4) qualityKey pairs with resolve.h::canonicalIdRelTo: the similarity is deliberate and load-bearing. They take the same (ing, symbol, root) and differ ONLY in which identity question they answer - canonicalIdRelTo builds the ENTITY id (path::scope::name, which must keep folding the 21 extern-C tree_sitter_X declarations into the one C function they actually are), qualityKey builds the SOURCE key (pathQualifiedKey, which must not fold anything). Merging them would re-create the exact bug this lane fixed. (3)+(5) baselineHeaderIsForeign pairs with crossref::isNullSha at 32 tokens: a shape match between two unrelated three-line string predicates, not shared behaviour. It was extracted precisely BECAUSE the tool objected to the same logic inline in readBaseline; there is no third spelling that satisfies both. +ack duplication 649a99661f221ead 18 now=18 was=0 p=test/receiverevidencefix/js/lib/application.js:48 lane FE-B (receiver evidence): lexical clone pairs, not shared logic — the per-language predicates (implicitReceiverLang, classNameReceiverLang, memberNeverReachesFreeFunction, isReceiverEvidenceKind, callableKind, classLike, isThisRoot) are one-line OR-lists over different enum sets that share only the shape with quality.h/model.h/slice.h predicates asking other questions; keyOf/typeKey build different keys into a reused buffer; classAt (innermost class span by byte) vs flipimpact innermostAtLine (by line, other table); impactViaColumn vs lintrules dependencyCapableMask share a bitmask idiom; the JS fixture encode|stringify is a deliberate twin (test/receiverevidencefix: same-named near misses); pargates.py is unchanged since cd87e30e (a regrouped pre-existing pair). The one real clone, the enclosing-function walk, was merged into innermostEnclosingFn instead of acked ack duplication 64b9cdaf064825c4 18 now=18 was=0 prov=recon sendAll (mcpserver.h, network send-all-tolerating-short-writes) and pathguard::writeAll (file write-all-tolerating-short-writes) are the same RETRY-LOOP shape over different syscalls (send vs write/EINTR) and different domains (socket vs file descriptor) — the tree already acks this idiom repeatedly (e.g. the three os:: syscall passthroughs vs profilePmc.h): no shared helper exists to call, and folding network and file write-loops together would put a dispatch where a three-line loop is ack duplication 64c50349786b7508 26 now=26 was=0 prov=recon nested-closure span-attribution fix (jsnestedcheck): spanContains is sameSpan's sibling predicate (containment vs identity — 26 shared tokens are the four ts_node_*_byte reads, nothing mergeable); captureTagsFacts verbosity is the constraint comment on the containment stop + churn=self is this fix editing the tags pass; webpack nest>=4 individually-scoped rows 18%->98%, gate jsnestedcheck green ack duplication 6562d36ca101b8a5 35 now=35 was=0 prov=recon 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. @@ -603,6 +629,7 @@ ack duplication 689ab18d529fb67c 21 now=21 was=0 prov=recon P3.2 plan-lint model ack duplication 689e8ff37b877fd0 169 P3 duplication: editCheckCallSites vs quality::topLevelCalleeNameHashes, 169 tokens. Both are 'one pass over ing.references with continue-filters, then sort+unique' — an IDIOM collision, not a reusable helper: different predicates, different element types (a pair vs a uint64 name hash) and different questions. Extracting a shared filter-sort-unique-over-references template parameterised by a lambda would be less readable than either and is the abstraction-for-its-own-sake G2 warns about. Kept as two clear loops, deliberately. ack duplication 68b1751fdaff3631 19 now=19 was=0 prov=recon lane fix/cpp-macro-member-reparse-2026-09-11: test/extentfix/leak.cpp and test/extentfix/control/leak.cpp are the extent detector's designed fixture PAIR (the control is the leak with semicolons added), so their free functions are byte-identical by construction; the pair only re-keyed because the leak's macro moved to lowercase (the member-macro re-parse repairs the ALL-CAPS spelling, so the detector needs a shape that still derails) and the leak copies are filed under IndiaError:: ack duplication 68b8ec360ff58496 38 a two-field DISCLOSE sink: the switch maps each reason to its own field, the shape every such sink shares +ack duplication 698b647d75b377de 73 now=73 was=0 p=src/lintrules.h:311 lane FE-B (receiver evidence): lexical clone pairs, not shared logic — the per-language predicates (implicitReceiverLang, classNameReceiverLang, memberNeverReachesFreeFunction, isReceiverEvidenceKind, callableKind, classLike, isThisRoot) are one-line OR-lists over different enum sets that share only the shape with quality.h/model.h/slice.h predicates asking other questions; keyOf/typeKey build different keys into a reused buffer; classAt (innermost class span by byte) vs flipimpact innermostAtLine (by line, other table); impactViaColumn vs lintrules dependencyCapableMask share a bitmask idiom; the JS fixture encode|stringify is a deliberate twin (test/receiverevidencefix: same-named near misses); pargates.py is unchanged since cd87e30e (a regrouped pre-existing pair). The one real clone, the enclosing-function walk, was merged into innermostEnclosingFn instead of acked ack duplication 69bea33de722cde5 23 now=23 was=0 prov=recon Integration train 1b (lane/reader-fuzzers @ 8f6f86f5): the fuzz harness appendPod deliberately re-implements the on-disk writers (qsnapPut's byte append) so the harness shares no code with the reader it attacks ack duplication 6a595c85c47abd60 74 now=74 was=0 prov=recon facet=switch-name-table per-enumerator switches name every enumerator and carry no default: on purpose — that is what makes -Werror=switch a compile error at each one when an enumerator is appended; two such switches over one enum share the enumerator spelling by construction ack duplication 6a7680fbd8a5083d 20 by=src/* now=20 was=0 prov=recon 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) @@ -614,11 +641,13 @@ ack duplication 6edeb2565e612fbb 56 now=56 was=0 prov=recon DISCLOSE sinks: a re ack duplication 7195353aaff6ffd4 46 now=46 was=0 prov=recon incidental shape similarity of small JSON/string builders across unrelated protocols (#279 LSP wire helpers vs MCP/CLI/quality code); sharing a helper would couple the LSP wire format to them ack duplication 72d40216c74f7fec 26 incidental shape similarity of small JSON/string builders across unrelated protocols (#279 LSP wire helpers vs MCP/CLI/quality code); sharing a helper would couple the LSP wire format to them 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 73e06cf71f4ea8bc 38 now=38 was=0 p=src/ingest_binds.h:1263 lane FE-B (receiver evidence): lexical clone pairs, not shared logic — the per-language predicates (implicitReceiverLang, classNameReceiverLang, memberNeverReachesFreeFunction, isReceiverEvidenceKind, callableKind, classLike, isThisRoot) are one-line OR-lists over different enum sets that share only the shape with quality.h/model.h/slice.h predicates asking other questions; keyOf/typeKey build different keys into a reused buffer; classAt (innermost class span by byte) vs flipimpact innermostAtLine (by line, other table); impactViaColumn vs lintrules dependencyCapableMask share a bitmask idiom; the JS fixture encode|stringify is a deliberate twin (test/receiverevidencefix: same-named near misses); pargates.py is unchanged since cd87e30e (a regrouped pre-existing pair). The one real clone, the enclosing-function walk, was merged into innermostEnclosingFn instead of acked ack duplication 7589afcf46d892d1 38 by=src/* now=38 was=0 prov=recon 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 75b5d63d5a992e21 44 now=44 was=0 prov=recon DISCLOSE sinks: a reason-to-field switch is the shape every multi-reason sink shares; -Wswitch exhaustiveness over DisclosureWhy is the point (Diagnostics.h 4b) ack duplication 767f5200ffbdb095 54 now=54 was=0 prov=recon 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 now=22 was=0 prov=recon 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 774b8194ffdecea7 59 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 78dd7140b7c84cd3 20 now=20 was=0 p=src/cachelint.h:154 lane FE-B (receiver evidence): lexical clone pairs, not shared logic — the per-language predicates (implicitReceiverLang, classNameReceiverLang, memberNeverReachesFreeFunction, isReceiverEvidenceKind, callableKind, classLike, isThisRoot) are one-line OR-lists over different enum sets that share only the shape with quality.h/model.h/slice.h predicates asking other questions; keyOf/typeKey build different keys into a reused buffer; classAt (innermost class span by byte) vs flipimpact innermostAtLine (by line, other table); impactViaColumn vs lintrules dependencyCapableMask share a bitmask idiom; the JS fixture encode|stringify is a deliberate twin (test/receiverevidencefix: same-named near misses); pargates.py is unchanged since cd87e30e (a regrouped pre-existing pair). The one real clone, the enclosing-function walk, was merged into innermostEnclosingFn instead of acked ack duplication 7981b72e3b2a8a8b 42 now=42 was=0 prov=recon 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 duplication 7ad6b911a1122824 71 now=71 was=0 prov=recon a probe TU deliberately mirrors layout declarations (#224 structlayout_probe.cpp) ack duplication 7ad7c707204ff7b3 23 now=23 was=0 prov=recon qsnapPut is constrained to types with unique object representations instead of asserting trivially-copyable, which leaves its body the same one-line byte append ByteW::u32 is; the two live in different translation units on purpose (quality.h cannot include the ingest cache) @@ -648,17 +677,21 @@ ack duplication 9dc9e2676d6a3d0c 311 now=311 was=0 prov=recon timsort vendoring: ack duplication a051e0695aee77da 65 now=65 was=0 prov=recon facet=switch-name-table per-enumerator switches name every enumerator and carry no default: on purpose — that is what makes -Werror=switch a compile error at each one when an enumerator is appended; two such switches over one enum share the enumerator spelling by construction ack duplication a15350d5f326584b 53 now=53 was=0 prov=recon dynmapsimd harness Arm C (sparseCsr x86 SSE2 round): refDot is the deliberate independent double-accumulated oracle for the csr kernels — structural echo of cloneSketchMatches' accumulate loop is coincidental, not a reuse site ack duplication a183672c2cfcd17b 39 by=src/* now=39 was=0 prov=recon 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 a32200f6101b4432 47 now=47 was=0 p=src/model.h:246 lane FE-B (receiver evidence): lexical clone pairs, not shared logic — the per-language predicates (implicitReceiverLang, classNameReceiverLang, memberNeverReachesFreeFunction, isReceiverEvidenceKind, callableKind, classLike, isThisRoot) are one-line OR-lists over different enum sets that share only the shape with quality.h/model.h/slice.h predicates asking other questions; keyOf/typeKey build different keys into a reused buffer; classAt (innermost class span by byte) vs flipimpact innermostAtLine (by line, other table); impactViaColumn vs lintrules dependencyCapableMask share a bitmask idiom; the JS fixture encode|stringify is a deliberate twin (test/receiverevidencefix: same-named near misses); pargates.py is unchanged since cd87e30e (a regrouped pre-existing pair). The one real clone, the enclosing-function walk, was merged into innermostEnclosingFn instead of acked ack duplication a405a9c684c97953 781 now=781 was=0 prov=recon 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 a42cd763fdab0077 101 a probe TU deliberately mirrors layout declarations (#224 structlayout_probe.cpp) ack duplication a5943aaae0184b8f 30 readability-wave1 (naming lens): ncAnyOf is a one-expression std::find wrapper, sortNotes a one-expression std::stable_sort wrapper — 30 normalized tokens of the SAME STL-adapter idiom over unrelated types (a char-class set test vs a note sort). There is no call ncAnyOf could make to sortNotes, and rewriting the std::find as a hand loop would only hide the detector. Kept as written. ack duplication a702e882c0354944 44 now=44 was=0 prov=recon DISCLOSE sinks: a reason-to-field switch is the shape every multi-reason sink shares; -Wswitch exhaustiveness over DisclosureWhy is the point (Diagnostics.h 4b) ack duplication a7aef5cdfefbb6e0 89 now=89 was=0 prov=recon sibling row-emitter PAIR (writeUnindexedExtRows/writeLangRows) - exactly TWO members: the key a7aef5cdfefbb6e0 is fnv1a64('writeLangRows\nwriteUnindexedExtRows\n') and nothing else. WAVE-3 VERIFIER CORRECTION (P6-6): this reason previously named writeHealthRows as a third member, which is in NO clone group at all - that made the ack unverifiable against the ledger and made '2-instance' read as a three-way judgement it never was; and it cited 'the DOD doctrine' (G2), a DATA-LAYOUT guardrail that says nothing about a six-line emitter running once per report. The engineering call is unchanged and was independently attacked and NOT refuted. Its real authority is --quality-delta's own 'a wrong abstraction beats a low score' plus this repo's own fold threshold: src/mergescout.h:637 writeSymRows and src/main.cpp:8746 writeDropRows were folded because the attribute SCHEMA is identical and only the tag varies, while writeOversizeRows/writeDropRows sit deliberately UNFOLDED in the same block at a LARGER similarity=0.90 tokens=139, for exactly the discriminator that applies here - differing attribute sets. writeLangRows/writeUnindexedExtRows differ in the key attribute name (x= vs n=), the key SOURCE (a stored field ue.ext vs a derived rw::langTag) and the attribute arity (files= vs files=+symbols=), so they fall on the repo's own keep-separate side; every consolidation sketch is net longer, needs two callables per site, and drags the fixed-buffer snprintf into a lambda past test/fixedbufsweep.sh:259's per-site census (which this wave already had to re-pin 191->193). TREND, recorded rather than left silent: this six-emitter block now carries TWO unfolded clone pairs (139 preexisting + 89 new) and accretes sibling emitters by design. At a THIRD same-schema sibling, writeSymRows' three-caller precedent flips the answer - and it cannot creep there silently, because this ack is keyed at exactly tokens=89 over exactly these two members, so a third member is a new member set, a new group, and a new finding ack duplication a8ae4177bad34c1b 47 now=47 was=0 prov=recon facet=switch-name-table per-enumerator switches name every enumerator and carry no default: on purpose — that is what makes -Werror=switch a compile error at each one when an enumerator is appended; two such switches over one enum share the enumerator spelling by construction +ack duplication aaaee0ca15e7adfd 56 now=56 was=0 p=src/model.h:246 lane FE-B (receiver evidence): lexical clone pairs, not shared logic — the per-language predicates (implicitReceiverLang, classNameReceiverLang, memberNeverReachesFreeFunction, isReceiverEvidenceKind, callableKind, classLike, isThisRoot) are one-line OR-lists over different enum sets that share only the shape with quality.h/model.h/slice.h predicates asking other questions; keyOf/typeKey build different keys into a reused buffer; classAt (innermost class span by byte) vs flipimpact innermostAtLine (by line, other table); impactViaColumn vs lintrules dependencyCapableMask share a bitmask idiom; the JS fixture encode|stringify is a deliberate twin (test/receiverevidencefix: same-named near misses); pargates.py is unchanged since cd87e30e (a regrouped pre-existing pair). The one real clone, the enclosing-function walk, was merged into innermostEnclosingFn instead of acked ack duplication aada023eba533a38 35 now=35 was=0 prov=recon lane C plain-text prose tier (test/textdocscheck.sh): .rst/.adoc/.org/.mdx join kLangTable on Lang::Markdown so --recall can answer from an ADR that is not written in markdown. All five gating rows are this lane's own footprint. TWO short-horizon-churn churn=self rows: kLangTable is the language table this change exists to extend, and kParserVer is the cache key an extraction change is REQUIRED to move (ingest_cache.h's own note says so) — both are structural for any lane of this kind, not thrash. THREE clone rows on isMarkdownGrammarExtension, all 35-token idiom collisions on a one-line membership predicate: it now spells the sorted-table + std::binary_search + is_sorted static_assert shape that externalnames.h::isShellBuiltinName/isPythonBuiltin/isCFamilyStdName already carry (their own note at externalnames.h:98 records this exact collision and settles on this shape), and the KindCounts::total pair is a std::accumulate over std::begin/std::end normalizing to the same token stream. Two cheaper spellings were tried and REJECTED by measurement first: a hand-rolled scan loop is the five-instance clone shape ingest.h::isNonTextExtension's note already names, and a std::find one-liner cloned KindCounts::total alone. What was FIXED rather than acked in this pass: six duplication rows (the loop -> the house binary_search shape) and kLangTable's verbosity row 96->120 (the tiling essay moved out of the table body onto the seam above it). ack duplication aafecfcf6097b13e 20 now=20 was=0 prov=recon sendAll (mcpserver.h, network send-all-tolerating-short-writes) and pathguard::writeAll (file write-all-tolerating-short-writes) are the same RETRY-LOOP shape over different syscalls (send vs write/EINTR) and different domains (socket vs file descriptor) — the tree already acks this idiom repeatedly (e.g. the three os:: syscall passthroughs vs profilePmc.h): no shared helper exists to call, and folding network and file write-loops together would put a dispatch where a three-line loop is +ack duplication ac8c6ea52654e17b 33 now=33 was=0 p=test/pargates.py:510 lane FE-B (receiver evidence): lexical clone pairs, not shared logic — the per-language predicates (implicitReceiverLang, classNameReceiverLang, memberNeverReachesFreeFunction, isReceiverEvidenceKind, callableKind, classLike, isThisRoot) are one-line OR-lists over different enum sets that share only the shape with quality.h/model.h/slice.h predicates asking other questions; keyOf/typeKey build different keys into a reused buffer; classAt (innermost class span by byte) vs flipimpact innermostAtLine (by line, other table); impactViaColumn vs lintrules dependencyCapableMask share a bitmask idiom; the JS fixture encode|stringify is a deliberate twin (test/receiverevidencefix: same-named near misses); pargates.py is unchanged since cd87e30e (a regrouped pre-existing pair). The one real clone, the enclosing-function walk, was merged into innermostEnclosingFn instead of acked ack duplication af2e342eda9648ed 50 wave merge 2026-08-20 (harvestexec): isTypeMentionNode, from lane/resolver-precision. Both duplication rows pair it with an EXISTING member of src/ingest.cpp's kind-table family (isBaseTypeNode, isJsonShapeModifier) — a static const char* const table with one commented row per language, scanned by strcmp. What the clone detector sees is that shared idiom, and it is right that it is shared: it is the file's house pattern for a node-kind predicate, and every future language row lands as one commented line rather than an edit to a condition. Neither collapse is available. Merging into isBaseTypeNode would be a CORRECTNESS change, not a cleanup: base-type admits eight kinds (qualified_identifier, generic_type, user_type...) and a type MENTION must admit only type_identifier — that narrowness is precisely the round-1 ACCEPT result, and test/typerefcheck.sh fails if it widens. Collapsing today's single-row table to a bare strcmp would remove the per-language comment slot the siblings use and would have to be undone by the first non-C++ grammar added. Acked as a deliberate instance of an established shape, not as a shortcut. ack duplication b16454c12b64a72e 52 now=52 was=0 prov=recon cachefix fixture functions deliberately mirror common loop shapes; token-matches to unrelated src helpers are the fixture doing its job ack duplication b3ba17bfe8eaffde 38 taskroute v1 landing: (1) the five clone/new-clone rows are taskroute.h re-implementing word-boundary find / comma join / enum-name helpers that exist only as OTHER modules private-namespace statics (darkflags/docdrift/mcprefusal/accessshape) — importing them would cross-couple unrelated modules; consolidation home is the REGISTERED infra refactor round (PLAN board, ranked second) which owns the infra:: helper extraction; (2) kTotalFlagArms churn=self fires on every flag addition by construction (one-shared-bump discipline) — the field-report churn-advisory-unless-combined backlog item is the real fix; (3) complexity/verbosity on main is the new verb dispatch arm growing main.cpp main by the minimum a flag costs, MISATTRIBUTED by path to bench/agentloop/analyze.py:280 by the known cross-file churn-keying bug (fix exists unpushed at d593de3); analyze.py is untouched in this diff +ack duplication b3f940da0f8dbdd7 65 now=65 was=0 p=src/quality.h:1216 lane FE-B (receiver evidence): lexical clone pairs, not shared logic — the per-language predicates (implicitReceiverLang, classNameReceiverLang, memberNeverReachesFreeFunction, isReceiverEvidenceKind, callableKind, classLike, isThisRoot) are one-line OR-lists over different enum sets that share only the shape with quality.h/model.h/slice.h predicates asking other questions; keyOf/typeKey build different keys into a reused buffer; classAt (innermost class span by byte) vs flipimpact innermostAtLine (by line, other table); impactViaColumn vs lintrules dependencyCapableMask share a bitmask idiom; the JS fixture encode|stringify is a deliberate twin (test/receiverevidencefix: same-named near misses); pargates.py is unchanged since cd87e30e (a regrouped pre-existing pair). The one real clone, the enclosing-function walk, was merged into innermostEnclosingFn instead of acked ack duplication b54c8d83f3ad053f 60 now=60 was=0 p=src/infra/namesplit.h:157 token-shape duplication false positive from the F3 base-name hoist: namesplit::afterLast (rfind + remove_prefix) shares no logic with doctorWhichVersionLine (popen, find newline, resize). The real clone (noteRead / immediateScope / baseNameOf) is gone. ack duplication b6e9e385c201d22f 45 now=45 was=0 prov=recon incidental shape similarity of small JSON/string builders across unrelated protocols (#279 LSP wire helpers vs MCP/CLI/quality code); sharing a helper would couple the LSP wire format to them ack duplication b7f09332215eedbc 70 now=70 was=0 prov=recon 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 @@ -678,6 +711,7 @@ ack duplication c0e6e599da682c8a 36 now=36 was=0 prov=recon idiom-class clone: a ack duplication c0f9c47a32e956c7 71 now=71 was=0 prov=recon a probe TU deliberately mirrors layout declarations (#224 structlayout_probe.cpp) ack duplication c4adad88b18202d6 50 now=50 was=0 prov=recon DISCLOSE sinks: a reason-to-field switch is the shape every multi-reason sink shares; -Wswitch exhaustiveness over DisclosureWhy is the point (Diagnostics.h 4b) ack duplication c622254cf9e13d87 104 now=104 was=0 prov=recon 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 c63561350b4b9a0a 38 now=38 was=0 p=test/receiverevidencefix/js/lib/search.js:3 lane FE-B (receiver evidence): lexical clone pairs, not shared logic — the per-language predicates (implicitReceiverLang, classNameReceiverLang, memberNeverReachesFreeFunction, isReceiverEvidenceKind, callableKind, classLike, isThisRoot) are one-line OR-lists over different enum sets that share only the shape with quality.h/model.h/slice.h predicates asking other questions; keyOf/typeKey build different keys into a reused buffer; classAt (innermost class span by byte) vs flipimpact innermostAtLine (by line, other table); impactViaColumn vs lintrules dependencyCapableMask share a bitmask idiom; the JS fixture encode|stringify is a deliberate twin (test/receiverevidencefix: same-named near misses); pargates.py is unchanged since cd87e30e (a regrouped pre-existing pair). The one real clone, the enclosing-function walk, was merged into innermostEnclosingFn instead of acked ack duplication c665833b2734f5d0 28 now=28 was=0 prov=recon two --skipped parse-time classes, each a one-call wrapper over collectSkipClass; each conditional legend clause rides only with its own rows ack duplication c72c4118f59d3ca3 34 now=34 was=0 prov=recon D3 Windows port (lane/win32-d3-onto-os): four short token clones between os.h's Windows half and unrelated code, each a generic idiom rather than a shared rule — a one-line std::ranges::any_of membership lambda (win32TableProduces | cli.h shipsViewFlag, 34 tokens), a case-folding endsWith (oswin::endsWithAsciiCaseless | darkflags endsWithView, 45 tokens; os_win32_logic.h is a leaf infra header with no project includes, so it cannot call darkflags), an RAII reset (os_win32.cpp Unique::reset | pathguard OwnedFd::reset, 26 tokens; a HANDLE/SOCKET/HLOCAL owner cannot be an fd owner), and a three-term suffix OR (isWslLauncher | two unrelated predicates, 26 tokens). The larger clones (table scans, the PATHEXT reader) were refactored away instead of acked. ack duplication c7ac66bb47a14f56 155 now=155 was=0 prov=recon facet=switch-name-table per-enumerator switches name every enumerator and carry no default: on purpose — that is what makes -Werror=switch a compile error at each one when an enumerator is appended; two such switches over one enum share the enumerator spelling by construction @@ -701,6 +735,7 @@ ack duplication d6d46219be2f34de 44 now=44 was=0 prov=recon DISCLOSE sinks: a re ack duplication d74386e39efcdffa 36 by=src/extentsuspect.h now=36 was=0 prov=recon 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 d77ef6ec7d956c38 46 now=46 was=0 prov=recon audit pass 2026-08-06: per-cause as_* disclosure blocks in writeFieldAffinity follow the sibling one-if-per-attr pattern; chaseTypeCanPoint/isChaseRhsRow/looksLikePath are 2-call string predicates whose shared helper would be a worse abstraction; walks.cpp fixture twins are deliberate discriminating traps; short-horizon-churn is the audit editing just-landed code; runDefaultMap growth is the mapCtxOpenBytes fix + its measurement comment ack duplication d842417635ec5627 38 by=src/* now=38 was=0 prov=recon 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 duplication da2b082324acc9b0 20 now=20 was=0 p=src/model.h:114 honesty-small-068: lexical clone false positives on one-line helpers. isStructOrNamedType (a two-term kind test) matched routematch::methodsCompatible and localsCountedLang, and lspKindWord (a table read behind its EXPECTS) matched ConfigScopes::scope: each shares only the shape of a one-line boolean or indexed return, asks a different question of a different type, and a merge would name neither. Earlier rounds of this lane FIXED the real findings instead: pathText complexity/verbosity (one shared pathUnreachedXml clause), lspKindWord's switch near-clone of langTag (now a table), three sum/any helpers (sums carried). ack duplication daefdef824b8a96b 40 scopeless-fold lane: the five gating rows are the new mechanism itself, read one by one. (1) remapAckIdentity complexity 26->29: it resolves THREE rescue routes now (rename, content, and the 2026-08-25 key-scheme replay) where it resolved two; the growth is one route-selection ternary plus a third switch arm, and collapsing them would hide WHICH mechanism moved an ack, which is the disclosure this round exists to provide. A branchless array tally was tried and measured identical, so it bought nothing. (2)+(4) qualityKey pairs with resolve.h::canonicalIdRelTo: the similarity is deliberate and load-bearing. They take the same (ing, symbol, root) and differ ONLY in which identity question they answer - canonicalIdRelTo builds the ENTITY id (path::scope::name, which must keep folding the 21 extern-C tree_sitter_X declarations into the one C function they actually are), qualityKey builds the SOURCE key (pathQualifiedKey, which must not fold anything). Merging them would re-create the exact bug this lane fixed. (3)+(5) baselineHeaderIsForeign pairs with crossref::isNullSha at 32 tokens: a shape match between two unrelated three-line string predicates, not shared behaviour. It was extracted precisely BECAUSE the tool objected to the same logic inline in readBaseline; there is no third spelling that satisfies both. ack duplication dc449422d3ac892c 26 now=26 was=0 prov=recon D3 Windows port (lane/win32-d3-onto-os): four short token clones between os.h's Windows half and unrelated code, each a generic idiom rather than a shared rule — a one-line std::ranges::any_of membership lambda (win32TableProduces | cli.h shipsViewFlag, 34 tokens), a case-folding endsWith (oswin::endsWithAsciiCaseless | darkflags endsWithView, 45 tokens; os_win32_logic.h is a leaf infra header with no project includes, so it cannot call darkflags), an RAII reset (os_win32.cpp Unique::reset | pathguard OwnedFd::reset, 26 tokens; a HANDLE/SOCKET/HLOCAL owner cannot be an fd owner), and a three-term suffix OR (isWslLauncher | two unrelated predicates, 26 tokens). The larger clones (table scans, the PATHEXT reader) were refactored away instead of acked. ack duplication dc5ec483298d7df6 214 now=214 was=0 prov=recon 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) @@ -708,6 +743,7 @@ ack duplication dc9bb84f09aa1b94 50 now=50 was=0 prov=recon DISCLOSE sinks: a re ack duplication ddce9efe392269e6 61 now=61 was=0 prov=recon 2026-08-19 subtoken acronym round: lexSubtokenHash now lowercases EVERY byte (an all-caps run is one token, so interior uppercase is reachable), which turned its head-special-case into a plain absorb loop and made it a 61-token type-3 match for the two other FNV loops in the tree (mcpindex byteHash, clones cloneSketchMatches). Different basis constants and different semantics; unifying them is a separate round, and reshaping correct code purely to miss the clone detector would be gaming the number. ack duplication de772ce9497e6cc2 425 now=425 was=0 prov=recon 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) ack duplication dec50094e5aef506 74 now=74 was=0 prov=recon 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 ded26f9f9c045bfc 74 now=74 was=0 p=src/receiverevidence.h:50 lane FE-B (receiver evidence): lexical clone pairs, not shared logic — the per-language predicates (implicitReceiverLang, classNameReceiverLang, memberNeverReachesFreeFunction, isReceiverEvidenceKind, callableKind, classLike, isThisRoot) are one-line OR-lists over different enum sets that share only the shape with quality.h/model.h/slice.h predicates asking other questions; keyOf/typeKey build different keys into a reused buffer; classAt (innermost class span by byte) vs flipimpact innermostAtLine (by line, other table); impactViaColumn vs lintrules dependencyCapableMask share a bitmask idiom; the JS fixture encode|stringify is a deliberate twin (test/receiverevidencefix: same-named near misses); pargates.py is unchanged since cd87e30e (a regrouped pre-existing pair). The one real clone, the enclosing-function walk, was merged into innermostEnclosingFn instead of acked ack duplication df5a70dc16c85001 33 now=33 was=0 prov=recon 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 e0406bb747448d85 154 now=154 was=0 prov=recon ingest() decomposition: emitBindingAliases/emitRouteDefs are the FFI-alias and route-def sort ladders moved verbatim out of ingest()'s body into named phase helpers (ingest_model.h); as inline statements the clone scanner could not see them, as small functions their comparator shape token-matches sortLintRows - different element types and orderings, no shareable contract, zero behavior change (argvdiff byte-identical) ack duplication e2d22ac45d11d555 19 now=19 was=0 prov=recon review of #219: the run= relativity claim is CONDITIONAL, so the predicate has to reach every site that states it — prLegendText and writeFlipHeader take rootRelativeRuns (and its root= attribute) because the legend sentence and the command spelling must be decided by ONE fact, testmap.h runsAreRootRelative; a legend that claims a relativity the spelling does not have is the defect this round fixes. The duplication row pairs that 19-token predicate with mcp.h mcpOmitsGitVerbs, which asks a different question about a different field and shares only the shape of a two-term boolean return — merging them would name neither. @@ -721,6 +757,8 @@ ack duplication e73a3cff0eab73b6 44 now=44 was=0 prov=recon DISCLOSE sinks: a re ack duplication e75742478839369d 31 now=31 was=0 prov=recon 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 e762f1286e66d100 38 now=38 was=0 prov=recon DISCLOSE sinks: a reason-to-field switch is the shape every multi-reason sink shares; -Wswitch exhaustiveness over DisclosureWhy is the point (Diagnostics.h 4b) ack duplication e79604c61e955de9 130 now=130 was=0 prov=recon facet=switch-name-table a distinct display vocabulary over the same enum: #279's LSP hover words (function/variable/interface), not the terse XML tag table symTag +ack duplication e7b8b8f3fb63b7c5 65 now=65 was=0 p=src/quality.h:1216 lane FE-B (receiver evidence): lexical clone pairs, not shared logic — the per-language predicates (implicitReceiverLang, classNameReceiverLang, memberNeverReachesFreeFunction, isReceiverEvidenceKind, callableKind, classLike, isThisRoot) are one-line OR-lists over different enum sets that share only the shape with quality.h/model.h/slice.h predicates asking other questions; keyOf/typeKey build different keys into a reused buffer; classAt (innermost class span by byte) vs flipimpact innermostAtLine (by line, other table); impactViaColumn vs lintrules dependencyCapableMask share a bitmask idiom; the JS fixture encode|stringify is a deliberate twin (test/receiverevidencefix: same-named near misses); pargates.py is unchanged since cd87e30e (a regrouped pre-existing pair). The one real clone, the enclosing-function walk, was merged into innermostEnclosingFn instead of acked +ack duplication e828c02eebfa38f2 20 now=20 was=0 p=src/model.h:663 lane FE-B (receiver evidence): lexical clone pairs, not shared logic — the per-language predicates (implicitReceiverLang, classNameReceiverLang, memberNeverReachesFreeFunction, isReceiverEvidenceKind, callableKind, classLike, isThisRoot) are one-line OR-lists over different enum sets that share only the shape with quality.h/model.h/slice.h predicates asking other questions; keyOf/typeKey build different keys into a reused buffer; classAt (innermost class span by byte) vs flipimpact innermostAtLine (by line, other table); impactViaColumn vs lintrules dependencyCapableMask share a bitmask idiom; the JS fixture encode|stringify is a deliberate twin (test/receiverevidencefix: same-named near misses); pargates.py is unchanged since cd87e30e (a regrouped pre-existing pair). The one real clone, the enclosing-function walk, was merged into innermostEnclosingFn instead of acked ack duplication e91c5e004c251a91 24 now=24 was=0 prov=recon module-constant round (2026-08-12, test/moduleconstcheck.sh): the four short-horizon-churn rows are the documented extraction-bump protocol itself — kParserVer and its quality.h mirror MUST move in the same diff (qextractionkeycheck), dropConstantCapture is the policy function this round exists to change, and cudaMemorySpaceQualifierOf's edit is the dedup the quality gate itself demanded (169-token clone dissolved into childTokenAmong). The 24-token ncBoolTypeName|cudaMemorySpaceQualifierOf pair is a cross-domain wrapper-shape coincidence (naming-lens vocab membership vs tree-sitter child scan over disjoint token sets in different files); merging them would be the wrong abstraction the delta header warns against. ack duplication e94fce0d5caa811d 66 now=66 was=0 prov=recon 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 ea4ae03ca699bce4 35 now=35 was=0 prov=recon lane C plain-text prose tier (test/textdocscheck.sh): .rst/.adoc/.org/.mdx join kLangTable on Lang::Markdown so --recall can answer from an ADR that is not written in markdown. All five gating rows are this lane's own footprint. TWO short-horizon-churn churn=self rows: kLangTable is the language table this change exists to extend, and kParserVer is the cache key an extraction change is REQUIRED to move (ingest_cache.h's own note says so) — both are structural for any lane of this kind, not thrash. THREE clone rows on isMarkdownGrammarExtension, all 35-token idiom collisions on a one-line membership predicate: it now spells the sorted-table + std::binary_search + is_sorted static_assert shape that externalnames.h::isShellBuiltinName/isPythonBuiltin/isCFamilyStdName already carry (their own note at externalnames.h:98 records this exact collision and settles on this shape), and the KindCounts::total pair is a std::accumulate over std::begin/std::end normalizing to the same token stream. Two cheaper spellings were tried and REJECTED by measurement first: a hand-rolled scan loop is the five-instance clone shape ingest.h::isNonTextExtension's note already names, and a std::find one-liner cloned KindCounts::total alone. What was FIXED rather than acked in this pass: six duplication rows (the loop -> the house binary_search shape) and kLangTable's verbosity row 96->120 (the tiling essay moved out of the table body onto the seam above it). @@ -762,6 +800,7 @@ ack new-clone-of-reused-helper 0e9742973611fd71 3 T3 disclosure-gap fix 2026-08- ack new-clone-of-reused-helper 1096a473dedb29cf 4 now=4 was=0 prov=recon taskroute v1 landing: (1) the five clone/new-clone rows are taskroute.h re-implementing word-boundary find / comma join / enum-name helpers that exist only as OTHER modules private-namespace statics (darkflags/docdrift/mcprefusal/accessshape) — importing them would cross-couple unrelated modules; consolidation home is the REGISTERED infra refactor round (PLAN board, ranked second) which owns the infra:: helper extraction; (2) kTotalFlagArms churn=self fires on every flag addition by construction (one-shared-bump discipline) — the field-report churn-advisory-unless-combined backlog item is the real fix; (3) complexity/verbosity on main is the new verb dispatch arm growing main.cpp main by the minimum a flag costs, MISATTRIBUTED by path to bench/agentloop/analyze.py:280 by the known cross-file churn-keying bug (fix exists unpushed at d593de3); analyze.py is untouched in this diff ack new-clone-of-reused-helper 15ba8af502e0d24e 3 assignment types (narrowcheck arms 44-51): api-surface buildFieldNarrowTables gains the class-name set (1 to 2 params) because its local-name set must drop an assignment's callee-read type that no class is called, the same rule Rule 2's varType table applies, and the set is built once in buildGraph and once in collectFieldUseSites rather than re-derived per call. duplication/new-clone-of-reused-helper classNameSet ~ layout.h buildAggIndex (93 tokens): classNameSet is the Class/Struct/Interface loop buildGraph already carried inline for Rule 2c, hoisted into resolve.h so collectFieldUseSites can share it; buildAggIndex maps a name to its ids in layout.h, which includes graph.h, so resolve.h cannot reuse it and a common helper would invert the include order for a 7-line loop with a different value type. ack new-clone-of-reused-helper 1cd9e6b6ec0829c1 3 now=3 was=0 prov=recon 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 202edf39c263e3af 3 now=3 was=0 p=src/lsp.h:379 honesty-small-068: lexical clone false positives on one-line helpers. isStructOrNamedType (a two-term kind test) matched routematch::methodsCompatible and localsCountedLang, and lspKindWord (a table read behind its EXPECTS) matched ConfigScopes::scope: each shares only the shape of a one-line boolean or indexed return, asks a different question of a different type, and a merge would name neither. Earlier rounds of this lane FIXED the real findings instead: pathText complexity/verbosity (one shared pathUnreachedXml clause), lspKindWord's switch near-clone of langTag (now a table), three sum/any helpers (sums carried). ack new-clone-of-reused-helper 340861e6b3c185e4 3 now=3 was=0 prov=recon Two shapes, both read and both kept. (1) docDriftText/strayContentText/flipText are three-line index-plus-captureXml front doors that were already near-identical; adding a page argument moved two of them across the 95-token bar. Folding them costs a wrapper that hides which compute call each makes, on the exact seam an MCP reader needs to see. (2) budgetBytesForTokens vs ceilingAllowanceBytes is a SHAPE match, not a duplicate: one spends kBudgetHeadroom (the shaping budget), the other kCeilingFirstEntryTolerance (the overshoot bar), they are used in opposite directions, and serialize.h states the distinction where both are read. Merging them would be the real bug. ack new-clone-of-reused-helper 36e29c8cfeef798b 120 now=120 was=0 prov=recon 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) ack new-clone-of-reused-helper 395f3040c6dd8ee1 5 now=5 was=0 prov=recon a probe TU deliberately mirrors layout declarations (#224 structlayout_probe.cpp) @@ -785,6 +824,7 @@ ack new-clone-of-reused-helper 6a595c85c47abd60 5 now=5 was=0 prov=recon facet=s ack new-clone-of-reused-helper 6a7680fbd8a5083d 4 by=src/* now=4 was=0 prov=recon 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 now=4 was=0 prov=recon 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 73e06cf71f4ea8bc 5 now=5 was=0 p=src/ingest_binds.h:1263 lane FE-B (receiver evidence): lexical clone pairs, not shared logic — the per-language predicates (implicitReceiverLang, classNameReceiverLang, memberNeverReachesFreeFunction, isReceiverEvidenceKind, callableKind, classLike, isThisRoot) are one-line OR-lists over different enum sets that share only the shape with quality.h/model.h/slice.h predicates asking other questions; keyOf/typeKey build different keys into a reused buffer; classAt (innermost class span by byte) vs flipimpact innermostAtLine (by line, other table); impactViaColumn vs lintrules dependencyCapableMask share a bitmask idiom; the JS fixture encode|stringify is a deliberate twin (test/receiverevidencefix: same-named near misses); pargates.py is unchanged since cd87e30e (a regrouped pre-existing pair). The one real clone, the enclosing-function walk, was merged into innermostEnclosingFn instead of acked ack new-clone-of-reused-helper 7981b72e3b2a8a8b 3 now=3 was=0 prov=recon 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 7ad7c707204ff7b3 14 now=14 was=0 prov=recon qsnapPut is constrained to types with unique object representations instead of asserting trivially-copyable, which leaves its body the same one-line byte append ByteW::u32 is; the two live in different translation units on purpose (quality.h cannot include the ingest cache) ack new-clone-of-reused-helper 845088dc7642af45 4 now=4 was=0 prov=recon 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 @@ -800,11 +840,13 @@ ack new-clone-of-reused-helper b96b091322ca90c2 3 by=src/extentsuspect.h now=3 w ack new-clone-of-reused-helper bea5a095bdc44cd5 5 now=5 was=0 prov=recon Integration train 2b: the gate-local one-line helpers run(){ perl -e 'alarm 30; exec @ARGV' "$BIN" "$@" 2>/dev/null; }, cnt() and defs() in test/cpptmplscopecheck.sh (#256) beside test/cppqualcheck.sh and test/callformcheck.sh (#243) are the self-contained-gate idiom this repo spells inline in each gate; test/lib has no shared runner or attribute reader to source, and a gate is meant to read and run on its own. The clone count moved from 3 to 5 only because both lanes' gates now share one tree; #256 acked the same idiom at 3. ack new-clone-of-reused-helper c085689a09c879ee 3 now=3 was=0 prov=recon 2026-08-19 subtoken acronym round: lexSubtokenHash now lowercases EVERY byte (an all-caps run is one token, so interior uppercase is reachable), which turned its head-special-case into a plain absorb loop and made it a 61-token type-3 match for the two other FNV loops in the tree (mcpindex byteHash, clones cloneSketchMatches). Different basis constants and different semantics; unifying them is a separate round, and reshaping correct code purely to miss the clone detector would be gaming the number. ack new-clone-of-reused-helper c0e6e599da682c8a 3 now=3 was=0 prov=recon idiom-class clone: a NAMED one-line std::find predicate. What matches is the std::find idiom itself — 36 normalized tokens, zero shared domain identifiers with ncAnyOf/namesNode, different value types, different subsystems. Inlining it at its two call sites to dodge the row would be metric-gaming; the name is the documentation. +ack new-clone-of-reused-helper c63561350b4b9a0a 3 now=3 was=0 p=test/receiverevidencefix/js/lib/search.js:3 lane FE-B (receiver evidence): lexical clone pairs, not shared logic — the per-language predicates (implicitReceiverLang, classNameReceiverLang, memberNeverReachesFreeFunction, isReceiverEvidenceKind, callableKind, classLike, isThisRoot) are one-line OR-lists over different enum sets that share only the shape with quality.h/model.h/slice.h predicates asking other questions; keyOf/typeKey build different keys into a reused buffer; classAt (innermost class span by byte) vs flipimpact innermostAtLine (by line, other table); impactViaColumn vs lintrules dependencyCapableMask share a bitmask idiom; the JS fixture encode|stringify is a deliberate twin (test/receiverevidencefix: same-named near misses); pargates.py is unchanged since cd87e30e (a regrouped pre-existing pair). The one real clone, the enclosing-function walk, was merged into innermostEnclosingFn instead of acked ack new-clone-of-reused-helper c7ac66bb47a14f56 3 now=3 was=0 prov=recon facet=switch-name-table per-enumerator switches name every enumerator and carry no default: on purpose — that is what makes -Werror=switch a compile error at each one when an enumerator is appended; two such switches over one enum share the enumerator spelling by construction ack new-clone-of-reused-helper c8b32435256b863e 4 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 new-clone-of-reused-helper ca79cc414dda8197 5 now=5 was=0 prov=recon Integration train 2b (PR #244, TS/JS literal receivers): isJsTsLitRecv (a five-case RecvKind membership test), jsLitCtorName (a five-case RecvKind -> constructor-name switch) and isJsTsBuiltinCtor (a five-string membership test) are each the smallest spelling of one fact about the Lit* enumerators appended in the same PR. Their token runs match refRoleTag, styleTag, votingStyle, langUsesHashComment, elixirTypedAttribute and a clone-idiom fixture by SHAPE (a short enum switch, an OR of equality tests), not by shared logic: no helper exists they could call, and folding unrelated enums into one table would couple them. Acked at the train that first measured them on the merged tree; the three stay adjacent in model.h beside the enum they describe. ack new-clone-of-reused-helper cf6c83caaec68565 10 now=10 was=0 prov=recon taskroute v1 landing: (1) the five clone/new-clone rows are taskroute.h re-implementing word-boundary find / comma join / enum-name helpers that exist only as OTHER modules private-namespace statics (darkflags/docdrift/mcprefusal/accessshape) — importing them would cross-couple unrelated modules; consolidation home is the REGISTERED infra refactor round (PLAN board, ranked second) which owns the infra:: helper extraction; (2) kTotalFlagArms churn=self fires on every flag addition by construction (one-shared-bump discipline) — the field-report churn-advisory-unless-combined backlog item is the real fix; (3) complexity/verbosity on main is the new verb dispatch arm growing main.cpp main by the minimum a flag costs, MISATTRIBUTED by path to bench/agentloop/analyze.py:280 by the known cross-file churn-keying bug (fix exists unpushed at d593de3); analyze.py is untouched in this diff ack new-clone-of-reused-helper d11c67611bc73d49 3 mcpgrepdegradedcheck round (2026-08-30): the gate's mcp_text/batch_sub/call shell harness is the 7th deliberate copy of mcpclidiffcheck's — gate scripts are self-contained by design (each runs standalone under RIPWIRE_BIN with no shared lib to source), the same trade every earlier MCP gate made; a shared test/lib refactor is its own round, not this diff's +ack new-clone-of-reused-helper da2b082324acc9b0 4 now=4 was=0 p=src/model.h:114 honesty-small-068: lexical clone false positives on one-line helpers. isStructOrNamedType (a two-term kind test) matched routematch::methodsCompatible and localsCountedLang, and lspKindWord (a table read behind its EXPECTS) matched ConfigScopes::scope: each shares only the shape of a one-line boolean or indexed return, asks a different question of a different type, and a merge would name neither. Earlier rounds of this lane FIXED the real findings instead: pathText complexity/verbosity (one shared pathUnreachedXml clause), lspKindWord's switch near-clone of langTag (now a table), three sum/any helpers (sums carried). ack new-clone-of-reused-helper daefdef824b8a96b 3 scopeless-fold lane: the five gating rows are the new mechanism itself, read one by one. (1) remapAckIdentity complexity 26->29: it resolves THREE rescue routes now (rename, content, and the 2026-08-25 key-scheme replay) where it resolved two; the growth is one route-selection ternary plus a third switch arm, and collapsing them would hide WHICH mechanism moved an ack, which is the disclosure this round exists to provide. A branchless array tally was tried and measured identical, so it bought nothing. (2)+(4) qualityKey pairs with resolve.h::canonicalIdRelTo: the similarity is deliberate and load-bearing. They take the same (ing, symbol, root) and differ ONLY in which identity question they answer - canonicalIdRelTo builds the ENTITY id (path::scope::name, which must keep folding the 21 extern-C tree_sitter_X declarations into the one C function they actually are), qualityKey builds the SOURCE key (pathQualifiedKey, which must not fold anything). Merging them would re-create the exact bug this lane fixed. (3)+(5) baselineHeaderIsForeign pairs with crossref::isNullSha at 32 tokens: a shape match between two unrelated three-line string predicates, not shared behaviour. It was extracted precisely BECAUSE the tool objected to the same logic inline in readBaseline; there is no third spelling that satisfies both. ack new-clone-of-reused-helper dc5ec483298d7df6 16 now=16 was=0 prov=recon 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) ack new-clone-of-reused-helper ddce9efe392269e6 4 now=4 was=0 prov=recon 2026-08-19 subtoken acronym round: lexSubtokenHash now lowercases EVERY byte (an all-caps run is one token, so interior uppercase is reachable), which turned its head-special-case into a plain absorb loop and made it a 61-token type-3 match for the two other FNV loops in the tree (mcpindex byteHash, clones cloneSketchMatches). Different basis constants and different semantics; unifying them is a separate round, and reshaping correct code purely to miss the clone detector would be gaming the number. @@ -813,6 +855,7 @@ ack new-clone-of-reused-helper dec50094e5aef506 3 now=3 was=0 prov=recon OPTREMA ack new-clone-of-reused-helper e5435e09a9b2e2be 3 now=3 was=0 prov=recon the quality baseline read seam takes its DISCLOSE sink like the notes and arch seams; the three seams keep one O_NOFOLLOW shape by design 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 e79604c61e955de9 42 now=42 was=0 prov=recon facet=switch-name-table a distinct display vocabulary over the same enum: #279's LSP hover words (function/variable/interface), not the terse XML tag table symTag +ack new-clone-of-reused-helper e828c02eebfa38f2 4 now=4 was=0 p=src/model.h:663 lane FE-B (receiver evidence): lexical clone pairs, not shared logic — the per-language predicates (implicitReceiverLang, classNameReceiverLang, memberNeverReachesFreeFunction, isReceiverEvidenceKind, callableKind, classLike, isThisRoot) are one-line OR-lists over different enum sets that share only the shape with quality.h/model.h/slice.h predicates asking other questions; keyOf/typeKey build different keys into a reused buffer; classAt (innermost class span by byte) vs flipimpact innermostAtLine (by line, other table); impactViaColumn vs lintrules dependencyCapableMask share a bitmask idiom; the JS fixture encode|stringify is a deliberate twin (test/receiverevidencefix: same-named near misses); pargates.py is unchanged since cd87e30e (a regrouped pre-existing pair). The one real clone, the enclosing-function walk, was merged into innermostEnclosingFn instead of acked ack new-clone-of-reused-helper ef69b96712422a31 3 by=src/extentsuspect.h now=3 was=0 prov=recon 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 now=3 was=0 prov=recon 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. @@ -820,6 +863,7 @@ ack new-clone-of-reused-helper ff14ff2b821304c8 4 by=src/* slice honesty round ( ack new-clone-of-reused-helper ffbb89e07f18c870 42 now=42 was=0 prov=recon per-enumerator switches name every enumerator and carry no default: on purpose — that is what makes -Werror=switch a compile error at each one when an enumerator is appended; two such switches over one enum share the enumerator spelling by construction 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. ack params 060a064b6ffa7775 9 cid=f210de8290422917 now=9 was=8 p=src/quality.h:7477 lane/ack-provenance-backfill: the legacy-ack backfill (clone kinds reconstructed from the current tree, marked prov=recon), its refresh/unverified freshness rule, and qackconcurrencycheck arm 9 — plus this lane's own minor footprint on the ack reader/writer it extends +ack params 08c6875a741930ba 9 cid=38a064a2fb06dae6 now=9 was=8 p=src/verbs_for.h:2103 lane FE-B (receiver evidence, via="name"): the growth is the hedge's own surfaces — memberOnlyReceiver/receiverOf read the receiver chain (memberPath/memberCtor) every language's member call now records; emitColumnarSymbolRows, emitCalleeCallsBlock and MCP pathText emit via="name" and its legend sentence only when a row carries it; packBodies/packHops take the per-edge hedge bits and the legend-charged flag (one parameter each, every caller updated). Each is gate-pinned by test/receiverevidencecheck.sh (CLI/MCP parity arms); splitting the emitters is a joint map-rows lane after train 26b (owner 2026-10-07), which redesigns these rows ack params 08d796d1b006560c 6 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 params 0a3d16d6f3139408 10 cid=e2b9873df9888866 by=src/* 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: card A1 pre-apply preview: preview= is a DEFAULTED flag on the ONE edit-check assembler rather than a second emitter — two emitters could drift, and a preview that disagrees with the post-hoc answer is worth nothing (test/editpreviewcheck.sh compares the two documents byte-for-byte). The +20 LOC and the churn are the legend sentence that tells a reader the numbers describe bytes that were never written. ack params 0d7b8741392284d1 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. @@ -830,9 +874,11 @@ ack params 19e15f944de795a8 6 cid=e1412db291c8eaa4 timsort vendoring: every row ack params 1c11c9480374c3a4 7 cid=5ca90f74bad127e0 owner 2026-09-19: MCP for twin discloses stubbed sections; readable || kept over a gate-shaped |; forTaskText split is a separate refactor | 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 params 28c51bbd0032f9ec 9 cid=bdd33965f128de92 bench/capsweep write_screen renders the nine screen.tsv columns capsweepcheck pins by name; a record object would move the schema the gate reads ack params 3478654139c90f0f 7 cid=8524565c31be122d review of #219: the run= relativity claim is CONDITIONAL, so the predicate has to reach every site that states it — prLegendText and writeFlipHeader take rootRelativeRuns (and its root= attribute) because the legend sentence and the command spelling must be decided by ONE fact, testmap.h runsAreRootRelative; a legend that claims a relativity the spelling does not have is the defect this round fixes. The duplication row pairs that 19-token predicate with mcp.h mcpOmitsGitVerbs, which asks a different question about a different field and shares only the shape of a two-term boolean return — merging them would name neither. -ack params 3561d0281d324276 13 V1 harvest 2026-08-15: packBodies +8 cx/+20 LOC is the withFileContext branch + fileCtx table build/lookup for octocode F2's sibs=/inc=; the attribute-building itself was extracted to appendFileExpandContextAttrs (mirroring the pre-existing emitCalleeCallsBlock split) to keep this at the minimum needed to wire the new opt-in path +ack params 3561d0281d324276 17 cid=6013544ad9e00162 now=17 was=15 p=src/serialize.h:6226 lane FE-B (receiver evidence, via="name"): the growth is the hedge's own surfaces — memberOnlyReceiver/receiverOf read the receiver chain (memberPath/memberCtor) every language's member call now records; emitColumnarSymbolRows, emitCalleeCallsBlock and MCP pathText emit via="name" and its legend sentence only when a row carries it; packBodies/packHops take the per-edge hedge bits and the legend-charged flag (one parameter each, every caller updated). Each is gate-pinned by test/receiverevidencecheck.sh (CLI/MCP parity arms); splitting the emitters is a joint map-rows lane after train 26b (owner 2026-10-07), which redesigns these rows | prior: V1 harvest 2026-08-15: packBodies +8 cx/+20 LOC is the withFileContext branch + fileCtx table build/lookup for octocode F2's sibs=/inc=; the attribute-building itself was extracted to appendFileExpandContextAttrs (mirroring the pre-existing emitCalleeCallsBlock split) to keep this at the minimum needed to wire the new opt-in path +ack params 382f69e9fab00162 7 cid=3f6e5cc8c76d7cb6 now=7 was=0 p=src/graph.h:5207 lane FE-B (receiver evidence, via="name"): the growth is the hedge's own surfaces — memberOnlyReceiver/receiverOf read the receiver chain (memberPath/memberCtor) every language's member call now records; emitColumnarSymbolRows, emitCalleeCallsBlock and MCP pathText emit via="name" and its legend sentence only when a row carries it; packBodies/packHops take the per-edge hedge bits and the legend-charged flag (one parameter each, every caller updated). Each is gate-pinned by test/receiverevidencecheck.sh (CLI/MCP parity arms); splitting the emitters is a joint map-rows lane after train 26b (owner 2026-10-07), which redesigns these rows ack params 3c07d993bfdbce53 9 cid=d0076087db5b1b9b 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 | prior: or-arise rung 2 (--slice-flow/--slice-depth): the ten gating rows are this lane's own flag-addition footprint and nothing foreign — printUsage/validateConfig grow the two new help entries and three refusal arms every modifier flag must add; sliceBundleText/sliceWalk grow the flow legend+rows and the all-occurrence output param (contract kept source-compatible via defaulted flowSpec); runSlice grows the seed-VAR refusal and flow wiring; per-symbol growth is the cost of the registered rung-2 contract in EVALS ack params 453ce415b663d773 6 cid=27e74116f23adc21 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 params 45bb9f7c5bc37916 12 cid=c8ed741b38b4c0a9 now=12 was=10 p=src/serialize.h:6655 lane FE-B (receiver evidence, via="name"): the growth is the hedge's own surfaces — memberOnlyReceiver/receiverOf read the receiver chain (memberPath/memberCtor) every language's member call now records; emitColumnarSymbolRows, emitCalleeCallsBlock and MCP pathText emit via="name" and its legend sentence only when a row carries it; packBodies/packHops take the per-edge hedge bits and the legend-charged flag (one parameter each, every caller updated). Each is gate-pinned by test/receiverevidencecheck.sh (CLI/MCP parity arms); splitting the emitters is a joint map-rows lane after train 26b (owner 2026-10-07), which redesigns these rows ack params 49e172c2aa455e68 6 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 params 4b788e6f0a75bc80 6 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 params 4d421276056367a9 6 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. @@ -843,9 +889,10 @@ ack params 5710beada2095a34 9 cid=3371683faa811117 preloaded-corpus hoist: lexic ack params 59f050855874875f 6 cid=79ec141006215e25 P9 the folded edit receipt: runEditVerb gains the postCheck opt-out parameter (defaulted true, so every existing call site is source-compatible) and the line-range + post-check splice; editplan's ensureStage and receipt each gain the root parameter they need to spell one identity (M12's root-relative rule, applied to the sibling it missed) and to run the per-op post-check. The +15 LOC in runEditVerb is the copy-out-before-the-index-rebuild discipline the fold requires — every reference into ing dangles once getIndex re-ingests, and that is stated in the code. ack params 5ec38fbd414fa4d4 6 cid=35883c203ba33e28 lane/tc-sliceat MCP half: dispatchMcpLine +9cx/+21LOC is the per-verb dispatch cost every advertised tool pays (the var/flow/depth arg reads, the depth band static_assert, and the one slice branch); the verb logic itself lives in mcpverbs.h sliceText (new-symbol, mirrors the CLI runSlice refusal-for-refusal with sliceBundleText as the one shared emitter). Gate-covered red-first in test/mcpslicecheck.sh ack params 6302e2e27e23bcde 6 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 params 6e58b0a307757079 23 cid=912117812c5cc12d by=src/* Phase 4 lane (lpin= disclosure + localityKey tie-break, 2026-09-03): serialize/serializeJson each gain ONE trailing defaulted locPinOut param (the identical shape every honesty counter took — ambOut/unresolvedOut/bind); classifyPin churn=self is the one-line reroute of its Locality outcome through isLocalityPin so the shipped marker and the census label are the same predicate; runAround churn=self is the one-argument extension at its serialize call, the same edit every serialize caller took (main.cpp x4, mcpverbs analyze). Six duplicated sum/at chains folded into counterTotal/counterAt in the same change; astropy map + census byte-identical before and after that fold. +ack params 6e58b0a307757079 27 cid=cd68c661a9c12758 by=src/* now=27 was=26 p=src/serialize.h:8704 lane FE-B (receiver evidence, via="name"): the growth is the hedge's own surfaces — memberOnlyReceiver/receiverOf read the receiver chain (memberPath/memberCtor) every language's member call now records; emitColumnarSymbolRows, emitCalleeCallsBlock and MCP pathText emit via="name" and its legend sentence only when a row carries it; packBodies/packHops take the per-edge hedge bits and the legend-charged flag (one parameter each, every caller updated). Each is gate-pinned by test/receiverevidencecheck.sh (CLI/MCP parity arms); splitting the emitters is a joint map-rows lane after train 26b (owner 2026-10-07), which redesigns these rows | prior: Phase 4 lane (lpin= disclosure + localityKey tie-break, 2026-09-03): serialize/serializeJson each gain ONE trailing defaulted locPinOut param (the identical shape every honesty counter took — ambOut/unresolvedOut/bind); classifyPin churn=self is the one-line reroute of its Locality outcome through isLocalityPin so the shipped marker and the census label are the same predicate; runAround churn=self is the one-argument extension at its serialize call, the same edit every serialize caller took (main.cpp x4, mcpverbs analyze). Six duplicated sum/at chains folded into counterTotal/counterAt in the same change; astropy map + census byte-identical before and after that fold. ack params 775b773b1a3d2349 11 cid=dff500c80ee403ac 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 params 7c2c696cc3c55bd4 8 cid=fafc1666106ab470 E1 seam rules (terminality round A): applyEdit gains the SeamInfo out-param (7->8) so every surface can disclose trailing_newline_folded/separator_padded; the 7-arg wrapper was removed rather than kept as a duplicate +ack params 7c8bcef86c83b1aa 10 cid=280a01aa1f0483f0 now=10 was=9 p=src/verbs_for.h:1867 lane FE-B (receiver evidence, via="name"): the growth is the hedge's own surfaces — memberOnlyReceiver/receiverOf read the receiver chain (memberPath/memberCtor) every language's member call now records; emitColumnarSymbolRows, emitCalleeCallsBlock and MCP pathText emit via="name" and its legend sentence only when a row carries it; packBodies/packHops take the per-edge hedge bits and the legend-charged flag (one parameter each, every caller updated). Each is gate-pinned by test/receiverevidencecheck.sh (CLI/MCP parity arms); splitting the emitters is a joint map-rows lane after train 26b (owner 2026-10-07), which redesigns these rows ack params 80a2f3a7c92fbed4 6 cid=b272c333d7d8f297 Narrower's inline members are measured under its constructor; the growth is the reviewed using-declaration re-export and Rule 2c member-field veto rules (lane/using-base-reexport, #280) ack params 81fbe59b4a35659b 11 cid=c5e9778e250e41f1 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 params 851e83b4505f10f6 12 cid=1730ab232c9a0037 by=src/* A2 (dropped_positive, 2026-09-03): collectJsonSigEntries gained the rank + positivesContentSkippedOut trailing params and the three-way positive/content-skip/budget split droppedPositiveCount needs — the minimum surface to feed the shared arithmetic; see docs/EVALS.md A2 registration. @@ -858,8 +905,10 @@ ack params a6718fdf5bbb9f01 6 cid=0a16bab15ec4d994 timsort vendoring: every row ack params a8b774025a21bdc6 6 cid=8c396521251254f3 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 params ab7737f3582352d5 6 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 params c02fa9205afe3baf 7 pack-task budget round (verifier K1+K2, 2026-08-19): +4 ccx / +14 LOC in packTaskBundleText is the section REORDER (bodies allocated last, after the four fine-grained prefix sections) plus the one-shot reflow lap's ranking branch; the three list-section top-ups were factored into reflowListSection rather than inlined, which is why the delta is this small. selectMonotoneBodySubset +1 ccx / +8 LOC is the one early return that admits the top-ranked candidate at every pool. churn=self on both is this change's own edit window. Measured payoff on this repo, --pack-task=rank the call graph: callers 13/20 to 20/20 and fill 52.5% to 59.8% at the default 6000-token budget, 43.5% to 90.6% at 8000; the task-named body now survives every budget increase. Both properties gated red-first in test/packtaskmonotoncheck.sh -ack params ca97a4b6bf07887b 25 cid=72922b04b834af89 by=src/* Phase 4 lane (lpin= disclosure + localityKey tie-break, 2026-09-03): serialize/serializeJson each gain ONE trailing defaulted locPinOut param (the identical shape every honesty counter took — ambOut/unresolvedOut/bind); classifyPin churn=self is the one-line reroute of its Locality outcome through isLocalityPin so the shipped marker and the census label are the same predicate; runAround churn=self is the one-argument extension at its serialize call, the same edit every serialize caller took (main.cpp x4, mcpverbs analyze). Six duplicated sum/at chains folded into counterTotal/counterAt in the same change; astropy map + census byte-identical before and after that fold. +ack params c4d50b7393d16274 8 cid=62d07177d287ad05 now=8 was=7 p=src/verbs_navigate.h:74 lane FE-B (receiver evidence, via="name"): the growth is the hedge's own surfaces — memberOnlyReceiver/receiverOf read the receiver chain (memberPath/memberCtor) every language's member call now records; emitColumnarSymbolRows, emitCalleeCallsBlock and MCP pathText emit via="name" and its legend sentence only when a row carries it; packBodies/packHops take the per-edge hedge bits and the legend-charged flag (one parameter each, every caller updated). Each is gate-pinned by test/receiverevidencecheck.sh (CLI/MCP parity arms); splitting the emitters is a joint map-rows lane after train 26b (owner 2026-10-07), which redesigns these rows +ack params ca97a4b6bf07887b 29 cid=cfe6534d0f63b7c4 by=src/* now=29 was=28 p=src/serialize.h:2626 lane FE-B (receiver evidence, via="name"): the growth is the hedge's own surfaces — memberOnlyReceiver/receiverOf read the receiver chain (memberPath/memberCtor) every language's member call now records; emitColumnarSymbolRows, emitCalleeCallsBlock and MCP pathText emit via="name" and its legend sentence only when a row carries it; packBodies/packHops take the per-edge hedge bits and the legend-charged flag (one parameter each, every caller updated). Each is gate-pinned by test/receiverevidencecheck.sh (CLI/MCP parity arms); splitting the emitters is a joint map-rows lane after train 26b (owner 2026-10-07), which redesigns these rows | prior: Phase 4 lane (lpin= disclosure + localityKey tie-break, 2026-09-03): serialize/serializeJson each gain ONE trailing defaulted locPinOut param (the identical shape every honesty counter took — ambOut/unresolvedOut/bind); classifyPin churn=self is the one-line reroute of its Locality outcome through isLocalityPin so the shipped marker and the census label are the same predicate; runAround churn=self is the one-argument extension at its serialize call, the same edit every serialize caller took (main.cpp x4, mcpverbs analyze). Six duplicated sum/at chains folded into counterTotal/counterAt in the same change; astropy map + census byte-identical before and after that fold. ack params cc1e357ae3abf4b0 6 cid=27af8baf185c5ada main.cpp split 2026-08-29, stage 1: deliberate promotion of the six cross-family helpers to their domain headers (gitChangedFiles->situ.h, gitChurnCounts/mineChurnPerFile->gitmine.h, the dead-code trio->quality.h) as rw/rw::quality inline — the only intended api-surface change of the split; bodies verbatim, argvdiffcheck-proven byte-identical +ack params cfa725743786fd0c 11 cid=43654511ac4641e4 now=11 was=10 p=src/verbs_for.h:2174 lane FE-B (receiver evidence, via="name"): the growth is the hedge's own surfaces — memberOnlyReceiver/receiverOf read the receiver chain (memberPath/memberCtor) every language's member call now records; emitColumnarSymbolRows, emitCalleeCallsBlock and MCP pathText emit via="name" and its legend sentence only when a row carries it; packBodies/packHops take the per-edge hedge bits and the legend-charged flag (one parameter each, every caller updated). Each is gate-pinned by test/receiverevidencecheck.sh (CLI/MCP parity arms); splitting the emitters is a joint map-rows lane after train 26b (owner 2026-10-07), which redesigns these rows ack params d42c85b67bd0956f 7 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. ack params d9c7b2164cb0b8c5 6 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 params dda0db55532bd5e1 12 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. @@ -869,6 +918,7 @@ ack params ed4b3f43f7f19909 9 T3 disclosure-gap fix 2026-08-22: verbosity/churn ack params eea83c3db0f03d69 19 cid=012e11d249150604 by=src/* 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 params f5c01847f9a77e62 6 cid=68a5c5ed63c1ce7b 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 params f6dedd851e4b2a25 10 cid=8b170f73ca092237 or-arise rung 2 (--slice-flow/--slice-depth): the ten gating rows are this lane's own flag-addition footprint and nothing foreign — printUsage/validateConfig grow the two new help entries and three refusal arms every modifier flag must add; sliceBundleText/sliceWalk grow the flow legend+rows and the all-occurrence output param (contract kept source-compatible via defaulted flowSpec); runSlice grows the seed-VAR refusal and flow wiring; per-symbol growth is the cost of the registered rung-2 contract in EVALS +ack params f843cd4aaded1a41 7 cid=23a2ed80b7310ad0 now=7 was=0 p=src/ingest_binds.h:4141 lane FE-B (receiver evidence, via="name"): the growth is the hedge's own surfaces — memberOnlyReceiver/receiverOf read the receiver chain (memberPath/memberCtor) every language's member call now records; emitColumnarSymbolRows, emitCalleeCallsBlock and MCP pathText emit via="name" and its legend sentence only when a row carries it; packBodies/packHops take the per-edge hedge bits and the legend-charged flag (one parameter each, every caller updated). Each is gate-pinned by test/receiverevidencecheck.sh (CLI/MCP parity arms); splitting the emitters is a joint map-rows lane after train 26b (owner 2026-10-07), which redesigns these rows ack short-horizon-churn 0024ff89023ae1f8 5 S2c classifier-gap round: churn=self on meter_init/meter_classify_bash/meter_classify_other is this round's own edit window on hooks/ripwire-nudge.sh, which the S2b sweep round, the fixture-isolation round and this one all landed in on the same day — the horizon is counting one wave of meter work, not an unstable symbol. No behaviour is defended by the ack: every classifier verdict is pinned line-by-line by test/hookcheck.sh section (12b) arms C4-C11, each verified RED against this file at origin/main before the fix existed, and the arm contract by A1-A4. The verbosity row on meter_classify_bash (59 to 67 LOC, minor, unacked) is the resolution order becoming explicit — head, walk, scan, shell-misc — and is left visible on purpose ack short-horizon-churn 00e484ac1f953c3d 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 0130d0ed5ed1f4cd 2 cid=ced9892f114d85a0 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. @@ -1527,10 +1577,12 @@ ack verbosity 218041bccb277b92 104 cid=69bc85c464d51fa1 lane B1 cap disclosure: ack verbosity 225d0a144176c808 115 cid=fdf6f7041c20fcbe 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 verbosity 24637f7c69207cb9 1135 cid=b7513601dadd2b3e t14-cleanup: heals stale ack-ledger identities (kParserVer moved since these rows were written) and acks this lane's own minor footprint — printSkillScanArtifact's +1 defaulted fullLegend param, packTaskBundleText's bodyless-placeholder clause, kHelpHead's --readability validation note, kCompactAttributeReadings' merge-scout anchoring= entry — all additive/self-contained, none foreign ack verbosity 28449092af4cecb7 74 cid=ca3960652d2d85b9 by=src/* round-4 F-02 (tested= partition disclosure): the three gating rows are short-horizon-churn churn=self on the three seams the one new legend clause is spliced at — callHierarchyLegendOpen (callers/callees), impactText (the MCP impact twin) and runImpact (the CLI impact arm). No logic moved: each is a +1 %s in an existing printf argument list, and the clause itself is a single constexpr string in graphlegend.h. The disclosure exists because the tested= lens walks CALL EDGES out of indexed test symbols, so a shell or CLI-level test driving the built binary as a subprocess is invisible to it — on this repo own src/, tested almost entirely by ~500 test/*.sh gates, --impact read radius_untested=48 and --callers hop_untested=9 with nothing in either legend saying what untested meant there. Placement follows the 0-bytes-when-inert rule: the clause rides beside the partition it qualifies and nowhere else, so uses and the for lens pay nothing (asserted). +311 B on impact/callers/callees; test/graphlegendbudgetcheck.sh budgets raised once for it (callers 2700 to 3050, impact 3100 to 3450) with the reason in that gate header, both still below the pre-fix numbers the ratchet was built against. Gate: test/impactpartitioncheck.sh assertion (4), run RED first on a pre-fix binary (all three carrying documents missing all three anchor phrases). +ack verbosity 2878bcd6447c8e5c 87 cid=1e8475e13c08c82f now=87 was=71 p=src/graph.h:2998 lane FE-B (receiver evidence, via="name"): the growth is the hedge's own surfaces — memberOnlyReceiver/receiverOf read the receiver chain (memberPath/memberCtor) every language's member call now records; emitColumnarSymbolRows, emitCalleeCallsBlock and MCP pathText emit via="name" and its legend sentence only when a row carries it; packBodies/packHops take the per-edge hedge bits and the legend-charged flag (one parameter each, every caller updated). Each is gate-pinned by test/receiverevidencecheck.sh (CLI/MCP parity arms); splitting the emitters is a joint map-rows lane after train 26b (owner 2026-10-07), which redesigns these rows ack verbosity 28bbae8cfa29c022 86 cid=696dc4894e340595 by=src/* round-4 F-02 (tested= partition disclosure): the three gating rows are short-horizon-churn churn=self on the three seams the one new legend clause is spliced at — callHierarchyLegendOpen (callers/callees), impactText (the MCP impact twin) and runImpact (the CLI impact arm). No logic moved: each is a +1 %s in an existing printf argument list, and the clause itself is a single constexpr string in graphlegend.h. The disclosure exists because the tested= lens walks CALL EDGES out of indexed test symbols, so a shell or CLI-level test driving the built binary as a subprocess is invisible to it — on this repo own src/, tested almost entirely by ~500 test/*.sh gates, --impact read radius_untested=48 and --callers hop_untested=9 with nothing in either legend saying what untested meant there. Placement follows the 0-bytes-when-inert rule: the clause rides beside the partition it qualifies and nowhere else, so uses and the for lens pay nothing (asserted). +311 B on impact/callers/callees; test/graphlegendbudgetcheck.sh budgets raised once for it (callers 2700 to 3050, impact 3100 to 3450) with the reason in that gate header, both still below the pre-fix numbers the ratchet was built against. Gate: test/impactpartitioncheck.sh assertion (4), run RED first on a pre-fix binary (all three carrying documents missing all three anchor phrases). | prior: impact tested-partition (card A6): the MCP impact twin gains the radius_tested/radius_untested root counts and the per-row tested= mark, mirroring the CLI so mcpclidiffcheck stays byte-parity — +10 LOC of emission, no new branch ack verbosity 2a2f2487082cc6d8 153 cid=ba772c32f101b4bb or-arise rung 2 (--slice-flow/--slice-depth): the ten gating rows are this lane's own flag-addition footprint and nothing foreign — printUsage/validateConfig grow the two new help entries and three refusal arms every modifier flag must add; sliceBundleText/sliceWalk grow the flow legend+rows and the all-occurrence output param (contract kept source-compatible via defaulted flowSpec); runSlice grows the seed-VAR refusal and flow wiring; per-symbol growth is the cost of the registered rung-2 contract in EVALS | prior: mechanical flag-addition footprint of --scope: one help entry, one composition guard, one kViewFlags row and one Config member. printUsage/validateConfig grow by construction on every flag this tool adds. ack verbosity 2abd26fdd3d1e6df 215 cid=80501bdf3db6fba6 by=src/* lane/n2-i punch-list round (2026-09-02), F-04/F-07/F-09/F-14/F-16/F-17 fixes: computePlanLint +4cx/+15LOC is the stat()-before-open non-regular-file guard (F-09, a directory used to lint as a clean empty plan); runEditVerb +4cx/+20LOC is the CRLF-target payload-harmonization branch plus the replaced_bytes/file_eol/eol_normalized receipt fields (F-07/F-16); computeDocDrift +10LOC is the filter-matched-nothing early refusal (F-04, mirrors --scope/--dead-code's own filter refusal); runPlanLint churn=self is this same round's one-line message-specialization edit. Each is gate-covered red-first in test/docdriftcheck.sh §7, test/planlintcheck.sh §5b and test/mcpeditcheck.sh §1/§1b; no unrelated logic rides in. Full detail in LANE_REPORT.md. | prior: 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 verbosity 2b0d39faec60fc08 91 R-R root-relative emission lane: threading the corpus root into 8 emitters is the change itself — +1 param each (contract-change), +3 cx from each pathRel lambda, and the verbosity of the relativization plus its comments. Reviewed row by row; none is avoidable without abandoning root-relative emission. Storage keys unmoved (baseline + ack ledger byte-identical across the cure). +ack verbosity 2bea60141008e4d0 80 cid=0f6117d2b7b8058c now=80 was=0 p=src/receiverevidence.h:261 lane FE-B (receiver evidence, via="name"): the growth is the hedge's own surfaces — memberOnlyReceiver/receiverOf read the receiver chain (memberPath/memberCtor) every language's member call now records; emitColumnarSymbolRows, emitCalleeCallsBlock and MCP pathText emit via="name" and its legend sentence only when a row carries it; packBodies/packHops take the per-edge hedge bits and the legend-charged flag (one parameter each, every caller updated). Each is gate-pinned by test/receiverevidencecheck.sh (CLI/MCP parity arms); splitting the emitters is a joint map-rows lane after train 26b (owner 2026-10-07), which redesigns these rows ack verbosity 31105421c3ce88ae 249 cid=86626441189f2998 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: 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). ack verbosity 3298be65bf6058ef 1265 cid=7327ba02472bb8c7 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: M1: self-churn on the four symbols this change edits — printUsage (the --legend help text now states the MCP default), kMcpValueFields (the legend field description, the one place every declaring tool's schema states the posture), packTaskBundleText (dropped_positive moved from the ledger prose to the ctx root) and dispatchMain (--batch --legend=compact now compacts its sub-answers). Short-horizon churn measures edit recency, and these are the edits. ack verbosity 3561d0281d324276 277 V1 harvest 2026-08-15: packBodies +8 cx/+20 LOC is the withFileContext branch + fileCtx table build/lookup for octocode F2's sibs=/inc=; the attribute-building itself was extracted to appendFileExpandContextAttrs (mirroring the pre-existing emitCalleeCallsBlock split) to keep this at the minimum needed to wire the new opt-in path @@ -1546,6 +1598,8 @@ ack verbosity 3d87404c1cdf50ec 180 cid=15a146f0ee876b3f C1 F-06/F-07/F-10 (the l ack verbosity 3e0a074094789d60 79 cid=3256a8c50529082d Same three sites as the complexity rows and the same reason: LOC grew where a disclosure or a real computation landed (dispatchMcpLine +71 over five pagedResult arms and the legend refusal; symbolQueryJson +26 for the attributes the CLI twin has always carried). None of it is repetition a helper would absorb. ack verbosity 44b41056b999b501 111 D2 audit-regression fixes: renderWholeFiles/chooseExpandServe gain a shaping param each (compress + pack-budget composition, both single-caller, callers updated in the same edit); anchor-row loop and disclosure comments add the LOC/ccx; churn is this lane's own self-churn on the four symbols it edited ack verbosity 45b52ada32f63c11 565 cid=877f2f471ee33bc4 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 | prior: 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 verbosity 45bb9f7c5bc37916 108 cid=c8ed741b38b4c0a9 now=108 was=101 p=src/serialize.h:6655 lane FE-B (receiver evidence, via="name"): the growth is the hedge's own surfaces — memberOnlyReceiver/receiverOf read the receiver chain (memberPath/memberCtor) every language's member call now records; emitColumnarSymbolRows, emitCalleeCallsBlock and MCP pathText emit via="name" and its legend sentence only when a row carries it; packBodies/packHops take the per-edge hedge bits and the legend-charged flag (one parameter each, every caller updated). Each is gate-pinned by test/receiverevidencecheck.sh (CLI/MCP parity arms); splitting the emitters is a joint map-rows lane after train 26b (owner 2026-10-07), which redesigns these rows +ack verbosity 48768a385100895f 99 cid=5cbec57476270c83 now=99 was=97 p=src/verbs_for.h:3595 lane FE-B (receiver evidence, via="name"): the growth is the hedge's own surfaces — memberOnlyReceiver/receiverOf read the receiver chain (memberPath/memberCtor) every language's member call now records; emitColumnarSymbolRows, emitCalleeCallsBlock and MCP pathText emit via="name" and its legend sentence only when a row carries it; packBodies/packHops take the per-edge hedge bits and the legend-charged flag (one parameter each, every caller updated). Each is gate-pinned by test/receiverevidencecheck.sh (CLI/MCP parity arms); splitting the emitters is a joint map-rows lane after train 26b (owner 2026-10-07), which redesigns these rows ack verbosity 49ea9954a4e77a06 384 cid=638312475b7a6935 round ec5e3c3..HEAD, lane L7 P3/P8/P12 (nextverbcheck, qualitycheck/testgatecheck/dmmcheck bar= arms, grep re-pins) on top of wave-1/2 L4 M15 gauge + L9 root= + L8 receipts: every enumerated root carries next=, numeric quality rows carry bar=, test-gate ccx_bar=, grep's wrapper gone — each writer grew one clause per contract, measured and cut | prior: capture-audit 2026-09-04 wave-1 close, lane L3 H4 (lane-L3.md §quality): emitGrepReport +15 LOC is the window's legend clause and the auxPage line the H4 paging fix requires; L3 measured it as 'is the fix', trimmed once in ab3ae08 ack verbosity 4a7106e488a2aa80 68 cid=da287af067736058 M1: self-churn on the four symbols this change edits — printUsage (the --legend help text now states the MCP default), kMcpValueFields (the legend field description, the one place every declaring tool's schema states the posture), packTaskBundleText (dropped_positive moved from the ledger prose to the ctx root) and dispatchMain (--batch --legend=compact now compacts its sub-answers). Short-horizon churn measures edit recency, and these are the edits. ack verbosity 4b309450f25c2b44 1731 markdown section tier (mdsectioncheck, kParserVer 63): extractMarkdown grew from a 1-line-heading line scanner into the tree-sitter section extractor (spans+hierarchy+links) — its cx/verbosity/params growth IS the feature, reviewed; ingest gains the md dispatch + the mdNestsTooDeep OOB guard (yaml posture); buildRecall gains the section-granular body path (disclosed [sections: note]); doctorProbeGrammars gains the parse-probe row for the no-tags.scm grammar (helper split out same commit); kLangTable/kParserVer/printUsage/quality.h-mirror churn=self is this tier's own edit window. 94-arm mdsectioncheck green both flavours, pargates 396 green, sequential regression 417 green, repo-wide ASan+LSan clean @@ -1566,17 +1620,20 @@ ack verbosity 6031be13b40a1b6f 205 cid=58496e25cbf67431 round ec5e3c3..HEAD, the ack verbosity 6302e2e27e23bcde 64 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 verbosity 639de1c3670999f9 185 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 verbosity 685ade09a168535e 96 cid=124bb29c88050ae1 Same three sites as the complexity rows and the same reason: LOC grew where a disclosure or a real computation landed (dispatchMcpLine +71 over five pagedResult arms and the legend refusal; symbolQueryJson +26 for the attributes the CLI twin has always carried). None of it is repetition a helper would absorb. | prior: T1 completeness claims (complete= on grep/whereis): the +1 on streamBlobs is the deliberate DEFAULTED StreamBlobStats* param (null-object sink inside, no per-site null test; every existing caller byte-identical) so whereis can prove its scan exhaustive before claiming; cx/LOC on streamBlobs/computeWhereis/writeWhereisPage/emitGrepReport is the claim computation plus its in-band legend (the honesty text IS the feature); churn=self on those plus grepCollect/dispatchMcpLine is this lane own edit window. Gated red-first by test/completecheck.sh (24 arms, 10 red pre-fix; mutation arms force cap/offset/budget/unreadable-file/regex-mode/oversized-blob and assert the attribute VANISHES); full plain suite green, 21 touched-family gates green under ASan+LSan, determinism x3, xmllint clean -ack verbosity 6979fa794f2a5154 423 cid=9132efbcc6dfcbb7 now=423 was=420 p=src/ingest_sidecap.h:1706 fn-literal bodies: captureTagsFacts gains the defBodyNodeOf call that hands a literal-bound name its literal (the @name and Lang ride along) and reads params/cx from it: a +3 cx / +3 verbosity call site at the def-extraction seam | prior: ruby-attr class-DSL lane (review round; kParserVer 119->120): duplication/new-clone elixirArguments|elixirTarget is token-shape coincidence only - elixirTarget is the shared fieldIdentifierText primitive plus a null guard, elixirArguments reads a different field with no identifier gate; no shared logic to lift. captureTagsFacts +1cx/+4verb is the two-line gated call site of the inline-visibility lift. +ack verbosity 6979fa794f2a5154 466 cid=027d9c38f3666ebb now=466 was=464 p=src/ingest_sidecap.h:1732 lane FE-B (receiver evidence, via="name"): the growth is the hedge's own surfaces — memberOnlyReceiver/receiverOf read the receiver chain (memberPath/memberCtor) every language's member call now records; emitColumnarSymbolRows, emitCalleeCallsBlock and MCP pathText emit via="name" and its legend sentence only when a row carries it; packBodies/packHops take the per-edge hedge bits and the legend-charged flag (one parameter each, every caller updated). Each is gate-pinned by test/receiverevidencecheck.sh (CLI/MCP parity arms); splitting the emitters is a joint map-rows lane after train 26b (owner 2026-10-07), which redesigns these rows | prior: fn-literal bodies: captureTagsFacts gains the defBodyNodeOf call that hands a literal-bound name its literal (the @name and Lang ride along) and reads params/cx from it: a +3 cx / +3 verbosity call site at the def-extraction seam ack verbosity 6a38d70787d09e64 74 cid=f6a0c09dfc153ece Kotlin port (PR #126): the leading whitespace sample is walked by codepoint (jsonesc::utf8SeqLen) so invalid UTF-8 folds into errNodes/errBytes, deduplicated against the ERROR spans tree-sitter recovery already counted; without it a garbage byte run swallowed by recovery reads as a healthy file in --skipped. ack verbosity 6acbcaa854eada23 289 cid=e5c009ae9a0d8393 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 verbosity 6e58b0a307757079 227 cid=cd68c661a9c12758 now=227 was=222 p=src/serialize.h:8704 lane FE-B (receiver evidence, via="name"): the growth is the hedge's own surfaces — memberOnlyReceiver/receiverOf read the receiver chain (memberPath/memberCtor) every language's member call now records; emitColumnarSymbolRows, emitCalleeCallsBlock and MCP pathText emit via="name" and its legend sentence only when a row carries it; packBodies/packHops take the per-edge hedge bits and the legend-charged flag (one parameter each, every caller updated). Each is gate-pinned by test/receiverevidencecheck.sh (CLI/MCP parity arms); splitting the emitters is a joint map-rows lane after train 26b (owner 2026-10-07), which redesigns these rows ack verbosity 7004309695fb79f1 265 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 verbosity 7051b3950aaf4c14 82 cid=7919a3d486bba416 lane B1 cap disclosure: doc_mentions_capped= is contracted to be PROVABLE, never guessed, and that proof is the +6/+16. It is the in-loop test that a doc below its anchor's lift target was the one a cap turned away, plus docLiftWasRefused for the other half of the total cap, which ends the OUTER loop over anchors already consulted. A one-line flag would instead say 'there might be more', which is the fabricated fact in the other direction. +ack verbosity 71459175b4b3d120 122 cid=31f157ab1a674d7b now=122 was=121 p=src/ingest_binds.h:4410 lane FE-B (receiver evidence, via="name"): the growth is the hedge's own surfaces — memberOnlyReceiver/receiverOf read the receiver chain (memberPath/memberCtor) every language's member call now records; emitColumnarSymbolRows, emitCalleeCallsBlock and MCP pathText emit via="name" and its legend sentence only when a row carries it; packBodies/packHops take the per-edge hedge bits and the legend-charged flag (one parameter each, every caller updated). Each is gate-pinned by test/receiverevidencecheck.sh (CLI/MCP parity arms); splitting the emitters is a joint map-rows lane after train 26b (owner 2026-10-07), which redesigns these rows ack verbosity 719e8441be665024 959 cid=e508c8076e15f8e3 lane win-build-green (review F1/F2/F4): ci.yml's jobs key grows because the windows job stopped asserting exit codes and started asserting answers — a --version SHAPE check (semver + emit= + built_from=), fixture-map floors that separate a real crawl from the 951-byte empty one a Windows path bug produces, ctest --no-tests=error, and the cl leg's own expectation (configure must succeed, build must fail, no D8xxx flag regression hiding behind the seam, every erroring file ours, first error one of the extension codes) built with -k 0 so its log is the inventory. Every added line is an assertion or the reason for one; shrinking this key means deleting the checks the review asked for. Same shape as the release.yml jobs acks above. ack verbosity 73ac37ff2580b0b0 86 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 verbosity 763ff87870373241 84 cid=0f3e66f03f7be1f1 round ec5e3c3..HEAD, the MCP door across L6 (batch two-grammar), L8 P9 (post_check on the edit verbs), V2 F3/F7/F8/F9 (receipt keys, batch attribution, top-level array reader, legend present-but-empty) and L7 P1 (legend on 16 verbs): dispatchMcpLine/runBatchSub/runCliEdit are the one dispatch each of those lanes had to pass through | 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 verbosity 767211b0b7743f79 86 cid=f8439fcff51f596b 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 verbosity 775b773b1a3d2349 780 cid=6b4320dfd43b49dd the --recall root-relative path-token round: the +1 param IS the root each scorer relativizes against, and the LOC is its rationale ack verbosity 7a04eee0ff6ec2d7 87 markdown section tier (mdsectioncheck, kParserVer 63): extractMarkdown grew from a 1-line-heading line scanner into the tree-sitter section extractor (spans+hierarchy+links) — its cx/verbosity/params growth IS the feature, reviewed; ingest gains the md dispatch + the mdNestsTooDeep OOB guard (yaml posture); buildRecall gains the section-granular body path (disclosed [sections: note]); doctorProbeGrammars gains the parse-probe row for the no-tags.scm grammar (helper split out same commit); kLangTable/kParserVer/printUsage/quality.h-mirror churn=self is this tier's own edit window. 94-arm mdsectioncheck green both flavours, pargates 396 green, sequential regression 417 green, repo-wide ASan+LSan clean +ack verbosity 7ab4bd7047f034ec 576 cid=df13b023c4001487 now=576 was=0 p=src/receiverevidence.h:114 lane FE-B (receiver evidence, via="name"): the growth is the hedge's own surfaces — memberOnlyReceiver/receiverOf read the receiver chain (memberPath/memberCtor) every language's member call now records; emitColumnarSymbolRows, emitCalleeCallsBlock and MCP pathText emit via="name" and its legend sentence only when a row carries it; packBodies/packHops take the per-edge hedge bits and the legend-charged flag (one parameter each, every caller updated). Each is gate-pinned by test/receiverevidencecheck.sh (CLI/MCP parity arms); splitting the emitters is a joint map-rows lane after train 26b (owner 2026-10-07), which redesigns these rows ack verbosity 7c0e356e60b323ba 70 cid=4451e903bc990da2 Same three sites as the complexity rows and the same reason: LOC grew where a disclosure or a real computation landed (dispatchMcpLine +71 over five pagedResult arms and the legend refusal; symbolQueryJson +26 for the attributes the CLI twin has always carried). None of it is repetition a helper would absorb. | prior: 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 verbosity 7e26e5533d486c7a 265 cid=aa596d9bb05d621d 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. | prior: round ec5e3c3..HEAD, the task lenses across V1 N1 (est_tokens on the root), L10b (route= trim, doc_mentions=), L6 (budget_tokens=), V2 F2/F5/F6 (over_ceiling on every rung, smallest-ceiling rule, route bracket) and L7 P3/P10 (r=1 next=, one outer partition legend): five lanes grew runForLens/packTaskBundleText/fromTraceBundleText/computeLensRanking past each other's acked magnitudes ack verbosity 7ed8ad2c213537a4 375 R-R root-relative emission lane: threading the corpus root into 8 emitters is the change itself — +1 param each (contract-change), +3 cx from each pathRel lambda, and the verbosity of the relativization plus its comments. Reviewed row by row; none is avoidable without abandoning root-relative emission. Storage keys unmoved (baseline + ack ledger byte-identical across the cure). @@ -1585,7 +1642,7 @@ ack verbosity 80a2f3a7c92fbed4 590 cid=b272c333d7d8f297 by=src/* Narrower's inli ack verbosity 81fbe59b4a35659b 171 cid=c5e9778e250e41f1 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 verbosity 851e83b4505f10f6 129 cid=1730ab232c9a0037 by=src/* A2 (dropped_positive, 2026-09-03): collectJsonSigEntries gained the rank + positivesContentSkippedOut trailing params and the three-way positive/content-skip/budget split droppedPositiveCount needs — the minimum surface to feed the shared arithmetic; see docs/EVALS.md A2 registration. ack verbosity 86db2ff4e22cae54 70 cid=b9c9829a2aac834e 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 verbosity 89b3fd14c192c899 1494 cid=bf9296f0f9d8d2a3 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) | prior: wave merge 2026-08-20 (harvestexec): TWO seams in buildGraph, both from this wave, measured together at the merged head (cx 753->764, LOC 1327->1360). (a) The RefRole::Type namespace gate, from lane/resolver-precision: +8 cx / +23 LOC, ONE inserted block - a stable in-place filter over the assembled candidate set (12 lines of code) plus the 9-line derivation comment above it, plus a 3-line re-route of the isClassLike lambda through the same predicate. Not extracted to a helper on purpose, and the reason is the gate's own subject matter - on THIS loop the predicate is a provable no-op (the loop admits only role=Call, un-narrowed by resolve.h doctrine, and role=Macro, which retagMacroCallReferences already proves uniquely macro), so the block exists to be the ONE seam a future round edits, and hiding it behind a call would move the lines without removing them while making that edit invisible. test/nsfiltercheck.sh arm 2 is the executable form of the no-op claim and fails if the narrowing ever starts biting here. The measurable effect lives in contextratio.h's all-roles resolution, and the role that reaches it is RefRole::Extends, NOT RefRole::Type - collectFacts continues on Type 28 lines before it calls resolveCandidates, so a Type reference can never arrive there, and what keeps a type mention from spraying across same-named functions is that continue, not this predicate. What the predicate keeps from spraying is a BASE CLAUSE: on test/nsfilterfix, 'class Derived : public Handler' binds to both 'class Handler' and the free 'int Handler( int )' without it (ents 1->2, amb 0->1). Corrected 2026-08-20 by adversarial verification (V-5): the two source comments this ack leaned on named the wrong role, and test/nsfiltercheck.sh arm 5 now pins the real effect on the real role - the gate is red under full removal of the narrowing, which it was not before. Acked separately below. (b) The S5-E compose lang guard, from claude/kind-kepler-0c9b90: +3 cx / +10 LOC, one langCompatible branch plus its rationale comment, so HAS-A resolution applies the same language gate as every other admission site; before it a C++ member bound to a Python/TS same-named class and the duplicate defeated the (ownerSym,fieldName) dedup. Recorded red-first by test/composelangcheck.sh (mixed-lang fixture test/composelangfix, C<->C++ bridge control kept green). Cost is bounded and one-time for both: buildGraph does not grow again when a role or a language is added, only when a seam is. +ack verbosity 89b3fd14c192c899 1711 cid=9fe16468f6ebb08e by=src/* now=1711 was=1458 p=src/graph.h:5400 lane FE-B (receiver evidence, via="name"): the growth is the hedge's own surfaces — memberOnlyReceiver/receiverOf read the receiver chain (memberPath/memberCtor) every language's member call now records; emitColumnarSymbolRows, emitCalleeCallsBlock and MCP pathText emit via="name" and its legend sentence only when a row carries it; packBodies/packHops take the per-edge hedge bits and the legend-charged flag (one parameter each, every caller updated). Each is gate-pinned by test/receiverevidencecheck.sh (CLI/MCP parity arms); splitting the emitters is a joint map-rows lane after train 26b (owner 2026-10-07), which redesigns these rows | prior: 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 verbosity 8a92173ded649e17 158 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 verbosity 8c07caf47e2f33a4 134 cid=0002f98cf93b4127 Same three sites as the complexity rows and the same reason: LOC grew where a disclosure or a real computation landed (dispatchMcpLine +71 over five pagedResult arms and the legend refusal; symbolQueryJson +26 for the attributes the CLI twin has always carried). None of it is repetition a helper would absorb. ack verbosity 8d58de9bb922f582 119 T1 completeness claims (complete= on grep/whereis): the +1 on streamBlobs is the deliberate DEFAULTED StreamBlobStats* param (null-object sink inside, no per-site null test; every existing caller byte-identical) so whereis can prove its scan exhaustive before claiming; cx/LOC on streamBlobs/computeWhereis/writeWhereisPage/emitGrepReport is the claim computation plus its in-band legend (the honesty text IS the feature); churn=self on those plus grepCollect/dispatchMcpLine is this lane own edit window. Gated red-first by test/completecheck.sh (24 arms, 10 red pre-fix; mutation arms force cap/offset/budget/unreadable-file/regex-mode/oversized-blob and assert the attribute VANISHES); full plain suite green, 21 touched-family gates green under ASan+LSan, determinism x3, xmllint clean @@ -1618,9 +1675,11 @@ ack verbosity c4465160da273627 184 cid=a5da89d8d9da8562 L10 finding 9b: buildStr ack verbosity c5726c667bc4ffa4 172 cid=524bd36f6a257651 lane B1 cap disclosure: +17 lines of census and the argument that it is free. The three caps are read off the loops WITHOUT moving a break point — the stop reason is now read from the surviving loop index instead of a hoisted guard — and the comments pinning that equivalence are what stops the next reader simplifying the disclosure back into a wrong number. Complexity came back under its own baseline in this lane's refactor commit; only the line count is left. ack verbosity c5f22701d0c88cb6 80 cid=0dd38dd533f8c01b 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 verbosity c769c8e4d28544b7 87 cid=3815c5b4e0b5991d L10b finding 1: doc_mentions=/mention_anchored= root attrs on --for (small, bounded conditional growth) +ack verbosity c79b36c44111aca3 75 cid=c0aaf33a6026048d now=75 was=0 p=src/ingest_binds.h:189 lane FE-B (receiver evidence, via="name"): the growth is the hedge's own surfaces — memberOnlyReceiver/receiverOf read the receiver chain (memberPath/memberCtor) every language's member call now records; emitColumnarSymbolRows, emitCalleeCallsBlock and MCP pathText emit via="name" and its legend sentence only when a row carries it; packBodies/packHops take the per-edge hedge bits and the legend-charged flag (one parameter each, every caller updated). Each is gate-pinned by test/receiverevidencecheck.sh (CLI/MCP parity arms); splitting the emitters is a joint map-rows lane after train 26b (owner 2026-10-07), which redesigns these rows ack verbosity c840f9a6ec2e6c6b 73 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 verbosity c8c7bb0104aa16b8 76 cid=d54042b64ff7b78e H11 (lane ca-L2): writeBaseline gains a DEFAULTED absorbedGating parameter so a dirty pin's absorbed count reaches the sidecar; --edit-check reports incompatible=0 (both existing callers still bind) | prior: 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 verbosity ca97a4b6bf07887b 653 cid=b2c9107cb2d8ac03 Same three sites as the complexity rows and the same reason: LOC grew where a disclosure or a real computation landed (dispatchMcpLine +71 over five pagedResult arms and the legend refusal; symbolQueryJson +26 for the attributes the CLI twin has always carried). None of it is repetition a helper would absorb. | 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 verbosity cab2f93db3992faf 224 cid=34e51af7ebbf56db now=224 was=0 p=src/ingest_binds.h:4183 lane FE-B (receiver evidence, via="name"): the growth is the hedge's own surfaces — memberOnlyReceiver/receiverOf read the receiver chain (memberPath/memberCtor) every language's member call now records; emitColumnarSymbolRows, emitCalleeCallsBlock and MCP pathText emit via="name" and its legend sentence only when a row carries it; packBodies/packHops take the per-edge hedge bits and the legend-charged flag (one parameter each, every caller updated). Each is gate-pinned by test/receiverevidencecheck.sh (CLI/MCP parity arms); splitting the emitters is a joint map-rows lane after train 26b (owner 2026-10-07), which redesigns these rows ack verbosity cb7342964b38db9c 112 cid=3af54969638510bc by=src/* member-variable round (card A3): usesText grows the Owner.field member-selector branch (resolveFieldSelector + bare-name refusal listing spellings), the registered contract of this round | prior: lane/r10-cheap-buckets (r10 GitNexus fix round, LB-A + LB-G). SIX gating rows, ONE lane, read one by one before acking. THREE api-surface contract-changes, all deliberate parameter additions that ARE the feature: (a) mcpverbs usesText 2->3 params, taking McpPageArgs exactly as impactText already did, because the MCP uses verb gained the same default site cap as the CLI and an MCP-only agent that reads capped=1 needs a hatch it can reach (mcpclidiffcheck LENS 1 pins the two surfaces' root-attribute sets equal, so capping one and not the other is a divergence, not a saving); both dispatch sites AND kMcpVerbFields updated in the SAME commit, verified by mcpclidiffcheck/mcpverbscheck/usescheck green. (b)+(c) serialize packSignatures 17->18 and packSignaturesJson 11->12, both taking a trailing hasRelevanceFloor bool, default false so every non---for caller is byte-identical (verified: default map, pack-task, expand, exemplar, recall, hotspots, callers, grep, impact all unchanged). The flag cannot be replaced by passing a smaller topN, because those emitters read topN==0 as ALL, so a query nothing scores on would emit the whole corpus. THREE verbosity rows are the new code itself, already cut twice in this lane: duplicated rule bodies hoisted into relevanceFloorCut/pathTierIndexOver/compareTierThenPath, then the restated rationale moved to those helpers' headers - together taking gating from 13 to 6. What remains is runForLens +10, runCallHierarchy +11 and usesText +14 lines of genuinely new behaviour (the floor cut and its note plumbing; the tier index, the page window and the conditional legend clause). Splitting runForLens is a real refactor of its own - it was 658 lines before this lane touched it - and does not belong in an output-composition fix round. ack verbosity d42c85b67bd0956f 214 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: round ec5e3c3..HEAD, lane L7 P3/P8/P12 (nextverbcheck, qualitycheck/testgatecheck/dmmcheck bar= arms, grep re-pins) on top of wave-1/2 L4 M15 gauge + L9 root= + L8 receipts: every enumerated root carries next=, numeric quality rows carry bar=, test-gate ccx_bar=, grep's wrapper gone — each writer grew one clause per contract, measured and cut ack verbosity d44595768a7cf3af 106 cid=ad934f87aaaed8df 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. @@ -1642,6 +1701,7 @@ ack verbosity ed0cbdfe7fa72e8c 62 cid=efa4525003131a5f capture-audit 2026-09-04 ack verbosity eea83c3db0f03d69 518 cid=6fe919c0dd008ef5 by=src/* capture-audit 2026-09-04 wave-1 close, lane L4 (floor + paging vocabulary, lane-L4.md): body growth of the emitters that gained M2's capped=1 => paging-quintet disclosure, M11's priced root (est_tokens=/budget_tokens=/over_ceiling=/withheld_rows= on pack-task/from-trace/handoff), finding 4's ladder (packSignatures), H5/M15 floor + gauge and rule-4 count_capped/any_of findings_capped (runDefaultMap/runLint; runLint also carries L10's compiled= mapping). Each is the disclosure plus its ceiling arithmetic, pinned by estchargecheck/truncvocabcheck/collectioncapcheck/floormarkcheck | 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 verbosity efe685bb50482275 200 cid=10ded7c311552cd1 L10 finding 9b: buildStructRow now copies layout.h's caveat kind(s) into a new AffStruct.why field, writeFieldAffinity emits why= on unmodeled rows — the complexity/verbosity growth is that copy+emit cost | prior: audit pass 2026-08-06: per-cause as_* disclosure blocks in writeFieldAffinity follow the sibling one-if-per-attr pattern; chaseTypeCanPoint/isChaseRhsRow/looksLikePath are 2-call string predicates whose shared helper would be a worse abstraction; walks.cpp fixture twins are deliberate discriminating traps; short-horizon-churn is the audit editing just-landed code; runDefaultMap growth is the mapCtxOpenBytes fix + its measurement comment ack verbosity f1aa10922e41f258 84 cid=6670a895f0520c6a E3 (terminality round A, lane E): run_editsuite.py: the --cost-arith subcommand (E3's arithmetic, per task) +ack verbosity f7cf3b3c2851b336 135 cid=b44353ed67df2205 now=135 was=131 p=src/compactlegend.h:305 lane FE-B (receiver evidence, via="name"): the growth is the hedge's own surfaces — memberOnlyReceiver/receiverOf read the receiver chain (memberPath/memberCtor) every language's member call now records; emitColumnarSymbolRows, emitCalleeCallsBlock and MCP pathText emit via="name" and its legend sentence only when a row carries it; packBodies/packHops take the per-edge hedge bits and the legend-charged flag (one parameter each, every caller updated). Each is gate-pinned by test/receiverevidencecheck.sh (CLI/MCP parity arms); splitting the emitters is a joint map-rows lane after train 26b (owner 2026-10-07), which redesigns these rows ack verbosity f8d747d123b4143d 94 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 verbosity fce48dedc40bc8eb 63 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 verbosity fd4cc5c6c0ad17d9 154 markdown section tier (mdsectioncheck, kParserVer 63): extractMarkdown grew from a 1-line-heading line scanner into the tree-sitter section extractor (spans+hierarchy+links) — its cx/verbosity/params growth IS the feature, reviewed; ingest gains the md dispatch + the mdNestsTooDeep OOB guard (yaml posture); buildRecall gains the section-granular body path (disclosed [sections: note]); doctorProbeGrammars gains the parse-probe row for the no-tags.scm grammar (helper split out same commit); kLangTable/kParserVer/printUsage/quality.h-mirror churn=self is this tier's own edit window. 94-arm mdsectioncheck green both flavours, pargates 396 green, sequential regression 417 green, repo-wide ASan+LSan clean diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d22e195d..f4e190411 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,112 @@ not published here — see `docs/EVALS.md` for the instruments behind the headli ## [Unreleased] +### Changed — a call bound by name alone keeps its rows, marked `via="name"`; typed receivers resolve + +A member call `x.m()` (or, where the receiver is implicit, a bare `m()`) bound to every in-repo definition spelled +`m` that the name ladder reached, and each such row read as a confident edge with nothing behind it but the name: a +request context's `ctx.onerror()` drawn to the `Application.onerror` its file defines, a WeakMap's `.get()` drawn to +the same file's accessor, a Go struct field's `item.text.Get()` drawn to an unrelated `Merger.Get`, a bare `flush()` +in a Java class with an outside base drawn to an unrelated class's `flush`. + +- **Receivers the source types now resolve** (JavaScript, TypeScript, Python, Go, Java, Kotlin, C#, Swift): `this` / + `self` / `cls` inside a class and `super` / `base` through its bases; a parameter or local whose class is written + (a TS/Python/Go/Java/C#/Kotlin/Swift annotation or declared type, a Go method receiver, a JS `new Foo()`, Java/C# + `new Foo()` (`var` included), Python `Foo()`, Kotlin/Swift `Foo()` or Go `Foo{}` initializer, an import alias of the + class); a constructed receiver (`new Foo().m()`); a class-name receiver; a chain of fields whose classes are stated + (`this.bucket = new Schemas()`, Python `self.x = Foo()`, Go struct and embedded fields, Java/C# fields and + properties, Kotlin/Swift properties and Kotlin `val` constructor properties, read through `this`/`self` or bare); + Python `feed = parser.feed`. A Go module alias proves its package by the exact import path: the nearest `go.mod`'s + module path plus the directory below it, or the path a local `replace` gives a directory (in a tree with no `go.mod` + at all, a directory that ends the import path). A local is read where its language scopes it: a block's declaration + from the declaration to the block's end, a parameter in its function or lambda, a Python name in its whole function. + Every other binding of a name also hides the field or the outer local it shadows there — a loop, lambda, catch, + resource or `with`/`except` variable, a pattern, `case`, `out var` or `match` capture, a destructuring entry, Swift + `if`/`guard`/`while let`, a Go range / type-switch / short variable, a JS or Python reassignment (a statically typed + variable keeps its declared type when reassigned). Such a binding + names its own class only when it is written or constructed with one (a typed loop, lambda or resource variable); otherwise the call + through it is name-only, like a call through an untyped local or an interface-typed receiver with several + implementors. A written type that names a generic's type parameter (``, `[T any]`, `def f[T]`) names no class, + even when a class of that name exists. Wider than the language, and so name-only rather than resolved: a pattern + variable hides its field in its whole enclosing block (an `else` branch too), a Java local hides a field anywhere in + its method, and a Python rebinding anywhere in its function. Not read, the floor: a nested function or class + declaration that reuses a typed binding's name (JS/TS/Python), Kotlin's implicit `it` beside a field named `it`, and a + Python conditional or later rebinding, which still types the variable for the whole function: `tank = Barrel()` in a + branch proves `Barrel.spill` for a `tank.spill()` beside it, even one before the assignment (the older assignment-type + rule, which this change does not read). A JS/TS call on the class object reaches its `static` members only, + and a call on an instance never one. A Ruby call's candidates that Ruby's own method lookup proves (the entries below) are its + answer, never `via="name"`. +- **A call nothing proves is NAME-ONLY.** Its candidates the language's own lookup proves are its answer (an + implicit receiver's class and bases, a free function in scope, the module a receiver alias names). With none + proven, every same-file and same-directory candidate is listed — a lone global one too, because one candidate is + not evidence — and each row carries `via="name"` (MCP: `"via":"name"`) on `--callees`, `--callers`, `--path`, + `--connect`'s ``, the `` rows of `--expand` and `--for`, the default map's `` rows (and its + `--json` and MCP `analyze` twins), and MCP `find_symbol`, `find_referencing_symbols`, `impact` and + `path_between`. An `--impact` row that no all-proven path reaches inherits + it. A legend sentence rides exactly when such a row does and says what it does not mean: not that the edge is + false. Rule 3's include-file narrow no longer decides a member call: the file a caller imports says nothing about + its receiver. Ranking keeps the old ladder's pick (the same-file, else same-directory rung): the extra candidates + are listed at edge weight zero, so a guess never moves PageRank. +- **The map merges what it would repeat.** A map `` printed one `` row per same-named by-name candidate, N + byte-identical rows; it now prints such a `via="name"` group once with `x="N"`, and `--callees` on that symbol + lists every candidate with its file. The map's legend is a short spelling of the same reading (and defines `x=`); + the compact dialect states both in its element rows. The README's `--max-tokens=3000` map keeps 20 rows. +- Cost, measured over 210 calls on eight public repositories (`--no-cache`): +2.04% bytes in total (C trees: no + change); the `--max-tokens=3000` maps of the eight repositories keep 337 rows (331 before the change). The gate is + `test/receiverevidencecheck.sh` (every surface, CLI/MCP parity, census conservation, and near misses + for every evidence rule). Ingest records the receiver chain and the Java/C#/Kotlin/Swift declarations + (`kParserVer` 156 in this release, see the versions note; `kCacheVersion` 29), so a cache written by an earlier build is re-parsed. +### Added — `--quality-delta` gains a twelfth kind, `defect-shape`: four defect shapes code review kept finding by hand + +`kind="defect-shape"` rows name a known defect SHAPE the change added, in `defect=` (`src/defectshape.h`): +- `format-arity`: a literal `std::format` / `format_to` / `format_to_n` / `print` / `println`, `fmt::`, + `rw::emitTo` / `rw::formatTo` or Python `"literal".format(…)` format whose replacement fields do not match its + arguments. `std::format_string` rejects too few arguments at compile time and accepts too many silently; Python + raises on too few and ignores too many. Two sides of a merge that each add the same field and one argument merge + cleanly into one field too few — the case this was built for. +- `utf8-cut` (C++): display text cut at a byte cap and given an ellipsis, with no UTF-8 back-off in the function. +- `dedup-first` (C++): `std::unique` keeping the first of a run over a type with a severity field that neither the + predicate nor the sort before it reads. +- `vacuous-assert` (Bash test scripts): an absence assertion read off a run whose failure is never checked, so a + crash reads as PASS. + +A site is new only when its normalized text occurs more often than among the baseline's sites of that shape, +repo-wide: a moved or renamed defect is not new, an edited one is, and so is a second identical copy. **New-symbol rows never gate, except defect-shape format-arity**: a +placeholder/argument mismatch is a defect, not debt, so it gates on new code too. The exit predicate lives in one +place (`quality::rowGates`) and every surface asks it — `gating=` and the row's `gating`, `--json` / MCP `"gating"`, +`--ack-only=gating`, the scoped-out and allow-dirty counts, the stderr line. `utf8-cut`, `dedup-first` and +`vacuous-assert` are always `sev="minor"`: report-only by facet, not by size. `--ack-only=FACET` acks one shape; the +ack key carries the facet AND the definition's sites of that shape, so an ack covers exactly the defects it named: +it never suppresses another shape, nor a different defect of the same shape swapped or added into the same definition +(that row re-reports and the old ack is listed stale). + +Measured on the eight review findings it was built from: recall 7/8 (format-arity 2/2 on the train-25 keep-both merge, +utf8-cut 1/1, dedup-first 1/1, vacuous-assert 3/4 — the fourth sits beside a later check on content derived from the +same run, which already fails the script on a crash). With every site of a tree treated as new: format-arity 0 false +positives on this tree (1,330 literal-format calls), on the Python 3.13 standard library (405 `"…".format(` calls) +and on libc++'s std::format tests (80 calls; its 24 hits are the deliberately ill-formed `.verify.cpp` calls), and 0 +false rows on a review's 76 adversarial candidate shapes plus the gate's 19 macro / preprocessor / escape near-misses +(an argument that may be a macro expanding to an argument list, `#ifdef` arms inside the call, `#if 0`, +`"\x7b\x7d"`): when the argument count cannot be known the call is skipped, never guessed. vacuous-assert: 46 rows (66 sites) on this tree's gates, each an absence read off an +unchecked run; sample 30/30, Wilson 95% lower bound 0.89. An absence beside a positive on the same capture — an +`if`/`elif` condition of `&&`-joined greps, or a grep through a function the script defines — is guarded (a crash +fails the positive); before that guard the same tree gave 49 rows, of which 3 were such false positives (a seeded +sample of 28/30, lower bound 0.79). The `tools/list` manifest grows 46,869 → 46,930 B on this release's tree (46,732 → 46,793 B on the lane's base, +61 B both ways): the quality_delta description counts 12 kinds and +states the format-arity exception. + +Not checked, so no row there is no verdict: Python %-formatting and f-strings; a format in a named constant, a macro +or a runtime wrapper; a pack expansion; an unqualified `format()`; the `std::print` ostream overload; a named dedup +predicate; a utf8 cut in a function that backs off a continuation byte anywhere; a run behind a script-defined +wrapper function or a text utility reading a file; an absence read off a FILE a run redirected into (`"$BIN" … >"$TMP/f"`, +then `grep -q PAT "$TMP/f"`) — the gates' most common output form, the next candidate; an argument that may be a macro +(a name the file `#define`s, function-like or object-like; a FUNCTION-LIKE macro another file of the tree defines; or +an ALL_CAPS name followed by `(`), a call with a preprocessor directive inside it or under a literal `#if 0`, an escape +that spells a brace. Two floors of that skip: the tree's macro list holds function-like macros only, so a lower-case +object-like macro defined in another file (`#define xyargs p.x, p.y` in a header) is read as one argument and its call +can be a false format-arity row; and only a literal `#if 0` is skipped — `#if false`, `#if (0)`, `#if 0 && …` and the +`#else` of `#if 1` are read as live code. The qsnap blob scheme moves (19 in this release, see the versions note); the +legend dictionary gains `r defect=`. + ### Changed — a Ruby call to self, or on an instance the code builds, answers from its own side: an instance never reaches `def self.m` The entry below left one floor open, (i). A call to self, or on a receiver the code builds, read a class's defs from both @@ -757,6 +863,242 @@ which puts the 12 back on `Core`'s def as single edges. `kParserVer` 129 → 130. New records, same layout: `kCacheVersion` stays 27, and Ruby caches re-parse once. +### Fixed — `--path` no longer says "no directed call path" when its search met a call the graph has no edge for + +`--path=A,B` and the MCP `path_between` verb answered `reachable="0" hint="no directed call path"` whenever the search over +resolved call edges did not reach `B`, even when that search had passed a call the graph keeps no edge for: a call the +resolver declined (several candidates, none local, nothing chose one), an unresolved call, a function handed off as a +value (`.then( handleResponse )`), or a call through a parameter. Any of those can be the missing hop. Such an answer now +says the search was incomplete: `searched=` (the symbols it reached), `gaps="declined:D,unresolved:U,value:V,through:T,name:M"`, +`gap_syms=` with up to three `` rows (nearest `from=` first, `gap_syms_capped="1"` past that) and a +`next=` that expands their bodies. A `` row is where the search could not see, never a hop. Ambiguous calls are not +counted (every candidate has an edge the search follows), nor calls to names defined nowhere in the tree. A call bound by +name alone (`via="name"`) counts as a `name` gap when the tree defines that name, same language and kind, somewhere the +search never reached (a namesake another searched symbol reaches was searched, and is not counted): the search follows +only the candidates such a call lists, so a namesake in another directory is never searched and may be the missing hop (`run(obj)` calling `obj.process()`, bound to the same-directory +`A.process`, while `other/B.process` calls the target). When the +search met no such call the answer is unchanged, byte for byte. The legend dictionary gains three entries. A floor: +the `next=` names a gap row by file and name, so where one file holds two definitions of that name it serves both. Gate: +`test/pathgapcheck.sh`. + +### Fixed — a Go named type's `t=` says what it is: `type`, `functype` or `alias`, not `struct` + +Every Go `type_spec` was labelled `t="struct"`, so `type TestName string` and `type F func(...)` read as structs, and +`type A = B` was not indexed at all. The kind now follows the written form: a struct is `t="struct"`, an interface +`t="iface"`, a func type `t="functype"`, an alias `t="alias"` (now indexed), and every other defined type (`string`, +`[]T`, `map`, `chan`, a pointer, an array, another named type, a generic over any of these) `t="type"`. Grouped +`type ( … )` blocks and generic types follow the same rule. The three new kinds behave as `struct` did everywhere else (a +conversion `TestName( s )` keeps its caller edge), `--graph-query`'s `kind()` accepts them, and the map's `--legend=full` +gains a clause for them only on a tree that has one. Only Go is split: other languages' typedefs, aliases and enums still +read `t="struct"`. A floor: a Go 1.24 generic alias `type A[T any] = B` reads `t="type"`, not `t="alias"`, because +the grammar gives it no alias node. `kParserVer` moves (see the versions note), so every ingest cache is re-indexed once. +Gate: `test/gokindcheck.sh`. + +### Fixed — a count the index cannot vouch for is marked a floor beside the number; safe-delete rows carry the call line + +A zero printed as a total read as "nothing uses this" where the index itself held evidence of a miss: +`--grep`'s `` on a method whose only call the resolver declined to bind, or on a module variable that +other files read (a call count cannot see a read); `--safe-delete` answering `callers="0" uses="0" risk="none-found"` +on a C struct whose type mentions are not indexed; and the `--for` bundle's ``, which counted the +implementor rows it LISTED after narrowing to its own files (`implementors="1"` on a tree with six). + +- **Row floors.** `callers_floor="1"` (grep `` and the MCP `grep` `enclosing` rows, `--safe-delete`) and + `implementors_floor="1"` (`` rows, bundle and `--lego`) ride beside the count exactly when the index holds + evidence of a miss for that definition: a declined call that named it, a call or extends clause spelled like it that + bound to no definition, a use as a value, or a kind used by reading or naming it (a variable, class, struct, + interface). `floor_next=` (rows) / `next=` (`--safe-delete`) is the call that lists the rest — the uses verb, or the + literal grep where the language's reads are never indexed. A count with no such evidence stays a plain total: a + static function nobody calls still reads `callers="0" risk="none-found" dead_code_candidate="1"`, and a recursive + function's call to itself counts as bound (the graph records no self-edge). +- **`--safe-delete`.** `uses_floor="1"` when the definition's uses are reads or type mentions this run did not index, + and `risk="unmodelled"` (new value) when nothing was found for such a kind — never `none-found`. Each caller row + carries `sites_l=`, the ascending call-site lines spelled like the symbol (the edit-check verb's own pass), beside + `p=`, which stays the line where the caller is defined. +- **`--for` ``.** `implementors=` is now the lego verb's own count; a shorter list adds `implementors_shown=` and + `implementors_next="--lego=FILE:NAME"`. The MCP `for` twin now narrows its `` block with the root prefix the + CLI passes; before, it compared root-relative rows against absolute paths and served no `` at all, so MCP + `for` answers on a tree with interfaces now carry that block (up to 12 rows), as the CLI's always did. +- Every new attribute is defined present-only in the answer that carries it (compact and full dialects). Answers + without one are byte-identical (34 argv across three corpora). Gate: `countfloorcheck`. + + +### Fixed — test infrastructure: `rubyrecvnarrowcheck` absence arms no longer pass on a crashed run + +The four "`--callers=X` does not list the row" arms read an empty capture, so a binary that crashed or refused passed +them. They now go through one helper that reads the absence only off a run that exited 0 and produced its `` +root, and fails by name otherwise. + +### Fixed — the skill scanner's per-line / joined-body dedupe keeps the worst row of a (line, rule) + +The scanner's two passes can report the same (line, rule), and the dedupe sorted with a non-stable sort on (line, rule) +and kept the first row, so which severity survived was left to the sort. It now sorts stably with the worst severity first +(the rule the shell-script pass merge already follows), as one shared helper. No input reaches the collision today (a +rule's severity is a function of its line), so output is unchanged; `test/skillscan.sh` drives the helper with colliding +pairs in both orders. + +### Fixed — a `--scan-skills` / `wrap` finding's excerpt is cut on a character boundary + +A skill line longer than the 120-byte excerpt cap was cut at a byte offset, so a multibyte UTF-8 character straddling the +cap left an invalid tail in the excerpt (the text is arbitrary and untrusted). The cut now backs off to the code-point +boundary, the same rule as the other text caps, and the `...` is added only when bytes were really dropped; a line of +exactly the cap is shown whole. `test/skillscan.sh` pins a 2-byte and a 4-byte character at the cap, a line of exactly +120 bytes, and a plain overlong line. + +### Fixed — `--help-task` routing coverage figures are labelled as tuning-corpus numbers + +The 0.907 / 0.918 coverage and 1.000 precision figures in `docs/EVALS.md` were measured on the tuning corpus. On the held-out prompts `--help-task` recommended on 28 of 214 positive prompts (0.131, measured 2026-10-02), below the pre-registered ≥ 0.60 coverage bar, and the held-out gate that registration called for is not yet built. Docs only; no behaviour change. + +### Changed — router and meter default to a per-session control split; channel events and agent hand-backs are not prompts + +With no `~/.ripwire/meter.conf` and no `RIPWIRE_METER_ARM`, the Claude Code prompt router, the tool-call router and +the meter used to put every session on `treatment`, so the registered treatment-versus-control comparison had no control +group on a default install (issue #381). The unconfigured default is now `arm=auto`, the stable per-session split +the meter already had; `arm=treatment` and `arm=control` are still honoured. User-visible consequence: about half of +the sessions on an unconfigured install now get NO injected advice and NO SessionStart primer (the control arm); +`arm=treatment` in `~/.ripwire/meter.conf` restores the old behaviour. The Claude Code and Codex prompt-router hooks +and the `--help-task` classifier now treat a prompt that starts with ``, as a harness event, like `` and +``: no classification, a `skip-system` log row. ``, `` and a prompt that merely +mentions a wrapper mid-sentence still route; a prompt that itself starts with a literal `` or `` +fragment (a pasted RSS fragment without its prolog, say) is treated as a harness event and is not routed. The +classifier's leading-whitespace strip now also covers `\v` and `\f`, as the hooks' strip always did, and the hooks' +strip is now those same six ASCII bytes in every locale: it used bash's `[:space:]`, which in a UTF-8 locale also +stripped a leading U+00A0 (and other Unicode spaces), so a hook skipped a prompt the classifier routes. `meter.conf` +parsing, in `resolve_arm` of both router hooks and in the meter, no longer loses a final line that has no newline +and no longer reads a CRLF value (`arm=control\r`) as `treatment`. `bench/routing_ab_report.py` prints per-arm counts, says `NO CONTROL ARM` when an arm has no prompts, and marks the 2026-09-02 +registration window void (it had no control arm); the readout clock restarts at this release, and the report prints +the new window's readout date once its start date is recorded in docs/EVALS.md. Gates: `routehookcheck`, +`codexpromptroutecheck`, `taskroutecheck`, `toolcallroutecheck`, `hookcheck`, `routingreportcheck`. + +### Added — `test/mcpreloadasancheck.sh`: MCP index reloads under a sanitizer build + +A cached object that points into the MCP server's index dangles when the index is rebuilt in place; a cached +`ValueRefIndex` once did exactly that (heap-use-after-free after a rebuild that kept its content stamp), and no single-query +gate can see it. The gate runs one long-lived `--mcp` session on the sanitizer binary for 20 cycles of query, stamp-keeping +mutation (chmod, a new non-source file, an empty directory), content mutation (file rewrite or the edit verbs), root +switch, workspace and memory release. Every query answer is compared with a one-shot CLI run on the same tree state; the +`analyze` answer on a dirty root is compared with a cold server instead. It asserts that each rebuild it drives happened +(`rebuilt=1`) and that there were zero sanitizer reports. The memory-release arm is host-dependent: when no release session +meets its premises it prints a named SKIP and the other arms still decide the verdict. The whole gate SKIPs by name when no +sanitizer build is present, and exits 2 when `RIPWIRE_ASAN_BIN` is set to a path with no executable. The freed-chunk +quarantine stays on except in the memory-release sessions. It goes red on the build that still had that bug and is green +after the fix. + + +### Added — `--for` rows say where a definition ends (`e=`); code ranks above the docs that repeat a question + +- **`e=` on `` rows.** A `--for` signature row carried only `l=`, the line of the definition's name, so "which body + holds line N" was not answerable from the row. Every `` row of a `--for` answer (CLI XML, `--json` as `"e"`, and the + MCP `for` tool) now carries `e=`, the 1-based line where that definition ends, body-inclusive, right after `l=`. It + is ABSENT — never 0, never a guess — when the extent is not known: a markdown or config row, a module-scope row, a + row flagged `extent_suspect=`, or a span the file's current bytes cannot hold. `l=` keeps its meaning, so a + definition can start above `l=` (a return type on the line before, a decorator). The attribute is exempt from the + signature-row byte budget, so the default ceiling admits the same rows it admitted before; it costs about 8 bytes + per row. An answer whose explicit ceiling is TIGHTER than the default signature budget (`--token-budget` below it, a + body ceiling, MCP `budget_tokens` likewise) carries no `e=`: its est_tokens promise and its rows stay exactly what they + were. A ceiling at or above the default carries `e=` exactly as the default does. For measurement only, the + experimental environment switch `RIPWIRE_FOR_ENDLINES=always|auto|never` moves that one decision (CLI `--for` in both + dialects and MCP `for`): `auto`, the default, is the rule above; `always` adds `e=` under every ceiling with the same + rows, so est_tokens may then exceed a tight budget; `never` drops it everywhere. Any other value falls back to `auto` + and says so on stderr. It is not a flag and may be removed once measured. + `--pack-signatures`, `--pack-task`, `--from-trace` and the map keep their bytes. +- **Code above docs.** On a question that does not ask about docs, the code rows of the SHOWN signature set now come + first and its markdown rows after them. It is a reorder of the same rows: the score order and the byte budget still + choose which rows are shown, so no doc row is ever evicted by it; the shown rows' `r=` values are kept as a set and + reassigned in the new order (so for a moved row `r=` is no longer its score rank), and `` (JSON `"docs_after_code"`) says how many doc rows moved. + A question that names docs (`doc…`, `readme`, `guide`, `tutorial`, `manual`, `markdown`, `wiki`, `faq`, `howto`, a + change or translation cue, or a named `.md`/`.rst` file) keeps the plain score order, and so does `--no-route`. + An answer under an explicit ceiling TIGHTER than the default signature budget (`--token-budget` below it, MCP + `budget_tokens` below it, or a body ceiling, `--max-tokens` with `--detail`) is not reordered and carries no + `docs_after_code`: it is the answer it was before the reorder, so its est_tokens promise stays what it was (the reading + would be uncharged bytes). These are the same three ceilings that drop `e=`, decided by one predicate. + `RIPWIRE_NO_DOCS_AFTER_CODE`, set to any value (even empty or `0`), turns the reorder off everywhere (CLI and MCP `for`). +- **A hop row needs a resolved callee.** In a compact `--for` answer's ``, a candidate whose every callee edge + was bound by name alone (the `via="name"` rows of the receiver-evidence entry above) gets no `` row and is counted + in `noedge=`, whose reading is "no RESOLVED callee found", never "none exists". The header then defines `via=` only + when a hop that keeps its row has such a callee. Gates: `test/forsigspancheck.sh` (H), `test/docdemotecheck.sh` (f). + +Gate: `forsigspancheck` (fixture `test/forcompletefix`, C/JS/Python/TS). + +### Changed — leaner answers: `--whereis` lists definitions, two lossless row spellings, the MCP legend once per session + +Measured on 90 rung-0 answers of a comparison table (eight repos, three in-sample and five held out), base vs this +build: 631,732 → 611,785 B for the lossless changes alone (−3.2%), 568,600 B with the `--whereis` default (−10.0%). +Every gold item the base answers supplied is still supplied (502 of 502), and decoding the lossless spellings gives +the base answer back element for element on all 90. + +- **`--whereis=SYM` lists the definitions and counts the references, when that answer is more complete or shorter.** Every + `kind="def"` row is listed; the `kind="ref"` rows are counted in one `` element whose `next=` lists exactly those rows. The default serves this page when it + lists MORE definitions than the `--whereis-listing=all` page does under the same row cap (a capped all page can list + fewer: HEAD's references fill the cap before the branch definitions arrive), whatever its bytes. When both pages list + the same definitions, it serves this page only if it is strictly shorter in bytes (compared as written and in the + compact legend), and a tie lists every hit. On a symbol with few references (one to three on a 60-symbol sample) the + count and its legend reading cost more than the rows they replace, so the default never serves a page that lists + fewer definitions, nor, for the same definitions, one that lists fewer rows in more bytes (an explicit + `--whereis-listing=defs` is served as asked). + `--whereis-listing=defs|refs|all` (MCP `listing`) picks the rows; `all` is the whole hit list. An answer with no + reference row, or no definition row (the mentions are then the answer), lists every hit. `kind="def"` is the + parser's label: a definition it does not model (a Ruby `define_method`, a `setattr`, a name bound by assignment) is + among the counted references, and the legend says so. Median where-defined answer 10,455 → 1,560 B. + `shown=`/`capped=`/`--limit`/`--offset` and `` window the listed rows; `hits=` counts every row; + `complete=` reads the listing. +- **Lossless:** a `--whereis` row on HEAD's commit omits `tip=`/`date=` (they read `at=` and the new root + `head_date=`); a `--for` lens row omits `cx=`/`ccx=`/`in=` when 0 (the legend says an absent one is 0; the rows + kept by the byte budget are unchanged). Median per class: how-it-works −0.24 KB, orient-for −0.26 KB, + where-defined −1.95 KB; map answers are unchanged. +- **MCP: the legend session opens on the first answer.** A stdio session no longer waits for a read of + `ripwire://legend-dict`: its first answer carries its legend inline, later answers take the `legend="ref"` + posture (rows first, each definition sent once per session, the core carried by the first ref answer). + `--mcp-legend=inline` keeps the previous posture; `legend:"compact"`/`"full"` still keep one answer inline; the + CLI and the HTTP transport are unchanged. A ten-call session on one repo: 45,463 → 32,622 B (inline) → 29,679 B. +- **Manifest.** The `tools/list` manifest grows 46,732 → 46,869 B: whereis declares `listing`. + +Gates: `crossrefcheck` (LEAN L1–L16 — L11–L14 the same-definitions bytes rule: one ref, 73 refs, the exact tie and one +byte either side, the MCP twin; L15 and L16 the definitions-first rule: a capped answer whose longer defs page lists more +definitions is served, and one whose pages list the same definitions keeps the shorter all page, each with its MCP twin +— and listing=all twins), `completecheck`, `legendrefcheck` (J), `mcptwinclaimscheck` +(A-default), `compactlegendcheck` (re-pinned with the measured bytes; a listing=all twin pin), `mcpmanifestcheck`. + + +### Fixed — every disclosed cut names the call that recovers it: `--for`'s ``, `--lint`'s per-rule floor, `--impact`'s import tier + +Three cuts were disclosed and still dead ends — counted, with no pasteable call that serves the rest: + +- **`--for` (and the MCP `for` twin, and `--for --json`).** A `` block cut by the payload + ceiling or an explicit `--token-budget` now carries `next=` — the same ranked lens re-run with the signatures given the + whole ceiling, sized from the untrimmed block so nothing is cut (`--for=TASK --signatures-only [ranking flags] + [--pack-top-n=N] --token-budget=T`) — and, when rows were dropped, `next_offset=`: the candidate index the cut starts at + (the last printed row's rank, so a slot that prints no row cannot skew it). `--json` carries `"sigs_next"` and + `"sigs_next_offset"`. The MCP twin carries no CLI argv (an MCP client re-calls the tool): `next_budget_tokens=T` names + the `budget_tokens` a re-call needs, beside the same `next_offset=`. Nothing consumes `next_offset=` yet + (`--for --offset=N` pages the file-grain list, a different one; `next=` re-runs the list), and its clause says so. + Present-only legend clauses define each. At the + default ceiling, and at any explicit ceiling wide enough that the signature side is frozen at the default's, they are + exempt from the signature trim — the rows shown are the ones they were. Under a tighter explicit ceiling (a hard one) + `next=` rides alone, and rows pay for it ONLY where paying is what makes the answer fit: the answer with every row the + cut leaves plus `next=` is served whenever it fits; only when it lands past its ceiling are rows dropped to pay (a + capped compact answer then serves the rows a paid full one keeps — fewer than `--legend=full` serves only where the + compact header is the larger one and full fits unpaid), and that paid answer is served only when it fits. Past + the ceiling either way (the rank 1..4 floor has nothing left to give, or the MCP `for` answer, whose header overshoots + `budget_tokens` on its own), no row is dropped for it — the answer keeps the rows the cut alone leaves, `next=` still + ships (the call that recovers a cut is never the thing cut), and the root says `over_ceiling="1"`, with a clause naming + the cause where the answer's own ceiling was otherwise met. Measured on 20 queries at a 2000-token budget: the CLI keeps + every row on 19 (the 20th was over its ceiling and now pays one row to fit), `--json` on all 20, MCP `for` on all 20 + (19 of them over their ceiling either way); at 4000, MCP `for` pays on the 6 answers that fit only by paying. + No budget, ceiling or token conversion changed. +- **`--lint` / `--lint-rules` and `--sarif`.** A rule that spends its per-rule match budget (`count_capped="1"`) now makes + the root carry `findings_next=` (SARIF: `runs[0].properties.findingsNext`, which keeps `--sarif`): only the floored + rules, under a 10x budget, through the new `--lint-max-per-rule=N` (sets each rule's budget either way; the default + stays 5000). A re-run still floored names its own. +- **`--impact`.** A cut import tier dropped `importers_next=` when `--impact=SYM --limit=N` passed 120 bytes, so a long + selector lost its only continuation with no marker. It is now always emitted in full, like every other `next=`. + +Also: the `ripwire-handoff` skill passed `--top-k=20` to `--for`, which does not read it; it now spells +`--signatures-only`. Gates: `forrankordercheck` (9), `lintbudgetcheck` (5), `impactimportcheck` (#9d), `estchargecheck` +(#11 A7 unpaid twin), `compactlegendcheck` (P4 floor twin, P1-B), `forrankordercheck` (10) and (11), `fordisclosurecheck` (#2c). + + ### Added — MCP `grep` rows carry the matched text and a `fetch_body` handle (CLI parity) The MCP `grep` hit row was `{file, line, in}`: no matched text, so an agent re-read every file it had just searched, and @@ -834,13 +1176,30 @@ call-shaped references, so on such a function `--callers`, `--callees`, `--impac tree, django and webpack, 30 were identical. The 3 that differed are in scope: a read row became `role="value"`; a callee answer gained 2 rows; `--dead-code` dropped 4 functions that a `NODE_SET_METHOD` argument or a CommonJS `module.exports` table holds. +- **Depth.** The capture does not descend past 512 levels of nesting (a 300-link C `else if` chain reaches it). That + cut is disclosed, never silent: every answer that reads value references for the cut file's language — the + callers/callees roots, `--impact`, `--safe-delete`, `--dead-code`, `--uses`, `--path` (beside `to_value_refs=`), + `--json` and the MCP twins — carries `value_refs_depth_capped=N` (files cut) and `value_refs_depth_at=FILE:LINE` + (the first cut), so a missing row, a `count=` or a listed dead function there reads as a floor. The named + exceptions read the rows and do not yet carry it: `--quality-delta`'s dead-code kind and its `value-ref-excluded=` + count (CLI and MCP), `--verify='uses(SYM)'` / `'unused(SYM)'` (their `count=` is a floor there; the verdict is not: + a witness confirms, an absence is `not-established`), and the LSP references and hover handlers, which have no + attribute to carry it. An answer over a tree with no cut is byte-identical. +- **Second review.** A parameter of a prototype, a function-pointer typedef or parameter, a C++ member declaration, a + Go func type or interface method, or a TS function type, `declare function` or interface signature no longer hides a + same-named function in the enclosing scope (`signal( SIGINT, handler )` beside `typedef void (*fn_t)( int handler )` + was no row). A function that only stores ITSELF (`timer_set( tick )` inside tick) is no longer kept off `--dead-code` + or `dead_code_candidate`, as a recursive self-call is no caller. `--callees` rows are per binding site and written + callee: two calls `tbl.k(); tbl.k();` through one slot are one row, and `sites=` counts binding sites, not calls. A + known floor: one slot called under two spellings (`tbl.k()` and `tbl["k"]()`) is two written callees, so two rows. A C `[expr]` designator, a numeric key + and an identifier key are capped like every other written slot. - **Cost.** A cold default map costs +3.6% CPU on this tree, +6.3% on django and +5.2% on webpack (median of 5, `sim/refval_cpu.sh`). - **Cache.** `kParserVer` moves. - **Manifest.** The `tools/list` manifest grows 46,591 → 46,732 B: the two find descriptions name `valueRefs` as not a proven call. -Gate: `test/recallshapecheck.sh`. It has 177 arms across C, C++, JS, JSX, TS, TSX, Python and Go: +Gate: `test/recallshapecheck.sh`. It has 201 CLI arms (plus the parity checks) across C, C++, JS, JSX, TS, TSX, Python and Go: - positives; - near-miss negatives for every guard, each proven able to fail by a mutation (`sim/refval_mutate.sh`); - named floors; @@ -1254,7 +1613,7 @@ per page instead. Gate: `test/impactdepthcheck.sh`. ### Changed — the versions this release moves, stated once -`kParserVer` 124 → 148 (the function-literal fix takes 128; #338 and #325 take 129; the body-less C/C++ type-specifier +`kParserVer` 124 → 156 (the function-literal fix takes 128; #338 and #325 take 129; the body-less C/C++ type-specifier span fix and the TypeScript `await f(x)` / `!f(x)` calls each took a number of their own on their branches, as did the false-edge resolution (134, 135), the value-reference rows (140) and the Ruby method-lookup changes (#373: one per step from 130 to 137 — bare-word calls, mixins, typed receivers, RSpec targets, Rails declared calls, Rake and Jbuilder @@ -1262,12 +1621,14 @@ files, RSpec matcher chains, class objects — then 142 and 145 for its rebase a change); 141 sat above every number a branch build of unreleased work had used, and the review fixes to the global-object shadow and the value-reference slot text take 143, above 141's full-use file tag 142. The Ruby method lookup lands at 148, renumbered from its branch's 145: other branch builds in flight have used up to 147, and 148's full-use file tag is -149, so no cache such a build wrote is read as this release's), `kCacheVersion` 25 → 28 -(the function-literal fix's record changes, then the false-edge fix's member-call fields; the Ruby branch's appended -binding kinds need no bump of their own) and `kQSnapCacheScheme` 15 → 17 +149. The Go named-type kinds and the train 25 re-review's extraction fixes each took 145 on their branches, and the +receiver-evidence records took 145 to 154 on theirs; all three land together at 156: branch builds have used up to 154, +whose full-use file tag is 155, so no cache such a build wrote is read as this release's), `kCacheVersion` 25 → 29 +(the function-literal fix's record changes, then the false-edge fix's member-call fields, then the receiver chain +`memberPath`/`memberCtor`; the Ruby branch's appended binding kinds need no bump of their own) and `kQSnapCacheScheme` 15 → 19 (the `--quality-delta` error-masking and placeholder changes, then the dead kind agreeing with `--dead-code` on functions -held as values). Every ingest cache written by an earlier build is refused and re-indexed once, and every -cached quality snapshot is recomputed. The session legend dictionary is `dictv=04d7833c60d2d5bd entries=784`. +held as values, then on a function that only stores itself, then the defect-shape records of the twelfth kind). Every ingest cache written by an earlier build is refused and re-indexed once, and every +cached quality snapshot is recomputed. The session legend dictionary is `dictv=593769ab691623d8 entries=806`. ### Fixed — a call the language resolves outside the tree no longer binds to a same-named in-repo definition @@ -1281,7 +1642,8 @@ the result as a confident edge. Three shapes are now resolved the way the langua - a JavaScript/TypeScript call on a global object (`JSON.parse`, `Buffer.from`, `crypto.subtle.verify`) or to a global function (`fetch`) in a file that neither imports nor declares that name; - a call through `require( 'pkg' )`, `import * as ns from 'pkg'` or a name destructured from a global object, and a Go - call through an import whose path no go.mod in the tree contains. + call through an import whose path no go.mod in the tree contains (a go.mod path is read as its grammar spells it: + bare, quoted or a raw string, with a trailing comment dropped, so `replace "x" => "./x"` puts x in the tree). Such a call has no edge and is counted `external=` where the language proves the target is outside the tree (a builtin, a global, an outside package or `use`, a Go predeclared function, a C library name), `unresolved=` otherwise. A name an import binds from inside the tree, and every implicit-receiver language (Java, C#, C++, Kotlin, Swift, Ruby, @@ -1313,6 +1675,18 @@ left it behind when a suite timeout killed the gate. `scripts/gatebound.sh` now also arm an `alarm` of their own, and a sanitized harness that will not start is a SKIP, like one that will not link. `test/pargatescheck.sh` arm (O) stops each gate mid-harness (TERM and KILL) and asserts no harness survives. +### Fixed — an ASan runtime that hangs before `main` is reported at configure time, and the ASan-harness gates no longer time out on it + +On macOS 26.7 with Command Line Tools 26.3 (Apple clang 17.0.0), every `-fsanitize=address` binary deadlocks in the +sanitizer runtime's own start-up, so each ASan gate ended as an `rc=124` timeout and an `-DRIPWIRE_ASAN=ON` tree built +binaries that never reached `main`. An Apple `-DRIPWIRE_ASAN=ON` configure now builds and runs an empty ASan program +under a 10 s timeout (`RIPWIRE_ASAN_PROBE_TIMEOUT`) and stops with the two fixes (update the Command Line Tools/Xcode, or +configure with Homebrew `llvm@22`); a pass is cached and prints nothing, and non-ASan or non-Apple configures run no probe. +The gates that compile their own ASan harness (`connectcorecheck`, `radixsimdcheck`, `dynmapsimdcheck`) prefer +`llvm@22`'s `clang++` when installed (an explicit `CXX` wins); with no working ASan toolchain they, and +`oswin32logiccheck` arm (B) and `strkerncheck`, print a named `SKIP` with the reason and exit 0. No assertion changed. +`scripts/asanprobe.sh` holds the compiler choice and the probe. + ### Fixed — C/C++: a body-less `enum X` in a function signature no longer poses as the function's encloser tmux writes `static enum cmd_retval` on one line and the function name on the next. The tags query indexes every named diff --git a/CMakeLists.txt b/CMakeLists.txt index 64b843521..47d1aa7c9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1015,13 +1015,78 @@ if(WIN32) endif() if(RIPWIRE_ASAN) - # AppleClang 17's ASan runtime hangs in its own start-up on macOS 26 (Darwin 25), before main, so every - # sanitizer run times out. CI's Xcode 26.6 AppleClang 21 is unaffected. A warning, not an error: the - # tree still builds, and CONTRIBUTING's sanitizer section has the Homebrew LLVM 22 recipe. - if(APPLE AND CMAKE_CXX_COMPILER_ID STREQUAL "AppleClang" AND CMAKE_CXX_COMPILER_VERSION VERSION_LESS 21 - AND CMAKE_HOST_SYSTEM_VERSION VERSION_GREATER_EQUAL 25) - message(WARNING "RIPWIRE_ASAN: AppleClang ${CMAKE_CXX_COMPILER_VERSION} on macOS 26 is known to hang in " - "ASan start-up before main. Configure this tree with Homebrew LLVM 22 (CONTRIBUTING.md, Sanitizer build).") + # Some macOS toolchains ship an ASan runtime that deadlocks in its own start-up, before main (Apple clang 17.0.0 / + # Command Line Tools 26.3 on macOS 26.7: libSystem's malloc init enters AsanInitFromRtl, whose shadow-memory + # set-up allocates through dyld and re-enters it). Every sanitizer binary and every ASan gate then just hangs, and + # the contributor sees only timeouts. So on Apple, with ASan on, run an empty -fsanitize=address program once, under + # a timeout, and stop with the fix if it does not finish. Nothing here runs without RIPWIRE_ASAN or off Apple, and a + # pass is cached per (compiler, version, host OS) so a reconfigure does not pay for it again and prints nothing new. + if(APPLE AND CMAKE_SYSTEM_NAME STREQUAL "Darwin" AND NOT CMAKE_CROSSCOMPILING + AND CMAKE_CXX_COMPILER_ID MATCHES "Clang") + set(RIPWIRE_ASAN_PROBE_TIMEOUT 10 CACHE STRING "seconds the configure-time ASan start-up probe may take (macOS)") + # CMake never re-detects CMAKE_CXX_COMPILER_VERSION (or the host OS version) in an existing build tree, and a Command + # Line Tools/Xcode update replaces the compiler behind the same /usr/bin/c++. So the key also carries the ASan + # runtime the compiler links today (its path and modification time): an in-place toolchain update re-runs the probe. + execute_process(COMMAND ${CMAKE_CXX_COMPILER} ${CMAKE_CXX_COMPILER_ARG1} -print-file-name=libclang_rt.asan_osx_dynamic.dylib + OUTPUT_VARIABLE _ripwire_asan_rt OUTPUT_STRIP_TRAILING_WHITESPACE ERROR_QUIET) + set(_ripwire_asan_rt_stamp "") + if(EXISTS "${_ripwire_asan_rt}") + file(TIMESTAMP "${_ripwire_asan_rt}" _ripwire_asan_rt_stamp "%s" UTC) + endif() + set(_ripwire_asan_probe_key + "${CMAKE_CXX_COMPILER}|${CMAKE_CXX_COMPILER_VERSION}|${CMAKE_HOST_SYSTEM_VERSION}|${CMAKE_OSX_SYSROOT}|${CMAKE_EXE_LINKER_FLAGS}|${_ripwire_asan_rt}@${_ripwire_asan_rt_stamp}") + if(NOT "${_ripwire_asan_probe_key}" STREQUAL "${_RIPWIRE_ASAN_PROBE_PASSED}") + set(_ripwire_asan_probe_dir "${CMAKE_BINARY_DIR}/CMakeFiles/ripwire_asan_probe") + file(MAKE_DIRECTORY "${_ripwire_asan_probe_dir}") + file(WRITE "${_ripwire_asan_probe_dir}/probe.cpp" "int main() { return 0; }\n") + set(_ripwire_asan_probe_cmd ${CMAKE_CXX_COMPILER} ${CMAKE_CXX_COMPILER_ARG1}) + if(CMAKE_OSX_SYSROOT) + list(APPEND _ripwire_asan_probe_cmd -isysroot "${CMAKE_OSX_SYSROOT}") + endif() + if(CMAKE_OSX_ARCHITECTURES) + foreach(_ripwire_asan_probe_arch IN LISTS CMAKE_OSX_ARCHITECTURES) + list(APPEND _ripwire_asan_probe_cmd -arch "${_ripwire_asan_probe_arch}") + endforeach() + endif() + separate_arguments(_ripwire_asan_probe_ldflags NATIVE_COMMAND "${CMAKE_EXE_LINKER_FLAGS}") + execute_process( + COMMAND ${_ripwire_asan_probe_cmd} ${_ripwire_asan_probe_ldflags} -fsanitize=address + "${_ripwire_asan_probe_dir}/probe.cpp" -o "${_ripwire_asan_probe_dir}/probe" + RESULT_VARIABLE _ripwire_asan_probe_build_rc + OUTPUT_VARIABLE _ripwire_asan_probe_build_out ERROR_VARIABLE _ripwire_asan_probe_build_out) + if(NOT _ripwire_asan_probe_build_rc EQUAL 0) + message(FATAL_ERROR "RIPWIRE_ASAN: ${CMAKE_CXX_COMPILER} could not build an empty -fsanitize=address program " + "(${_ripwire_asan_probe_build_rc}):\n${_ripwire_asan_probe_build_out}\n" + "This toolchain has no usable AddressSanitizer runtime. Install the Command Line Tools/Xcode that ship one, or " + "configure with Homebrew LLVM (CONTRIBUTING.md, Sanitizer build):\n" + " cmake -S . -B asan -DRIPWIRE_ASAN=ON -DCMAKE_C_COMPILER=$(brew --prefix llvm@22)/bin/clang " + "-DCMAKE_CXX_COMPILER=$(brew --prefix llvm@22)/bin/clang++") + endif() + execute_process( + COMMAND ${CMAKE_COMMAND} -E env ASAN_OPTIONS=detect_leaks=0 "${_ripwire_asan_probe_dir}/probe" + TIMEOUT ${RIPWIRE_ASAN_PROBE_TIMEOUT} + RESULT_VARIABLE _ripwire_asan_probe_run_rc + OUTPUT_QUIET ERROR_QUIET) + if(NOT _ripwire_asan_probe_run_rc EQUAL 0 AND NOT _ripwire_asan_probe_run_rc MATCHES "timeout") + message(FATAL_ERROR "RIPWIRE_ASAN: an empty program built with ${CMAKE_CXX_COMPILER} -fsanitize=address exited " + "(${_ripwire_asan_probe_run_rc}) instead of returning 0: this toolchain's AddressSanitizer runtime fails at " + "start-up. Update the Command Line Tools / Xcode, or configure with Homebrew LLVM (CONTRIBUTING.md, Sanitizer " + "build).") + elseif(NOT _ripwire_asan_probe_run_rc EQUAL 0) + message(FATAL_ERROR "RIPWIRE_ASAN: an empty program built with ${CMAKE_CXX_COMPILER} -fsanitize=address did not " + "run to completion within ${RIPWIRE_ASAN_PROBE_TIMEOUT} s (${_ripwire_asan_probe_run_rc}). This toolchain's " + "AddressSanitizer runtime deadlocks in its own start-up, before main, on this macOS (seen with Apple clang " + "17.0.0 / Command Line Tools 26.3 on macOS 26.7), so every sanitizer binary and gate would hang. Either " + "update the Command Line Tools / Xcode (softwareupdate, or Xcode's Settings > Components), or configure with " + "Homebrew LLVM, which carries a runtime that starts:\n" + " brew install llvm@22\n" + " cmake --fresh -S . -B asan -DRIPWIRE_ASAN=ON -DCMAKE_C_COMPILER=$(brew --prefix llvm@22)/bin/clang " + "-DCMAKE_CXX_COMPILER=$(brew --prefix llvm@22)/bin/clang++\n" + "(CONTRIBUTING.md, Sanitizer build, has the matching linker flags.) Raise RIPWIRE_ASAN_PROBE_TIMEOUT only if " + "this host is simply very busy.") + endif() + set(_RIPWIRE_ASAN_PROBE_PASSED "${_ripwire_asan_probe_key}" CACHE INTERNAL "ASan start-up probe passed for this toolchain") + endif() endif() set(_ripwire_windows_clangcl_asan OFF) set(_ripwire_no_omit_frame_pointer -fno-omit-frame-pointer) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 316a49837..f0471c3f6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -38,7 +38,10 @@ LSAN_OPTIONS=suppressions=lsan_suppressions.txt ./asan/ripwire >/dev/null **macOS 26 with the Command Line Tools' AppleClang 17: ASan hangs before `main`.** Every `-fsanitize=address` binary, even an empty `main`, hangs in ASan's start-up (shadow-memory set-up walking the dyld shared cache), so each ASan gate just times out. CI's Xcode 26.6 AppleClang 21 is -not affected, and CMake warns when it sees the affected pair. Configure the sanitizer tree with +not affected, and an `-DRIPWIRE_ASAN=ON` configure on macOS now runs an empty ASan program once under a +10 s timeout and stops with the fix if it hangs (the two fixes: update the Command Line Tools/Xcode, or use +Homebrew LLVM 22 as below), while the gates that compile their own ASan harness prefer `llvm@22`'s `clang++` +when it is installed (an explicit `CXX` wins) and otherwise SKIP by name. Configure the sanitizer tree with Homebrew LLVM 22 instead, linking its own libc++ so the headers and the dylib are one release (Homebrew clang otherwise links the system libc++ against its libc++ 22 headers): @@ -842,6 +845,36 @@ round. Each takes minutes to check; each miss costs a review round. claim; a dedup or merge of findings keeps the most severe row, not the first; a substring match on a name is whole-word or bounded (probe `catalog` and `dialog` for `log`); a per-tag or per-file cap cannot make the result depend on file order. +14. **A gate fails closed.** A shell gate checks that a value is present and numeric before it compares it, so an + erroring comparison never falls through to PASS; it SKIPs a case only for a named, expected missing premise and + FAILs on any other; an absence ("no row X", "nothing skipped") is read only off a command that succeeded and + produced its root or rows; when one harness call is added to sibling gates, diff the call sites so every arm + runs. A wrapper records the runner's own exit status right after it, and a reported `rc=0` is checked against + the log's failure count. +15. **Claims match every surface.** A comment, legend or doc that says a case is handled names the arm that proves + it, or the claim goes. After a behaviour change, search every surface that describes it (CLI help, MCP tool + descriptions, legends, docs) for sentences the change made false, and re-check size ceilings such as the MCP + tool list. A count over a set (overloads, roots, shards) counts distinct entities; probe two members that meet + the same one. An external read on an answer path tells "could not read" from "read, empty" and discloses the + failure, never "none". +16. **A match key names the real entity.** A filter, dedup or match identifies owner + name in its binding + scope, never file + name; probe two owners of one name in one file, and a bare name beside a same-named + class member. +17. **A gate detects what it needs and skips by name.** A gate that needs a sanitizer, a tool or a locale + checks for it itself and SKIPs naming it when absent; a guard on machine state exits rather than printing; + a new row or attribute name does not match the name patterns existing gates grep for. +18. **After any merge, count format placeholders against arguments** at every format call either side + touched: two sides adding the same `{}` merge cleanly and silently drop an argument. Never edit a tree + while its suite runs. +19. **Commit before you mutate.** Commit the fix before a mutation test, and undo a mutation by restoring the + committed file, never by checking out over uncommitted work; a build's `built_from` matches the commit it + claims. +20. **A text cap cuts on a UTF-8 boundary** and adds the ellipsis only when bytes were dropped; probe a + multibyte character straddling the cap, a text of exactly the cap, and the same slot in every language + that writes it. +21. **A derived cache dies with its parent.** A cached object that points into a rebuilt structure is dropped + wherever that structure is reassigned, never kept on a content stamp; probe a rebuild that keeps the + stamp (a chmod, a new non-source file) under the sanitizer build. **The gate count is a build product.** It is stated in `README.md`, `docs/EVALS.md` and `present/deck5_ripwire_build.js` — eight sites — and every one of them is written by diff --git a/INSTALL.md b/INSTALL.md index 2f1d7a2a7..09758889d 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -185,6 +185,12 @@ rm -rf "$h/substitution.jsonl" "$h/routing.jsonl" "$h/routing-pending" "$h/meter rmdir "$h" 2>/dev/null || true ``` +`meter.conf` is optional and the installer never writes it. With no `meter.conf` (and no `RIPWIRE_METER_ARM` +in the environment) the hooks assign each session to the `treatment` or `control` arm of the router and +meter A/B by a stable hash of the session id (about half each), so a default install has a control group. Control sessions +get the same logging and no injected advice. To force every session onto one side, write `arm=treatment` or +`arm=control` into `$h/meter.conf` (or set `RIPWIRE_METER_ARM`); `arm=auto` spells out the default. + **4. The MCP server** (only if you added it). - Claude Code: `claude mcp remove ripwire`. diff --git a/README.md b/README.md index f7523c566..0f9f2afd6 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ **The ripgrep of AI context. A map before your agent reads the repo — and a check on what it writes.** Ranked, deterministic call graph: what to touch, what it breaks, which tests to run. On the edit: blast -radius, tests that reach it, eleven quality kinds reporting only what got worse, forgotten co-changes, fields +radius, tests that reach it, twelve quality kinds reporting only what got worse, forgotten co-changes, fields read and written, names that resolve more than one way. **Just want to use it?** Install it with the one line below, then start each coding session by telling your agent to @@ -845,8 +845,11 @@ firing on the same function is corroboration rather than one metric counted twic That matters most for code an agent wrote. Empty-catch error masking is **+47%** more common in AI-authored commits, a function rewritten again inside two weeks **+15%** more likely, and reuse is *declining* as AI's share of commits grows (GitClear, *AI Copilot Code Quality*, 2026). Ten of -`--quality-delta`'s 11 kinds each target one measured mode like those (the eleventh, `placeholder`, -lists the stubs and TODOs a change adds, and never gates), and it reports **only what your +`--quality-delta`'s 12 kinds each target one measured mode like those (the eleventh, `placeholder`, +lists the stubs and TODOs a change adds, and never gates; the twelfth, `defect-shape`, catches four +defect shapes review kept finding by hand — +new-symbol rows never gate, except defect-shape format-arity, +because a format/argument mismatch is a defect, not debt), and it reports **only what your change made worse** — then `--exemplar` shows the pattern in your own repo to copy, and `--test-gate` names the tests that must run before "done." @@ -945,9 +948,9 @@ Full retrieval tables — including the MRR figures behind the router numbers ab ## What it answers
-185 long flags across seven families, plus the MCP server — and --help-task names the ONE command a task wants, or abstains honestly when the evidence is too thin +188 long flags across seven families, plus the MCP server — and --help-task names the ONE command a task wants, or abstains honestly when the evidence is too thin -Around the core sit 185 long flags advertised in `--help`, across seven families — plus an MCP +Around the core sit 188 long flags advertised in `--help`, across seven families — plus an MCP server, so a coding agent can call any of them mid-task instead of grepping and reading whole files. `--help` prints one line per flag (~4.5K tokens); `--help=--FLAG` prints that flag's full entry with every caveat, `--help=SECTION` one family, and `--help=all` the whole catalog. @@ -2082,9 +2085,9 @@ wrong, and it has. These are the results that say so, all in-tree, all published ### In the tests
-671 gate scripts, five contracts no unit test can hold, and the house rule: write the gate before the code it measures +678 gate scripts, five contracts no unit test can hold, and the house rule: write the gate before the code it measures -`test/regression.sh` names **671 gate scripts** and is the authoritative list; +`test/regression.sh` names **678 gate scripts** and is the authoritative list; `python3 test/pargates.py . ./build/ripwire -j 6` runs the same set in parallel. On top of them sit the contracts that do not fit a unit test: two runs byte-identical, warm output identical to cold, output that pipes clean through `xmllint --noout`, a sanitizer build with `-fno-sanitize-recover=all`, and a @@ -2644,7 +2647,7 @@ identity of the index, and says which one is at fault. ### 5. Command families -The `--help` output groups 185 long flags advertised in `--help` into seven families. The `--help=` +The `--help` output groups 188 long flags advertised in `--help` into seven families. The `--help=` column below is the argument that prints one family: `ripwire --help=navigate`. `ripwire --help=--FLAG` prints one flag's full text — the caveats, the units, what it refuses and why. `ripwire --help=all` is the whole catalog, about 46,000 tokens. @@ -2838,7 +2841,7 @@ python3 test/pargates.py . ./build/ripwire -j 6 A new gate script must be added to `test/regression.sh` in the same change. The gate `test/manifestcheck.sh` enforces this rule. -Another gate derives the cap inventory. The tool has 236 compile-time caps and 7 ranking parameters. +Another gate derives the cap inventory. The tool has 240 compile-time caps and 7 ranking parameters. `docs/LIMITS.md` lists each cap, its value, and whether the file discloses a truncation when the cap fires, and `python3 docs/limits_build.py --check` proves that list against `src/`. `docs/TUNING.md` lists the measured cost of each cap. diff --git a/bench/routing_ab_report.py b/bench/routing_ab_report.py index 92082cbc1..ecabee51d 100755 --- a/bench/routing_ab_report.py +++ b/bench/routing_ab_report.py @@ -36,8 +36,9 @@ # and NO VERDICT below 40 recommended prompts per arm — that floor is enforced unconditionally below; # there is no flag that overrides it, and none should be added. # -# WHAT THIS NEVER OPENS. Exactly the two paths named by --routing and --meter — nothing else on disk, -# ever. routing.jsonl carries no prompt text by construction (a cksum and a byte length only), and this +# WHAT THIS NEVER OPENS. Exactly the two paths named by --routing and --meter, plus the one registration +# file docs/EVALS.md (--evals) read for the window-2 start date and nothing else — no other path on +# disk, ever. routing.jsonl carries no prompt text by construction (a cksum and a byte length only), and this # script never reads a field that could hold any (`detail`, transcripts, repo content); every number # printed below is a count, a rate, or a file path the caller supplied. # @@ -65,7 +66,9 @@ # Exit 0 on a normal readout OR a refusal (refusing below the floor is a correct answer, not a # failure); non-zero only when an input file's content cannot be read as this instrument's data at all. import argparse +import datetime import os +import re import subprocess import sys import json @@ -76,6 +79,84 @@ AGENT = "claude" # routing.jsonl is shared with the Codex router; see header +def default_evals(): + """docs/EVALS.md next to this script (bench/ and docs/ are siblings in the checkout).""" + return os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir, "docs", "EVALS.md") + + +# The prompt-router registration (docs/EVALS.md, "Claude Code prompt router — PRE-REGISTERED 2026-09-02") +# carries the readout clock as ONE bold line, `**Window start:** YYYY-MM-DD` (or `PENDING`). Window 1 (the +# 2026-09-02 registration) had no control arm on a default install (issue #381), so it is VOID for the +# verdict and is printed as such, never as a readout date; window 2 starts at the release that ships +# `arm=auto`, and its readout date is start + 28 days, with the one-time extension at start + 42 days. The +# registration states neither date: they are derived here from the start. Read from that section only. +REGISTRATION_HEADING = "### Claude Code prompt router — PRE-REGISTERED" +WINDOW_START_LINE_RE = re.compile(r"^\*\*Window start:\*\*[ \t]*(.*)$", re.M) +WINDOW_START_DATE_RE = re.compile(r"(\d{4}-\d{2}-\d{2})(?!\d)") +READOUT_DAYS = 28 +EXTENDED_DAYS = 42 +WINDOW1_LINE = ("prompt-router window 1 (registered 2026-09-02): VOID -- no control arm (issue #381); " + "not a readout") +PENDING_LINE = ("prompt-router readout date: PENDING -- window 2 starts at the release that ships arm=auto " + "(no Window start date in docs/EVALS.md yet)") + + +def read_window_start(path): + """(start, pending, problem). `start` is a `datetime.date` when the registration records a window-2 + start date; `pending` is True when it says PENDING or carries no `**Window start:**` line at all; + `problem` is a one-line reason when the clock could not be read (file missing, section missing, a + start that is neither a calendar date nor PENDING) so the report says "unknown" and why.""" + try: + with open(path, "r", encoding="utf-8", errors="replace") as fh: + text = fh.read() + except OSError: + return None, False, "registration file not readable: %s" % os.path.normpath(path) + begin = text.find(REGISTRATION_HEADING) + if begin < 0: + return None, False, "no prompt-router registration section in %s" % os.path.normpath(path) + nxt = text.find("\n### ", begin + 1) + section = text[begin:nxt if nxt > 0 else len(text)] + line = WINDOW_START_LINE_RE.search(section) + if line is None: + return None, True, None + value = line.group(1).strip() + if value.upper().startswith("PENDING"): + return None, True, None + m = WINDOW_START_DATE_RE.match(value) + if m is None: + return None, False, "the `**Window start:**` line is neither a YYYY-MM-DD date nor PENDING" + try: + return datetime.date.fromisoformat(m.group(1)), False, None + except ValueError: + return None, False, "the `**Window start:**` date is not a valid calendar date" + + +def flag_line(label, d, today): + """One readout-date line: the date, how far it is from `today`, and a loud flag once it has passed (a + passed date is exactly when an unread, underpowered window needs a decision).""" + delta = (today - d).days + if delta > 0: + return "%s: %s -- PASSED %d day(s) ago (as of %s)" % (label, d.isoformat(), delta, today.isoformat()) + if delta == 0: + return "%s: %s -- TODAY" % (label, d.isoformat()) + return "%s: %s -- in %d day(s) (as of %s)" % (label, d.isoformat(), -delta, today.isoformat()) + + +def readout_lines(start, pending, problem, today): + """The report's readout lines. Window 1 is ALWAYS printed as VOID -- it is never given a readout date, + so no output of this script can call the 2026-09-02 window passed. Window 2 follows its start date.""" + lines = [WINDOW1_LINE] + if problem: + lines.append("readout date: UNKNOWN -- %s" % problem) + elif pending or start is None: + lines.append(PENDING_LINE) + else: + lines.append(flag_line("prompt-router window 2 readout date", start + datetime.timedelta(days=READOUT_DAYS), today)) + lines.append(flag_line("prompt-router window 2 extended readout date (one-time)", + start + datetime.timedelta(days=EXTENDED_DAYS), today)) + return lines + + def default_home(): return os.environ.get("RIPWIRE_HOME", os.path.join(os.path.expanduser("~"), ".ripwire")) @@ -262,6 +343,17 @@ def report_one_router(router, routing_rows, meter_rows): print("%-10s %8d %12d %10d %8s %9d" % (arm, st["prompts"], st["recommended"], st["adopted"], rate_s, st["sessions"])) + # Per-arm counts on one line, and a LOUD flag for an empty arm: a window with no control prompts + # (the shipped default put every session on treatment until issue #381) cannot yield the registered + # treatment-minus-control difference no matter how long it runs. + print("arm counts: treatment=%d control=%d prompts" + % (arm_stats["treatment"]["prompts"], arm_stats["control"]["prompts"])) + for arm, other in (("control", "treatment"), ("treatment", "control")): + if arm_stats[arm]["prompts"] == 0: + print("!!! NO %s ARM -- %s has 0 prompts in this window (the other arm has %d); " + "the registered treatment-minus-control difference CANNOT be computed from it !!!" + % (arm.upper(), arm, arm_stats[other]["prompts"])) + covered, total = join_coverage(prompts, meter_rows) if total: print("join coverage: %d/%d routing row(s) (%.1f%%) have a session with >=1 meter row" @@ -284,6 +376,31 @@ def report_one_router(router, routing_rows, meter_rows): return 0 +def parse_today(arg): + """`--today` as a date (default: the current UTC date), or None when it is not YYYY-MM-DD.""" + if not arg: + return datetime.datetime.now(datetime.timezone.utc).date() + try: + return datetime.date.fromisoformat(arg) + except ValueError: + return None + + +def print_readout(evals_path, today): + """The readout-clock lines. They belong to the PROMPT router's registration; the toolcall + router has its own registration and is not dated here.""" + for line in readout_lines(*read_window_start(evals_path), today): + print(line) + + +def pick_routers(requested, routing_rows): + """The routers to report: the one asked for, else every router in the log (prompt first), else the + empty prompt-router table so a log with nothing in it still shows its shape.""" + if requested: + return [requested] + return routers_present(routing_rows) or [DEFAULT_ROUTER] + + def main(): ap = argparse.ArgumentParser( description="adoption-within-two A/B readout for the Claude Code routers (docs/EVALS.md §4 and " @@ -294,6 +411,12 @@ def main(): help="path to substitution.jsonl (default: $RIPWIRE_HOME or ~/.ripwire, substitution.jsonl)") ap.add_argument("--since", default=None, help="only rows with at >= this ISO8601 timestamp") ap.add_argument("--until", default=None, help="only rows with at < this ISO8601 timestamp") + ap.add_argument("--evals", default=default_evals(), + help="path to docs/EVALS.md, read only for the prompt-router registration's window-2 " + "start date (default: the docs/EVALS.md next to this script)") + ap.add_argument("--today", default=None, + help="YYYY-MM-DD to compare the window-2 readout date against (default: the current UTC date; " + "a fixed value makes the report reproducible)") ap.add_argument("--router", default=None, help="report only this router (e.g. prompt, toolcall) instead of every router " "present in the log") @@ -311,6 +434,11 @@ def main(): % args.meter, file=sys.stderr) return 1 + today = parse_today(args.today) + if today is None: + print("routing_ab_report: --today must be YYYY-MM-DD, got %r" % args.today, file=sys.stderr) + return 2 + routing_rows = filter_window(routing_rows, args.since, args.until) print("routing_ab_report -- routing=%s meter=%s" % (args.routing, args.meter)) @@ -319,15 +447,9 @@ def main(): len(meter_rows), meter_bad, "found" if meter_existed else "not found")) if args.since or args.until: print("window: [%s, %s)" % (args.since or "-inf", args.until or "+inf")) + print_readout(args.evals, today) - if args.router: - routers = [args.router] - else: - routers = routers_present(routing_rows) - if not routers: - routers = [DEFAULT_ROUTER] # nothing in the log yet -- still show the empty prompt-router table - - for router in routers: + for router in pick_routers(args.router, routing_rows): report_one_router(router, routing_rows, meter_rows) return 0 diff --git a/bench/tokenaudit/pin.py b/bench/tokenaudit/pin.py index d57567b27..820950293 100644 --- a/bench/tokenaudit/pin.py +++ b/bench/tokenaudit/pin.py @@ -18,6 +18,7 @@ import argparse import os +import re import shutil import subprocess import sys @@ -34,11 +35,16 @@ ("pack-signatures", ["--pack-signatures"]), ("expand", ["--expand=billableTotal"]), ("for-named", ["--for=billableTotal"]), + # for-named's TWIN (PROCESS rule 4): the same question on the e=-free path. A leading NAME=VALUE token is an + # environment assignment, not argv (tokenbudgetcheck #18 reads it the same way); every other pin runs with + # RIPWIRE_FOR_ENDLINES unset. + ("for-named-noe", ["RIPWIRE_FOR_ENDLINES=never", "--for=billableTotal"]), ("for-budgeted", ["--for=dedupe", "--token-budget=400"]), ("pack-task", ["--pack-task=dedupe"]), ] EST = b'est_tokens="' +ENV_TOKEN = re.compile(r"^[A-Z][A-Z0-9_]*=") def est_of(out: bytes): @@ -66,7 +72,7 @@ def main(): lines = [ "# est_calib pins — REAL tokenizer counts of ripwire's output on test/estcalibfix.", - "# label o200k cl100k est_at_pin_time argv... (space-separated; no argv may contain a space)", + "# label o200k cl100k est_at_pin_time argv... (space-separated; no argv may contain a space; a leading NAME=VALUE is env)", "# Regenerate: python3 bench/tokenaudit/pin.py --bin build/ripwire (needs tiktoken)", "# Read by: test/tokenbudgetcheck.sh #18 — the calibration band. See bench/tokenaudit/README.md.", ] @@ -75,7 +81,13 @@ def main(): shutil.copytree(fixture, os.path.join(tmp, "f")) for label, argv in PINNED: # the pinned counts describe the FULL-legend document (tokenbudgetcheck #18 asks for the same posture) - p = subprocess.run([binpath, "f"] + argv + ["--legend=full"], capture_output=True, cwd=tmp) + n = 0 + while n < len(argv) and ENV_TOKEN.match(argv[n]): + n += 1 + envs, args = argv[:n], argv[n:] + env = {k: v for k, v in os.environ.items() if k != "RIPWIRE_FOR_ENDLINES"} + env.update(a.split("=", 1) for a in envs) + p = subprocess.run([binpath, "f"] + args + ["--legend=full"], capture_output=True, cwd=tmp, env=env) if p.returncode != 0: print("pin.py: '%s' exited %d" % (label, p.returncode), file=sys.stderr) return 1 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 80c7fd288..3516a028e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -378,7 +378,13 @@ call graph's visibility (same file first; C `static` stays in its file; JS/TS/Py import; Go stays in its package) and serves them as separate `` rows with `value_refs=N`: where the value lands (`into=`), and which functions may call through that slot (`called_by=`/`through=`). A row is a clue to follow, not a proven call; `--dead-code` and `--safe-delete` treat a value use as -a reason a function is not dead. +a reason a function is not dead (one made by another function or at file scope: a function that only +stores itself stays dead, as a recursive self-call is no caller). The capture stops at 512 levels of +nesting; a file it cut carries one depth-cut record, and every answer that reads value references for +that file's language says so (`value_refs_depth_capped=`, `value_refs_depth_at=`): the callers/callees roots, +`--impact`, `--safe-delete`, `--dead-code`, `--uses`, `--path`, the JSON form and the MCP twins. The exceptions, which +do not yet say so: `--quality-delta`'s dead-code kind and its `value-ref-excluded=` count (CLI and MCP), +`--verify='uses(SYM)'` / `'unused(SYM)'` (their `count=` is then a floor), and the LSP references and hover handlers. ### graph — the CSR diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index a78cd60e9..b4d03761b 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -9,7 +9,7 @@ python3 docs/docs_commands_build.py --bin build/ripwire The flag surface below is read from `ripwire --help`, so it cannot disagree with the shipped binary. `test/docscommandscheck.sh` fails if it ever does — in either direction. -Sample output is lifted from a real recorded run (`docs/captures/COMMANDS_showcase_2026-09-14.md`), trimmed to the first few lines and +Sample output is lifted from a real recorded run (`docs/captures/COMMANDS_showcase_2026-10-08.md`), trimmed to the first few lines and scrubbed of local paths. It is illustrative, not a golden: run the command yourself for the current shape. @@ -44,13 +44,13 @@ Two limits apply to nearly everything here and are not repeated in every section **zoom the detail ladder** — [`--detail`](#--detailn) · [`--pack-signatures`](#--pack-signatures) · [`--outline`](#--outlineab) · [`--expand`](#--expandab) · [`--compress`](#--compress) · [`--pack-top-n`](#--pack-top-nn) · [`--no-redact`](#--no-redact) -**assess quality / structure** — [`--metrics`](#--metrics) · [`--deps`](#--deps) · [`--hotspots`](#--hotspots) · [`--clones`](#--clones) · [`--biggest-first`](#--biggest-first) · [`--nonlocal-state`](#--nonlocal-state) · [`--ensemble`](#--ensemble) · [`--quality-panel`](#--quality-panelpreset) · [`--context-ratio`](#--context-ratio) · [`--naming-calibration`](#--naming-calibration) · [`--naming-consistency`](#--naming-consistency) · [`--naming-locals`](#--naming-locals) · [`--comment-coherence`](#--comment-coherence) · [`--cochange`](#--cochangefile) · [`--cochange-recur`](#--cochange-recurk) · [`--cochange-groups`](#--cochange-groups) · [`--since`](#--sincerevdate) · [`--arch`](#--archfile) · [`--arch`](#--archfile---baseline) · [`--arch`](#--archfile---baseline-update) · [`--lint`](#--lint) · [`--lint-catalog`](#--lint-catalog) · [`--lint-rules`](#--lint-rulesdir) · [`--lint-select`](#--lint-selectprefix) · [`--lint-ignore`](#--lint-ignoreprefix) · [`--sarif`](#--sarif) · [`--with-profile`](#--with-profilefile) · [`--communities`](#--communities) · [`--community`](#--communityid) · [`--zoom`](#--zoomdepth) · [`--report`](#--report) · [`--seams`](#--seams) · [`--mermaid`](#--mermaid) · [`--owners`](#--ownerssym) · [`--dead-code`](#--dead-codedir) · [`--quality-baseline`](#--quality-baseline) · [`--allow-dirty`](#--allow-dirty) · [`--quality-delta`](#--quality-delta) · [`--quality-delta`](#--quality-deltarevab) · [`--dmm`](#--dmmrevab) · [`--quality-ack`](#--quality-ackreason) · [`--ack-only`](#--ack-onlysubstrsubstr) · [`--scope`](#--scopeglobglob) · [`--edit-check`](#--edit-checksym) · [`--replace-symbol-body`](#--replace-symbol-bodytarget) · [`--insert-before-symbol`](#--insert-before-symboltarget) · [`--insert-after-symbol`](#--insert-after-symboltarget) · [`--edit-payload`](#--edit-payloadfile-) · [`--edit-target-file`](#--edit-target-filepath) · [`--no-post-check`](#--no-post-check) · [`--edit-plan`](#--edit-planfile) · [`--dry-run`](#--dry-run----apply) · [`--safe-delete`](#--safe-deletesym) · [`--slice`](#--slicesymvar) · [`--slice-flow`](#--slice-flowbackfwdboth) · [`--slice-depth`](#--slice-depthn) · [`--at`](#--atfileline) · [`--pr-context`](#--pr-contextbaseref) · [`--merge-scout`](#--merge-scoutrefref) · [`--plan-lanes`](#--plan-lanesn---taskgoal) · [`--plan-lanes`](#--plan-lanes---brieffile) · [`--stray-content`](#--stray-contentsubstr) · [`--plan`](#--plan) · [`--abi`](#--abi) · [`--whereis`](#--whereissym) · [`--flags`](#--flagssubstr) · [`--flip`](#--flipname) · [`--layout`](#--layoutstruct) · [`--field-affinity`](#--field-affinitystruct) · [`--doc-drift`](#--doc-driftsubstr) · [`--doc-drift`](#--doc-drift---gateability) · [`--with-history`](#--with-history) · [`--plan-lint`](#--plan-lintfile) · [`--from-trace`](#--from-tracefile) · [`--run-trace`](#--run-tracecmd) · [`--run-timeout`](#--run-timeoutseconds) · [`--note-add`](#--note-addtarget-text) · [`--notes`](#--notes) · [`--pack-task`](#--pack-tasktask) · [`--partition`](#--partitionn) · [`--with-graph`](#--with-graph) · [`--export`](#--exportccjsonfile) · [`--batch`](#--batchfile) +**assess quality / structure** — [`--metrics`](#--metrics) · [`--deps`](#--deps) · [`--hotspots`](#--hotspots) · [`--clones`](#--clones) · [`--biggest-first`](#--biggest-first) · [`--nonlocal-state`](#--nonlocal-state) · [`--ensemble`](#--ensemble) · [`--quality-panel`](#--quality-panelpreset) · [`--context-ratio`](#--context-ratio) · [`--naming-calibration`](#--naming-calibration) · [`--naming-consistency`](#--naming-consistency) · [`--naming-locals`](#--naming-locals) · [`--comment-coherence`](#--comment-coherence) · [`--cochange`](#--cochangefile) · [`--cochange-recur`](#--cochange-recurk) · [`--cochange-groups`](#--cochange-groups) · [`--since`](#--sincerevdate) · [`--arch`](#--archfile) · [`--arch`](#--archfile---baseline) · [`--arch`](#--archfile---baseline-update) · [`--lint`](#--lint) · [`--lint-catalog`](#--lint-catalog) · [`--lint-rules`](#--lint-rulesdir) · [`--lint-select`](#--lint-selectprefix) · [`--lint-ignore`](#--lint-ignoreprefix) · [`--lint-max-per-rule`](#--lint-max-per-rulen) · [`--sarif`](#--sarif) · [`--with-profile`](#--with-profilefile) · [`--communities`](#--communities) · [`--community`](#--communityid) · [`--zoom`](#--zoomdepth) · [`--report`](#--report) · [`--seams`](#--seams) · [`--mermaid`](#--mermaid) · [`--owners`](#--ownerssym) · [`--dead-code`](#--dead-codedir) · [`--quality-baseline`](#--quality-baseline) · [`--allow-dirty`](#--allow-dirty) · [`--quality-delta`](#--quality-delta) · [`--quality-delta`](#--quality-deltarevab) · [`--dmm`](#--dmmrevab) · [`--quality-ack`](#--quality-ackreason) · [`--ack-only`](#--ack-onlysubstrsubstr) · [`--scope`](#--scopeglobglob) · [`--edit-check`](#--edit-checksym) · [`--replace-symbol-body`](#--replace-symbol-bodytarget) · [`--insert-before-symbol`](#--insert-before-symboltarget) · [`--insert-after-symbol`](#--insert-after-symboltarget) · [`--edit-payload`](#--edit-payloadfile-) · [`--edit-target-file`](#--edit-target-filepath) · [`--no-post-check`](#--no-post-check) · [`--edit-plan`](#--edit-planfile) · [`--dry-run`](#--dry-run----apply) · [`--safe-delete`](#--safe-deletesym) · [`--slice`](#--slicesymvar) · [`--slice-flow`](#--slice-flowbackfwdboth) · [`--slice-depth`](#--slice-depthn) · [`--at`](#--atfileline) · [`--pr-context`](#--pr-contextbaseref) · [`--merge-scout`](#--merge-scoutrefref) · [`--plan-lanes`](#--plan-lanesn---taskgoal) · [`--plan-lanes`](#--plan-lanes---brieffile) · [`--stray-content`](#--stray-contentsubstr) · [`--plan`](#--plan) · [`--abi`](#--abi) · [`--whereis`](#--whereissym) · [`--whereis-listing`](#--whereis-listingwhich) · [`--flags`](#--flagssubstr) · [`--flip`](#--flipname) · [`--layout`](#--layoutstruct) · [`--field-affinity`](#--field-affinitystruct) · [`--doc-drift`](#--doc-driftsubstr) · [`--doc-drift`](#--doc-drift---gateability) · [`--with-history`](#--with-history) · [`--plan-lint`](#--plan-lintfile) · [`--from-trace`](#--from-tracefile) · [`--run-trace`](#--run-tracecmd) · [`--run-timeout`](#--run-timeoutseconds) · [`--note-add`](#--note-addtarget-text) · [`--notes`](#--notes) · [`--pack-task`](#--pack-tasktask) · [`--partition`](#--partitionn) · [`--with-graph`](#--with-graph) · [`--export`](#--exportccjsonfile) · [`--batch`](#--batchfile) **self-diagnosis** — [`--doctor`](#--doctor) · [`--agent`](#--agentcodexclaude) · [`--skipped`](#--skipped) **security — scan skill files for injection / exfiltration patterns (exit 2 = CRITICAL, 1 = WARN,** — [`--scan-skill`](#--scan-skillfile) · [`--scan-skills`](#--scan-skillsdir) · [`--force`](#--force) -**knobs / modes** — [`--rank-by`](#--rank-bypagerankauthorityhubrrfchurnchurn-decay) · [`--in`](#--indir) · [`--format`](#--formatxmlcolumnarrows) · [`--format`](#--formatcandidates) · [`--legend`](#--legendfullcompact) · [`--legend-dict`](#--legend-dictroster) · [`--json`](#--json) · [`--limit`](#--limitn---offsetm) · [`--exclude`](#--excludesubstr) · [`--map-diff`](#--map-diff) · [`--cache`](#--cachepath) · [`--index-out`](#--index-outbase) · [`--no-cache`](#--no-cache) · [`--no-ignore`](#--no-ignore) · [`--max-file-size`](#--max-file-sizenkmg) · [`--max-memory`](#--max-memorynkmg) · [`--refetch`](#--refetch) · [`--scip`](#--scipindexscip) · [`--pin-census`](#--pin-censusfile) · [`--mcp`](#--mcp) · [`--mcp-tools`](#--mcp-toolslist) · [`--lsp`](#--lsp) · [`--listen`](#--listenhostport) · [`--mcp-token`](#--mcp-tokent) · [`--allow-remote-edits`](#--allow-remote-edits) · [`--eval-stray`](#--eval-strayfile) · [`--eval`](#--eval) · [`--eval-retrieval`](#--eval-retrieval) · [`--eval-mined`](#--eval-minedfile) · [`--eval-skills`](#--eval-skillsfile) · [`-h`](#-h---help) · [`-v`](#-v---version) +**knobs / modes** — [`--rank-by`](#--rank-bypagerankauthorityhubrrfchurnchurn-decay) · [`--in`](#--indir) · [`--format`](#--formatxmlcolumnarrows) · [`--format`](#--formatcandidates) · [`--legend`](#--legendfullcompact) · [`--legend-dict`](#--legend-dictroster) · [`--json`](#--json) · [`--limit`](#--limitn---offsetm) · [`--exclude`](#--excludesubstr) · [`--map-diff`](#--map-diff) · [`--cache`](#--cachepath) · [`--index-out`](#--index-outbase) · [`--no-cache`](#--no-cache) · [`--no-ignore`](#--no-ignore) · [`--max-file-size`](#--max-file-sizenkmg) · [`--max-memory`](#--max-memorynkmg) · [`--refetch`](#--refetch) · [`--scip`](#--scipindexscip) · [`--pin-census`](#--pin-censusfile) · [`--mcp`](#--mcp) · [`--mcp-legend`](#--mcp-legendwhen) · [`--mcp-tools`](#--mcp-toolslist) · [`--lsp`](#--lsp) · [`--listen`](#--listenhostport) · [`--mcp-token`](#--mcp-tokent) · [`--allow-remote-edits`](#--allow-remote-edits) · [`--eval-stray`](#--eval-strayfile) · [`--eval`](#--eval) · [`--eval-retrieval`](#--eval-retrieval) · [`--eval-mined`](#--eval-minedfile) · [`--eval-skills`](#--eval-skillsfile) · [`-h`](#-h---help) · [`-v`](#-v---version) --- @@ -68,21 +68,21 @@ _Same map, capped to the 5 highest-ranked symbols._ ``` $ ./build/ripwire . --top-k=5 - - - - - - - - - + + + + - + - + + + + -... [10 more line(s); run it to see the whole thing] + + +... [11 more line(s); run it to see the whole thing] ``` **Shaped by:** `--token-budget`, `--for`, `--recall`, `--graph-query`, `--pack-signatures`, `--expand`, `--from-trace`, `--run-trace` @@ -104,21 +104,15 @@ _SHAPE the map to fit ~1500 tokens (binary-search top-K)._ ``` $ ./build/ripwire . --max-tokens=1500 - - - - - - - - - - + + + + - + -... [1 more line(s); run it to see the whole thing] + ``` **Shaped by:** `--token-budget`, `--recall`, `--detail`, `--pr-context`, `--from-trace`, `--run-trace`, `--limit` @@ -141,7 +135,8 @@ _GATE form: exit 3 if the map's own est_tokens exceeds the budget (over-budget f ``` $ ./build/ripwire . --token-budget=100 - + + ``` **Shaped by:** `--top-k`, `--max-tokens`, `--for`, `--recall`, `--handoff`, `--pr-context`, `--from-trace`, `--run-trace` @@ -164,8 +159,8 @@ _The honest half of the contract: a task with no ripwire-shaped evidence ABSTAIN ``` $ ./build/ripwire . --help-task="write a cheerful release announcement" - - + + ``` @@ -186,19 +181,19 @@ _Name-shaped query: the router picks name-exact BM25 (header says which/why)._ ``` $ ./build/ripwire . --for="rankGraphTeleport" - - - + + + - + PageRank with an explicit teleport / personalization vector p (Σp = 1). The prior is name-quality-biased through biasPrior() so all rank modes share one weighting seam; the transition matrix (edgesinline RankedGraph rankGraphTeleport( const Graph& g, const std::vector<float>&am … [line truncated: 31 more bytes on this line] -#### The convergence disclosure contract -### Wave-2 adversarial verification (2026-08-19) — six probes against `aa97c9e` +#### The convergence disclosure contract +### Wave-2 adversarial verification (2026-08-19) — six probes against `aa97c9e` - + & p, float alpha = 0.85f ) ... [17 more line(s); run it to see the whole thing] ``` @@ -217,6 +212,25 @@ $ ./build/ripwire . --for="rankGraphTeleport" A comma-separated, order-insensitive, closed set (lego and/or compose, each named at most once); every other section of the bundle is unaffected. +**Try it** + +_Restore the / sections the --for bundle collapses to a counted stub by default (this is the stub's own next= spelling)._ + +``` +$ ./build/ripwire . --for="tree-sitter parse of a source file" --sections=lego,compose + + + + +inline bool topLevelEvidence( std::string_view source, const TSLanguage* language, const Predicate& predicate ) +The crawl's per-file byte ceiling. A text file larger than this is skipped: at this size it is o…constexpr std::size_t kDefaultMaxFileBytes = 4u * 1024u * 1024u +TSTree* parseTree( TSParser* parser, std::string_view src ) + +Exercise every registered grammar and its embedded query, reporting loaded and expected totalsinline DoctorGrammarProbe doctorProbeGrammars() +... [21 more line(s); run it to see the whole thing] +``` + **Shaped by:** `--for` ### `--signatures-only` @@ -231,14 +245,14 @@ _T3 opt-out: the signatures-only lens (no auto bodies, no bundle="auto" attribut ``` $ ./build/ripwire . --for="rankGraphTeleport" --signatures-only - - - + + + - + PageRank with an explicit teleport / personalization vector p (Σp = 1). The prior is name-quality-biased through biasPrior() so all rank modes share one weighting seam; the transition matrix (edgesinline RankedGraph rankGraphTeleport( const Graph& g, const std::vector<float>&am … [line truncated: 31 more bytes on this line] -#### The convergence disclosure contract -### Wave-2 adversarial verification (2026-08-19) — six probes against `aa97c9e` +#### The convergence disclosure contract +### Wave-2 adversarial verification (2026-08-19) — six probes against `aa97c9e` @@ -263,20 +277,18 @@ _Opt OUT of compact conceptual serving: restore the rank-first auto wal ``` $ ./build/ripwire . --for="tree-sitter parse of a source file" --auto-bodies - - - - - + + + + +inline bool topLevelEvidence( std::string_view source, const TSLanguage* language, const Predicate& predicate ) The crawl's per-file byte ceiling. A text file larger than this is skipped: at this size it is o…constexpr std::size_t kDefaultMaxFileBytes = 4u * 1024u * 1024u -TSTree* parseTree( TSParser* parser, std::string_view src ) - +TSTree* parseTree( TSParser* parser, std::string_view src ) + Exercise every registered grammar and its embedded query, reporting loaded and expected totalsinline DoctorGrammarProbe doctorProbeGrammars() - -errNodes/errBytes are a PARSER-STATE fact, never a syntax verdict: tree-sitter error recovery fi…struct FileHealth -enum class AstWalk : std::uint8_t -constexpr std::array<LangEntry, 48> kLangTable = -... [18 more line(s); run it to see the whole thing] +struct FileHealth +... [20 more line(s); run it to see the whole thing] ``` **Shaped by:** `--for` @@ -298,19 +310,18 @@ _Same query with routing forced OFF (plain subtoken+body BM25) — contrast with ``` $ ./build/ripwire . --for="rankGraphTeleport" --no-route - - - - - + + + + + uniform-teleport PageRank (the defaultinline RankedGraph rankGraph( const Graph& g, float alpha = 0.85f ) - + PageRank with an explicit teleport / personalization vector p (Σp = 1). The prior is name-quali…inline RankedGraph rankGraphTeleport( const Graph& g, const std::vector<float>& p, float alpha = 0.85f ) -inline ChurnRanking churnRankedGraph( const MainDispatch& d ) - +inline ChurnRanking churnRankedGraph( const MainDispatch& d ) + L10 (2026-09-04): the old wording claimed "the same corpus ranked by pagerank orders differently…inline constexpr const char* kChurnRankLegend = "<!-- rank_by=churn: k= is PageRank re-run with the teleport BIASED by git CHANGE-FREQUENCY over window= " "(a c… -struct RankedGraph -... [19 more line(s); run it to see the whole thing] +... [20 more line(s); run it to see the whole thing] ``` **Caveats (stated by the binary):** @@ -331,20 +342,17 @@ _Cut the result at the relevance cliff (Adaptive-k) — on a flat ranking nothin ``` $ ./build/ripwire . --for="tree-sitter parse of a source file" --adaptive - - - - - + + + + +inline bool topLevelEvidence( std::string_view source, const TSLanguage* language, const Predicate& predicate ) The crawl's per-file byte ceiling. A text file larger than this is skipped: at this size it is o…constexpr std::size_t kDefaultMaxFileBytes = 4u * 1024u * 1024u -TSTree* parseTree( TSParser* parser, std::string_view src ) - +TSTree* parseTree( TSParser* parser, std::string_view src ) + Exercise every registered grammar and its embedded query, reporting loaded and expected totalsinline DoctorGrammarProbe doctorProbeGrammars() - -errNodes/errBytes are a PARSER-STATE fact, never a syntax verdict: tree-sitter error recovery fi…struct FileHealth -enum class AstWalk : std::uint8_t -constexpr std::array<LangEntry, 48> kLangTable = -... [18 more line(s); run it to see the whole thing] +... [21 more line(s); run it to see the whole thing] ``` **Shaped by:** `--for`, `--detail` @@ -365,18 +373,17 @@ _Same task with the anchor disabled — the contrast the flag exists for._ ``` $ ./build/ripwire . --for="why does src/lexical.h chooseForRanker pick name-exact BM25" --no-mention-boost - - - - + + + + The name-exact ranker AS THE RETRIEVAL LENS SERVES IT: whole-name BM25 plus the definition-over-…inline std::vector<float> lexicalScoresNameExactRanked( const IngestResult& ing, std::string_view query, const std::vector<float>* symbolScoreMul ) - + P11.12: the interpretive footer for --eval's ranker table, pulled into its own function so the 9…inline void printEvalRankerNote() -inline int runEvalRetrieval( const IngestResult& ing, const Graph& g ) - -calling agent knows to reformulate rather than trust the ranking. Calibrated empirically (2026-0…inline constexpr float kWeakLexicalScoreThreshold = 1.0f -... [20 more line(s); run it to see the whole thing] +inline int runEvalRetrieval( const IngestResult& ing, const Graph& g ) + +... [21 more line(s); run it to see the whole thing] ``` **Caveats (stated by the binary):** @@ -397,17 +404,16 @@ _The same task with doc-mention surfacing OFF — the contrast the flag exists f ``` $ ./build/ripwire . --for="quality delta acks ledger rubber stamp" --no-doc-mention - - - - -inline QualityDeltaOutcome computeQualityDelta( const std::string& root ) -std::optional<int> refuseForeignAckSelection( const rw::Config& cfg, const rw::quality::Scope& scope, const std::vector<rw::quality::Regression>& outOfScope, st … [line truncated: 7 more bytes on this line] - + + + + +inline QualityDeltaOutcome computeQualityDelta( const std::string& root ) +std::optional<int> refuseForeignAckSelection( const rw::Config& cfg, const rw::quality::Scope& scope, const std::vector<rw::quality::Regression>& outOfSc … [line truncated: 14 more bytes on this line] + runQualityViews was NOT a dispatch chain — it held two branches, one of which was 298 lines. T…std::optional<int> runQualityDelta( const MainDispatch& d ) - -WHAT this delta is measured AGAINST, and on WHICH tree — the one place --quality-delta decides…struct DeltaBasis -... [21 more line(s); run it to see the whole thing] + +... [22 more line(s); run it to see the whole thing] ``` **Caveats (stated by the binary):** @@ -426,10 +432,9 @@ _Interface -> implementors view: every existing impl of the named interface; the ``` $ ./build/ripwire . --lego=Vehicle - - - - + + + @@ -457,10 +462,11 @@ _The repo's best-in-class instance to imitate before writing new code (picked by ``` $ ./build/ripwire . --exemplar="format byte sizes for humans" - - + - - + + - + - + - - + + - + + ... [17 more line(s); run it to see the whole thing] ``` @@ -653,21 +658,21 @@ _Stable (path/id) emit order — provider KV-cache hits across re-runs._ ``` $ ./build/ripwire . --order=stable --top-k=5 - - - - - - - - + + + + + + + - - -... [10 more line(s); run it to see the whole thing] + + + +... [11 more line(s); run it to see the whole thing] ``` **Shaped by:** `--no-stable` @@ -684,21 +689,20 @@ _--no-stable outside --mcp: what the flag does (or says) when there is no stable ``` $ ./build/ripwire . --no-stable --top-k=3 - - - - - - - - - + + + + - + - + + + + -... [2 more line(s); run it to see the whole thing] + + ``` --- @@ -717,20 +721,20 @@ _Ego graph around one symbol — depth 1 BY DEFAULT now (the root's depth= says ``` $ ./build/ripwire . --around=rankGraphTeleport - - - - - - + + - - - + + + + + + + ... [17 more line(s); run it to see the whole thing] ``` @@ -748,7 +752,8 @@ _Unknown-symbol REFUSAL shape (exit 1) with a did-you-mean from real edit distan ``` $ ./build/ripwire . --callers=DoesNotExist - + + ``` **Shaped by:** `--no-route`, `--callees`, `--uses`, `--impact`, `--expand`, `--dead-code`, `--edit-check`, `--slice-flow` @@ -769,18 +774,16 @@ _What SYM calls (1-hop out-edges)._ ``` $ ./build/ripwire . --callees=rankGraphTeleport - - - - - - - - - - - - + + + + + + + + + + ``` @@ -802,18 +805,17 @@ _The resolvable use-sites (call/read/write/import/extends) with file:line; count ``` $ ./build/ripwire . --uses=rankGraphTeleport - - - + + + + + - - - - - - - - + + + + + ``` @@ -837,20 +839,20 @@ _Composable node-set query: functions within 2 caller-hops of rankGraphTeleport. ``` $ ./build/ripwire . --graph-query='and(callers(name("rankGraphTeleport"),2),kind(all,fn))' - - - - - - - - - - - - - - + + + + + + + + + + + + + + ... [17 more line(s); run it to see the whole thing] ``` @@ -874,20 +876,20 @@ _Names referenced but never defined in-corpus (stdlib/third-party surface). The ``` $ ./build/ripwire . --external-surface - - - - - - - - - - - - - - + + + + + + + + + + + + + + ... [17 more line(s); run it to see the whole thing] ``` @@ -909,14 +911,13 @@ _Shortest directed call-path SRC -> DST. CHANGED: now reports from_p/to_p/from_d ``` $ ./build/ripwire . --path=main,rankGraphTeleport - - - - - - - - + + + + + + + ``` @@ -932,14 +933,12 @@ _Minimal connecting subgraph over 3 symbols (finds shared-caller joins)._ ``` $ ./build/ripwire . --connect=rankGraphTeleport,runEval,getIndex - - - - + + - - + + @@ -961,19 +960,19 @@ _Transitive blast radius — everything that reaches SYM. NOW carries shown/capp ``` $ ./build/ripwire . --impact=rankGraphTeleport - - - - - - - + + + + + + - - - + + - + + ... [17 more line(s); run it to see the whole thing] ``` @@ -1018,11 +1017,11 @@ _Markdown docs that name SYM in a backtick (doc<->code edges)._ ``` $ ./build/ripwire . --mentions=rankGraphTeleport - - - + + + - + @@ -1044,19 +1043,21 @@ _Test files that transitively reach the changed file._ ``` $ ./build/ripwire . --affected=src/graph.h - - - + + + + + + - - +... [5 more line(s); run it to see the whole thing] ``` **Shaped by:** `--exercises`, `--test-gate`, `--edit-target-file`, `--legend` @@ -1079,9 +1080,8 @@ _Which symbols a TEST FILE exercises — the reverse direction of --affected._ ``` $ ./build/ripwire . --exercises=test/regression.sh - - - ``` @@ -1106,7 +1106,7 @@ _Mid-task situational report for the current git diff — recorded against a CLE $ ./build/ripwire . --situ ripwire situational-awareness — 0 changed file(s), 0 symbols in them root: . -at: f8d431396 +at: f1e5c2e76 (0 changed files — working tree is clean, nothing to analyze) ``` @@ -1124,19 +1124,19 @@ _The continuation packet for the NEXT session: disk truth (branch/sha ``` $ ./build/ripwire . --handoff - - + + - - + - - - + + + + ... [1 more line(s); run it to see the whole thing] ``` @@ -1159,8 +1159,8 @@ _Pre-PR gate on a CLEAN tree: no obligations, exit 0._ ``` $ ./build/ripwire . --test-gate - - + untested blast radius ; exit 4 while either exists. tests_capped=/untested_capped=: 1 = cut. counts_floor=1: every count is a FLOOR, never a total. graph_ambiguous=/graph_unresolved=: resolver gauge. graph_unindexed=N: N files no grammar could read (the map header's unindexed=); their calls raise neither gauge. at=: commit+dirty+shallow. next=: the one pasteable follow-up. impacted=N: symbols that transitively call the change (changed symbols excluded). shown_tests=/shown_untested=: t rows and u rows printed, two independent counts. script_gates_unmodelled=N: test/*.sh runners in the corpus, a path count; not call-graph modelled. script_gates_registered=N: shell gates test/regression.sh registers as suite members. script_gates_mapped=N: registered gates with exact dependency evidence (literal paths or RIPWIRE_TEST_DEPS). script_gates_unresolved_dynamic=N: registered gates with no mappable deps; they may cover the change unlisted. ccx_bar=N: the cognitive-complexity bar a u row's ccx= is read against. untested_modscope=N: owners excluded from untested= (#324, uncallable); still in impacted=. tests=/untested=: tests to run (t total) / impacted symbols no test reaches (u total). --> + ``` @@ -1179,24 +1179,24 @@ every hit comes back with its enclosing symbol literal / regex search + enclosin **Try it** -_Regex search + enclosing symbol._ +_Literal trigram-indexed search. Each hit carries its MATCHED line as the element's own CDATA (the wrapper is gone), plus shown/capped/hits_capped and a pasteable next= on the root._ ``` -$ ./build/ripwire . --regex='fnv1a\w+' - - - - +$ ./build/ripwire . --grep=DISCLOSE + - - +$ ./build/ripwire . --grep=DISCLOSE --grep-context=1 + - - - - - - - - +$ ./build/ripwire . --grep=DISCLOSE --and=cache + - +CDATA text (/ context around it); complete=1 only for an exhaustive literal scan; = off-index. window: shown= capped= (capped=1 cut). hits_capped=1: hits= is a floor. root=: p= relative to it. parse_degraded=1: ERROR nodes in that parse. next=: the one pasteable f … [line truncated: 1241 more bytes on this line] + - - - - - - - + + - + + + + + ... [17 more line(s); run it to see the whole thing] ``` @@ -1300,21 +1300,21 @@ file requires anywhere in the same file. Second occurrence of --grep=/--regex= i _Drop every hit in a file that ALSO contains the --not literal anywhere (file scope)._ ``` -$ ./build/ripwire . --grep=DEGRADED_PATH_ALERT --not=test --grep-scope=file - - +CDATA text (/ context around it); complete=1 only for an exhaustive literal scan; = off-index. window: shown= capped= (capped=1 cut). hits_capped=1: hits= is a floor. root=: p= relative to it. parse_degraded=1: ERROR nodes in that parse. next=: the one pasteable f … [line truncated: 1241 more bytes on this line] + - - - - - - - + + - + + + + + ... [17 more line(s); run it to see the whole thing] ``` @@ -1333,21 +1333,21 @@ code (default) serves the CODE tier when any hit is code, and otherwise comment _Span tiers off: the exhaustive view — the comment and string hits the default tier held back (suppressed_comment=96 / suppressed_string=29 in the plain --grep block above) now print alongside the code hits; hits= grows accordingly._ ``` -$ ./build/ripwire . --grep=DEGRADED_PATH_ALERT --grep-in=any - - - - - - - +$ ./build/ripwire . --grep=DISCLOSE --grep-in=any + - - - - +$ ./build/ripwire . --grep=DISCLOSE --handles + - + +if data.get( "schema" ) != SCHEMA: raise SystemExit( f"{path}: unexpected schema {data.get('schema')!r} (expecte if not str( data.get( "tasks_lock_content_sha256", "" ) ).startswith( "questions:" ): train_repos = select_tasks if train_repos: raise SystemExit( f"{path}: records from repo(s) that re-derive to LocBench @@ -1414,8 +1414,7 @@ $ ./build/ripwire . --match='(if_statement)' if base["localization_hit"] is None or ctx["localization_hit"] is None: return 0.0 if bv: ratios.append( cv / bv - 1 ) if not ratios: return None, None -if rw is None or native is None: return None -... [20 more line(s); run it to see the whole thing] +... [21 more line(s); run it to see the whole thing] ``` **Shaped by:** `--no-redact`, `--sarif`, `--limit` @@ -1454,19 +1453,19 @@ _Raw BM25 ranking (debug lens; --for is the real verb)._ ``` $ ./build/ripwire . --query="teleport pagerank" --top-k=5 - - - - - - - - + + + + - + - + + + + + ... [17 more line(s); run it to see the whole thing] ``` @@ -1488,19 +1487,17 @@ _Importance-weighted detail: FULL bodies for top-2, signatures for the rest._ ``` $ ./build/ripwire . --for="pagerank power iteration" --detail=2 - - - - - + + + + + +NodeId order. Renumbering an otherwise-identical graph (the exact "same probe file, different so…inline constexpr float kScoreTieAbsEps = 1e-6f + Render one form of the disclosure. Empty string whenever there is nothing to say — no power it…inline std::string renderDisclosure( const RankDisclosure& d, DiscloseAs as ) - -What a ranked document discloses about the power iteration that ordered it. `isPageRank == false…struct RankDisclosure - -What a rank call hands back: the vector, and the power iteration's own account of itself. Struct…struct RankedGraph - -L10 (2026-09-04): the old wording claimed "the same corpus ranked by pagerank orders differently…inline constexpr const char* kChurnRankLegend = "<!-- rank_by=churn: k= is PageRank re-run with the teleport BIASED by git CHANGE-FREQUENCY over window= " "(a c… -... [19 more line(s); run it to see the whole thing] + +The PageRank power iteration itself — the numeric kernel every ranked document's order comes f…PageRankRun pageRankDouble( const sparseCsr<float>& inEdges, std::span<const double> weightedOutDegree, std::span<const double> teleport, std::span<double> r … [line truncated: 10 more bytes on this line] +... [21 more line(s); run it to see the whole thing] ``` **Shaped by:** `--max-tokens`, `--for`, `--signatures-only`, `--auto-bodies`, `--compress`, `--owners`, `--plan`, `--abi` @@ -1517,24 +1514,24 @@ test/showcasecapturecheck.sh (C) re-derives this range from the SAME repo every **Try it** -_Body-elided decl skeletons — recounted on this corpus. Measured as element bytes: the signature+doc elements --pack-signatures emits, against the SAME symbols' full bodies from --expand, with the CORPUS-ROOT PREFIX SUBTRACTED FROM BOTH SIDES. That subtraction is the whole methodology and the figure is meaningless without it: the root repeats inside every element's id= and p=, it is not what this verb elides, and counting it makes the headline a function of how deep the checkout happens to sit on disk — on one corpus, three spellings of the same root read 18.6 points apart before the subtraction and agree exactly after it. Root-neutralised on THIS repo: 91.8% fewer bytes at top-10, 85.2% at top-50, 85.5% at top-100 (re-derived 2026-09-12 when the rows dropped the path-repeating id= for the short sc= scope: the signature side shrank, the bodies did not; before that, 89.5% / 81.8% / 84.3% re-derived 2026-09-10 at the sibs= cap raise: kMaxExpandSibs went 8 -> 100, so --expand's bodies now carry the file context the old cap hid — 89.3% of all sibling names — and the body side is this ratio's DENOMINATOR, so the figure rises without --pack-signatures eliding anything new. Measured on a fixed tree with the top-50 membership and the signature side unchanged: top-50 from 71.0. A real re-derivation of a corpus that changed, not a tolerance edit; previously re-derived 2026-09-09 at the printf-family -> std::print conversion: converting ~1,500 emitter call sites to rw::emitTo/emitRaw/formatTo across 93 files changes how large the ranked symbols' BODIES are, and the body side is this ratio's denominator — top-50 from 72.3. A real re-derivation of a corpus that changed, not a tolerance edit; previously re-derived 2026-09-08 at the confident-zero round, issues #62/#63/#66: that change adds symbols to src/graphlegend.h and the new src/preprocdead.h and re-homes two long comment blocks from call sites onto the helpers they explain, which moves both WHICH symbols the ranked top-50 holds and how large their bodies are — top-50 from 74.0. A real re-derivation of a corpus that changed, not a tolerance edit; previously re-derived 2026-09-06 at the stranger-audit fix round: the doctor, cache-sweep and html-provenance bodies grew this corpus's BODY side, moving top-50 from 75.6 — a real re-derivation, not a tolerance edit; before that, re-derived 2026-09-05 at the capture-audit close: lane L7's P16 caps --expand's sibs= at 8 names, which SHRINKS the body side of this ratio and moved the figure down from 84.5/80.2/80.6 — the V1 2026-08-15 re-center, when sibs=/inc= first grew the body side from 70.0/61.0/63.8, in reverse; both were real re-derivations, not tolerance edits). top-50 is the number to quote, because the sigs payload is top-50 regardless of --top-k and is therefore what THIS command emits. A single small/trivial body can still invert it (signature+doc bigger than the body), like the --format=columnar sibling below. test/showcasecapturecheck.sh (C) re-derives all three from this repo every run, in the same quantity, and fails if the caption and the recount drift apart._ +_Body-elided decl skeletons — recounted on this corpus. Measured as element bytes: the signature+doc elements --pack-signatures emits, against the SAME symbols' full bodies from --expand, with the CORPUS-ROOT PREFIX SUBTRACTED FROM BOTH SIDES. That subtraction is the whole methodology and the figure is meaningless without it: the root repeats inside every element's id= and p=, it is not what this verb elides, and counting it makes the headline a function of how deep the checkout happens to sit on disk — on one corpus, three spellings of the same root read 18.6 points apart before the subtraction and agree exactly after it. Root-neutralised on THIS repo: 92.7% fewer bytes at top-10, 88.7% at top-50, 87.6% at top-100 (re-derived 2026-09-20 when the native Windows port landed: src/infra/os_win32.cpp and os_win32_logic.h are ~3,900 lines of new, heavily commented infra that the ranked top-10/50/100 now reach, and their bodies are this ratio's DENOMINATOR, so the figure rises without --pack-signatures eliding anything new, from top-50 87.1. Measured by test/showcasecapturecheck.sh's own recount arm on the combined tree, which is the gate that would otherwise report the drift; before that, re-derived 2026-09-17 at the self-check macro vocabulary rename: VERIFY/VERIFY_TEXT/VERIFY_DEBUG_ONLY/VERIFY_NOT_REACHED/VERIFY_SAME_THREAD/VERIFY_NO_ALIAS*/DYNMAP_VERIFY/DEGRADED_PATH_ALERT renamed to ASSUME/EXPECTS/ENSURES/DASSERT/UNREACHABLE/ASSUME_SAME_THREAD*/ASSUME_NO_ALIAS*/DYNMAP_ASSUME/DISCLOSE (plus new VALIDATE sites) across 839 identifier renames in 179 files, and a hand-written Diagnostics.h/diagnostics.cpp replacing the old ones outright: this moves both WHICH symbols the ranked top-10/50/100 hold and how large their bodies are, from top-50 85.2. A real re-derivation of a corpus that changed, not a tolerance edit; previously re-derived 2026-09-12 when the rows dropped the path-repeating id= for the short sc= scope: the signature side shrank, the bodies did not; before that, 89.5% / 81.8% / 84.3% re-derived 2026-09-10 at the sibs= cap raise: kMaxExpandSibs went 8 -> 100, so --expand's bodies now carry the file context the old cap hid — 89.3% of all sibling names — and the body side is this ratio's DENOMINATOR, so the figure rises without --pack-signatures eliding anything new. Measured on a fixed tree with the top-50 membership and the signature side unchanged: top-50 from 71.0. A real re-derivation of a corpus that changed, not a tolerance edit; previously re-derived 2026-09-09 at the printf-family -> std::print conversion: converting ~1,500 emitter call sites to rw::emitTo/emitRaw/formatTo across 93 files changes how large the ranked symbols' BODIES are, and the body side is this ratio's denominator — top-50 from 72.3. A real re-derivation of a corpus that changed, not a tolerance edit; previously re-derived 2026-09-08 at the confident-zero round, issues #62/#63/#66: that change adds symbols to src/graphlegend.h and the new src/preprocdead.h and re-homes two long comment blocks from call sites onto the helpers they explain, which moves both WHICH symbols the ranked top-50 holds and how large their bodies are — top-50 from 74.0. A real re-derivation of a corpus that changed, not a tolerance edit; previously re-derived 2026-09-06 at the stranger-audit fix round: the doctor, cache-sweep and html-provenance bodies grew this corpus's BODY side, moving top-50 from 75.6 — a real re-derivation, not a tolerance edit; before that, re-derived 2026-09-05 at the capture-audit close: lane L7's P16 caps --expand's sibs= at 8 names, which SHRINKS the body side of this ratio and moved the figure down from 84.5/80.2/80.6 — the V1 2026-08-15 re-center, when sibs=/inc= first grew the body side from 70.0/61.0/63.8, in reverse; both were real re-derivations, not tolerance edits). top-50 is the number to quote, because the sigs payload is top-50 regardless of --top-k and is therefore what THIS command emits. A single small/trivial body can still invert it (signature+doc bigger than the body), like the --format=columnar sibling below. test/showcasecapturecheck.sh (C) re-derives all three from this repo every run, in the same quantity, and fails if the caption and the recount drift apart._ ``` $ ./build/ripwire . --pack-signatures --top-k=10 - - - - - - - - - - + + + + - + + + + + + - ... [17 more line(s); run it to see the whole thing] ``` @@ -1552,7 +1549,12 @@ _Control-flow skeleton of one symbol, payload-only via the new --top-k=0._ ``` $ ./build/ripwire . --outline=rankGraphTeleport --top-k=0 -& p, float alpha = 0.85f ) + + + + +& p, float alpha = 0.85f ) { PROFILE_SCOPE_DESCRIBE( "rankGraph: PageRank (power iteration)" ); const std::vector pw = biasPrior( g, p ); @@ -1561,12 +1563,7 @@ $ ./build/ripwire . --outline=rankGraphTeleport --top-k=0 std::vector rankDouble( N, 0.0 ); PageRankRun run{}; // an N == 0 graph never enters the kernel: { 0, converged } — see PageRankRun if( N ) - { - ... - } - std::vector r( N, 0.f ); - std::transform( rankDouble.begin(), rankDouble.end(), r.begin(), []( double value ) { return float( value ); } ); -... [3 more line(s); run it to see the whole thing] +... [8 more line(s); run it to see the whole thing] ``` **Shaped by:** `--top-k`, `--expand`, `--compress`, `--no-redact`, `--limit`, `--max-memory` @@ -1583,11 +1580,11 @@ _NEW since the last capture: --top-k=0 means PAYLOAD-ONLY — no ranked map ride ``` $ ./build/ripwire . --top-k=0 --expand=rankGraphTeleport - - + + +, with a BRANCH-FREE size(). Both, not one or the other. @@ -1663,10 +1664,6 @@ $ ./build/ripwire . --pack-top-n=3 --top-k=0 // canonByName / shard maps, and ~100 more structures after the conversion wave): WRITE-ONCE during the // parse/merge, then READ-HOT during resolve. // -// Three things matter for that shape, and the layout below gets all three: -// • no per-list malloc — the N small lists that would each allocate are inline; -// • a BRANCH-FREE size() — `return sz_`, because the size lives in its own field; -// • 16 BYTES per instance — because `inl_` and `heap_` are never both live, so they share storage. ... [17 more line(s); run it to see the whole thing] ``` @@ -1688,14 +1685,14 @@ _--no-redact: emit bodies verbatim (credential redaction is on by default)._ ``` $ ./build/ripwire . --expand=readAckRecords --top-k=0 --no-redact - - + - - + + - + - + - + @@ -1758,20 +1755,20 @@ _File->file dependency graph (god-files, cycles)._ ``` $ ./build/ripwire . --deps - - - - - - - - - - - - - - + + + + + + + + + + + + + + ... [17 more line(s); run it to see the whole thing] ``` @@ -1794,20 +1791,20 @@ _Complexity x recent git churn (maintenance pain)._ ``` $ ./build/ripwire . --hotspots - - - + - - - - - - - - - - + + + + + + + + + + + + ... [17 more line(s); run it to see the whole thing] ``` @@ -1823,20 +1820,20 @@ _Token-normalized duplicate bodies._ ``` $ ./build/ripwire . --clones - - - - - - - + + + + + + - - - - - + + + + + + ... [17 more line(s); run it to see the whole thing] ``` @@ -1854,17 +1851,16 @@ _Per-function size ranking, largest Halstead volume/token entropy/lines first ``` $ ./build/ripwire . --biggest-first --limit=8 - - - - - - - - - - - + + + + + + + + + + ``` @@ -1888,20 +1884,20 @@ _Per function, the non-local MUTABLE state it can reach (transitively), most wri ``` $ ./build/ripwire . --nonlocal-state --limit=8 - - - - + - + + ... [17 more line(s); run it to see the whole thing] ``` @@ -1923,20 +1919,20 @@ _The family join: per function, which of four orthogonal evidence families fire, ``` $ ./build/ripwire . --ensemble --limit=8 - - - - + + - + - - + + + + ... [17 more line(s); run it to see the whole thing] ``` @@ -1954,20 +1950,20 @@ _THE single wide-angle quality read: six families in one pass, an eligible/ranke ``` $ ./build/ripwire . --quality-panel - - - - + + - + + + + + + + - - - - ... [17 more line(s); run it to see the whole thing] ``` @@ -1991,16 +1987,15 @@ _The local-reasoning lens: to understand this symbol, how much must you know tha ``` $ ./build/ripwire . --context-ratio --limit=8 - - - - - - - - - -... [22 more line(s); run it to see the whole thing] + + + + + + + +... [23 more line(s); run it to see the whole thing] ``` **Shaped by:** `--limit` @@ -2023,21 +2018,21 @@ _Score the naming-* rules against this repo's own rename history: proxy=old/(old ``` $ ./build/ripwire . --naming-calibration - - - - + + + + - + - -

-

-

-... [13 more line(s); run it to see the whole thing] + +

+

+

+... [14 more line(s); run it to see the whole thing] ``` **Caveats (stated by the binary):** @@ -2058,20 +2053,20 @@ _The corpus's OWN case-convention vote per (language, kind) group; off-conventio ``` $ ./build/ripwire . --naming-consistency --limit=8 - - - - - - - - + + + + + + + - + - + - + + ... [17 more line(s); run it to see the whole thing] ``` @@ -2095,20 +2090,20 @@ _The opt-in --lint modifier: naming predicates over LOCAL variable names too, C/ ``` $ ./build/ripwire . --lint --naming-locals - - - + + + - + - - + + - + ... [17 more line(s); run it to see the whole thing] ``` @@ -2130,16 +2125,16 @@ _Functions WITH a doc comment, most name-restating first: c_coeff (high = the co ``` $ ./build/ripwire . --comment-coherence --limit=8 - - - - - + + + + + + + + + - - - - ``` @@ -2163,19 +2158,17 @@ _Files that change together in git (hidden coupling)._ ``` $ ./build/ripwire . --cochange - - - - - - - + + + + + + - - - - - + + + + ... [22 more line(s); run it to see the whole thing] ``` @@ -2197,19 +2190,17 @@ _Only pairs whose co-change RECURS in 2+ sub-windows of the mined window (sub_wi ``` $ ./build/ripwire . --cochange --cochange-recur=2 - - - - - - - - + + + + + + + - - - - + + + ... [22 more line(s); run it to see the whole thing] ``` @@ -2225,20 +2216,20 @@ _Modularity-violation GROUPS instead of pairs: "X co-changes with {A,B,C}, none ``` $ ./build/ripwire . --cochange --cochange-groups - - - - - - - - + + + + + + + + + + - - - - - + + + ... [17 more line(s); run it to see the whole thing] ``` @@ -2254,20 +2245,20 @@ _Hotspots scoped to RECENT churn (the regression lens)._ ``` $ ./build/ripwire . --hotspots --since="2 weeks ago" - - - - - - - - - - - - - - + + + + + + + + + + + + + + ... [17 more line(s); run it to see the whole thing] ``` @@ -2291,9 +2282,9 @@ _Enforce layering rules (exit 2 on violation) — run against the repo's own tes ``` $ ./build/ripwire . --arch=test/archfix/rules.txt - - - + @@ -2321,9 +2312,9 @@ _Enforce layering rules (exit 2 on violation) — run against the repo's own tes ``` $ ./build/ripwire . --arch=test/archfix/rules.txt - - - + @@ -2347,9 +2338,9 @@ _Enforce layering rules (exit 2 on violation) — run against the repo's own tes ``` $ ./build/ripwire . --arch=test/archfix/rules.txt - - - + @@ -2375,20 +2366,20 @@ _Built-in AST checks (c-cast, goto, unsafe-c-fn, ...)._ ``` $ ./build/ripwire . --lint - - - + + + - + - - + + - + ... [17 more line(s); run it to see the whole thing] ``` @@ -2411,8 +2402,8 @@ _The built-in rule registry — one row per rule with sev=/category=/rationale/l ``` $ ./build/ripwire . --lint-catalog - - + + a C-style cast — cppcoreguidelines-pro-type-cstyle-cast prefers the explicit static_cast/const_cast/reinterpret_cast a goto statement — cppcoreguidelines-avoid-goto a do/while loop shape (on Swift, its do/catch block shares the same grammar node and also matches) @@ -2421,8 +2412,7 @@ $ ./build/ripwire . --lint-catalog a doubly-parenthesized expression — readability-redundant-parentheses an if-body that is just `;` — bugprone-suspicious-semicolon a C-style typedef struct/union where `using` is preferred -a non-trivial numeric literal inside a function body, outside a const/constexpr init -... [20 more line(s); run it to see the whole thing] +... [21 more line(s); run it to see the whole thing] ``` **Shaped by:** `--lint` @@ -2437,8 +2427,8 @@ _User lint rules (YAML, ast-grep style) from a directory._ ``` $ ./build/ripwire . --lint-rules=test/lintrulesfix/rules - - + + use LOG() instead of printf @@ -2449,7 +2439,7 @@ $ ./build/ripwire . --lint-rules=test/lintrulesfix/rules ``` -**Shaped by:** `--lint-catalog`, `--lint-select`, `--lint-ignore`, `--sarif` +**Shaped by:** `--lint-catalog`, `--lint-select`, `--lint-ignore`, `--lint-max-per-rule`, `--sarif` ### `--lint-select=PREFIX[,...]` @@ -2485,23 +2475,51 @@ _DROP two families, applied after selection; the raw select=/ignore= you passed ``` $ ./build/ripwire . --lint --lint-ignore=naming-,cache- - - - + + - + - - + + - - + + ... [17 more line(s); run it to see the whole thing] ``` +### `--lint-max-per-rule=N` + +**Answers:** (with --lint / --lint-rules) set each rule's match budget, below or above the default 5000. + +The default is a runaway guard, not a target. A rule that spends its budget carries count_capped="1" (its count= is a FLOOR) and the root names the call that counts the rest: findings_next= (SARIF: run properties findingsNext, --sarif kept) re-runs only the floored rules under a 10x budget; a re-run still floored names its own. Raising it costs time and memory in proportion to the matches kept + +**Try it** + +_A rule that spends its own match budget (here 4, set BELOW the default 5000 so the floor shows on this repo): goto carries count_capped="1", its count= is a FLOOR, and the root names the call that counts the rest — findings_next= re-runs only the floored rules under a 10x budget._ + +``` +$ ./build/ripwire . --lint --lint-select=goto --lint-max-per-rule=4 + + + +goto done; +goto epilogue; +goto epilogue; +goto epilogue; + +``` + +**Caveats (stated by the binary):** + +- The default is a runaway guard, not a target. +- A rule that spends its budget carries count_capped="1" (its count= is a FLOOR) and the root names the call that counts the rest: findings_next= (SARIF: run properties findingsNext, --sarif kept) re-runs only the floored rules under a 10x budget; +- a re-run still floored names its own. + ### `--sarif` **Answers:** (with --lint / --lint-rules) the SAME findings as SARIF 2.1.0 instead of the native XML block — the shape github/codeql-action/upload-sarif consumes for code scanning. @@ -2527,7 +2545,7 @@ $ ./build/ripwire . --lint --sarif ... [21 more line(s); run it to see the whole thing] ``` -**Shaped by:** `--legend` +**Shaped by:** `--lint-max-per-rule`, `--legend` **Caveats (stated by the binary):** @@ -2571,20 +2589,20 @@ _Cluster the call graph into cohesive modules._ ``` $ ./build/ripwire . --communities - - - - + + + + + + + + + - + + - - - - - - ... [17 more line(s); run it to see the whole thing] ``` @@ -2602,10 +2620,9 @@ _Drill into ONE call-graph community by id — the drill= the --communities outp ``` $ ./build/ripwire . --community=0 - - - + + ``` @@ -2628,20 +2645,20 @@ _Nested module hierarchy (multi-level Louvain) + cross-module bridges — levels ``` $ ./build/ripwire . --zoom - - - - - + + + - - + - + - + + + ... [17 more line(s); run it to see the whole thing] ``` @@ -2666,16 +2683,16 @@ $ ./build/ripwire . --report # ripwire architecture report -2039 files · 18699 symbols · 22330 edges · 1313 modules (11420 call-graph isolated) +2688 files · 25558 symbols · 40392 edges · 2494 modules (11469 call-graph isolated) Root: `.` -Call-graph isolate provenance: 2633 declaration, 1162 header, 3670 source, 3955 document; 0 connected Louvain singletons +Call-graph isolate provenance: 3087 declaration, 1678 header, 2119 source, 4585 document; 0 connected Louvain singletons -## Modules (call-graph clusters; showing 12 of 1313) -- **src::assign@infra/svector.h:342:19905 [resolve,compute,collect]** — 652 symbols -- **src::emplace@infra/svector.h:408:22477 [resolve,parse,run]** — 617 symbols -- **src::emitTo@infra/emit.h:51:2620 [write,run,emit]** — 377 symbols +## Modules (call-graph clusters; showing 12 of 2494) +- **src::emitTo@infra/emit.h:53:2760 [write,run,emit]** — 1685 symbols +- **src::append@elixir_resolve.h:111:4875 [add,resolve,compute]** — 1021 symbols +- **src::emplace@infra/svector.h:408:22477 [resolve,scan,find]** — 605 symbols ... [17 more line(s); run it to see the whole thing] ``` @@ -2691,20 +2708,20 @@ _Cross-module call seams no test reaches. NOW carries seam_pairs/shown/capped._ ``` $ ./build/ripwire . --seams - - - - - - - - - + + + + + + + - + + ... [17 more line(s); run it to see the whole thing] ``` @@ -2723,17 +2740,17 @@ $ ./build/ripwire . --mermaid %% ripwire --mermaid: module (directory) dependency graph — node = dir (symbol count), edge = inter-module calls (>= 3). Render at mermaid.live. flowchart LR subgraph sg0 ["src"] - n98["src
5422"] - n99["src/infra
630"] + n101["src
7731"] + n102["src/infra
1280"] end subgraph sg1 ["test"] - n100["test
3546"] - n269["test/fixtures/recallpassage
314"] - n255["test/expandmodefix
151"] - n257["test/expandsibsfix
149"] - n315["test/massfix
77"] - n297["test/legofix
60"] - n335["test/optremarksfix
59"] + n103["test
5057"] + n333["test/fixtures/recallpassage
314"] + n280["test/expandmodefix
151"] + n282["test/expandsibsfix
149"] + n535["test/rubyattrsfix
85"] + n411["test/massfix
77"] + n492["test/receiverevidencefix/js/lib
74"] ... [17 more line(s); run it to see the whole thing] ``` @@ -2751,20 +2768,20 @@ _Bus-factor: recency-weighted author ownership per file._ ``` $ ./build/ripwire . --owners - - - - - - - - - - - - - - + + + + + + + + + + + + + + ... [17 more line(s); run it to see the whole thing] ``` @@ -2774,17 +2791,16 @@ $ ./build/ripwire . --owners **Answers:** list internal functions with no caller anywhere in the indexed tree internal source functions with no caller found in the indexed tree — a name-based graph reading, not a confidence score (dynamic dispatch, reflection and macro-generated callers are invisible to it); -=DIR scopes to whole path components (dir or filename) and REFUSES a filter that names nothing indexed. A symbol whose definition is produced by a SELF-REGISTERING test/benchmark macro is never reported: doctest TEST_CASE/ TEST_CASE_FIXTURE/SCENARIO, gtest TEST/TEST_F/TEST_P, Catch2, Google Benchmark — a static initializer registers them, so a name-based call graph cannot see the caller and every one of them would be a false positive. Extend the list for your own framework with `.ripwire_config`'s one key, `register_macros = NAME[, NAME...]` (one directive per line, # comments). The exemption is DISCLOSED, never silent: register-macro-excluded="N" rides the report and prints even at 0. Exempt from dead-code only — such a symbol still participates in clone detection. A function a table, field or argument holds as a VALUE (--callers' rows) is not reported either: value-ref-excluded="N" counts them, absent at 0 (matched by name, not a proven call). A LEADING ./ anchors DIR at the repo ROOT (=./src matches only the top-level src/ subtree); a bare name (=src) matches that component ANYWHERE in the tree, including nested (test/fixture/src/…) +=DIR scopes to whole path components (dir or filename) and REFUSES a filter that names nothing indexed. A symbol whose definition is produced by a SELF-REGISTERING test/benchmark macro is never reported: doctest TEST_CASE/ TEST_CASE_FIXTURE/SCENARIO, gtest TEST/TEST_F/TEST_P, Catch2, Google Benchmark — a static initializer registers them, so a name-based call graph cannot see the caller and every one of them would be a false positive. Extend the list for your own framework with `.ripwire_config`'s one key, `register_macros = NAME[, NAME...]` (one directive per line, # comments). The exemption is DISCLOSED, never silent: register-macro-excluded="N" rides the report and prints even at 0. Exempt from dead-code only — such a symbol still participates in clone detection. A function a table, field or argument holds as a VALUE (--callers' rows) is not reported either: value-ref-excluded="N" counts them, absent at 0 (matched by name, not a proven call); a function that only stores ITSELF still is. A value walk cut by deep nesting is disclosed on the root, with value_refs_depth_at= naming the first cut (the list may over-report there). A LEADING ./ anchors DIR at the repo ROOT (=./src matches only the top-level src/ subtree); a bare name (=src) matches that component ANYWHERE in the tree, including nested (test/fixture/src/…) **Try it** -_High-confidence internal functions with no caller. NOTE the filter is a path-COMPONENT match: 'src' matches any .../src/... segment; use ./src to pin the root directory._ +_Internal functions with no caller found in the index — a name-based graph reading, not a confidence score. NOTE the filter is a path-COMPONENT match: 'src' matches any .../src/... segment; use ./src to pin the root directory._ ``` $ ./build/ripwire . --dead-code=src - - - + + ``` @@ -2838,9 +2854,9 @@ $ ./build/ripwire . --quality-baseline --allow-dirty ### `--quality-delta` -**Answers:** before a PR: report ONLY what your change made worse, across 11 kinds — pair with --test-gate. +**Answers:** before a PR: report ONLY what your change made worse, across 12 kinds — pair with --test-gate. -Each kind is measured against the baseline (complexity/verbosity/nesting/params/dup/dead/api-surface + error-masking/short-horizon-churn/new-clone-of-reused-helper + placeholder); every finding is classified by ORIGIN: a symbol that EXISTED at the baseline and got worse (preexisting-worse="N", no attribute on the row) vs one that exists only because the code is NEW (new-symbol="N", origin="new-symbol" on the row). A small numeric delta is additionally sev="minor". EXIT 2 ONLY on preexisting-worse AND major AND unacked — the gating="N" header count. New-symbol rows are still PRINTED (they are the debt you are adding — read them), they just never gate; exit 0 means "nothing that already existed got worse", not "clean". Clone kinds classify by member set (new-symbol only if EVERY member is new); short-horizon-churn is preexisting by construction. LIMIT: origin is canonId (path::scope::name) identity, so a RENAMED/MOVED symbol reads as new and a regression carried in with the move will not gate. error-masking = a NEW empty/pass/comment-only handler, or log-only (a broad handler whose body only logs and never names the error) or rethrow-only (the sole handler re-throws it unchanged); those two gate only where their precision was measured (Python) and are sev="minor" in every other language. placeholder = a stub the change ADDED (todo!()/unimplemented!(), Kotlin TODO(), NotImplementedException, a bare raise NotImplementedError as a free function's body, a throw/raise/panic/assert saying "not implemented") or a comment line opening with TODO/FIXME that names no issue (#12, ABC-12, a URL); new-symbol by construction, so it never gates. Counted per enclosing symbol, like error-masking: a file-level TODO outside every definition is not counted. Test-fixture dirs + doc sections are exempt from dead-code/churn; churn needs COMMITTED thrash evidence (rewritten across recent commits AND again by this diff), never the current edit alone WHICH FLOOR IT COMPARES AGAINST, and a side effect: the sidecar is honored only when the sha it was pinned at EQUALS the current git HEAD (strict equality — an ancestor commit describes a DIFFERENT tree, so everything committed since would read as your regression). A sidecar pinned anywhere else is STALE: this verb then DELETES it from your working tree (self-heal, so the next run does not rediscover the dead pin) and auto-compares the working tree vs git HEAD instead. Re-pin with --quality-baseline. The read-only MCP quality_delta verb applies the SAME staleness test but never deletes. A sidecar at the current HEAD that ANOTHER ripwire build pinned (its producer stamp names other sources — a dead set depends on how calls were resolved) is FOREIGN: both arms ignore it, never delete it, and auto-compare vs git HEAD. Which floor was actually used is on every report as baseline=: sidecar | git-HEAD | git-HEAD (stale sidecar removed) | git-HEAD (stale sidecar ignored) | git-HEAD (foreign sidecar ignored) — the stale two say a stale sidecar existed, and 'removed' means the file is gone. A non-git root has no HEAD to fall back to, so its sidecar is honored whenever this build pinned it; without one there, or with another build's, the verb exits 1. +Each kind is measured against the baseline (complexity/verbosity/nesting/params/dup/dead/api-surface + error-masking/short-horizon-churn/new-clone-of-reused-helper + placeholder + defect-shape); every finding is classified by ORIGIN: a symbol that EXISTED at the baseline and got worse (preexisting-worse="N", no attribute on the row) vs one that exists only because the code is NEW (new-symbol="N", origin="new-symbol" on the row). A small numeric delta is additionally sev="minor". EXIT 2 ONLY on a major AND unacked row that is preexisting-worse or defect-shape format-arity (any origin) — the gating="N" header count. New-symbol rows are still PRINTED (they are the debt you are adding — read them); new-symbol rows never gate, except defect-shape format-arity; exit 0 means "nothing that already existed got worse", not "clean". Clone kinds classify by member set (new-symbol only if EVERY member is new); short-horizon-churn is preexisting by construction. LIMIT: origin is canonId (path::scope::name) identity, so a RENAMED/MOVED symbol reads as new and a regression carried in with the move will not gate. error-masking = a NEW empty/pass/comment-only handler, or log-only (a broad handler whose body only logs and never names the error) or rethrow-only (the sole handler re-throws it unchanged); those two gate only where their precision was measured (Python) and are sev="minor" in every other language. placeholder = a stub the change ADDED (todo!()/unimplemented!(), Kotlin TODO(), NotImplementedException, a bare raise NotImplementedError as a free function's body, a throw/raise/panic/assert saying "not implemented") or a comment line opening with TODO/FIXME that names no issue (#12, ABC-12, a URL); new-symbol by construction, so it never gates. Counted per enclosing symbol, like error-masking: a file-level TODO outside every definition is not counted. defect-shape = a known defect shape the change ADDED, named by defect=: format-arity (a literal std::format/print/format_to, fmt::, rw::emitTo/formatTo or Python "literal".format whose fields do not match its arguments — GATES on any origin, a defect not debt), utf8-cut and dedup-first (C++) and vacuous-assert (Bash test scripts) — report-only, always sev="minor". A site is new only when its text occurs more often than in the baseline: a moved one is not. Test-fixture dirs + doc sections are exempt from dead-code/churn; churn needs COMMITTED thrash evidence (rewritten across recent commits AND again by this diff), never the current edit alone WHICH FLOOR IT COMPARES AGAINST, and a side effect: the sidecar is honored only when the sha it was pinned at EQUALS the current git HEAD (strict equality — an ancestor commit describes a DIFFERENT tree, so everything committed since would read as your regression). A sidecar pinned anywhere else is STALE: this verb then DELETES it from your working tree (self-heal, so the next run does not rediscover the dead pin) and auto-compares the working tree vs git HEAD instead. Re-pin with --quality-baseline. The read-only MCP quality_delta verb applies the SAME staleness test but never deletes. A sidecar at the current HEAD that ANOTHER ripwire build pinned (its producer stamp names other sources — a dead set depends on how calls were resolved) is FOREIGN: both arms ignore it, never delete it, and auto-compare vs git HEAD. Which floor was actually used is on every report as baseline=: sidecar | git-HEAD | git-HEAD (stale sidecar removed) | git-HEAD (stale sidecar ignored) | git-HEAD (foreign sidecar ignored) — the stale two say a stale sidecar existed, and 'removed' means the file is gone. A non-git root has no HEAD to fall back to, so its sidecar is honored whenever this build pinned it; without one there, or with another build's, the verb exits 1. **Try it** @@ -2848,20 +2864,20 @@ _On a CLEAN tree: nothing got worse, exit 0. The gating shape is in the sandbox ``` $ ./build/ripwire . --quality-delta - -, acked. value-ref-excluded=N: internal functions kept off because a table, field or argument in another function or at file scope holds them (matched by name, not a proven call); a floor. at=: commit+dirty+shallow. stale=N: ack ledger rows whose target no longer applies (sa rows); never gating. preexisting-worse=N: regressions on symbols that existed at baseline; they gate when major. new-symbol=N: findings on NEW code; new-symbol rows never gate, except defect-shape format-arity. register-macro-excluded=N: symbols kept out of dead-code as self-registering test/bench macros; a floor. api-new-surface=N: new PUBLIC symbols; a count, never gates, not in regressions=. renames=/rename_window_commits=: git rename pairs read over that many commits, to re-file baseline and acks. acked_by_rename=/acked_by_content=: acked= suppressions matched via git renames / an equal body hash. renames_window_truncated=1: history is deeper than the rename window, older renames unread. acked=N: findings suppressed by the ack ledger, listed as sa rows; never gating. sa key=/why=: the stale ack's ledger hash / target-gone (names nothing now) or finding-gone (no longer fires). head_basis=identity: the floor is this tree's own snapshot; archived-index-hidden: refused, a tracked path is skip-worktree/assume-unchanged; absent: the archived HEAD tree. --> + + + + + + - - - - - - - - - + + + ... [17 more line(s); run it to see the whole thing] ``` @@ -2869,13 +2885,13 @@ $ ./build/ripwire . --quality-delta **Caveats (stated by the binary):** -- New-symbol rows are still PRINTED (they are the debt you are adding — read them), they just never gate; +- new-symbol rows never gate, except defect-shape format-arity; - LIMIT: origin is canonId (path::scope::name) identity, so a RENAMED/MOVED symbol reads as new and a regression carried in with the move will not gate. - error-masking = a NEW empty/pass/comment-only handler, or log-only (a broad handler whose body only logs and never names the error) or rethrow-only (the sole handler re-throws it unchanged); ### `--quality-delta=REV|A..B` -**Answers:** the same 11-kind report between two committed trees — a whole branch at once the same 11-kind report between two COMMITTED TREES instead of the working tree vs a baseline — the WAVE-level measurement (=A..B = tree B against tree A; +**Answers:** the same 12-kind report between two committed trees — a whole branch at once the same 12-kind report between two COMMITTED TREES instead of the working tree vs a baseline — the WAVE-level measurement (=A..B = tree B against tree A; =REV = that commit against its FIRST PARENT; an EMPTY side of the range means HEAD). Same grammar --dmm= takes, and A...B is REFUSED rather than read as A..B. Use it to measure a whole integration branch at once (--quality-delta=..): per-lane checks each compare against their own baseline and cannot see a regression the WAVE introduced. Identical output contract to the bare form — same kinds, gating="N", exit 2, and the same .ripwire_quality_acks ratchet (acks are keyed root-relative, so a ledger recorded from working-tree runs applies unchanged). base_ref= and target_ref= disclose the two RESOLVED shas. No sidecar is read, written or deleted by this form, and at= is omitted: the two refs ARE the anchor. A==B is a legal, empty, exit-0 comparison. ONE KIND CANNOT BE MEASURED HERE and says so as churn="unavailable": short-horizon-churn needs git history at the tree being judged, and both trees are materialized OUT of the repo into temp dirs. The other 10 kinds are computed exactly as the bare form computes them. @@ -2885,20 +2901,20 @@ _On a CLEAN tree: nothing got worse, exit 0. The gating shape is in the sandbox ``` $ ./build/ripwire . --quality-delta - -, acked. value-ref-excluded=N: internal functions kept off because a table, field or argument in another function or at file scope holds them (matched by name, not a proven call); a floor. at=: commit+dirty+shallow. stale=N: ack ledger rows whose target no longer applies (sa rows); never gating. preexisting-worse=N: regressions on symbols that existed at baseline; they gate when major. new-symbol=N: findings on NEW code; new-symbol rows never gate, except defect-shape format-arity. register-macro-excluded=N: symbols kept out of dead-code as self-registering test/bench macros; a floor. api-new-surface=N: new PUBLIC symbols; a count, never gates, not in regressions=. renames=/rename_window_commits=: git rename pairs read over that many commits, to re-file baseline and acks. acked_by_rename=/acked_by_content=: acked= suppressions matched via git renames / an equal body hash. renames_window_truncated=1: history is deeper than the rename window, older renames unread. acked=N: findings suppressed by the ack ledger, listed as sa rows; never gating. sa key=/why=: the stale ack's ledger hash / target-gone (names nothing now) or finding-gone (no longer fires). head_basis=identity: the floor is this tree's own snapshot; archived-index-hidden: refused, a tracked path is skip-worktree/assume-unchanged; absent: the archived HEAD tree. --> + + + + + + - - - - - - - - - + + + ... [17 more line(s); run it to see the whole thing] ``` @@ -2922,8 +2938,8 @@ _The Delta Maintainability Model scalar for the WORKING TREE vs HEAD — recorde ``` $ ./build/ripwire . --dmm - -

is a home/system directory; - Each tool call over the --max-memory limit is refused by name; +### `--mcp-legend=WHEN` + +**Answers:** the stdio MCP server's legend posture: session (the default) or inline session: the first answer of a session carries its legend inline; + +later answers list rows first, carry each definition only the first time the session meets it, and end with (the first of them also carries the core). inline: every answer keeps its legend until the client reads ripwire://legend-dict. legend:"compact"/"full" on a call keeps that answer inline either way. Stdio only. + +**Try it** + +_--mcp-legend=inline: every answer of the stdio session carries its own legend (the default, session, sends each definition once per session after the first answer)._ + +``` +$ ./build/ripwire '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize"}' '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"whereis","arguments":{"path":".","symbol":"rankGraphTeleport"}}}' '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"whereis","arguments":{"path":".","symbol":"computeOnePairOverlap"}}}' | ./build/ripwire --mcp --mcp-legend=inline +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-11-25","serverInfo":{"name":"ripwire","version":"1.0"},"capabilities":{"tools":{},"resources":{}},"instructions":"Map before reading files. Start a new task with explore; use from_trace for an error; use impact plus uses before changing a sym … [line truncated: 735 more bytes on this line] +{"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":" | +| **The gate suite** | `test/regression.sh`, `test/pargates.py` | 678 gate scripts plus the determinism, cache-transparency and golden contracts. | | **`--quality-delta`** | `src/quality.h` | Ten measured code-quality failure modes, reported only where a change made them worse. | ### The labeling protocol (why the held-out eval is allowed to disagree with the ranker) @@ -1104,7 +1104,7 @@ exists so that the next negative is as publishable as the last one. **The mechanism under test.** `hooks/ripwire-claude-route.sh`, a Claude Code `UserPromptSubmit` hook. On every submitted prompt it asks the deterministic `--help-task` classifier — the same one gated by -`test/taskroutecheck.sh` at precision 1.000 / harmful 0.000 — and, **only** when that classifier +`test/taskroutecheck.sh` at precision 1.000 / harmful 0.000 on the tuning corpus — and, **only** when that classifier returns `status="recommend"`, injects ONE paste-ready command as `additionalContext`. At `status="abstain"` it injects nothing at all. It is the Claude Code port of `hooks/ripwire-codex-route.sh`, which has shipped for Codex since 2026-08-28 and has never seen a @@ -1157,6 +1157,13 @@ measures in the wild; a coverage collapse invalidates the readout the same way a drift does. (c) Route accuracy in the field is **not** measurable from this log by construction — the rows are hash-only — and no number resembling it will be reported from it. +**Window 1 (registered 2026-09-02): VOID for the verdict.** Until issue #381 an unconfigured install resolved every session to `treatment`, so this window had no control arm, and the registered treatment − control difference cannot be computed from it. No verdict is read from it. Its four- and six-week dates (2026-09-30, 2026-10-14) are not readout dates. + +**Window 2: the readout clock restarts at the release that ships `arm=auto` as the default.** The rule is unchanged: one readout at four weeks, at least 40 recommended prompts per arm, and one extension to six weeks if the readout is underpowered. +**Window start:** PENDING (the release that ships `arm=auto` writes its release date here as YYYY-MM-DD) + +`bench/routing_ab_report.py` reads the `Window start` line: a date gives the readout date (start + 28 days) and the one-time extension date (start + 42 days); `PENDING` gives no date. + **Decision rule.** One readout, at four weeks (or six, under the extension). KEEP → the router stays as shipped. REWORD → one revision of the injected framing text, then a fresh registration with a new band; the mechanism is not re-litigated. **REMOVE → the hook is unregistered from the installer and deleted**, @@ -1193,6 +1200,19 @@ population is biased toward the intents it already covers. **The readout is a LATER session.** This lane ships the instrument and the band. Nothing in this registration is a result. +**Amendment, 2026-10-07 (issue #381) — the shipped default previously gave no control arm.** The control arm +above is real only for a session that resolves to `control`. The hooks resolved an unconfigured arm (no +`RIPWIRE_METER_ARM`, no `arm=` line in `~/.ripwire/meter.conf`) to `treatment`, and nothing in the installer +wrote `arm=auto`, so a machine that never created `meter.conf` logged only treatment sessions: one such +machine reported 18968 treatment prompts and 0 control prompts over four weeks, and the treatment-minus-control +difference registered above could not be computed from them. The default is now `auto` (the stable +session-hash split) in `hooks/ripwire-claude-route.sh`, `hooks/ripwire-claude-toolroute.sh` and +`hooks/ripwire-nudge.sh`; an explicit `arm=treatment` or `arm=control` is still honoured. Rows written before +the change by an unconfigured hook carry `arm="treatment"` with no control population, so they belong to the void window 1 and to no readout. **Pre-period observation, not a readout:** field data from one machine on the old treatment-only default (issue #381, 2026-09-10 to 2026-10-07) had 1,926 distinct injected recommendations. None of them was followed by the recommended verb within the same turn (0/1,926 same-turn adoptions; 3 were followed by any ripwire call). This observation enters neither window's verdict. The same issue taught the prompt hooks to skip MCP channel events (``) and sub-agent +hand-backs (``) like `` and ``: they are harness events, +not prompts. `bench/routing_ab_report.py` now prints per-arm counts, says `NO CONTROL ARM` when an arm has no +prompts, and marks the 2026-09-02 registration window void (it had no control arm); the readout clock restarts at the release that ships `arm=auto`, and the report prints the new window's readout date once its start date is recorded in this section. + **The readout instrument.** `python3 bench/routing_ab_report.py [--routing PATH] [--meter PATH] [--since AT] [--until AT]` (defaults: `$RIPWIRE_HOME/routing.jsonl` and `$RIPWIRE_HOME/substitution.jsonl`) computes exactly the metric above: per arm, recommended-prompt @@ -2301,12 +2321,14 @@ verb, so a composed expression the verb would refuse fails the gate rather than | --- | ---: | ---: | | skills the router can name (of 16, `ripwire-router` excluded) | **8** | **16** | | audit's 39 surface phrasings, recommends | **3** | **9** | -| corpus `split=test` (n=114) accuracy / coverage | 0.754 / 0.627 | **0.939 / 0.907** | -| corpus `split=dev` (n=111) accuracy / coverage | — | 0.946 / 0.929 | -| corpus `split=all` (n=225) accuracy / coverage | 0.809 / 0.730 | **0.942 / 0.918** | -| precision / harmful / neg-specificity, every split | 1.000 / 0.000 / 1.000 | 1.000 / 0.000 / 1.000 | +| tuning corpus `split=test` (n=114) accuracy / coverage | 0.754 / 0.627 | **0.939 / 0.907** | +| tuning corpus `split=dev` (n=111) accuracy / coverage | — | 0.946 / 0.929 | +| tuning corpus `split=all` (n=225) accuracy / coverage | 0.809 / 0.730 | **0.942 / 0.918** | +| tuning corpus precision / harmful / neg-specificity, every split | 1.000 / 0.000 / 1.000 | 1.000 / 0.000 / 1.000 | | the 189 rows that predate this tier, (status, intent, resolved_symbols) | — | **0 differing** | +The accuracy, coverage and precision figures in this table (0.907, 0.918, 1.000) were measured on the tuning corpus, `test/taskroutefix/prompts.tsv`, the corpus the intents were written against. On the held-out prompts, `test/skillevalfix/prompts.tsv`, `--help-task` recommended on 28 of 214 positive prompts (28/214 = 0.131, measured 2026-10-02), below the pre-registered coverage bar of ≥ 0.60; the held-out gate that registration called for is not yet built. + The remaining 30 of 39 are declined by design and are gated as such: six SHAPING flags (`--scope`, `--slice-depth`, `--slice-flow`, `--allow-dirty`, `--no-ignore`, `--no-post-check`) are modifiers on other verbs and not commands a one-command router can recommend alone; `--pin-census` is eval-only; and @@ -5866,7 +5888,7 @@ copy here would be exactly the dialect divergence that gate exists to catch. Com tags, wrap, stable-order defaults), seven individually invoked standalone gates (`g1freshcheck`, `skillscan`, `htmlexport`, `compresscheck`, `handoffcheck`, `releaseinstallcheck`, `taskroutecheck`), and a single loop -naming **671 gate scripts**, all of which exist on disk. +naming **678 gate scripts**, all of which exist on disk. `python3 test/pargates.py . ./build/ripwire -j 6` runs the same scripts in parallel so a full verification fits in one sitting. It does not modify `regression.sh`. @@ -6167,15 +6189,29 @@ regenerated file. It skips (exit 0) when no reference binary is given, self-test and asserts it left the tree unmodified — an assertion that is itself **controlled**: a stray file is created on purpose, must be detected, and must then be gone. -### `--quality-delta`'s eleven kinds: ten measured failure modes and placeholder +### `--quality-delta`'s twelve kinds: ten measured failure modes, placeholder and defect-shape These are the exact `kind=` strings the binary emits, from `src/quality.h`: `complexity` · `verbosity` · `nesting` · `params` · `duplication` · `dead-code` · `api-surface` · -`error-masking` · `short-horizon-churn` · `new-clone-of-reused-helper` · `placeholder` +`error-masking` · `short-horizon-churn` · `new-clone-of-reused-helper` · `placeholder` · `defect-shape` The first ten each target a failure mode measured in the literature; `placeholder` (added 0.6.5) is an -honesty check on "done" — a stub or TODO the change added — and never gates. +honesty check on "done" — a stub or TODO the change added — and never gates. `defect-shape` carries four +facets in `defect=` (`format-arity`, `utf8-cut`, `dedup-first`, `vacuous-assert`), shapes this project's own +code review kept finding; only `format-arity` gates, and on any origin (new-symbol rows never gate, except +defect-shape format-arity). Measured (2026-10-07): recall 7/8 on the eight review findings it was built +from; with every site treated as new, 0 format-arity false positives on this tree (1,330 literal-format calls), +on the Python 3.13 standard library (405 calls) and on libc++'s std::format tests (80 calls; the 24 hits are its +deliberately ill-formed `.verify.cpp` calls); vacuous-assert sample precision 30/30 on this tree's gates +(46 rows, Wilson 95% lower bound 0.89) after fix round 1 (2026-10-08) taught the guard the positive on the same +capture spelled as an `if`/`elif` `&&` condition or through a script-defined function — before it, 3 of 49 rows were +such false positives and the seeded sample read 28/30 (lower bound 0.79), not the 30/30 first published. Floor: an +absence read off a file a run redirected into is not judged (`redactcheck.sh:81`, `namingcalibrationcheck.sh:292`). +Two format-arity floors (a delta review, 2026-10-08; 0 hits on the three corpora above): the macro skip sees a name this +file `#define`s and a function-like macro the tree defines, not a lower-case object-like macro defined only in another +file; and only a literal `#if 0` is skipped, so `#if false`, `#if (0)`, `#if 0 && …` and the `#else` of `#if 1` are +read as live. Note that some user-facing summaries abbreviate four of these (`dup`, `dead`, `churn`, `clone-of-reused-helper` / `reuse-decline`). **Match against the strings above** when grepping real @@ -6969,7 +7005,7 @@ Listed because the reason is more useful than the silence. shipped**. See `bench/locbench/anchorhop_calib.json`. The mention anchor's reproducible numbers are the ablations in §4. - **A single round gate-count.** Two in-tree numbers disagree (`test/pargates.py`'s docstring says - ~210; `test/argvdiffcheck.sh` says 200+), while the loop in `test/regression.sh` names 671. The + ~210; `test/argvdiffcheck.sh` says 200+), while the loop in `test/regression.sh` names 678. The loop is the authority; the stale docstrings are a known drift. Since 2026-09-10 the number is not written by hand anywhere: `docs/gatecount_build.py` derives it from the loop and rewrites every published site, `test/gatecountcheck.sh` fails if any of them drifts, and `test/manifestcheck.sh` diff --git a/docs/LIMITS.md b/docs/LIMITS.md index fbeb13a73..40041f310 100644 --- a/docs/LIMITS.md +++ b/docs/LIMITS.md @@ -17,10 +17,10 @@ it once, marked `×N`. | total caps | files | caps whose file discloses | caps whose file discloses NOTHING | | --- | --- | --- | --- | -| 236 | 94 | 135 | **101** | +| 240 | 96 | 140 | **100** | Plus 7 ranking and apportionment parameters, in their own table below: they are not caps, they -are not counted as caps, and 236 + 7 is the 243 constants this generator parses out of `src/`. +are not counted as caps, and 240 + 7 is the 247 constants this generator parses out of `src/`. ## INDEXING, OUTPUT or BOUNDARY — which half of the answer a cap bounds @@ -38,8 +38,8 @@ None of them truncates anything, so none can be judged by `shown=`/`total=` and a disclosure — labelling them OUTPUT would ask for a `capped="1"` that could never honestly fire. The distinction was named in review on #108 and the rows below now carry it. -The `class` column below carries that answer where it is known. **130 of 236 caps are classified -(45 INDEXING, 41 OUTPUT, 44 BOUNDARY); the remaining 106 render `—`, which means NOT YET +The `class` column below carries that answer where it is known. **132 of 240 caps are classified +(47 INDEXING, 41 OUTPUT, 44 BOUNDARY); the remaining 108 render `—`, which means NOT YET CLASSIFIED — never "neither".** Classifications live in `docs/limits_classes.tsv`, a sidecar with a known expiry: the tag belongs on the declaration itself, and this file exists only because the round that @@ -89,7 +89,7 @@ refuse to write, so the column cannot be satisfied by pointing at nothing. ## Caps, by file -One table for each of the 94 files that declare a cap — the 236 caps counted above, and no parameter. +One table for each of the 96 files that declare a cap — the 240 caps counted above, and no parameter. ### `src/abicheck.h` @@ -134,7 +134,7 @@ Discloses: **none** ### `src/cli.h` -Discloses: `capped`, `bridges_capped`, `files_capped`, `inc_capped`, `mention_idents_capped`, `mention_syms_capped`, `modules_capped`, `rows_capped`, `sibs_capped`, `syms_capped`, `tests_capped`, `unflagged_capped`, `untested_capped` +Discloses: `capped`, `bridges_capped`, `count_capped`, `files_capped`, `inc_capped`, `mention_idents_capped`, `mention_syms_capped`, `modules_capped`, `rows_capped`, `sibs_capped`, `syms_capped`, `tests_capped`, `unflagged_capped`, `untested_capped` | constant | value | class | note | | --- | --- | --- | --- | @@ -505,11 +505,11 @@ Discloses: **none** ### `src/ingest_valuerefs.h` -Discloses: **none** +Discloses: `value_refs_depth_capped` | constant | value | class | note | | --- | --- | --- | --- | -| `kVrMaxDepth` | `512` | — | the value-uses pass's own depth guard (ingest_sidecap.h kSideDepthUses) | +| `kVrMaxDepth` | `512` | INDEXING | the value-uses pass's own depth guard (ingest_sidecap.h kSideDepthUses); a cut is disclosed as value_refs_depth_capped= (valuerefs.h) | | `kVrTextCap` | `96` | — | a written slot / callee longer than this is cut with "…" | ### `src/jsrunner.h` @@ -571,7 +571,7 @@ Discloses: **none** ### `src/lintrules.h` -Discloses: **none** +Discloses: `count_capped` | constant | value | class | note | | --- | --- | --- | --- | @@ -726,6 +726,14 @@ Discloses: **none** | --- | --- | --- | --- | | `kMaxPartitions` | `16` | BOUNDARY | — | +### `src/pathgaps.h` + +Discloses: `gap_syms_capped` + +| constant | value | class | note | +| --- | --- | --- | --- | +| `kPathGapRows` | `3` | — | rows printed; gap_syms= counts them all, gap_syms_capped="1" says so | + ### `src/pattern.h` Discloses: `hits_capped` @@ -753,6 +761,7 @@ Discloses: **none** | `kMaxCacheBlobAgeDays` | `30.0` | BOUNDARY | — | | `kMaxCacheBlobCount` | `4096` | BOUNDARY | bound every future hygiene scan | | `kMaxEditLockAgeDays` | `1.0` | BOUNDARY | — | +| `kMaxFilesPerCommit` | `30` | — | — | | `kRenameMaxChain` | `8` | INDEXING | a→b→c… chain depth followed from one current path (disclosed) | | `kRenameMaxPairs` | `4000` | INDEXING | hard cap on recorded pairs (disclosed when hit) | @@ -788,6 +797,14 @@ Discloses: **none** | --- | --- | --- | --- | | `kDefaultRecallMaxTokens` | `8000` | — | — | +### `src/receiverevidence.h` + +Discloses: **none** + +| constant | value | class | note | +| --- | --- | --- | --- | +| `kWalkCap` | `64` | INDEXING | classes visited per base walk; deterministic (sorted chaUp) | + ### `src/redact.h` Discloses: **none** @@ -894,6 +911,7 @@ Discloses: `capped` | constant | value | class | note | | --- | --- | --- | --- | +| `kSkillExcerptMaxBytes` | `120` | — | — | | `kSkillScanFindingCap` | `200` | INDEXING | generous for one file or a small dir; caps a pathological --scan-skills sweep | ### `src/slice.h` @@ -938,7 +956,7 @@ Discloses: `name_ladder_capped` ### `src/valuerefs.h` -Discloses: `capped` +Discloses: `capped`, `value_refs_depth_capped` | constant | value | class | note | | --- | --- | --- | --- | diff --git a/docs/TUNING.md b/docs/TUNING.md index 8a09f448f..0dc297ff9 100644 --- a/docs/TUNING.md +++ b/docs/TUNING.md @@ -14,26 +14,26 @@ to production at defaults; that control is what makes these numbers mean anythin | cap declarations | distinct names | tunable | must stay `constexpr` | move >= 1 invocation | move nothing measurable | | --- | --- | --- | --- | --- | --- | -| 148 | 147 | 112 | 12 | **37** | 75 | +| 150 | 149 | 112 | 12 | **37** | 75 | -The first two columns are not the same number, and the gap is not a rounding: `src/` holds **148 cap -declarations** under **147 distinct names** (`kRowCap` declared in more than one file). The sweep +The first two columns are not the same number, and the gap is not a rounding: `src/` holds **150 cap +declarations** under **149 distinct names** (`kRowCap` declared in more than one file). The sweep patches by NAME, so its own population is NAMES and not declarations — and the two columns beside those are frozen at the commit named under Provenance while this census is re-read from `src/` on -every run, which makes them a third population again. Enumerated over the 147 names `src/` declares -today: **111 tunable**, **12 must stay `constexpr`**, and **24 declared since the sweep was +every run, which makes them a third population again. Enumerated over the 149 names `src/` declares +today: **111 tunable**, **12 must stay `constexpr`**, and **26 declared since the sweep was prepared, which no measurement has touched** (`kChurnMergeBombMaxFiles`, `kExtendedLengthThresholdUnits`, `kFieldIdCapacity`, `kForPageRowsDefault`, -`kForPageUnionSymbolCap`, `kMaxAstQueryNesting`, +18 more). 111 + 12 + 24 = 147, and `emit` refuses +`kForPageUnionSymbolCap`, `kMaxAstQueryNesting`, +20 more). 111 + 12 + 26 = 149, and `emit` refuses to render a partition that does not add up. The `tunable` column above reads 112 rather than 111 because one name the sweep classified is no longer declared in `src/` at all: `kSituTestRowsShown`. -Quoting "113 of 148" would be wrong in both halves at once, which is the shape of error a generated +Quoting "113 of 150" would be wrong in both halves at once, which is the shape of error a generated table exists to prevent. ## Read this ratio before the tables **37 of 112 tunable caps move any invocation at all. 75 move nothing measurable.** That is the -finding, and it says what NOT to do: this is not a 148-cap audit. Most of these constants are +finding, and it says what NOT to do: this is not a 150-cap audit. Most of these constants are inert on real invocations and should be left alone. The work worth doing is the small set below, plus the caps that fire SILENTLY — a cap that bites without disclosing is a defect independent of whether its value is right, and that fix is both cheaper and larger than any retuning. @@ -189,7 +189,7 @@ moving. Re-run `prepare|screen|sweep` to refresh them. ### `kLintMaxPerRule` = `5000` -`src/lintrules.h` — discloses: **none** — probe value `40000` — **4 verb(s) respond** +`src/lintrules.h` — discloses: `count_capped` — probe value `40000` — **4 verb(s) respond** | invocation | default | at probe | delta | | --- | --- | --- | --- | diff --git a/docs/captures/COMMANDS_showcase_2026-09-14.md b/docs/captures/COMMANDS_showcase_2026-09-14.md index 65e566861..23dc272e6 100644 --- a/docs/captures/COMMANDS_showcase_2026-09-14.md +++ b/docs/captures/COMMANDS_showcase_2026-09-14.md @@ -3945,9 +3945,9 @@ ripwire 0.6.1 (dev, AppleClang 21.0.0.21000101, emit=std::print, built_from=f8d4 - - - + + + ````` @@ -5351,7 +5351,7 @@ ripwire: --run-timeout=SECONDS modifies --run-trace — pass it too (e.g. ripwir *The session legend dictionary the MCP server serves as ripwire://legend-dict/full — one definition per line, headed by its dictv= version; no corpus needed. =roster lists the completeness attributes it defines.* ````` -ripwire legend dictionary ripwire.dict/v1 dictv=04d7833c60d2d5bd entries=784 +ripwire legend dictionary ripwire.dict/v1 dictv=cbadf4aa6acda0c2 entries=787 : the answer's rows come first; its root keeps only task= changed= from= to=, and this LAST child carries every other root attribute unchanged (schema= included); legend="ref": a definition is sent once per session (this dictionary's core, or the first answer that ne … [line truncated: 83 more bytes on this line] schema=ripwire.KEY/v1: the line ripwire.KEY/v1 below reads the answer's rows window: shown= total= capped= has_more= next_offset= offset= limit= page a list (capped=1 cut; next_offset= pastes as offset=) @@ -5381,7 +5381,7 @@ ripwire.impact/v1 : transitive blast radius of of=: reach s ripwire.path/v1 : one DIRECTED call path from= to to=, each a hop; reachable=0 hops=0 when none ripwire.connect/v1 : minimal joining subgraph: groups, terminals, joins, edges, ripwire.at/v1 : enclosing-definition chain at p=:l=: sym= innermost, chain= outermost-first, spans -… [755 more display lines; full output is 77894 bytes on 785 raw line(s)] +… [758 more display lines; full output is 78390 bytes on 788 raw line(s)] ````` ## `./build/ripwire . --lint --lint-select=cache-` @@ -5460,6 +5460,47 @@ ripwire.at/v1 : enclosing-definition chain at p=:l=: sym= innermost, chain= … [724 more display lines; full output is 67006 bytes on 1 raw line(s)] ````` +## `./build/ripwire . --lint --lint-select=goto --lint-max-per-rule=4` + +*A rule that spends its own match budget (here 4, set BELOW the default 5000 so the floor shows on this repo): goto carries count_capped="1", its count= is a FLOOR, and the root names the call that counts the rest — findings_next= re-runs only the floored rules under a 10x budget.* + +````` + + + +goto done; +goto epilogue; +goto epilogue; +goto epilogue; + +````` + +## `./build/ripwire . --lint --lint-select=goto --lint-max-per-rule=40` + +*Pasting that findings_next=: the same rule under a 10x budget counts its true total — no count_capped=, no findings_next=.* + +````` + + + +goto done; +goto epilogue; +goto epilogue; +goto epilogue; +goto epilogue; +goto epilogue; +goto epilogue; +goto epilogue; +goto epilogue; +goto epilogue; +goto epilogue; +goto epilogue; +goto epilogue; +goto done; +goto done; + +````` + ## `./build/ripwire . --lint --lint-select=cach-` *An unresolvable PREFIX refuses (exit 1) with a did-you-mean from a real edit distance (one character off cache-).* diff --git a/docs/captures/COMMANDS_showcase_2026-10-08.md b/docs/captures/COMMANDS_showcase_2026-10-08.md new file mode 100644 index 000000000..7f30dcf30 --- /dev/null +++ b/docs/captures/COMMANDS_showcase_2026-10-08.md @@ -0,0 +1,7519 @@ +# ripwire — every verb, run for real + +- **Date:** 2026-10-08 (regenerated capture; supersedes any older `docs/captures/COMMANDS_showcase_*.md`) +- **Lives in `docs/captures/`** — a directory the crawl/retrieval lenses SKIP (`kCrawlSkipDirs`, src/ingest.h): a generated doc that quotes every verb's output out-scores the source for any query about the tool and was measured at 77% of `--recall` on this repo when it sat at the root. `test/argvdiffcheck.sh` harvests its `## `-heading command lines as differential vectors — keep that format. +- **Version:** `ripwire 0.6.5 (dev, AppleClang 21.0.0.21000101, emit=std::print, built_from=f1e5c2e76)` +- **Repo:** the ripwire repo @ `f1e5c2e7` — **CLEAN — `git status --porcelain` is empty**. The diff-aware verbs (`--situ`/`--test-gate`/`--quality-delta`/`--pr-context`/`--map-diff`/`--edit-check`) answer a question about the WORKING TREE, so that condition is part of their answer and every one of their captions below states which tree it recorded against. A clean tree is the honest default for a showcase, so they appear TWICE: once here on the clean tree (their empty/exit-0 shape) and once in the final section against a throwaway `git clone --local` sandbox carrying one deliberate regression, so their real gating shapes are visible without writing a byte into the read-only repo. +- **Corpus:** the ripwire repo itself (dogfood), via `./build/ripwire` +- **Sandbox diff** (the last section only): `.ripwire_notes | 2 ++ + .ripwire_quality_acks | 1 + + src/infra/sortutil.h | 31 ++++++++++++++++++++++++++++--- + 3 files changed, 31 insertions(+), 3 deletions(-)` — one preexisting function made deeply nested, one function's arity changed 1 -> 2, one copy-paste duplicate helper, one new 8-parameter public function. + +**How to read the blocks:** ripwire's real XML output is minified — often ONE long line. For scanability, long minified lines are displayed re-wrapped with a line break at every tag seam (`><`). Header COMMENT lines (the legends) always appear in full — they are exempt from the per-line cut; any OTHER display line over 300 bytes is cut with a `… [line truncated: N more bytes]` marker, which can hit a long root element or row. `--plan-lanes` emits JSON and is re-wrapped at object seams the same way. Long outputs are cut to their first ~30 display lines with a `… [N more display lines; full output is M bytes]` marker giving the true size. Exit codes are recorded when non-zero; wall time when >1s. + +**Not run (and why):** `ripwire ` (network clone), `--listen` / `--mcp-token` / `--allow-remote-edits` (the HTTP-server posture; `--mcp` itself IS captured in its own section as a one-shot stdio JSON-RPC exchange, and `wrap claude` shows the wiring), `--arch --baseline[-update]` (state writer against the read-only repo — `--note-add` / `--quality-baseline` / `--quality-ack` / the three edit verbs / `--edit-plan` ARE shown, inside the throwaway sandbox clone; `--index-out` / `--pin-census` write to scratch), `--eval-mined` (needs a `minedpair.jsonl` artifact from `bench/mine_traces.py`; none present in the tree), `--refetch` (git-url only), `--lsp` (the editor transport — its one-shot dialogues are gated by `test/lspcheck.sh`, not by showcase blocks), `--max-memory` (the memory guard is silent on this repo by construction; its stops are gated by `test/memguardcheck.sh`), `--force` (wrap-only modifier), `--scan-skills` bare form (would sweep `~/.claude/skills`; the explicit-DIR form is shown instead), `--help` (208 lines — read it from the binary). + + +--- + +# understand a codebase cold + +## `./build/ripwire .` + +*The default ranked symbol map — start here when landing cold in a repo.* + +**wall time: 1.41s** + +````` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +… [726 more display lines; full output is 21893 bytes on 1 raw line(s)] +````` + +## `./build/ripwire . --top-k=5` + +*Same map, capped to the 5 highest-ranked symbols.* + +````` + + + + + + + + + + + + + + + + + + + + + + + + + +````` + +## `./build/ripwire . --top-k=0 --expand=rankGraphTeleport` + +*NEW since the last capture: --top-k=0 means PAYLOAD-ONLY — no ranked map rides along with the body you asked for.* + +````` + + + + + + + + + + +````` + +## `./build/ripwire . --token-budget=100` + +*GATE form: exit 3 if the map's own est_tokens exceeds the budget (over-budget failure shape).* + +**exit code: 3** + +````` + + +````` + +stderr: + +````` +ripwire: --token-budget exceeded: withheld_est_tokens=8816 > budget=100 +````` + +## `./build/ripwire . --for="incremental cache invalidation when a file content hash changes"` + +*The task lens: ranked signatures + quality metrics framed for the task.* + +**wall time: 3.05s** + +````` + + + + +incremental cache (--cache): per-file content hash + raw facts so a re-run re-parses ONLY c…constexpr std::uint32_t kCacheMagic = 0x4b505443 + +and every file whose content hash differs is re-parsed — so a stale or foreign blob self-heals…constexpr std::uint32_t kHeadSnapCacheScheme = 1 + +persistent in-memory index (parse once, reuse across MCP calls) ---- The MCP server is long-live…struct McpIndex + +the cached index for `root`, rebuilt only when stale (otherwise returned as-is, no parse, no gra…inline const McpIndex& getIndex( const std::string& root ) +inline McpRebuildBaseline mcpRebuildBaseline( const McpIndex& ix, bool isIncrementalPass ) +inline std::uint64_t contentHash64( std::string_view s ) noexcept +struct CacheEntry +inline void prewarmTagsQueries( const std::vector<std::string>& files, const HashMap<std::string, FileFacts>& cache, long long cacheWriteNs, IngestFile … [line truncated: 20 more bytes on this line] +inline std::uint64_t blobChecksum( std::string_view s ) noexcept +inline ContentIdIndex contentIdsBySym( const IngestResult& ing, const Graph& g, std::string_view root ) +inline bool ingestCommitTree( const std::string& root, const std::string& sha, const std::vector<std::string>& excludes, std::size_t maxFileBytes, IngestResult& … [line truncated: 7 more bytes on this line] +inline std::vector<CacheWriteRow> buildCacheWritePlan( const std::vector<std::uint32_t>& orderIn, const std::vector<std::uint64_t>& pathHashes … [line truncated: 25 more bytes on this line] +inline std::string reAbsolutize( std::string_view rel, std::string_view root ) +inline CachePathKeys buildCachePathKeys( const std::vector<std::string>& files, std::string_view rootDir ) +inline std::vector<std::vector<NodeId>> legoImplementorsOnSurface( const IngestResult& ing, const std::vector<std::vector<NodeId>>& impl … [line truncated: 63 more bytes on this line] +inline std::uint64_t indexContentHash( const std::vector<std::string>& files, const std::vector<long long>& fileMtime, const std::vector<std::uint64_t> … [line truncated: 25 more bytes on this line] +struct IngestFileScan +inline std::string makeHandle( const std::string& canonId, const std::string& path, const std::string& name, std::uint64_t contentHash ) +inline std::string spanTierMemoPath( const std::string& diskPath ) +inline RawFacts runParsePool( IngestResult& result, const char* rootDir, std::string_view cacheFile, bool captureValueUses, HashMap<std::string, FileFacts>& cac … [line truncated: 99 more bytes on this line] +inline void collectExtractPartials( const IngestFileScan& scan, IngestResult& result ) +inline std::uint64_t byteHash( const char* data, std::size_t n ) noexcept +… [45 more display lines; full output is 9403 bytes on 1 raw line(s)] +````` + +## `./build/ripwire . --for="rankGraphTeleport"` + +*Name-shaped query: the router picks name-exact BM25 (header says which/why).* + +````` + + + + + +PageRank with an explicit teleport / personalization vector p (Σp = 1). The prior is name-quality-biased through biasPrior() so all rank modes share one weighting seam; the transition matrix (edgesinline RankedGraph rankGraphTeleport( const Graph& g, const std::vector<float>&am … [line truncated: 31 more bytes on this line] +#### The convergence disclosure contract +### Wave-2 adversarial verification (2026-08-19) — six probes against `aa97c9e` + + + + + +& p, float alpha = 0.85f ) +{ + PROFILE_SCOPE_DESCRIBE( "rankGraph: PageRank (power iteration)" ); + const std::vector pw = biasPrior( g, p ); + const std::size_t N = pw.size(); + std::vector teleport( pw.begin(), pw.end() ); + std::vector rankDouble( N, 0.0 ); + PageRankRun run{}; // an N == 0 graph never enters the kernel: { 0, converged } — see PageRankRun + if( N ) + { + double teleportMass = 0.0; + for( const double value : teleport ) + { + teleportMass += value; + } + if( teleportMass > 0.0 ) + { +… [12 more display lines; full output is 4505 bytes on 29 raw line(s)] +````` + +## `./build/ripwire . --for="rankGraphTeleport" --no-route` + +*Same query with routing forced OFF (plain subtoken+body BM25) — contrast with the routed run.* + +````` + + + + + +uniform-teleport PageRank (the defaultinline RankedGraph rankGraph( const Graph& g, float alpha = 0.85f ) + +PageRank with an explicit teleport / personalization vector p (Σp = 1). The prior is name-quali…inline RankedGraph rankGraphTeleport( const Graph& g, const std::vector<float>& p, float alpha = 0.85f ) +inline ChurnRanking churnRankedGraph( const MainDispatch& d ) + +L10 (2026-09-04): the old wording claimed "the same corpus ranked by pagerank orders differently…inline constexpr const char* kChurnRankLegend = "<!-- rank_by=churn: k= is PageRank re-run with the teleport BIASED by git CHANGE-FREQUENCY over window= " "(a c… +struct RankedGraph +inline std::vector<float> takeRank( RankedGraph ranked, RankDisclosure& disclosureOut ) +inline std::vector<float> biasPrior( const Graph& g, const std::vector<float>& p ) +inline std::vector<float> churnTeleportWorkspace( const std::vector<std::string>& rootDirs, const IngestResult& ing, const char* since = "18 months ago& … [line truncated: 20 more bytes on this line] +inline ChurnRanking churnDecayRanking( const MainDispatch& d, const rw::SinceScope& sinceScope, bool isScoped, const char* verbLabel ) +inline std::string rankByText( const std::string& root, std::string_view mode, int topK, bool stable = false ) +PageRankRun pageRankDouble( const sparseCsr<float>& inEdges, std::span<const double> weightedOutDegree, std::span<const double> teleport, std::span<double> rank… +inline std::vector<float> churnDecayTeleport( const std::string& root, const IngestResult& ing, const SinceScope* scope = nullptr, bool* outHasChurnEvidence = n… +inline std::string didYouMean( const IngestResult& ing, std::string_view name ) +PageRankRun pageRankDouble( const sparseCsr<float>& inEdges, std::span<const double> weightedOutDegree, std::span<const double> teleport, std::span< … [line truncated: 45 more bytes on this line] +inline const McpIndex& getIndex( const std::string& root ) +inline std::vector<float> churnDecayTeleportWorkspace( const std::vector<std::string>& rootDirs, const IngestResult& ing, bool* outHasChurnEvidence = n … [line truncated: 12 more bytes on this line] +struct DataSectionsCut +inline int runEval( const std::string& root, const IngestResult& ing, const Graph& g, const std::vector<char>& currentDiff ) +inline std::vector<float> anchoredLexicalRank( const Graph& g, const std::vector<float>& lex ) +inline constexpr std::size_t kChurnMergeBombMaxFiles = 100 +### Fixed — `rank_by` over MCP no longer answers a different ranking than `--rank-by` on … [line truncated: 35 more bytes on this line] +### 2. Gates for A + +… [101 more display lines; full output is 15467 bytes on 72 raw line(s)] +````` + +## `./build/ripwire . --for="rankGraphTeleport" --signatures-only` + +*T3 opt-out: the signatures-only lens (no auto bodies, no bundle="auto" attribute) — contrast with the terminal default above.* + +````` + + + + + +PageRank with an explicit teleport / personalization vector p (Σp = 1). The prior is name-quality-biased through biasPrior() so all rank modes share one weighting seam; the transition matrix (edgesinline RankedGraph rankGraphTeleport( const Graph& g, const std::vector<float>&am … [line truncated: 31 more bytes on this line] +#### The convergence disclosure contract +### Wave-2 adversarial verification (2026-08-19) — six probes against `aa97c9e` + + + + +````` + +## `./build/ripwire . --for="tree-sitter parse of a source file" --adaptive` + +*Cut the result at the relevance cliff (Adaptive-k) — on a flat ranking nothing is cut and the header says so ([adaptive: kept N of N]).* + +````` + + + + +inline bool topLevelEvidence( std::string_view source, const TSLanguage* language, const Predicate& predicate ) +The crawl's per-file byte ceiling. A text file larger than this is skipped: at this size it is o…constexpr std::size_t kDefaultMaxFileBytes = 4u * 1024u * 1024u +TSTree* parseTree( TSParser* parser, std::string_view src ) + +Exercise every registered grammar and its embedded query, reporting loaded and expected totalsinline DoctorGrammarProbe doctorProbeGrammars() +struct FileHealth +enum class AstWalk : std::uint8_t +inline RevSide sliceAtRev( const std::string& root, const std::string& sha, const std::string& rel, const Symbol& sym, SliceFam fam, const ::TSLanguage* grammar…struct SliceScan +constexpr std::array<LangEntry, 53> kLangTable = +inline bool astroFenceTailIsBlank( std::string_view src, std::size_t from, std::size_t to ) noexcept +constexpr std::uint32_t kMaxKotlinStringNestDepth = 128u +std::vector<LocalNameFact> collectGatedLocalNames( std::string_view defBytes, std::uint32_t defStartLine, Lang lang ) +inline bool hasPhantomScopeSeparator( TSNode qualified ) noexcept +inline ModuleScan scanModule( std::string_view source, std::string_view path ) +bool jsonNestsTooDeep( std::string_view bytes ) noexcept +constexpr std::uint32_t kMaxYamlNestDepth = 64u +SpanTierBatch spanTiersOfFiles( std::span<const std::string> diskPaths, bool useMemo ) +inline bool hasMainGuard( std::string_view source ) +inline void runParseWorker( ParsePoolShared& sh, unsigned t ) +inline SliceScan sliceScanDefinition( const std::string& src, const Symbol& sym, SliceFam fam, const ::TSLanguage* grammar, std::string_view varName ) +struct LintRule +enum class DisclosureWhy : std::uint8_t +inline std::string buildMemoryStopAttr( const IngestResult& ing, bool json ) +struct AstQuerySpec +… [52 more display lines; full output is 9566 bytes on 1 raw line(s)] +````` + +## `./build/ripwire . --for="why does src/lexical.h chooseForRanker pick name-exact BM25"` + +*Mention anchoring (default-on): a path and a Symbol literally named in the task get lifted; the header says what anchored.* + +````` + + + + +The name-exact ranker AS THE RETRIEVAL LENS SERVES IT: whole-name BM25 plus the definition-over-…inline std::vector<float> lexicalScoresNameExactRanked( const IngestResult& ing, std::string_view query, const std::vector<float>* symbolScoreMul ) + +P11.12: the interpretive footer for --eval's ranker table, pulled into its own function so the 9…inline void printEvalRankerNote() +inline int runEvalRetrieval( const IngestResult& ing, const Graph& g ) + +calling agent knows to reformulate rather than trust the ranking. Calibrated empirically (2026-0…inline constexpr float kWeakLexicalScoreThreshold = 1.0f +inline RouteChoice chooseForRanker( const IngestResult& ing, std::string_view query ) +inline std::string candidatesRootTag( std::size_t keep, std::size_t corpusCount, const CandidateProvenance& prov ) +inline std::vector<float> lexicalScoresNameExactTiered( const IngestResult& ing, std::string_view query, const std::vector<float>* symbolScoreMul ) +inline int runEvalSkills( const std::string& root, const IngestResult& ing, const Graph& g, const std::string& labelsPath ) +inline int runEvalMined( const std::string& root, const IngestResult& ing, const Graph& g, const std::string& path ) +struct NameCorpusStats +struct LensRanking +inline bool hasIdentifierShape( std::string_view w ) noexcept +inline int runEval( const std::string& root, const IngestResult& ing, const Graph& g, const std::vector<char>& currentDiff ) +struct Bm25Params +inline double bm25ImpactBound( double idf, double T, const Bm25Params& p ) noexcept +inline std::string packTaskText( const std::string& root, const std::string& task, std::size_t budgetTokens, RedactCounts* redact = nullptr, std::uint32_t partitionCount = 0, … [line truncated: 27 more bytes on this line] +rw::LensRanking computeLensRanking( const MainDispatch& d, std::string_view task, bool compactCandidate = false, bool fullDistribution = false ) // deep-tail: the file-grain tail rea … [line truncated: 65 more bytes on this line] +struct CandidateProvenance +inline constexpr int kLexWeightDoc = 2 +std::optional<int> runEvalViews( const MainDispatch& d ) +inline std::string_view takeQualifiedIdent( std::string_view src, std::size_t& i ) +inline std::vector<float> lexicalScoresNameExact( const IngestResult& ing, std::string_view query ) +struct RecallSectionPick +… [55 more display lines; full output is 10030 bytes on 1 raw line(s)] +````` + +## `./build/ripwire . --for="why does src/lexical.h chooseForRanker pick name-exact BM25" --no-mention-boost` + +*Same task with the anchor disabled — the contrast the flag exists for.* + +````` + + + + +The name-exact ranker AS THE RETRIEVAL LENS SERVES IT: whole-name BM25 plus the definition-over-…inline std::vector<float> lexicalScoresNameExactRanked( const IngestResult& ing, std::string_view query, const std::vector<float>* symbolScoreMul ) + +P11.12: the interpretive footer for --eval's ranker table, pulled into its own function so the 9…inline void printEvalRankerNote() +inline int runEvalRetrieval( const IngestResult& ing, const Graph& g ) + +calling agent knows to reformulate rather than trust the ranking. Calibrated empirically (2026-0…inline constexpr float kWeakLexicalScoreThreshold = 1.0f +inline std::string candidatesRootTag( std::size_t keep, std::size_t corpusCount, const CandidateProvenance& prov ) +inline std::vector<float> lexicalScoresNameExactTiered( const IngestResult& ing, std::string_view query, const std::vector<float>* symbolScoreMul ) +inline int runEvalSkills( const std::string& root, const IngestResult& ing, const Graph& g, const std::string& labelsPath ) +inline int runEvalMined( const std::string& root, const IngestResult& ing, const Graph& g, const std::string& path ) +struct NameCorpusStats +struct LensRanking +inline bool hasIdentifierShape( std::string_view w ) noexcept +inline RouteChoice chooseForRanker( const IngestResult& ing, std::string_view query ) +inline int runEval( const std::string& root, const IngestResult& ing, const Graph& g, const std::vector<char>& currentDiff ) +struct Bm25Params +inline double bm25ImpactBound( double idf, double T, const Bm25Params& p ) noexcept +inline std::string packTaskText( const std::string& root, const std::string& task, std::size_t budgetTokens, RedactCounts* redact = nullptr, std::uint32_t partitionCount = 0, … [line truncated: 27 more bytes on this line] +rw::LensRanking computeLensRanking( const MainDispatch& d, std::string_view task, bool compactCandidate = false, bool fullDistribution = false ) // deep-tail: the file-grain tail rea … [line truncated: 65 more bytes on this line] +struct CandidateProvenance +inline constexpr int kLexWeightDoc = 2 +std::optional<int> runEvalViews( const MainDispatch& d ) +inline std::string_view takeQualifiedIdent( std::string_view src, std::size_t& i ) +inline std::vector<float> lexicalScoresNameExact( const IngestResult& ing, std::string_view query ) +struct RecallSectionPick +… [57 more display lines; full output is 9925 bytes on 1 raw line(s)] +````` + +## `./build/ripwire . --lego=Vehicle` + +*Interface -> implementors view: every existing impl of the named interface; the method contract is extracted for the C-family/Java/TS/Python tiers — for a Rust trait (this fixture) it discloses caveat="not-extracted-for-lang" rather than an empty list.* + +````` + + + + + + + + + +````` + +## `./build/ripwire . --exemplar="format byte sizes for humans"` + +*The repo's best-in-class instance to imitate before writing new code (picked by ROLE).* + +````` + + + + +ripwire '.' --verify='calls(runDefaultMap, rankGraphTeleport)' + + +````` + +## `./build/ripwire . --help-task="write a cheerful release announcement"` + +*The honest half of the contract: a task with no ripwire-shaped evidence ABSTAINS with zero commands rather than guessing.* + +````` + + + + +````` + +## `./build/ripwire . --recall="quality delta gating exit codes"` + +*Most relevant DOCS' full bodies (markdown only) — recall what is already written down.* + +````` +ripwire recall — "quality delta gating exit codes" — 113 relevant of 216 document files, best-first — total=113 shown=8 capped=1 truncated=7 generated_demoted=3 max_tokens=8000 share_bytes=2161 est_tokens=5489 + +━━ README.md (relevance 8.038) ━━ [sections: 2 of 25 selected (60 in doc), section-granular; whole doc 214142 B; lines="2710-2722,2723-2730"; dropped_by_budget=23] +#### 6.2 Exit codes + +This is the part to wire into a script. + +| Code | Meaning | +| --- | --- | +| 0 | The command completed. | +| 1 | The command refused the request. A refusal names the reason on stderr. When `--callers`, `--callees`, `--uses`, `--impact` or `--path` refuse a selector that matches no indexed definition, they also print an answer on stdout: the verb's element with `found="0"` and the name to retry with. | +| 2 | A policy gate fired: `--arch` found a layering violation, `--scan-skill` found a CRITICAL, `--quality-delta` found new debt. | +| 3 | The output exceeded the token budget that you set. | +| 4 | `--test-gate` found an open obligation. | +| 5 | The memory guard stopped the run: over `--max-memory` (default 65% of the machine's memory) with no partial answer possible, or a verb other than the default map facing a partial index. One stderr line names the limit and the override. | + +#### 6.3 JSON output + +`--json` emits JSON instead of XML, with keys mirroring the XML attribute names one to one. It is an +allow-list: the default map, `--for`, `--pack-task`, `--callers`, `--callees`, `--impact`, +`--quality-delta`, `--test-gate`, `--metrics`, and `--plan-lanes`, which is JSON-native. Every other +verb refuses on stderr with exit 1 rather than silently falling back to XML, so a verb added tomorrow +refuses by default. + + + +━━ prompts/help-wanted/quality-delta-unchanged-tree-zero.md (relevance 7.384) ━━ [sections: 1 of 18 selected (21 in doc), section-granular; whole doc 25463 B; lines="133-158"; dropped_by_budget=17] +### Root spelling (maintainer-owned; reproduce it once so you recognise it) + +```bash +… [159 more lines, 14007 bytes total] +````` + +## `./build/ripwire /aux/kbcorpus --recall="field affinity cache line data layout which fields are read together" --top-k=3 --max-tokens=1200` + +*The directory-as-knowledge-base pattern: --recall pointed at a 1416688-byte scratch dir of DUMPED TOOL OUTPUT (a git log, this repo's own generated command reference, --help text, an architecture doc, a fabricated JSON access log) instead of a source repo — no index to build, no daemon.* + +````` +ripwire recall — "field affinity cache line data layout which fields are read together" — 2 relevant of 2 document files, best-first — total=2 shown=1 capped=1 truncated=1 max_tokens=1200 share_bytes=2040 est_tokens=957 + +━━ commands.md (relevance 10.145) ━━ [sections: 1 of 159 selected (188 in doc), section-granular; whole doc 551924 B; lines="3754-3758"; dropped_by_budget=158] [truncated: 1865 of 7739 bytes] +### `--field-affinity[=STRUCT]` + +**Answers:** find fields that are read together but declared far apart — the cache-locality lens the CACHE-LOCALITY lens: which fields are READ TOGETHER but declared FAR APART. + +Builds a static field CO-ACCESS affinity graph (one observation per indexed C-family function body) and diffs it against the DECLARED field order and 64-byte cache-line geometry, reusing --layout's LP64 offset model. Bare = every aggregate in the repo, ranked by separation cost; =STRUCT narrows the … [line truncated: 1355 more bytes on this line] + +(capped: 1 of 2 relevant document files omitted — raise --max-tokens/budget_tokens or narrow the query for 1 more (~2548-byte budget)) +````` + +stderr: + +````` +ripwire: redacted 1 secret from emitted context (keyword-gated-secret=1) — pass --no-redact to disable +````` + +## `./build/ripwire . --tree` + +*File-by-file orientation map (top symbols per file).* + +````` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +… [367 more display lines; full output is 11200 bytes on 1 raw line(s)] +````` + +## `./build/ripwire . --html=/aux/map2.html` + +*Self-contained HTML force-directed call graph.* + +**wall time: 1.38s** + +````` +(empty) +````` + +Artifact written: + +````` + 159214 /aux/map2.html +````` + +## `./build/ripwire . --order=stable --top-k=5` + +*Stable (path/id) emit order — provider KV-cache hits across re-runs.* + +````` + + + + + + + + + + + + + + + + + + + + + + + + + +````` + + +--- + +# navigate / answer a question + +## `./build/ripwire . --around=rankGraphTeleport` + +*Ego graph around one symbol — depth 1 BY DEFAULT now (the root's depth= says so): ~6 KB where the 2-hop neighbourhood is ~64 KB on this repo.* + +````` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +… [195 more display lines; full output is 9060 bytes on 1 raw line(s)] +````` + +## `./build/ripwire . --around=rankGraphTeleport --around-depth=2` + +*The restoring knob: --around-depth=2 brings back the whole 2-hop neighbourhood (depth="2" on the root) — pay for it only when the 1-hop view was not enough.* + +````` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +… [2616 more display lines; full output is 77875 bytes on 1 raw line(s)] +````` + +## `./build/ripwire . --around=rankGraphTeleport --around-fanout=4` + +*The other knob: --around-fanout=4 keeps only the 4 strongest edges per node (default 32) — the same 1-hop depth, a quarter of the rows.* + +````` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +… [20 more display lines; full output is 4221 bytes on 1 raw line(s)] +````` + +## `./build/ripwire . --callers=rankGraphTeleport` + +*Who calls SYM (1-hop in-edges).* + +````` + + + + + + + + + + +````` + +## `./build/ripwire . --callers=rankGraphTeleport --legend=full` + +*The same rows under --legend=full: the default is the compact dialect (one compact comment plus schema="ripwire.callers/v1" on the root), and --legend=full restores the prose legend — every row byte and every completeness attribute (counts_floor=, graph_ambiguous=, next=) identical, ~3 KB more legend. Works on EVERY XML verb.* + +````` + + + + + + + + + + + +````` + +## `./build/ripwire . --callers=DoesNotExist` + +*Unknown-symbol REFUSAL shape (exit 1) with a did-you-mean from real edit distance.* + +**exit code: 1** + +````` + + +````` + +stderr: + +````` +ripwire: --callers symbol not found: DoesNotExist +````` + +## `./build/ripwire . --callees=rankGraphTeleport` + +*What SYM calls (1-hop out-edges).* + +````` + + + + + + + + + + + +````` + +## `./build/ripwire . --uses=rankGraphTeleport` + +*The resolvable use-sites (call/read/write/import/extends) with file:line; count= is a floor.* + +````` + + + + + + + + + + + + +````` + +## `./build/ripwire . --graph-query='and(callers(name("rankGraphTeleport"),2),kind(all,fn))'` + +*Composable node-set query: functions within 2 caller-hops of rankGraphTeleport.* + +````` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +… [28 more display lines; full output is 3649 bytes on 1 raw line(s)] +````` + +## `./build/ripwire . --external-surface` + +*Names referenced but never defined in-corpus (stdlib/third-party surface). The root carries names/shown/capped; the default is a 100-row window now, so total= and a pasteable next= join them when it bites (the explicit --limit form carries the same quintet). The sh BUILTINS (cd/echo/set…) are dropped and COUNTED as builtins_excluded= — grep/sed/git stay, they ARE the surface.* + +````` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +… [73 more display lines; full output is 5583 bytes on 1 raw line(s)] +````` + +## `./build/ripwire . --external-surface --include-builtins` + +*The restoring knob: --include-builtins keeps the shell builtins in the same 100-row window (they now compete for rows, so the window's tail differs).* + +````` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +… [73 more display lines; full output is 5446 bytes on 1 raw line(s)] +````` + +## `./build/ripwire . --path=main,rankGraphTeleport` + +*Shortest directed call-path SRC -> DST. CHANGED: now reports from_p/to_p/from_defs and resolves the right `main` (was reachable="0").* + +````` + + + + + + + + +````` + +## `./build/ripwire . --connect=rankGraphTeleport,runEval,getIndex` + +*Minimal connecting subgraph over 3 symbols (finds shared-caller joins).* + +````` + + + + + + + + + + +````` + +## `./build/ripwire . --impact=rankGraphTeleport` + +*Transitive blast radius — everything that reaches SYM. NOW carries shown/capped.* + +````` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +… [50 more display lines; full output is 5372 bytes on 1 raw line(s)] +````` + +## `./build/ripwire . --mentions=rankGraphTeleport` + +*Markdown docs that name SYM in a backtick (doc<->code edges).* + +````` + + + + + + + + + + +````` + +## `./build/ripwire . --affected=src/graph.h` + +*Test files that transitively reach the changed file.* + +````` + + + + + + + + + + + + + + + + + + +````` + +## `./build/ripwire . --situ` + +*Mid-task situational report for the current git diff — recorded against a CLEAN tree (contrast with the sandbox run below).* + +````` +ripwire situational-awareness — 0 changed file(s), 0 symbols in them +root: . +at: f1e5c2e76 + (0 changed files — working tree is clean, nothing to analyze) +````` + +## `./build/ripwire . --test-gate` + +*Pre-PR gate on a CLEAN tree: no obligations, exit 0.* + +**wall time: 2.11s** + +````` + + +````` + +## `./build/ripwire . --grep=DISCLOSE` + +*Literal trigram-indexed search. Each hit carries its MATCHED line as the element's own CDATA (the wrapper is gone), plus shown/capped/hits_capped and a pasteable next= on the root.* + +````` + +if data.get( "schema" ) != SCHEMA: raise SystemExit( f"{path}: unexpected schema {data.get('schema')!r} (expecte +if not str( data.get( "tasks_lock_content_sha256", "" ) ).startswith( "questions:" ): train_repos = select_tasks +if train_repos: raise SystemExit( f"{path}: records from repo(s) that re-derive to LocBench +if base and ctx and base["status"] == "ok" and ctx["status"] == "ok": paired.append( ( instance_id, base["re +if not repos: return 0.0, [] +if base["localization_hit"] is None or ctx["localization_hit"] is None: return 0.0 +if bv: ratios.append( cv / bv - 1 ) +if not ratios: return None, None +if rw is None or native is None: return None +if not paired: out["note"] = "zero complete paired (baseline,ripwire_cli) runs — nothing to analyze yet" +if contaminated: print( f" ** {contaminated} baseline run(s) invoked ripwire despite the no-ripwire contract " +if "note" in out: print( f" {out['note']}" ); return +if out["n_pairs"] != 27: failures.append( f"expected 27 paired runs, got {out['n_pairs']}" ) +if out["n_incomplete"] != 2: failures.append( f"expected 2 incomplete pairs (the orphan + the contaminated-basel +if out["n_repos"] != 3: failures.append( f"expected 3 repos, got {out['n_repos']}" ) +if out.get( "n_contaminated_baseline" ) != 1: failures.append( f"expected exactly 1 contaminated baseline run co +if not ( out["resolved_delta_mean"] > 0 ): failures.append( "expected a positive resolved-rate delta" ) +if not ( out["resolved_delta_bootstrap_95_lower"] > 0 ): failures.append( "expected a POSITIVE bootstrap 95% low +if out["tokens_out_ratio_p50"] is None or abs( out["tokens_out_ratio_p50"] - 0.08 ) > 1e-6: failures.append( f"e +if out.get( "n_resolved_pairs" ) != 27: failures.append( f"expected all 27 pairs resolution-scored, got {out.get +if out.get( "substitution_rate_baseline" ) != 0.0: failures.append( f"expected baseline substitution rate 0.0 (n +if out.get( "substitution_rate_ripwire" ) is None or abs( out["substitution_rate_ripwire"] - 0.75 ) > 1e-9: fail +if out.get( "n_substitution_ripwire" ) != 27: failures.append( f"expected 27 substitution-scored ripwire runs, g +if out3.get( "substitution_rate_ripwire" ) is not None or out3.get( "n_substitution_ripwire" ) != 0: failures.ap +if out2["n_pairs"] != 27: failures.append( f"evaluator-none: expected 27 pairs, got {out2['n_pairs']}" ) +if out2["n_resolved_pairs"] != 0: failures.append( f"evaluator-none: expected 0 resolution-scored pairs, got {ou +if out2["resolved_delta_mean"] is not None or out2["resolved_delta_bootstrap_95_lower"] is not None: failures.ap +if out2["tokens_out_ratio_p50"] is None or abs( out2["tokens_out_ratio_p50"] - 0.08 ) > 1e-6: failures.append( " +… [73 more display lines; full output is 16956 bytes on 1 raw line(s)] +````` + +## `./build/ripwire . --match='(if_statement) @i'` + +*The same shape query WITH an explicit capture — identical hits, no auto_captured= attribute.* + +**wall time: 1.17s** + +````` + +if data.get( "schema" ) != SCHEMA: raise SystemExit( f"{path}: unexpected schema {data.get('schema')!r} (expecte +if not str( data.get( "tasks_lock_content_sha256", "" ) ).startswith( "questions:" ): train_repos = select_tasks +if train_repos: raise SystemExit( f"{path}: records from repo(s) that re-derive to LocBench +if base and ctx and base["status"] == "ok" and ctx["status"] == "ok": paired.append( ( instance_id, base["re +if not repos: return 0.0, [] +if base["localization_hit"] is None or ctx["localization_hit"] is None: return 0.0 +if bv: ratios.append( cv / bv - 1 ) +if not ratios: return None, None +if rw is None or native is None: return None +if not paired: out["note"] = "zero complete paired (baseline,ripwire_cli) runs — nothing to analyze yet" +if contaminated: print( f" ** {contaminated} baseline run(s) invoked ripwire despite the no-ripwire contract " +if "note" in out: print( f" {out['note']}" ); return +if out["n_pairs"] != 27: failures.append( f"expected 27 paired runs, got {out['n_pairs']}" ) +if out["n_incomplete"] != 2: failures.append( f"expected 2 incomplete pairs (the orphan + the contaminated-basel +if out["n_repos"] != 3: failures.append( f"expected 3 repos, got {out['n_repos']}" ) +if out.get( "n_contaminated_baseline" ) != 1: failures.append( f"expected exactly 1 contaminated baseline run co +if not ( out["resolved_delta_mean"] > 0 ): failures.append( "expected a positive resolved-rate delta" ) +if not ( out["resolved_delta_bootstrap_95_lower"] > 0 ): failures.append( "expected a POSITIVE bootstrap 95% low +if out["tokens_out_ratio_p50"] is None or abs( out["tokens_out_ratio_p50"] - 0.08 ) > 1e-6: failures.append( f"e +if out.get( "n_resolved_pairs" ) != 27: failures.append( f"expected all 27 pairs resolution-scored, got {out.get +if out.get( "substitution_rate_baseline" ) != 0.0: failures.append( f"expected baseline substitution rate 0.0 (n +if out.get( "substitution_rate_ripwire" ) is None or abs( out["substitution_rate_ripwire"] - 0.75 ) > 1e-9: fail +if out.get( "n_substitution_ripwire" ) != 27: failures.append( f"expected 27 substitution-scored ripwire runs, g +if out3.get( "substitution_rate_ripwire" ) is not None or out3.get( "n_substitution_ripwire" ) != 0: failures.ap +if out2["n_pairs"] != 27: failures.append( f"evaluator-none: expected 27 pairs, got {out2['n_pairs']}" ) +if out2["n_resolved_pairs"] != 0: failures.append( f"evaluator-none: expected 0 resolution-scored pairs, got {ou +if out2["resolved_delta_mean"] is not None or out2["resolved_delta_bootstrap_95_lower"] is not None: failures.ap +if out2["tokens_out_ratio_p50"] is None or abs( out2["tokens_out_ratio_p50"] - 0.08 ) > 1e-6: failures.append( " +… [73 more display lines; full output is 16840 bytes on 1 raw line(s)] +````` + +## `./build/ripwire . --query="teleport pagerank" --top-k=5` + +*Raw BM25 ranking (debug lens; --for is the real verb).* + +````` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +… [24 more display lines; full output is 3484 bytes on 1 raw line(s)] +````` + + +--- + +# zoom the detail ladder + +## `./build/ripwire . --for="pagerank power iteration" --detail=2` + +*Importance-weighted detail: FULL bodies for top-2, signatures for the rest.* + +**wall time: 1.01s** + +````` + + + + + +NodeId order. Renumbering an otherwise-identical graph (the exact "same probe file, different so…inline constexpr float kScoreTieAbsEps = 1e-6f + +Render one form of the disclosure. Empty string whenever there is nothing to say — no power it…inline std::string renderDisclosure( const RankDisclosure& d, DiscloseAs as ) + +The PageRank power iteration itself — the numeric kernel every ranked document's order comes f…PageRankRun pageRankDouble( const sparseCsr<float>& inEdges, std::span<const double> weightedOutDegree, std::span<const double> teleport, std::span<double> r … [line truncated: 10 more bytes on this line] + +What a ranked document discloses about the power iteration that ordered it. `isPageRank == false…struct RankDisclosure +inline std::string rankByText( const std::string& root, std::string_view mode, int topK, bool stable = false ) +struct RankedGraph +inline constexpr const char* kChurnRankLegend = "<!-- rank_by=churn: k= is PageRank re-run with the teleport BIASED by git CHANGE-FREQUENCY over window= " "(a c… +inline std::uint32_t sliceRdIterationCeiling() noexcept +struct PageRankRun +inline std::uint32_t navRelevanceWeight( const Graph& g, NodeId n ) noexcept +inline RankedGraph rankGraphTeleport( const Graph& g, const std::vector<float>& p, float alpha = 0.85f ) +HOT_FILES = ( "src/pagerank.cpp", # the power-iteration loop — G2's no-allocation scope "src/infra/radixSort.h", # LSD radix entry points "src/infra/radixSort… +inline constexpr std::string_view kPowerShellPathPrependScope = "in PowerShell, for this window +inline std::string powerShellPathPrependHint( std::string_view programDir ) noexcept +inline std::string powerShellSingleQuote( std::string_view s ) noexcept +inline std::string path_prepend_hint( std::string_view dir ) +handle_t leftmost_leaf() const +COLD_FILES = ( # ── ingest sections that are not on the per-file / per-symbol default path ──────────────────── ( "src/ingest_astquery.h", "the --match / - … [line truncated: 48 more bytes on this line] +### Wave-2 adversarial verification (2026-08-19) — six probes against `aa97c9e` +### Same answer, a fraction of the tokens — read this table first if your agent is on a budget +### Anchor-only auto bodies — T3 substitution round, PRE-REGISTERED 2026-08-22 (before any fix code)< … [line truncated: 3 more bytes on this line] +## Background — read these, in this order +… [106 more display lines; full output is 14345 bytes on 72 raw line(s)] +````` + +## `./build/ripwire . --pack-signatures --top-k=10` + +*Body-elided decl skeletons — recounted on this corpus. Measured as element bytes: the signature+doc elements --pack-signatures emits, against the SAME symbols' full bodies from --expand, with the CORPUS-ROOT PREFIX SUBTRACTED FROM BOTH SIDES. That subtraction is the whole methodology and the figure is meaningless without it: the root repeats inside every element's id= and p=, it is not what this verb elides, and counting it makes the headline a function of how deep the checkout happens to sit on disk — on one corpus, three spellings of the same root read 18.6 points apart before the subtraction and agree exactly after it. Root-neutralised on THIS repo: 92.7% fewer bytes at top-10, 88.7% at top-50, 87.6% at top-100 (re-derived 2026-09-20 when the native Windows port landed: src/infra/os_win32.cpp and os_win32_logic.h are ~3,900 lines of new, heavily commented infra that the ranked top-10/50/100 now reach, and their bodies are this ratio's DENOMINATOR, so the figure rises without --pack-signatures eliding anything new, from top-50 87.1. Measured by test/showcasecapturecheck.sh's own recount arm on the combined tree, which is the gate that would otherwise report the drift; before that, re-derived 2026-09-17 at the self-check macro vocabulary rename: VERIFY/VERIFY_TEXT/VERIFY_DEBUG_ONLY/VERIFY_NOT_REACHED/VERIFY_SAME_THREAD/VERIFY_NO_ALIAS*/DYNMAP_VERIFY/DEGRADED_PATH_ALERT renamed to ASSUME/EXPECTS/ENSURES/DASSERT/UNREACHABLE/ASSUME_SAME_THREAD*/ASSUME_NO_ALIAS*/DYNMAP_ASSUME/DISCLOSE (plus new VALIDATE sites) across 839 identifier renames in 179 files, and a hand-written Diagnostics.h/diagnostics.cpp replacing the old ones outright: this moves both WHICH symbols the ranked top-10/50/100 hold and how large their bodies are, from top-50 85.2. A real re-derivation of a corpus that changed, not a tolerance edit; previously re-derived 2026-09-12 when the rows dropped the path-repeating id= for the short sc= scope: the signature side shrank, the bodies did not; before that, 89.5% / 81.8% / 84.3% re-derived 2026-09-10 at the sibs= cap raise: kMaxExpandSibs went 8 -> 100, so --expand's bodies now carry the file context the old cap hid — 89.3% of all sibling names — and the body side is this ratio's DENOMINATOR, so the figure rises without --pack-signatures eliding anything new. Measured on a fixed tree with the top-50 membership and the signature side unchanged: top-50 from 71.0. A real re-derivation of a corpus that changed, not a tolerance edit; previously re-derived 2026-09-09 at the printf-family -> std::print conversion: converting ~1,500 emitter call sites to rw::emitTo/emitRaw/formatTo across 93 files changes how large the ranked symbols' BODIES are, and the body side is this ratio's denominator — top-50 from 72.3. A real re-derivation of a corpus that changed, not a tolerance edit; previously re-derived 2026-09-08 at the confident-zero round, issues #62/#63/#66: that change adds symbols to src/graphlegend.h and the new src/preprocdead.h and re-homes two long comment blocks from call sites onto the helpers they explain, which moves both WHICH symbols the ranked top-50 holds and how large their bodies are — top-50 from 74.0. A real re-derivation of a corpus that changed, not a tolerance edit; previously re-derived 2026-09-06 at the stranger-audit fix round: the doctor, cache-sweep and html-provenance bodies grew this corpus's BODY side, moving top-50 from 75.6 — a real re-derivation, not a tolerance edit; before that, re-derived 2026-09-05 at the capture-audit close: lane L7's P16 caps --expand's sibs= at 8 names, which SHRINKS the body side of this ratio and moved the figure down from 84.5/80.2/80.6 — the V1 2026-08-15 re-center, when sibs=/inc= first grew the body side from 70.0/61.0/63.8, in reverse; both were real re-derivations, not tolerance edits). top-50 is the number to quote, because the sigs payload is top-50 regardless of --top-k and is therefore what THIS command emits. A single small/trivial body can still invert it (signature+doc bigger than the body), like the --format=columnar sibling below. test/showcasecapturecheck.sh (C) re-derives all three from this repo every run, in the same quantity, and fails if the caption and the recount drift apart.* + +````` + + + + + + + + + + + + + + + + + + + + + + + + + + + + +… [132 more display lines; full output is 11518 bytes on 1 raw line(s)] +````` + +## `./build/ripwire . --outline=rankGraphTeleport --top-k=0` + +*Control-flow skeleton of one symbol, payload-only via the new --top-k=0.* + +````` + + + + +& p, float alpha = 0.85f ) +{ + PROFILE_SCOPE_DESCRIBE( "rankGraph: PageRank (power iteration)" ); + const std::vector pw = biasPrior( g, p ); + const std::size_t N = pw.size(); + std::vector teleport( pw.begin(), pw.end() ); + std::vector rankDouble( N, 0.0 ); + PageRankRun run{}; // an N == 0 graph never enters the kernel: { 0, converged } — see PageRankRun + if( N ) + { + ... + } + std::vector r( N, 0.f ); + std::transform( rankDouble.begin(), rankDouble.end(), r.begin(), []( double value ) { return float( value ); } ); + return { std::move( r ), run.iterationCount, run.hasConverged }; +} +]]> +````` + +## `./build/ripwire . --outline=rankGraphTeleport:1-10 --top-k=0` + +*CHANGED: a line range on --outline is now STRIPPED with a stderr note (it used to refuse).* + +````` + + + + +& p, float alpha = 0.85f ) +{ + PROFILE_SCOPE_DESCRIBE( "rankGraph: PageRank (power iteration)" ); + const std::vector pw = biasPrior( g, p ); + const std::size_t N = pw.size(); + std::vector teleport( pw.begin(), pw.end() ); + std::vector rankDouble( N, 0.0 ); + PageRankRun run{}; // an N == 0 graph never enters the kernel: { 0, converged } — see PageRankRun + if( N ) + { + ... + } + std::vector r( N, 0.f ); + std::transform( rankDouble.begin(), rankDouble.end(), r.begin(), []( double value ) { return float( value ); } ); + return { std::move( r ), run.iterationCount, run.hasConverged }; +} +]]> +````` + +stderr: + +````` +ripwire: --outline=rankGraphTeleport:1-10: --outline has no line-range form — outlining the whole symbol (use --expand=rankGraphTeleport:1-10 for a body slice) +````` + +## `./build/ripwire . --expand=rankGraphTeleport --top-k=0` + +*Full body + inline callee signatures.* + +````` + + + + +, with a BRANCH-FREE size(). Both, not one or the other. +// +// ── THE DESIGN, AND WHY IT IS THIS ONE ─────────────────────────────────────────────────────────────── +// The shape the host tree leans on hardest is `Map>` — many tiny id-lists (byName / +// canonByName / shard maps, and ~100 more structures after the conversion wave): WRITE-ONCE during the +// parse/merge, then READ-HOT during resolve. +// +// Three things matter for that shape, and the layout below gets all three: +// • no per-list malloc — the N small lists that would each allocate are inline; +// • a BRANCH-FREE size() — `return sz_`, because the size lives in its own field; +// • 16 BYTES per instance — because `inl_` and `heap_` are never both live, so they share storage. +// +// The union is the whole trick. The previous revision of this file paid 8 extra bytes (24 B) for the +// explicit size field, on the reasoning that a branch-free size() was worth it. That was a false +// choice: union the inline array with the heap pointer and the struct reaches 16 B — ankerl's size — +// while the size stays in its own field and size() stays branch-free. +// +// THE TRADE THAT REMAINS, stated honestly. At 16 bytes you can have: +// • ankerl::svector's 3 inline slots, with a size() that branches on is_direct() (and, once spilled, +// dereferences into the heap block to read the size — a dependent load, not just a branch); or +// • this type's 2 inline slots, with size() branch-free. +// The measurement chose the second. See bench/SVECTORAB.md; the short version is below. +// +… [1014 more display lines; full output is 66264 bytes on 1040 raw line(s)] +````` + +stderr: + +````` +ripwire: --pack-top-n is deprecated — use --pack-task/--detail instead (unchanged behavior for now) +````` + + +--- + +# assess quality / structure + +## `./build/ripwire . --metrics --top-k=10` + +*Fan-in/out + complexity annotations on the map.* + +````` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +… [19 more display lines; full output is 5624 bytes on 1 raw line(s)] +````` + +## `./build/ripwire . --deps` + +*File->file dependency graph (god-files, cycles).* + +````` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +… [894 more display lines; full output is 23411 bytes on 1 raw line(s)] +````` + +## `./build/ripwire . --hotspots` + +*Complexity x recent git churn (maintenance pain).* + +**wall time: 1.30s** + +````` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +… [14 more display lines; full output is 5749 bytes on 1 raw line(s)] +````` + +## `./build/ripwire . --clones` + +*Token-normalized duplicate bodies.* + +**wall time: 2.03s** + +````` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +… [306 more display lines; full output is 16116 bytes on 1 raw line(s)] +````` + +## `./build/ripwire . --cochange` + +*Files that change together in git (hidden coupling).* + +**wall time: 1.34s** + +````` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +````` + +## `./build/ripwire . --hotspots --since="2 weeks ago"` + +*Hotspots scoped to RECENT churn (the regression lens).* + +**wall time: 1.12s** + +````` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +… [13 more display lines; full output is 5104 bytes on 1 raw line(s)] +````` + +## `./build/ripwire . --arch=test/archfix/rules.txt` + +*Enforce layering rules (exit 2 on violation) — run against the repo's own test fixture rules.* + +````` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +… [640 more display lines; full output is 93738 bytes on 1 raw line(s)] +````` + +## `./build/ripwire . --lint` + +*Built-in AST checks (c-cast, goto, unsafe-c-fn, ...).* + +**wall time: 1.94s** + +````` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +… [697 more display lines; full output is 69958 bytes on 1 raw line(s)] +````` + +## `./build/ripwire . --lint-rules=test/lintrulesfix/rules` + +*User lint rules (YAML, ast-grep style) from a directory.* + +````` + + + + +use LOG() instead of printf +use LOG() instead of printf +use LOG() instead of printf +use LOG() instead of printf +use LOG() instead of printf + +````` + +stderr: + +````` +ripwire: lint-rules: test/lintrulesfix/rules/malformed.yaml:5: expected 'key: value' — file skipped +[math degraded] lint-rules: malformed rule file skipped (lintrules.h:395, auto rw::parseLintRuleFile(const std::string &, std::string_view, std::vector &)::(anonymous class)::operator()(std::size_t, const char *) const — logged once per site) +ripwire: AST query did not compile for any grammar: (this_is_not_a_real_node @x @@@ ((( ) +````` + +## `./build/ripwire . --lint --with-profile=report.txt` + +*Join MEASURED heat onto --lint findings — runs in a tiny fabricated demo corpus (one cache-pointer-chase-loop finding under a PROFILE_SCOPE site) because a real report needs a RIPWIRE_PROFILE build; the finding inside the profiled scope gains heat_* columns from the report's #PROF_TSV row.* + +Input file: + +````` +#PROF_TSV_BEGIN one row per scope, aggregated across threads; counters are RAW integers +scope file line calls total_ms l1d_mpki +walk: chase pass x.cpp 9 12 48.500 7.250 +#PROF_TSV_END +````` + +````` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +… [13 more display lines; full output is 3428 bytes on 1 raw line(s)] +````` + +The joined finding — past the display cut above, extracted so the join is visible: + +````` +p = p->next +````` + +## `./build/ripwire . --communities` + +*Cluster the call graph into cohesive modules.* + +````` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +… [186 more display lines; full output is 16645 bytes on 1 raw line(s)] +````` + +## `./build/ripwire . --zoom` + +*Nested module hierarchy (multi-level Louvain) + cross-module bridges — levels_shown="2" of levels= BY DEFAULT over the 40 largest top modules (~8 KB, where the whole tree is ~220 KB); a module AT the cut carries children=.* + +````` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +… [169 more display lines; full output is 8620 bytes on 1 raw line(s)] +````` + +## `./build/ripwire . --zoom --zoom-levels=3` + +*The restoring knob: --zoom-levels=N prints N levels (0 = the whole tree); here three, ~11 KB.* + +````` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +… [689 more display lines; full output is 27707 bytes on 1 raw line(s)] +````` + +## `./build/ripwire . --report` + +*Architecture summary (modules, god-files, cycles) as markdown.* + +````` + + +# ripwire architecture report + +2688 files · 25558 symbols · 40392 edges · 2494 modules (11469 call-graph isolated) + +Root: `.` + +Call-graph isolate provenance: 3087 declaration, 1678 header, 2119 source, 4585 document; 0 connected Louvain singletons + +## Modules (call-graph clusters; showing 12 of 2494) +- **src::emitTo@infra/emit.h:53:2760 [write,run,emit]** — 1685 symbols +- **src::append@elixir_resolve.h:111:4875 [add,resolve,compute]** — 1021 symbols +- **src::emplace@infra/svector.h:408:22477 [resolve,scan,find]** — 605 symbols +- **src::kindIs@infra/nodekind.h:46:3271 [push,emit,collect]** — 360 symbols +- **src::pageWindow@pageview.h:129:10574 [run,emit,count]** — 179 symbols +- **docs::`--top-k=N`@COMMANDS.md:59:8861 [pack,allow,insert]** — 177 symbols +- **src::relForHash@model.h:1632:133351 [clone,compute,scan]** — 57 symbols +- **src/infra::DYNMAP_ASSUME@dynamic_map.hpp:90:4411 [erase,find,insert]** — 49 symbols +- **src::rowOffsets@infra/sparseCsr.h:248:8990 [connect,compute,verify]** — 39 symbols +- **src/infra::fail@os_win32.cpp:63:2553 [open,handle,fill]** — 39 symbols +- **src/infra::active@profilePmc.h:898:37172 [ensure,read,run]** — 38 symbols +- **test::no@receiverevidencecheck.sh:159:14987 [expand,map,connect]** — 36 symbols + +## God files (most depended-on; showing 10 of 476) +- `test/lib/clean-env.sh` — 185 dependents +- `src/model.h` — 88 dependents +- `src/infra/emit.h` — 86 dependents +- `src/infra/Diagnostics.h` — 60 dependents +- `src/serialize.h` — 40 dependents +… [33 more lines, 3644 bytes total] +````` + +## `./build/ripwire . --seams` + +*Cross-module call seams no test reaches. NOW carries seam_pairs/shown/capped.* + +````` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +… [92 more display lines; full output is 12415 bytes on 1 raw line(s)] +````` + +## `./build/ripwire . --mermaid` + +*Module (directory) dependency graph as a Mermaid diagram.* + +````` +%% ripwire --mermaid: module (directory) dependency graph — node = dir (symbol count), edge = inter-module calls (>= 3). Render at mermaid.live. +flowchart LR + subgraph sg0 ["src"] + n101["src
7731"] + n102["src/infra
1280"] + end + subgraph sg1 ["test"] + n103["test
5057"] + n333["test/fixtures/recallpassage
314"] + n280["test/expandmodefix
151"] + n282["test/expandsibsfix
149"] + n535["test/rubyattrsfix
85"] + n411["test/massfix
77"] + n492["test/receiverevidencefix/js/lib
74"] + n376["test/javamethodreffix
62"] + end + n73["docs
833"] + n2[".github
604"] + subgraph sg4 ["bench"] + n4["bench
545"] + n5["bench/agentloop
331"] + n49["bench/locbench/results/r5_pooling
237"] + n50["bench/locbench/results/r6_expansion
187"] + n59["bench/recalleval
113"] + n43["bench/locbench
107"] + n38["bench/headtohead/r3-headroom-2026-08-03
102"] + n65["bench/skillrater/results/2026-09-07/arms
100"] + n62["bench/shotgun
96"] + n45["bench/locbench/results/r1cpp_anchorhop
94"] + n11["bench/arb
85"] +… [21 more lines, 1622 bytes total] +````` + +## `./build/ripwire . --owners` + +*Bus-factor: recency-weighted author ownership per file.* + +**wall time: 1.31s** + +````` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +… [913 more display lines; full output is 100493 bytes on 1 raw line(s)] +````` + +## `./build/ripwire . --dead-code=src` + +*Internal functions with no caller found in the index — a name-based graph reading, not a confidence score. NOTE the filter is a path-COMPONENT match: 'src' matches any .../src/... segment; use ./src to pin the root directory.* + +````` + + + + +````` + +## `./build/ripwire . --exercises=test/regression.sh` + +*Which symbols a TEST FILE exercises — the reverse direction of --affected.* + +````` + + + +````` + +## `./build/ripwire . --community=0` + +*Drill into ONE call-graph community by id — the drill= the --communities output itself advertises.* + +````` + + + +````` + +## `./build/ripwire . --quality-delta` + +*On a CLEAN tree: nothing got worse, exit 0. The gating shape is in the sandbox section below.* + +**wall time: 15.43s** + +````` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +… [118 more display lines; full output is 14594 bytes on 1 raw line(s)] +````` + +stderr: + +````` +ripwire: no ./.ripwire_quality_baseline — auto-comparing the working tree vs git HEAD (commit the baseline with --quality-baseline to pin it) +````` + +## `./build/ripwire . --edit-check=rankGraphTeleport` + +*Fast per-symbol post-edit contract check vs git HEAD (unchanged on a clean tree).* + +**wall time: 8.54s** + +````` + + + + + + + + + +````` + +## `./build/ripwire . --pr-context` + +*No-LLM review-evidence bundle for the working-tree diff (clean tree = empty).* + +````` + + + +````` + +## `./build/ripwire . --pr-context=HEAD~1` + +*The BASEREF form: diffed against merge-base(BASEREF, HEAD), never the ref tip — here the previous commit on the current line (a ref with NO merge base falls back to a disclosed two-dot diff: anchor="ref-tip-two-dot").* + +**wall time: 2.12s** + +````` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +… [290 more display lines; full output is 19612 bytes on 1 raw line(s)] +````` + +## `./build/ripwire . --merge-scout=HEAD~2,HEAD~1` + +*Pairwise cross-arm conflict sites + suggested landing order (any committish sharing a merge base with HEAD works as an arm; one that does not is reported ok="0", never compared).* + +**wall time: 10.66s** + +````` + + + + + + + + + + + +````` + +## `./build/ripwire . --stray-content=lane/` + +*Which refs of the `lane/` ref family still hold divergent authored work vs HEAD, with verdicts.* + +**wall time: 3.21s** + +````` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +… [186 more display lines; full output is 25429 bytes on 1 raw line(s)] +````` + +## `./build/ripwire . --stray-content=arm/for-howitworks-067-f2add` + +*A second selection, the one branch `arm/for-howitworks-067-f2add` (no ref family left to select on this checkout), picked at capture time from the refs this checkout really has and never the selection above: merged refs are OMITTED from the rows and counted in merged=; refs sharing no merge base with HEAD (a shallow clone, or a pre-rewrite history) land in unknown= with ok="0" — the counters always reconcile against refs=.* + +**wall time: 1.11s** + +````` + + + + + + + + + + + + + + + + + + +````` + +## `./build/ripwire . --stray-content=lane/ --plan` + +*Select the genuinely-unmerged refs of the `lane/` ref family and feed them to merge-scout for a landing order (a fully merged selection yields an empty landing set — still a measurement, disclosed on the root).* + +**wall time: 60.73s** + +````` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +… [39161 more display lines; full output is 5744746 bytes on 1 raw line(s)] +````` + +## `./build/ripwire . --stray-content=zzzz-no-such-ref --plan` + +*A --plan filter that selects NO ref REFUSES (exit 1) naming the substring — before the wave-3 close this fell through to the '>512 refs match' sentence, and --abi under the same filter answered an empty measurement at exit 0.* + +**exit code: 1** + +````` +(empty) +````` + +stderr: + +````` +ripwire: --plan: --stray-content=zzzz-no-such-ref matches no local ref — a zero here would be a failure, not a measurement + (the filter is a substring match against refs/heads names; run bare --stray-content to list them, e.g. --stray-content=feat/) +````` + +## `./build/ripwire . --stray-content=lane/ --abi` + +*Cross-branch ABI-break gate over the `lane/` ref family: struct byte-contract drift on each ref's AUTHORED paths — exit 2 when any drift row is found (the only kind that gates), 0 when the compared refs are clean, and exit 1 if the --stray-content filter matches no ref at all.* + +**wall time: 15.96s** + +````` + + + + + + + + + + + + + + + +````` + +## `./build/ripwire . --whereis=rankGraphTeleport` + +*Which ref's tree defines or mentions SYM — HEAD first, then every local branch. The default lists the kind="def" rows (here the first 60, most on branches, each carrying tip= and date=) and counts the references in : on this symbol HEAD's references would fill the 60-row cap before the branch definitions arrive, so the whole list (below) shows fewer definitions, and the default serves the page that shows more of them, whatever its bytes.* + +**wall time: 12.33s** + +````` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +… [35 more display lines; full output is 18446 bytes on 1 raw line(s)] +````` + +## `./build/ripwire . --whereis=rankGraphTeleport --whereis-listing=all` + +*The whole hit list asked for by name: under the same 60-row cap HEAD's references fill the list, so it shows far fewer definitions (here HEAD's one) than the default above (here 60). The default serves the all page only when it shows the same definitions as the defs page in strictly fewer bytes (a tie serves it too).* + +**wall time: 11.28s** + +````` + + + + + + + + + + + + + + + + + + + + + + + + + + + + +… [34 more display lines; full output is 13695 bytes on 1 raw line(s)] +````` + +## `./build/ripwire . --whereis=computeOnePairOverlap --with-history` + +*Same, plus a git-history row (never / removed-by-commit) for names no tree carries.* + +**wall time: 16.83s** + +````` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +… [36 more display lines; full output is 15183 bytes on 1 raw line(s)] +````` + +## `./build/ripwire . --flags` + +*The dark-content dashboard: gates BUILT but OFF. CHANGED: no longer invents gates from comments/heredocs, so the count only reflects real ifndef/define, CMake option(), and getenv gates.* + +**wall time: 1.01s** + +````` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +… [374 more display lines; full output is 23065 bytes on 1 raw line(s)] +````` + +## `./build/ripwire . --flags --flip=RIPWIRE_ASAN` + +*Blast radius of turning ONE gate on: live code, symbols, transitive reach, covering tests.* + +````` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +````` + +## `./build/ripwire . --flags --flip=RIPWIRE_ASA` + +*Unknown-gate refusal (exit 1) with a did-you-mean from a real edit distance (one character off RIPWIRE_ASAN).* + +**exit code: 1** + +````` +(empty) +````` + +stderr: + +````` +ripwire: --flip: no gate named 'RIPWIRE_ASA' in . (did you mean 'RIPWIRE_ASAN'?) +ripwire: run `ripwire . --flags` for the gate table +````` + +## `./build/ripwire . --plan-lanes=3 --task="add a --since filter to the doc-drift verb and cover it with tests"` + +*NEW VERB: pre-hoc lane plan — which of 3 parallel worktrees would COLLIDE, before a line is written. JSON on stdout.* + +**wall time: 1.50s** + +````` +{"v":1,"verb":"plan-lanes","at":"f1e5c2e76","root":".","task":"add a --since filter to the doc-drift verb and cover it with tests","source":"partition","requested":3,"lane_count":3,"claim_key":"path+scope+name","on_conflict":"producing-lane-rebases","corpus":{"files":2688,"symbols":25558,"edges":403 … [line truncated: 339 more bytes on this line] +"symbols":[{"p":"./src/docdrift.h","n":"DriftResult","scope":"DriftResult","l":286,"id":"./src/docdrift.h::DriftResult::DriftResult"}, +{"p":"./src/docdrift.h","n":"computeDocDrift","scope":"docdrift","l":2441,"id":"./src/docdrift.h::docdrift::computeDocDrift"}, +{"p":"./src/docdrift.h","n":"kDocDriftLegend","scope":"docdrift","l":2739,"id":"./src/docdrift.h::docdrift::kDocDriftLegend"}, +{"p":"./src/docdrift.h","n":"writeDocDriftPage","scope":"docdrift","l":2909,"id":"./src/docdrift.h::docdrift::writeDocDriftPage"}, +{"p":"./src/mcpverbs.h","n":"docDriftText","scope":"rw","l":644,"id":"./src/mcpverbs.h::rw::docDriftText"}, +{"p":"./src/verbs_change.h","n":"runDocDrift","scope":"","l":1642,"id":null}]},"lanes":[{"id":"lane-0","task":"add a --since filter to the doc-drift verb and cover it with tests","claims":{"symbols":[{"p":"./src/verbs_change.h","n":"runAbiCheck","scope":"","key":"183da9aaf74a4a92","id":null,"id_addr … [line truncated: 113 more bytes on this line] +{"p":"./src/gitmine.h","n":"gitWindowBoundarySha","scope":"rw","key":"18627516699d7062","id":"./src/gitmine.h::rw::gitWindowBoundarySha","id_addressable":true,"id_collides_with":0,"l":1614,"ord":0,"overloads":1,"amb":3,"cx":3,"ccx":2,"churn":60,"tested":0}, +{"p":"./src/docdrift.h","n":"writeGateability","scope":"docdrift","key":"42c456dfb55faee4","id":"./src/docdrift.h::docdrift::writeGateability","id_addressable":true,"id_collides_with":0,"l":2685,"ord":0,"overloads":1,"amb":3,"cx":6,"ccx":6,"churn":36,"tested":0}, +{"p":"./src/darkflags.h","n":"writeFlags","scope":"darkflags","key":"5c2c4a2b311b8dba","id":"./src/darkflags.h::darkflags::writeFlags","id_addressable":true,"id_collides_with":0,"l":1298,"ord":0,"overloads":1,"amb":2,"cx":8,"ccx":8,"churn":32,"tested":0}, +{"p":"./src/docdrift.h","n":"recordWeakDisclosures","scope":"docdrift","key":"972779210e49c0cf","id":"./src/docdrift.h::docdrift::recordWeakDisclosures","id_addressable":true,"id_collides_with":0,"l":2430,"ord":0,"overloads":1,"amb":2,"cx":2,"ccx":1,"churn":36,"tested":0}, +{"p":"./src/testmap.h","n":"scriptGatesUnmodelledCount","scope":"rw","key":"98c4487e2e9e08bc","id":"./src/testmap.h::rw::scriptGatesUnmodelledCount","id_addressable":true,"id_collides_with":0,"l":1344,"ord":0,"overloads":1,"amb":0,"cx":4,"ccx":4,"churn":48,"tested":0}, +{"p":"./src/main.cpp","n":"scanReportVerbPrecedence","scope":"","key":"9ab981e987e8108e","id":null,"id_addressable":false,"id_collides_with":0,"l":2867,"ord":0,"overloads":1,"amb":33,"cx":8,"ccx":11,"churn":424,"tested":0}, +{"p":"./src/gitmine.h","n":"resolveSinceScope","scope":"rw","key":"9caaa3dbeaa688d0","id":"./src/gitmine.h::rw::resolveSinceScope","id_addressable":true,"id_collides_with":0,"l":326,"ord":0,"overloads":1,"amb":3,"cx":7,"ccx":7,"churn":60,"tested":0}, +{"p":"./src/main.cpp","n":"emitNoteAddUnprovenDefs","scope":"","key":"a950332ccfce6b8d","id":null,"id_addressable":false,"id_collides_with":0,"l":688,"ord":0,"overloads":1,"amb":1,"cx":2,"ccx":1,"churn":424,"tested":0}, +{"p":"./src/taskroute.h","n":"testCoverageTaskChoice","scope":"rw::taskroute","key":"bf72991aa28355c7","id":"./src/taskroute.h::rw::taskroute::testCoverageTaskChoice","id_addressable":true,"id_collides_with":0,"l":1392,"ord":0,"overloads":1,"amb":1,"cx":3,"ccx":2,"churn":39,"tested":0}, +{"p":"./src/mcpverbs.h","n":"unknownSubVerbRefusal","scope":"rw","key":"e336d39b0a6addea","id":"./src/mcpverbs.h::rw::unknownSubVerbRefusal","id_addressable":true,"id_collides_with":0,"l":5390,"ord":0,"overloads":1,"amb":1,"cx":3,"ccx":2,"churn":340,"tested":0}, +{"p":"./src/cli.h","n":"validateModifierGuards","scope":"rw","key":"ec2848a4801493a2","id":"./src/cli.h::rw::validateModifierGuards","id_addressable":true,"id_collides_with":0,"l":4571,"ord":0,"overloads":1,"amb":33,"cx":73,"ccx":66,"churn":450,"tested":0}], +"files":[{"p":"./src/cli.h","symbols":1,"churn":450,"ccx":66,"hotspot_rank":6}, +{"p":"./src/darkflags.h","symbols":1,"churn":32,"ccx":8,"hotspot_rank":45}, +{"p":"./src/docdrift.h","symbols":2,"churn":36,"ccx":7,"hotspot_rank":31}, +{"p":"./src/gitmine.h","symbols":2,"churn":60,"ccx":9,"hotspot_rank":20}, +{"p":"./src/main.cpp","symbols":2,"churn":424,"ccx":12,"hotspot_rank":4}, +{"p":"./src/mcpverbs.h","symbols":1,"churn":340,"ccx":2,"hotspot_rank":5}, +{"p":"./src/taskroute.h","symbols":1,"churn":39,"ccx":2,"hotspot_rank":38}, +{"p":"./src/testmap.h","symbols":1,"churn":48,"ccx":4,"hotspot_rank":36}, +{"p":"./src/verbs_change.h","symbols":1,"churn":70,"ccx":8,"hotspot_rank":23}]},"blast_radius":{"reaches":47,"files_total":17,"capped":false,"files":["./src/cli.h","./src/docdrift.h","./src/editplan.h","./src/gitmine.h","./src/main.cpp","./src/mcp.h","./src/mcpedit.h","./src/mcpserver.h","./src/mcpv … [line truncated: 194 more bytes on this line] +"tests_total":0,"tests_capped":false,"tests_granularity":"claimed-symbols","untested":47,"module_span":1,"notes":[], +"execution":{"policy":"codex-lane/v1","model":"gpt-5.6-sol","reasoning":"xhigh","rule":"wide-contract-work","basis":"structural-only","signals":{"claims":12,"files":9,"module_span":1,"max_ccx":66,"sum_ccx":118,"ambiguous_calls":85,"blast_reaches":47,"contract_touches":1,"conflicts":0,"untested":47," … [line truncated: 140 more bytes on this line] +{"id":"lane-1","task":"add a --since filter to the doc-drift verb and cover it with tests","claims":{"symbols":[{"p":"./src/main.cpp","n":"kJsonRideAlongFlags","scope":"","key":"2a90806fa5a580c1","id":null,"id_addressable":false,"id_collides_with":0,"l":3079,"ord":0,"overloads":1,"amb":0,"cx":0,"ccx … [line truncated: 28 more bytes on this line] +… [78 more display lines; full output is 21430 bytes on 1 raw line(s)] +````` + +## `./build/ripwire . --plan-lanes --brief=/aux/lanes_brief.txt` + +*NEW VERB, explicit form: one line per lane, lane boundaries are the ones you wrote (the defensible mode).* + +**wall time: 1.55s** + +Input file: + +````` +add a --since filter to the doc-drift verb +add the CLI parse arm and help text for the new filter +write regression tests for the new filter +````` + +````` +{"v":1,"verb":"plan-lanes","at":"f1e5c2e76","root":".","task":null,"source":"brief","requested":3,"lane_count":3,"claim_key":"path+scope+name","on_conflict":"producing-lane-rebases","corpus":{"files":2688,"symbols":25558,"edges":40392,"ambiguous":12495,"unresolved":12552},"carve":null,"core":{"files … [line truncated: 5 more bytes on this line] +"symbols":[]},"lanes":[{"id":"lane-0","task":"add a --since filter to the doc-drift verb","claims":{"symbols":[{"p":"./src/docdrift.h","n":"recordUnchecked","scope":"docdrift","key":"12641dab14abc8fd","id":"./src/docdrift.h::docdrift::recordUnchecked","id_addressable":true,"id_collides_with":0,"l":2 … [line truncated: 72 more bytes on this line] +{"p":"./src/docdrift.h","n":"sortWeakGroupsByPath","scope":"docdrift","key":"1944ba506280ff80","id":"./src/docdrift.h::docdrift::sortWeakGroupsByPath","id_addressable":true,"id_collides_with":0,"l":2401,"ord":0,"overloads":1,"amb":0,"cx":1,"ccx":0,"churn":36,"tested":0}, +{"p":"./src/mcpverbs.h","n":"docDriftText","scope":"rw","key":"1fa68e8d93c05a59","id":"./src/mcpverbs.h::rw::docDriftText","id_addressable":true,"id_collides_with":0,"l":644,"ord":0,"overloads":1,"amb":0,"cx":1,"ccx":0,"churn":340,"tested":0}, +{"p":"./src/cli.h","n":"kViewFlags","scope":"rw","key":"2a2f2487082cc6d8","id":"./src/cli.h::rw::kViewFlags","id_addressable":true,"id_collides_with":0,"l":3225,"ord":0,"overloads":1,"amb":0,"cx":0,"ccx":0,"churn":450,"tested":0}, +{"p":"./src/recall.h","n":"docFileMask","scope":"rw","key":"3149a219f599664c","id":"./src/recall.h::rw::docFileMask","id_addressable":true,"id_collides_with":0,"l":110,"ord":0,"overloads":1,"amb":0,"cx":4,"ccx":4,"churn":27,"tested":0}, +{"p":"./src/docdrift.h","n":"writeDocDriftPage","scope":"docdrift","key":"380b7de5df1cfd73","id":"./src/docdrift.h::docdrift::writeDocDriftPage","id_addressable":true,"id_collides_with":0,"l":2909,"ord":0,"overloads":1,"amb":12,"cx":14,"ccx":15,"churn":36,"tested":0}, +{"p":"./src/docdrift.h","n":"computeDocDrift","scope":"docdrift","key":"3b19cc3d8996c3b2","id":"./src/docdrift.h::docdrift::computeDocDrift","id_addressable":true,"id_collides_with":0,"l":2441,"ord":0,"overloads":1,"amb":13,"cx":26,"ccx":38,"churn":36,"tested":0}, +{"p":"./src/docdrift.h","n":"DriftResult","scope":"DriftResult","key":"422ab39546b6e0bd","id":"./src/docdrift.h::DriftResult::DriftResult","id_addressable":true,"id_collides_with":0,"l":286,"ord":0,"overloads":1,"amb":0,"cx":0,"ccx":0,"churn":36,"tested":0}, +{"p":"./src/verbs_change.h","n":"runDocDrift","scope":"","key":"904831e5c0f66b39","id":null,"id_addressable":false,"id_collides_with":0,"l":1642,"ord":0,"overloads":1,"amb":1,"cx":5,"ccx":4,"churn":70,"tested":0}, +{"p":"./src/ensemble.h","n":"kEnsembleChurnSince","scope":"ensemble","key":"a34fc78c05bf56a8","id":"./src/ensemble.h::ensemble::kEnsembleChurnSince","id_addressable":true,"id_collides_with":0,"l":120,"ord":0,"overloads":1,"amb":0,"cx":0,"ccx":0,"churn":23,"tested":0}, +{"p":"./src/mcpverbs.h","n":"unknownSubVerbRefusal","scope":"rw","key":"e336d39b0a6addea","id":"./src/mcpverbs.h::rw::unknownSubVerbRefusal","id_addressable":true,"id_collides_with":0,"l":5390,"ord":0,"overloads":1,"amb":1,"cx":3,"ccx":2,"churn":340,"tested":0}, +{"p":"./src/docdrift.h","n":"kDocDriftLegend","scope":"docdrift","key":"ff2ba74637bbbebf","id":"./src/docdrift.h::docdrift::kDocDriftLegend","id_addressable":true,"id_collides_with":0,"l":2739,"ord":0,"overloads":1,"amb":0,"cx":0,"ccx":0,"churn":36,"tested":0}], +"files":[{"p":"./src/cli.h","symbols":1,"churn":450,"ccx":0,"hotspot_rank":6}, +{"p":"./src/docdrift.h","symbols":6,"churn":36,"ccx":54,"hotspot_rank":31}, +{"p":"./src/ensemble.h","symbols":1,"churn":23,"ccx":0,"hotspot_rank":92}, +{"p":"./src/mcpverbs.h","symbols":2,"churn":340,"ccx":2,"hotspot_rank":5}, +{"p":"./src/recall.h","symbols":1,"churn":27,"ccx":4,"hotspot_rank":56}, +{"p":"./src/verbs_change.h","symbols":1,"churn":70,"ccx":4,"hotspot_rank":23}], +"symbols_total":3195,"symbols_capped":true},"blast_radius":{"reaches":13,"files_total":7,"capped":false,"files":["./src/docdrift.h","./src/main.cpp","./src/mcp.h","./src/mcpserver.h","./src/mcpverbs.h","./src/recall.h","./src/verbs_for.h"]},"tests_to_run":[], +"tests_total":0,"tests_capped":false,"tests_granularity":"claimed-symbols","untested":13,"module_span":5,"notes":[], +"execution":{"policy":"codex-lane/v1","model":"gpt-5.6-sol","reasoning":"xhigh","rule":"wide-contract-work","basis":"structural-only","signals":{"claims":12,"files":6,"module_span":5,"max_ccx":38,"sum_ccx":64,"ambiguous_calls":28,"blast_reaches":13,"contract_touches":1,"conflicts":1,"untested":13,"t … [line truncated: 139 more bytes on this line] +{"id":"lane-1","task":"add the CLI parse arm and help text for the new filter","claims":{"symbols":[{"p":"./scripts/optremarks.py","n":"main","scope":"","key":"01b3b880f77d1512","id":null,"id_addressable":false,"id_collides_with":110,"l":271,"ord":0,"overloads":1,"amb":0,"cx":23,"ccx":31,"churn":10, … [line truncated: 12 more bytes on this line] +{"p":"./src/cli.h","n":"HelpLine","scope":"rw","key":"1489844dbc310b82","id":"./src/cli.h::rw::HelpLine","id_addressable":true,"id_collides_with":0,"l":2849,"ord":0,"overloads":1,"amb":0,"cx":0,"ccx":0,"churn":450,"tested":0}, +{"p":"./src/cli.h","n":"kViewFlags","scope":"rw","key":"2a2f2487082cc6d8","id":"./src/cli.h::rw::kViewFlags","id_addressable":true,"id_collides_with":0,"l":3225,"ord":0,"overloads":1,"amb":0,"cx":0,"ccx":0,"churn":450,"tested":0}, +{"p":"./src/cli.h","n":"HelpFilter","scope":"HelpFilter","key":"3103a5e8bfc33d38","id":"./src/cli.h::HelpFilter::HelpFilter","id_addressable":true,"id_collides_with":0,"l":2928,"ord":0,"overloads":1,"amb":0,"cx":0,"ccx":0,"churn":450,"tested":0}, +{"p":"./docs/docs_commands_build.py","n":"main","scope":"","key":"4015853681ded3bc","id":null,"id_addressable":false,"id_collides_with":110,"l":865,"ord":0,"overloads":1,"amb":0,"cx":19,"ccx":28,"churn":17,"tested":0}, +{"p":"./src/slice.h","n":"sliceRdIterationCeiling","scope":"slicev","key":"449132b302bb4324","id":"./src/slice.h::slicev::sliceRdIterationCeiling","id_addressable":true,"id_collides_with":0,"l":1372,"ord":0,"overloads":1,"amb":0,"cx":9,"ccx":12,"churn":51,"tested":0}, +{"p":"./src/mcprefusal.h","n":"kMcpValueFields","scope":"rw::mcprefuse","key":"4a7106e488a2aa80","id":"./src/mcprefusal.h::rw::mcprefuse::kMcpValueFields","id_addressable":true,"id_collides_with":0,"l":304,"ord":0,"overloads":1,"amb":0,"cx":0,"ccx":0,"churn":41,"tested":0}, +{"p":"./src/cli.h","n":"serveHelpSelector","scope":"rw","key":"4b247ee064fc4e94","id":"./src/cli.h::rw::serveHelpSelector","id_addressable":true,"id_collides_with":0,"l":2993,"ord":0,"overloads":1,"amb":3,"cx":4,"ccx":3,"churn":450,"tested":0}, +… [60 more display lines; full output is 18774 bytes on 1 raw line(s)] +````` + +## `./build/ripwire . --plan-lanes=99 --task=x` + +*Out-of-range refusal shape for the lane count.* + +**exit code: 1** + +````` +(empty) +````` + +stderr: + +````` +ripwire: --plan-lanes=99 is out of range — N must be 2..16 (1 is not a fan-out) +````` + +## `./build/ripwire . --layout=Symbol` + +*CPU/GPU contract view of one struct: computed offsets/sizes/padding + mirror check.* + +````` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +````` + +## `./build/ripwire . --layout=Lang` + +*The honest refusal (exit 1): Lang is an `enum class`, not a struct — no offsets are fabricated.* + +**exit code: 1** + +````` +(empty) +````` + +stderr: + +````` +ripwire: --layout: 'Lang' is an enum, --layout models structs (a scoped/unscoped enum's underlying type is not a byte layout) +````` + +## `./build/ripwire . --doc-drift` + +*Which of this repo's doc claims are now false. CHANGED: row attribute at= renamed to tgt= (at= is now only the root sha stamp).* + +````` + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +… [243 more display lines; full output is 25243 bytes on 1 raw line(s)] +````` + +## `./build/ripwire . --doc-drift --gateability` + +*The finishable to-do list: docs whose LIVE failing anchors a date-stamp would reclassify.* + +````` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +… [269 more display lines; full output is 26442 bytes on 1 raw line(s)] +````` + +Tail of the same output — the `` section: + +````` + + + + + + + + + + + + + + + + + + + + + + + + + +````` + +## `./build/ripwire . --doc-drift --with-history` + +*Same report, with git history splitting stale mentions into deleted-by-commit vs never-existed.* + +````` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +… [222 more display lines; full output is 25269 bytes on 1 raw line(s)] +````` + +## `./build/ripwire . --from-trace=-` + +*Map a pasted stack trace onto indexed symbols. CHANGED: in_corpus= now reports the real count (was 0).* + +Input file: + +````` +AddressSanitizer:DEADLYSIGNAL +================================================================= +==41337==ERROR: AddressSanitizer: SEGV on unknown address 0x000000000018 (pc 0x000102f4a1c8 bp 0x00016d2f1a40 sp 0x00016d2f19e0 T0) + #0 0x102f4a1c8 in rw::rankGraphTeleport(Graph const&, std::vector const&, float) src/graph.h:7926 + #1 0x102f3e884 in rw::rankGraph(Graph const&, float) src/graph.h:7967 + #2 0x102e11f30 in runDefaultMap(MainDispatch const&) src/main.cpp:1634 + #3 0x102e01a44 in main src/main.cpp:3999 + #4 0x1a2b3c0dc in start+0x9dc (dyld:arm64e+0x60dc) +==41337==ABORTING +````` + +````` + + + + + + + + + + + +PageRank with an explicit teleport / personalization vector p (Σp = 1). The prior is name-quality-biased through biasPrior() so all rank modes share one weighting seam; the transition matrix (edgesinline RankedGraph rankGraphTeleport( const Graph& g, const std::vector<float>&am … [line truncated: 31 more bytes on this line] + +uniform-teleport PageRank (the defaultinline RankedGraph rankGraph( const Graph& g, float alpha = 0.85f ) +int runDefaultMap( const MainDispatch& d ) +int main( int argc, char** argv ) + + + +& p, float alpha = 0.85f ) +{ + PROFILE_SCOPE_DESCRIBE( "rankGraph: PageRank (power iteration)" ); + const std::vector pw = biasPrior( g, p ); + const std::size_t N = pw.size(); + std::vector teleport( pw.begin(), pw.end() ); + std::vector rankDouble( N, 0.0 ); + PageRankRun run{}; // an N == 0 graph never enters the kernel: { 0, converged } — see PageRankRun + if( N ) + { + double teleportMass = 0.0; +… [18 more display lines; full output is 5251 bytes on 29 raw line(s)] +````` + +## `./build/ripwire . --notes` + +*List all field notes (write-side memory) — the committed .ripwire_notes at the repo root, each with the sha/branch it was recorded at.* + +````` + + + + + + + + + + + gate scripts' claim (~line 1305) is NOT enforced — the derived-vs-stated sibling loop here covers docs/EVALS.md only. It drifted 407→451 unnoticed (fixed 2026-08-23). To close: grep both files ('file:line:' parsing) in the gateCountClaims arm.]]> + + + + +````` + +## `./build/ripwire . --pack-task="add a new output format flag to the CLI"` + +*ONE budget-shared bundle: ranking + top bodies + caller sigs + notes + tests_to_run. CHANGED: rows now carry n=/id=.* + +````` + + + + +Print the authoritative CLI usage and flag catalog to the caller-provided output streaminline void printUsage( std::FILE* out ) noexcept + +claude's `mcp add … -- ripwire --mcp`: the flag goes on the end of that command line, ahead of its newlineinline std::string wrapCliAddPost( const AgentTarget& row, std::string_view toolsArg ) + +ONE recipe path for every agent that can shell out, and the point is that the RECOMMENDATION does not vary by agent — only the MCP alternative does. Before this there were four near-identical CLI-fiinline void wrapEmitCliFirst( const AgentTarget& row, const std::string& token, c … [line truncated: 83 more bytes on this line] + +mcp-tools pass-through. Written into the server command only where its argument shape is known to be one more plain argument: the JSON args arrays (cursor/windsurf/gemini), opencode's command arrayinline bool wrapWritesToolsArg( const AgentTarget& row ) noexcept + +F7: a hostile/garbled frame line number (e.g. a fuzzed or truncated trace) can exceed UINT32_MAX; unchecked `v*10+d` wraps mod 2^32 (4294967297 -> 1), which then confidently maps to a REAL line in theinline std::uint32_t toUint( std::string_view s, bool& overflowed ) noexcept +struct AgentTarget + + + + + + + + + +… [59 more display lines; full output is 10197 bytes on 51 raw line(s)] +````` + +## `./build/ripwire . --pack-task="add a new output format flag to the CLI" --partition=3` + +*Fan-out form: one shared core + 3 per-agent slices carved along call-graph communities.* + +````` + wraps a ; bundle role=core|partition i= symbols= modules= bytes= tokens=: one agent's ctx, symbols= ids assigned, bytes= its size; tokens= = est_tokens= = bytes/2.36 (flat, the densest rate; the inner ctx est_tokens= is language-weighted). Inner ctx task= root= budget_tokens= dropped_positive=: the task, p= base, the slice's token target, ranked candidates its budget cut. of_top=: rows ranked (far) / bodies (callers); s rel=caller|callee shared=: 1-hop edge, bodies reached (absent at 1). window: shown= total= capped= (capped=1 cut). run_unknown=1: no runner derivable (a guess would be worse). est_tokens=: price as emitted (an upper bound under compact). : that callee matched by name alone (receiver unproven); it does NOT mean the edge is false. : rank N in this ranking, rows in r= order. : cyclomatic/cognitive complexity. : N callers in the index (absent: not measured). route=: the ranker: name-exact(X) = the task names symbol X (anchors: its evidence), subtoken+body = conceptual BM25 (:broad = 1-2 plain words, plain rg may also win; :declined(...) = a name hit refused as a common name). sc=: enclosing scope; the full id is p::sc::n (p= of the row or its ) and selectors take it. next=: the one pasteable follow-up. pure=1: const/constexpr signature (Swift: non-mutating) and no transitive side effect found; a hint. partitions=/requested=: partitions carved / asked for. modules=/split=: call-graph groups found / cuts forced to split one. core_symbols=/surface=: ids in the shared core / core plus the assignable remainder. budget_per_agent_tokens=: one agent's budget, core_budget_tokens= plus partition_budget_tokens=. total_bytes=N: bytes of all bundles together. overlap_mean=/overlap_max=: pairwise Jaccard over the ids partitions name, before trimming. shared_symbols=/union_symbols=: ids two or more partitions name / ids any partition names. core_overlap=: share of the core surface a partition reaches anyway. --> + + + + + +Print the authoritative CLI usage and flag catalog to the caller-provided output streaminline void printUsage( std::FILE* out ) noexcept + +claude's `mcp add … -- ripwire --mcp`: the flag goes on the end of that command line, ahead of…inline std::string wrapCliAddPost( const AgentTarget& row, std::string_view toolsArg ) + +ONE recipe path for every agent that can shell out, and the point is that the RECOMMENDATION doe…inline void wrapEmitCliFirst( const AgentTarget& row, const std::string& token, const std::string_view executablePath, const WrapListing& listing ) noexcept + +mcp-tools pass-through. Written into the server command only where its argument shape is known t…inline bool wrapWritesToolsArg( const AgentTarget& row ) noexcept + + + + + +inline bool printUsageTier( std::FILE* out, HelpTier tier, std::string_view want ) noexcept + + + + + + + + +NodeId order. Renumbering an otherwise-identical graph (the exact "same probe file, different so…inline constexpr float kScoreTieAbsEps = 1e-6f + +Render one form of the disclosure. Empty string whenever there is nothing to say — no power it…inline std::string renderDisclosure( const RankDisclosure& d, DiscloseAs as ) + +The PageRank power iteration itself — the numeric kernel every ranked document's order comes f…PageRankRun pageRankDouble( const sparseCsr<float>& inEdges, std::span<const double> weightedOutDegree, std::span<const double> teleport, std::span<double> r … [line truncated: 10 more bytes on this line] + +What a ranked document discloses about the power iteration that ordered it. `isPageRank == false…struct RankDisclosure +inline std::string rankByText( const std::string& root, std::string_view mode, int topK, bool stable = false ) +struct RankedGraph +inline constexpr const char* kChurnRankLegend = "<!-- rank_by=churn: k= is PageRank re-run with the teleport BIASED by git CHANGE-FREQUENCY over window= " "(a c… +inline std::uint32_t sliceRdIterationCeiling() noexcept +struct PageRankRun +inline std::uint32_t navRelevanceWeight( const Graph& g, NodeId n ) noexcept +inline RankedGraph rankGraphTeleport( const Graph& g, const std::vector<float>& p, float alpha = 0.85f ) +HOT_FILES = ( "src/pagerank.cpp", # the power-iteration loop — G2's no-allocation scope "src/infra/radixSort.h", # LSD radix entry points "src/infra/radixSort… +inline constexpr std::string_view kPowerShellPathPrependScope = "in PowerShell, for this window +inline std::string powerShellPathPrependHint( std::string_view programDir ) noexcept +inline std::string powerShellSingleQuote( std::string_view s ) noexcept +inline std::string path_prepend_hint( std::string_view dir ) +handle_t leftmost_leaf() const +### Wave-2 adversarial verification (2026-08-19) — six probes against `aa97c9e` +### Same answer, a fraction of the tokens — read this table first if your agent is on a budget +### Anchor-only auto bodies — T3 substitution round, PRE-REGISTERED 2026-08-22 (before any fix code)< … [line truncated: 3 more bytes on this line] +## Background — read these, in this order +## Design space and constraints +… [59 more display lines; full output is 9772 bytes on 10 raw line(s)] +````` + +## `./build/ripwire . --export=cc.json:/aux/ripwire2.cc.json` + +*Per-file metrics as CodeCharta cc.json.* + +**wall time: 1.23s** + +````` +(empty) +````` + +Artifact written: + +````` + 409075 /aux/ripwire2.cc.json +{"projectName":"project","apiVersion":"1.3","attributeDescriptors":{"loc":{"title":"Lines of Code","description":"Physical line count","direction":-1},"symbols":{"title":"Symbols","description":"Definitions in the file","direction":-1},"cx":{"title":"Cyclomatic Complexity","description":"Sum of per-symbol cyclomatic complexity","direction":-1},"cognitive_cx":{"title":"Cognitive Complexity","descri +````` + +## `./build/ripwire . --batch=/aux/batch2.txt` + +*One-turn sweep: 4 newline-delimited verb:arg sub-queries answered in ONE deduped .* + +**wall time: 2.95s** + +Input file: + +````` +for:incremental cache invalidation +callers:rankGraphTeleport +grep:DISCLOSE +lego:Vehicle +````` + +````` + + + + + + + + + +incremental cache (--cache): per-file content hash + raw facts so a re-run re-parses ONLY c…constexpr std::uint32_t kCacheMagic = 0x4b505443 + +Composed exactly the way every OTHER blob family is (quality.h): one fixed-width identity hex pe…inline std::string spanTierMemoPath( const std::string& diskPath ) + +Ingest the tree at `sha`, materialized out of `root`'s object store. The HEAD side reuses the SA…inline bool ingestCommitTree( const std::string& root, const std::string& sha, const std::vector<std::string>& excludes, std::size_t maxFileBytes, IngestResult&… … [line truncated: 4 more bytes on this line] + +P1-15 — the `_reingest` envelope field for a response whose handling ran an INCREMENTAL pass, …inline bool mcpRefreshedThisRequest( std::uint64_t passesAtEntry ) +inline McpRebuildBaseline mcpRebuildBaseline( const McpIndex& ix, bool isIncrementalPass ) +struct FileHealth +inline std::vector<std::vector<NodeId>> legoImplementorsOnSurface( const IngestResult& ing, const std::vector<std::vector<NodeId>>& implementors, const std::ve … [line truncated: 8 more bytes on this line] +inline const McpIndex& getIndex( const std::string& root ) +inline std::string mcpFreshFields( std::uint64_t passesAtEntry ) +struct McpIndex +inline std::pair<Snapshot, bool> computeHeadSnapshot( const std::string& root, const std::string_view* cacheNever = nullptr, std::size_t maxFileBytes = kDefault… +inline std::string receiptNextFor( const std::string& fileIdentity, const std::string& symbolName, const std::string& foldJson, const std::string& firstTestRun ) +inline RawFacts runParsePool( IngestResult& result, const char* rootDir, std::string_view cacheFile, bool captureValueUses, HashMap<std::string, FileFacts>& cache, const CacheLoadStats& ca … [line truncated: 67 more bytes on this line] +IngestResult ingest( const char* rootDir, const std::vector<std::string>& excludeSubstr, std::string_view cacheFile, std::size_t maxFileBytes, bool captureValueUses, std::string_view excludeLabel, bool … [line truncated: 37 more bytes on this line] +const char* cacheArtifactVerdict( const std::string& path, bool captureValueUses ) +static bool cachePathIsDirectory( const std::string& cachePath ) +inline constexpr std::array<std::string_view, 8> kCacheRuleNames = +inline std::string oracleCachePath( const std::string& root, const std::string& headSha ) +… [57 more display lines; full output is 42099 bytes on 1 raw line(s)] +````` + +stderr: + +````` +ripwire: cache : over its 2048 MiB budget — evicted 546 blob(s) of other roots or other ripwire builds (this build's blobs for this root are kept) +````` + + +--- + +# self-diagnosis + +## `./build/ripwire . --doctor` + +*Environment self-check: binary staleness, grammars, cache dir, git, tracked-binary staleness — exit 1 when any check fails (here: the PATH install is older than ./build).* + +**exit code: 1** — **wall time: 3.00s** + +````` + + + + + + + + + + +````` + + +--- + +# security + +## `./build/ripwire --scan-skill=skills/ripwire-orient/SKILL.md` + +*Scan a single skill file for injection/exfiltration patterns before installing.* + +````` + + + +````` + +stderr: + +````` +ripwire scan: 0 finding(s) in skills/ripwire-orient/SKILL.md +````` + +## `./build/ripwire --scan-skills=skills` + +*Scan a whole skills directory (exit 2 = CRITICAL, 1 = WARN). Explicit-DIR form only.* + +````` + + + +````` + +stderr: + +````` +ripwire scan: 0 finding(s) total (26 skill file(s) scanned, 0 unscannable file(s) skipped, 0 denylisted subtree(s) not descended) +````` + + +--- + +# knobs / modes + +## `./build/ripwire . --rank-by=churn --top-k=5` + +*Rank by git change-frequency prior instead of PageRank.* + +**wall time: 1.32s** + +````` + + + + + + + + + + + + + + + + + + + + + + + +````` + +## `./build/ripwire . --rank-by=churn-decay --since=HEAD~4 --exclude=test --exclude=docs --exclude=skills --in=src --limit=3` + +*Scope the recent-changes answer to ONE directory. The global block stays byte-identical, a second page follows it — n=/of= are its counts (of= IS the total, so the paging half carries no total=), capped="1" has_more="1" next_offset= offset= limit= page it, and next= replays THIS run's own corpus flags (--since/--exclude) so the page it names is a page of the same answer. The symbol map collapses to a disclosed stub — the map was not asked for and was not ranked at all, which is why the header carries no pr_iters=. merge_bombs_skipped= stays on the global block: it counts the window's skipped commits, not the directory's.* + +````` + + + + + + + + + + + + + + + + + + + + + + + + + +````` + +## `./build/ripwire . --rank-by=bogus --top-k=5` + +*An unknown value REFUSES (exit 1), NAMED, with the supported set listed.* + +**exit code: 1** + +````` +(empty) +````` + +stderr: + +````` +ripwire: --rank-by: unknown value 'bogus' (supported: pagerank|authority|hub|rrf|churn|churn-decay) +````` + +## `./build/ripwire . --callers=rankGraphTeleport --format=columnar` + +*Columnar output: paths table + parallel arrays, ~15-60% fewer tokens on MANY-row lists — small results can be LARGER (the columnar legend is a fixed cost).* + +````` + + +0=src/mcpindex.h 1=src/graph.h 2=src/eval.h 3=src/main.cpp + +0,1,1,2,3,3,3 +getIndex,rankGraph,anchoredLexicalRank,runEval,churnDecayRanking,churnRankedGraph,runDefaultMap +1165,7965,8611,171,1383,1422,1632 +fn,fn,fn,fn,fn,fn,fn +0,0,0,0,0,0,0 + + +````` + +## `./build/ripwire . --for="cache invalidation" --format=candidates --top-k=5` + +*Flat top-K export for an external reranker.* + +````` + + + +inline std::string spanTierMemoPath( const std::string& diskPath ) + + +inline std::vector<std::vector<NodeId>> legoImplementorsOnSurface( const IngestResult& ing, const std::vector<std::vector<NodeId>>& implementors, const std::vector<NodeId>& surfaceIds ) + + +### See the map — not just the numbers + + +inline std::string receiptNextFor( const std::string& fileIdentity, const std::string& symbolName, const std::string& foldJson, const std::string& firstTestRun ) + + +const char* cacheArtifactVerdict( const std::string& path, bool captureValueUses ) + + +````` + +## `./build/ripwire . --callers=rankGraphTeleport --format=bogus` + +*An unknown --format value REFUSES (exit 1), named, with the supported set listed.* + +**exit code: 1** + +````` +(empty) +````` + +stderr: + +````` +ripwire: --format: unknown value 'bogus' (supported: xml|columnar|rows|candidates) +````` + +## `./build/ripwire . --callers=rankGraphTeleport --json` + +*Machine-parseable JSON, same content, keys mirror the XML attrs.* + +````` +{"of":"rankGraphTeleport","defs":1,"count":7,"root":".","hop_tested":0,"hop_untested":7,"graph_ambiguous":12495,"graph_unresolved":12552,"graph_unindexed":255,"counts_floor":true,"callers":[{"t":"fn","n":"getIndex","p":"src/mcpindex.h:1165"}, +{"t":"fn","n":"rankGraph","p":"src/graph.h:7965"}, +{"t":"fn","n":"anchoredLexicalRank","p":"src/graph.h:8611"}, +{"t":"fn","n":"runEval","p":"src/eval.h:171"}, +{"t":"fn","n":"churnDecayRanking","p":"src/main.cpp:1383"}, +{"t":"fn","n":"churnRankedGraph","p":"src/main.cpp:1422"}, +{"t":"fn","n":"runDefaultMap","p":"src/main.cpp:1632"}]} +````` + +## `./build/ripwire . --hotspots --json` + +*JSON refusal shape: an unsupported verb refuses loudly instead of silently falling back to XML.* + +**exit code: 1** + +````` +(empty) +````` + +stderr: + +````` +ripwire: --json is not yet supported for --hotspots — supported: the default map, --for, --pack-task, --callers/--callees, --impact, --quality-delta, --test-gate, --metrics, and --plan-lanes which is JSON-native (e.g. ripwire --callers=SYM --json) +````` + +## `./build/ripwire . --hotspots --limit=3 --offset=3` + +*Pagination: 3 items, skipping the first 3 (deterministic seams).* + +**wall time: 1.33s** + +````` + + + + + + + +````` + +## `./build/ripwire . --ignore-tests --top-k=5` + +*Drop test paths from the corpus before ranking.* + +````` + + + + + + + + + + + + + + + + + + + + + + + + + +````` + +## `./build/ripwire . --exclude=present --exclude=bench --top-k=5` + +*Drop matching paths (repeatable) before ranking.* + +````` + + + + + + + + + + + + + + + + + + + + + + + + + +````` + +## `./build/ripwire . --map-diff --top-k=5` + +*Full map re-ranked with teleport toward git-changed files — clean tree, so changed=0 and it degrades to the plain map.* + +````` + + + + + + + + + + + + + + + + + + + + + + + + + +````` + +## `./build/ripwire . --no-cache --top-k=3` + +*Force a cold parse (bypass the warm TMPDIR cache) — shows the cold-vs-warm cost.* + +**wall time: 1.73s** + +````` + + + + + + + + + + + + + + +````` + +## `./build/ripwire . --cache=/aux/warm2.ripwirecache --top-k=3` + +*Explicit incremental cache at a path OUTSIDE the repo (first call writes it).* + +**wall time: 1.89s** + +````` + + + + + + + + + + + + + + +````` + +Artifact written: + +````` + 23065349 /aux/warm2.ripwirecache +````` + +## `./build/ripwire . --max-file-size=8K --top-k=3` + +*Skip files above a size bound before parsing (note the corpus shrink in the header).* + +````` + + + + + + + + + + + + + + + + + +````` + +## `./build/ripwire . --scip=does_not_exist.scip --callers=rankGraphTeleport` + +*SCIP overlay with a missing index REFUSES (exit 1) naming the file — never silently serves the name-based map you named a precision index to improve on (it used to degrade in silence).* + +**exit code: 1** + +````` +(empty) +````` + +stderr: + +````` +ripwire: --scip=does_not_exist.scip: cannot open the index — refusing rather than serving the name-based map you named a precision index to improve on (generate one with scip-clang/scip-python, or drop --scip) +````` + +## `./build/ripwire src test --top-k=5` + +*Multi-root workspace: ONE merged graph over two roots, paths labeled /.* + +**wall time: 1.62s** + +````` + + + + + + + + + + + + + + + + + + + + + + + + + + + +````` + +## `./build/ripwire . --eval` + +*Self-eval: co-change recall vs BM25.* + +**wall time: 16.59s** + +````` +ripwire --eval (co-change recovery, averaged over 80 historical commits) + ranker recall@5 recall@10 recall@20 + ripwire 1.2% 8.5% 12.7% + BM25 14.0% 14.9% 20.9% + BM25sub 15.8% 23.8% 31.8% + BM25body 11.3% 24.2% 39.4% + fused 11.3% 23.0% 29.6% + anchored 11.3% 24.2% 39.4% + same-dir 0.2% 1.8% 6.1% + random 0.2% 0.4% 0.7% <- floor (random ranking over F=2688 files) + note: `ripwire` here is the DEFAULT MAP's structural-only PageRank (importance, not + relatedness) — it is NOT what a --for/--query retrieval call ranks with. BM25 / + BM25sub / BM25body are QUERY-TIME lexical rankers (whole-name / subtoken / + subtoken+body); fused = RRF(ripwire, BM25sub); anchored = BM25body + anchored PPR + expansion (--for --anchor, EXPERIMENTAL). The SHIPPED default for --for/--query is + the subtoken+body lexical family (routed to name-exact only for an identifier-shaped + query — lexical.h chooseForRanker), so a gap between the ripwire and BM25* rows here + is structural-importance-vs-lexical-relatedness on a co-change task, not the shipped + retrieval path losing to an alternative it was never running. +````` + +## `./build/ripwire . --eval-retrieval` + +*Known-item retrieval eval: MRR + recall@k per ranker per query mode.* + +**wall time: 126.79s** + +````` +ripwire --eval-retrieval (known-item, 4000 doc-commented symbols; gold is in-corpus by construction) + sample: population=6431 scored=4000 rule=smallest-key CAPPED — a SUBSET, not the population + (smallest fnv1a64(scope::name) over the population, cut on the key so an identity is never split; + path- and order-independent, but a corpus this size is NOT graded exhaustively — say so when citing it) + ingest: lex=rich (persisted subtoken stats; no per-query corpus re-tokenize) + ranker query-mode MRR recall@1 recall@5 recall@10 + subtoken name 0.680 54.7% 84.4% 90.0% + subtoken doc-phrase 0.913 89.0% 93.5% 94.7% + name-exact name 0.892 81.3% 95.7% 97.0% + name-exact doc-phrase 0.013 0.5% 1.8% 2.6% + anchored name 0.688 56.6% 83.7% 88.3% + anchored doc-phrase 0.910 88.5% 93.5% 94.3% + routed name 0.895 81.5% 95.9% 97.2% + routed doc-phrase 0.911 89.0% 93.3% 94.4% + note: routing chose name-exact on 3986/4000 NAME queries (a NAME query is always identifier-shaped); + the confidence gate routes doc-phrase queries to name-exact ONLY when EVERY content word names a symbol + (or an explicit camel/snake token appears) AND every matched name is specific enough to anchor on — + a common name (many definitions, or a subtoken carried by many symbol names) declines the route — so + conceptual prose falls back to subtoken+body; routed tracks the better ranker on BOTH modes + (routed==name-exact on name, ~=subtoken+body on doc-phrase). +````` + +## `./build/ripwire . --eval-stray=/aux/stray_labels2.tsv` + +*Labelled verdict-accuracy eval for --stray-content — three labels over REAL local refs (names resolved at capture time); exit 3 when accuracy is under the floor. Read got= against v= in the stray-content run: a ref the verb could not analyse (unknown) must never be credited as a merged hit.* + +**exit code: 3** — **wall time: 4.17s** + +Input file: + +````` +# refverdict labels for --eval-stray (the first three local branches, resolved at capture time; a missing branch is padded with a nonexistent name on purpose) +arm/for-howitworks-067-f2add merged +arm/for-howitworks-067-placebo unmerged +arm/orient-map-067-narrow merged +````` + +````` + + + + + + +````` + +## `./build/ripwire skills --eval-skills=/aux/skills_labels2.tsv` + +*Labelled skill-ROUTING eval over the repo's own skills/ directory (4 hand-labelled prompts).* + +Input file: + +````` +orient in an unfamiliar codebase fast ripwire-orient judged +who calls this function and what is the blast radius ripwire-navigate judged +plan parallel worktrees so the lanes do not collide ripwire-change-check judged +what is the weather in Paris none neg +````` + +````` +ripwire --eval-skills (skill routing over K=16 candidate skills [ripwire-router excluded]; 3 positive + 1 negative prompts; corpus '/aux/skills_labels2.tsv'; split test=4 dev=0) + arm hit@1 hit@2 mrr sep-auc fire/abstain@ORACLE-th (upper bound) + overlap 66.7% 66.7% 0.689 0.000 50.0% (th=-1.000) + name 33.3% 66.7% 0.537 0.667 50.0% (th=0.000) + bm25-desc 66.7% 66.7% 0.689 0.667 75.0% (th=2.865) + bm25-full 33.3% 66.7% 0.611 1.000 50.0% (th=0.426) + for-routed 33.3% 33.3% 0.556 1.000 50.0% (th=0.686) + random 6.2% 12.5% 0.211 0.500 <- floor (uniform-random ranking; auc 0.5 by definition) + provenance hit@1 (bm25-desc): router 0/0, desc 0/0, judged 2/3 (desc rows quote the descriptions - expect them easiest; judged is the honest number) + judged-only hit@1 per arm: overlap 2/3, name 1/3, bm25-desc 2/3, bm25-full 1/3, for-routed 1/3 + router-magnet: with ripwire-router ADMITTED as a candidate it takes top-1 on 1/3 positive prompts (bm25-desc arm) - why it is excluded above + per-skill (bm25-desc): name / permitted-rows / won / pos-fires / false-fires / neg-fires + ripwire-before-you-build 0 0 1 1 0 + ripwire-change-check 1 0 0 0 0 + ripwire-find-bug 0 0 0 0 0 + ripwire-fresh-eyes 0 0 0 0 1 + ripwire-graph-query 0 0 0 0 0 + ripwire-handoff 0 0 0 0 0 + ripwire-layers 0 0 0 0 0 + ripwire-mcp 0 0 0 0 0 + ripwire-navigate 1 1 1 0 0 + ripwire-opt-remarks 0 0 0 0 0 + ripwire-orient 1 1 1 0 0 + ripwire-perf-target 0 0 0 0 0 + ripwire-quality-bar 0 0 0 0 0 + ripwire-reuse-first 0 0 0 0 0 + ripwire-security-scan 0 0 0 0 0 + ripwire-write-tests 0 0 0 0 0 + misses (overlap): + line 3 want=ripwire-change-check got=ripwire-before-you-build "plan parallel worktrees so the lanes do not collide" +… [19 more lines, 3664 bytes total] +````` + +## `./build/ripwire wrap claude` + +*Print the recipe to wire ripwire into Claude Code as an MCP server.* + +````` +# ripwire -> Claude Code (CLI-first) +# RECOMMENDED — this agent can run shell commands, so call the CLI directly. It costs +# nothing until you invoke it, and it reads CLAUDE.md, so the paste block below IS the wiring: +ripwire . --for="" --token-budget=2000 +# +# ...every follow-up call answers with the compact legend by default (terse definitions of only the +# attributes the answer carries; the payload is byte-identical either way). Add --legend=full when a +# definition's reasoning is needed -- `ripwire --help` carries the measured saving (one place, +# gate-held) -- this line does not repeat it, because two copies of a number is one that goes stale: +# ripwire . --callers=SYM --legend=full +# +# ALTERNATIVE — register the MCP server instead, for a warm index across calls: +claude mcp add ripwire -- ripwire --mcp +# verbs the agent can then call mid-task (33 total): +# read: analyze, rank_by, find_symbol, find_referencing_symbols, grep, cochange, memory_recall, situational_awareness, mentions, for, lego, owners, fetch_body, batch, flags, doc_drift, slice +# flagship reflex: exemplar, quality_delta, quality_baseline, impact, uses, affected, path_between, connect, explore, from_trace, edit_check, whereis, stray_content +# edit: replace_symbol_body, insert_before_symbol, insert_after_symbol +bash skills/install.sh # deploy to ${CLAUDE_CONFIG_DIR:-~/.claude}/skills (drift-gated) +bash skills/install.sh --hook # RECOMMENDED: advisory Read/Grep -> ripwire CLI nudge + session primer (opt-in, never blocks) +# +# context wiring — a binary on PATH is invisible to an agent until its rules file says when +# to reach for it. Paste the block below into CLAUDE.md: +# --- paste into CLAUDE.md --- +## ripwire — deterministic codebase maps (on PATH as `ripwire`) +Reach for it BEFORE blind grep + whole-file reads. First call ~1s cold; after that warm, ~0.1s. +- Orient on a task: `ripwire --for=""` — ranked, quality-annotated + signatures. Paste symbol/file names from the issue verbatim; named mentions get anchored. +- One task: `--pack-task="" --legend=compact`; before parallel agents: `--plan-lanes=N --task=""`, then read `lanes[].execution`. +- Have a stack trace / build error: `ripwire --from-trace=FILE --legend=compact` (`-` = stdin) — + paste the error, don't paraphrase it into a query. +… [14 more lines, 4078 bytes total] +````` + +## `./build/ripwire --version` + +*Version + short build info.* + +````` +ripwire 0.6.5 (dev, AppleClang 21.0.0.21000101, emit=std::print, built_from=f1e5c2e76) +````` + + +--- + +# navigate — seeds, claims, slices, shapes + +## `./build/ripwire . --at=src/graph.h:7926` + +*Hold a LOCATION, not a name: the enclosing-definition chain at FILE:LINE (a compiler error, a diff hunk, a stack frame), outermost -> innermost.* + +````` + + + + +````` + +## `./build/ripwire . --callers=@src/graph.h:7926` + +*The same seed in a SELECTOR position: @FILE:LINE resolves to the innermost enclosing definition, then --callers runs on it.* + +````` + + + + + + + + + + +````` + +## `./build/ripwire . --at=src/graph.h:999999` + +*A seed past the end of the file — the refusal shape for a faulted location.* + +**exit code: 1** + +````` +(empty) +````` + +stderr: + +````` +ripwire: the at flag's seed 'src/graph.h:999999' named no location (./src/graph.h has only 11822 lines — the seed asked for line 999999) +````` + +## `./build/ripwire . --verify="calls(runDefaultMap, rankGraphTeleport)"` + +*VERIFY a closed claim in one call: three-valued verdict (confirmed / refuted / not-established) with the evidence rows inline.* + +````` + + + + + +````` + +## `./build/ripwire . --verify="unused(rankGraphTeleport)"` + +*A claim that is FALSE — the refuted shape, with the references that refute it.* + +````` + + + + + + + + + + + + +````` + +## `./build/ripwire . --verify="contains(src/graph.h, \"no such literal anywhere\")"` + +*A literal-scan absence: refuted only with complete= evidence, never on a partial scan.* + +````` + + + +````` + +## `./build/ripwire . --verify="frobnicate(x)"` + +*An unparseable claim — the refusal names the accepted shapes.* + +**exit code: 1** + +````` +(empty) +````` + +stderr: + +````` +ripwire: --verify claim not recognized: 'frobnicate(x)' — unknown shape word. The claim language is CLOSED; the shapes are: calls(A, B) does A transitively call B · uses(SYM) is SYM referenced anywhere · unused(SYM) is SYM referenced nowhere · contains(FILE, "LITERAL") do FILE's bytes contain t … [line truncated: 218 more bytes on this line] +````` + +## `./build/ripwire . --slice=rankGraphTeleport` + +*Bare --slice=SYM: the INVENTORY of sliceable locals (), so a caller can pick VAR.* + +````` + + + + + + + + + + + + + + + + + +````` + +## `./build/ripwire . --slice=rankGraphTeleport:teleport` + +*Intra-procedural def-use slice of ONE variable: one row per line touching it, k=def|use|both, reaching definitions flow-sensitive (reach=cfg).* + +````` + + + + + + + teleport( pw.begin(), pw.end() );]]> + + + + + + + + +````` + +## `./build/ripwire . --slice=rankGraphTeleport:teleport --slice-flow=back --slice-depth=3` + +*TRANSITIVE backward value-flow from the seed variable, bounded BFS (depth= disclosed; a cut frontier says flow_truncated=1).* + +````` + + + + + + + teleport( pw.begin(), pw.end() );]]> + + + + + + + + + pw = biasPrior( g, p );]]> + + +& p, float alpha = 0.85f )]]> + + +& p, float alpha = 0.85f )]]> + + +````` + +## `./build/ripwire . --slice=rankGraphTeleport:teleport --slice-flow=fwd` + +*Forward flow: which statements the seed's value reaches, at the default depth bound.* + +````` + + + + + + + teleport( pw.begin(), pw.end() );]]> + + + + + + + + + + + + + + + + + + + + + 0.0 )]]> + + + + + + + + +````` + +## `./build/ripwire . --slice=rankGraphTeleport:nosuchvar` + +*A variable the definition does not bind — the refusal shape, naming the inventory.* + +**exit code: 1** + +````` +(empty) +````` + +stderr: + +````` +ripwire: --slice: no occurrence of 'nosuchvar' in rankGraphTeleport — sliceable locals: alpha, g, p, pw, N, teleport, rankDouble, run, teleportMass, value, inverseMass, r (bare --slice=rankGraphTeleport lists them with first-def lines) +````` + +## `./build/ripwire . --slice-depth=3` + +*--slice-depth without --slice-flow is refused loudly rather than silently ignored.* + +**exit code: 1** + +````` +(empty) +````` + +stderr: + +````` +ripwire: --slice-depth bounds the --slice-flow BFS — pass both (e.g. ripwire --slice=parseArgs:argIndex --slice-flow=fwd --slice-depth=4) +````` + +## `./build/ripwire . --slice=rankGraphTeleport:teleport --legend=compact` + +*The compact legend posture: rows byte-identical, a versioned schema id replaces the repeated explanatory prose — for a many-small-calls loop.* + +````` + + + + + + + teleport( pw.begin(), pw.end() );]]> + + + + + + + + +````` + +## `./build/ripwire . --pattern='rankGraphTeleport($A, $B, $C)'` + +*Structural search written in CODE: $NAME binds one node; grammars=/shapes= disclose what the pattern became per grammar (a 3-argument call shape — the 2-argument spelling has no call site in this repo and correctly reports hits=0).* + +**wall time: 1.16s** + +````` + +rankGraphTeleport( g, std::vector<float>( N, N ? 1.0f / float( N ) : 0.f ), alpha ) + +````` + +## `./build/ripwire . --pattern='DISCLOSE(...)'` + +*The ellipsis form over a macro-shaped call site; unsupported= names the families this verb does not serve.* + +**wall time: 1.23s** + +````` + +DISCLOSE( result, AbiResult::DisclosureWhy::NoMergeBase, "abi: no merge-base for a ref (unrelated history?) — that ref +DISCLOSE( Diagnostics::answerRefused, "--arch exits 1 with path:line and the reason on stderr; no report is printed", +DISCLOSE( baseline, ArchBaselineRead::DisclosureWhy::SymlinkRefused, "arch: refusing to read the arch baseline sidecar t +DISCLOSE( Diagnostics::answerRefused, "--baseline exits 1: pathguard names the refused link on stderr and the verb says +DISCLOSE( ex, Exclusions::DisclosureWhy::ForHeaderSaturated, "atoms: the for-header exclusion stream spent its whole bud +DISCLOSE( ex, Exclusions::DisclosureWhy::StmtCrementSaturated, "atoms: the statement-crement exclusion stream spent its +DISCLOSE( ex, Exclusions::DisclosureWhy::StmtAssignSaturated, "atoms: the statement-assign exclusion stream spent its wh +DISCLOSE( ex, Exclusions::DisclosureWhy::InnerCommaSaturated, "atoms: the inner-comma exclusion stream spent its whole b +DISCLOSE( st, Type3Stats::DisclosureWhy::PairCapHit, "clones: Type-3 pair cap hit — first N compared (both-gate-surviv +DISCLOSE( scan, CommentCoherenceScan::DisclosureWhy::UnreadableFile, "comment-coherence: an indexed file could not be re +DISCLOSE( st, StreamBlobStats::DisclosureWhy::ListUnwritable, "crossref: cannot write the blob-batch list — cross-bran +DISCLOSE( st, StreamBlobStats::DisclosureWhy::BatchNotStarted, "crossref: git cat-file --batch failed to start — cross +DISCLOSE( st, StreamBlobStats::DisclosureWhy::StreamEndedMidBlob, "crossref: git cat-file stream e +DISCLOSE( dropSink, RefEnumeration::DisclosureWhy::TipNotObjectName, "crossref: for-each-ref yielded a ref whose tip is +DISCLOSE( "crossref: refusing a diff whose revision arguments are not resolved object names" ) +DISCLOSE( sweep, ParallelSweep::DisclosureWhy::WorkerThrew, "crossref: a git worker threw — this shard of the sweep is +DISCLOSE( plumb, RefPlumbing::DisclosureWhy::RevisionNotObjectName, "crossref: ref tip or HEAD is not a resolved object +DISCLOSE( plumb, RefPlumbing::DisclosureWhy::MergeBaseNotObjectName, "crossref: merge-base returned something that is no +DISCLOSE( plumb, RefPlumbing::DisclosureWhy::NoMergeBase, "crossref: no merge-base for ref (shallow clone or unrelated h +DISCLOSE( "crossref: refusing to list a tree whose revision argument is not a resolved object name" ) +DISCLOSE( "whereis: the index's def sites match no HEAD row (working tree drifted from HEAD?) — keeping the lexical la +DISCLOSE( scan, WorktreeScan::DisclosureWhy::ListingFailed, "whereis: git could not list the working tree's changes — +DISCLOSE( scan, WorktreeScan::DisclosureWhy::OverPathCap, "whereis: more changed paths than the overlay reads — the re +DISCLOSE( scan, WorktreeScan::DisclosureWhy::CopyUnread, "whereis: a changed path could not be read from the working tre +DISCLOSE( out, CMakeScan::DisclosureWhy::SymlinkEscapesRoot, "flags: a CMake file's symlink target leaves the root — f +DISCLOSE( out, CMakeScan::DisclosureWhy::RootWalkFailed, "flags: cannot walk root for CMake files — cmake gates omitte +DISCLOSE( r, Result::DisclosureWhy::BaseTreeUnavailable, "dmm: the base commit's tree could not be materialized or parse +DISCLOSE( r, Result::DisclosureWhy::TargetTreeUnavailable, "dmm: the target commit's tree could not be materialized or p +… [73 more display lines; full output is 17740 bytes on 1 raw line(s)] +````` + +## `./build/ripwire . --pattern='x'` + +*A pattern that collapses to a bare token is REFUSED — never reported as hits=0.* + +**exit code: 1** + +````` +(empty) +````` + +stderr: + +````` +ripwire: --pattern: this pattern collapses to a single bare token, which is a TEXT search, not a structural one — a clean parse is not enough (a bare word parses fine in most grammars and means nothing structural). Give it a shape (foo($X), $A + $B, if ($C) { ... }) or use the grep flag for litera … [line truncated: 176 more bytes on this line] +````` + +## `./build/ripwire . --grep=DISCLOSE --and=cache` + +*Boolean grep: hits where BOTH literals share the matched line (--grep-scope=line is the default).* + +````` + + + +inline bool topLevelEvidence( std::string_view source, const TSLanguage* language, const Predicate& predicate ) +The crawl's per-file byte ceiling. A text file larger than this is skipped: at this size it is o…constexpr std::size_t kDefaultMaxFileBytes = 4u * 1024u * 1024u +TSTree* parseTree( TSParser* parser, std::string_view src ) + +Exercise every registered grammar and its embedded query, reporting loaded and expected totalsinline DoctorGrammarProbe doctorProbeGrammars() +struct FileHealth +enum class AstWalk : std::uint8_t +inline RevSide sliceAtRev( const std::string& root, const std::string& sha, const std::string& rel, const Symbol& sym, SliceFam fam, const ::TSLanguage* grammar…struct SliceScan +constexpr std::array<LangEntry, 53> kLangTable = +inline bool astroFenceTailIsBlank( std::string_view src, std::size_t from, std::size_t to ) noexcept +constexpr std::uint32_t kMaxKotlinStringNestDepth = 128u +std::vector<LocalNameFact> collectGatedLocalNames( std::string_view defBytes, std::uint32_t defStartLine, Lang lang ) +inline bool hasPhantomScopeSeparator( TSNode qualified ) noexcept +inline ModuleScan scanModule( std::string_view source, std::string_view path ) +bool jsonNestsTooDeep( std::string_view bytes ) noexcept +constexpr std::uint32_t kMaxYamlNestDepth = 64u +SpanTierBatch spanTiersOfFiles( std::span<const std::string> diskPaths, bool useMemo ) +inline bool hasMainGuard( std::string_view source ) +inline void runParseWorker( ParsePoolShared& sh, unsigned t ) +inline SliceScan sliceScanDefinition( const std::string& src, const Symbol& sym, SliceFam fam, const ::TSLanguage* grammar, std::string_view varName ) +struct LintRule +enum class DisclosureWhy : std::uint8_t +inline std::string buildMemoryStopAttr( const IngestResult& ing, bool json ) +struct AstQuerySpec +… [52 more display lines; full output is 9451 bytes on 1 raw line(s)] +````` + +## `./build/ripwire . --for="tree-sitter parse of a source file" --sections=lego,compose` + +*Restore the / sections the --for bundle collapses to a counted stub by default (this is the stub's own next= spelling).* + +````` + + + + +inline bool topLevelEvidence( std::string_view source, const TSLanguage* language, const Predicate& predicate ) +The crawl's per-file byte ceiling. A text file larger than this is skipped: at this size it is o…constexpr std::size_t kDefaultMaxFileBytes = 4u * 1024u * 1024u +TSTree* parseTree( TSParser* parser, std::string_view src ) + +Exercise every registered grammar and its embedded query, reporting loaded and expected totalsinline DoctorGrammarProbe doctorProbeGrammars() +struct FileHealth +enum class AstWalk : std::uint8_t +inline RevSide sliceAtRev( const std::string& root, const std::string& sha, const std::string& rel, const Symbol& sym, SliceFam fam, const ::TSLanguage* grammar…struct SliceScan +constexpr std::array<LangEntry, 53> kLangTable = +inline bool astroFenceTailIsBlank( std::string_view src, std::size_t from, std::size_t to ) noexcept +constexpr std::uint32_t kMaxKotlinStringNestDepth = 128u +std::vector<LocalNameFact> collectGatedLocalNames( std::string_view defBytes, std::uint32_t defStartLine, Lang lang ) +inline bool hasPhantomScopeSeparator( TSNode qualified ) noexcept +inline ModuleScan scanModule( std::string_view source, std::string_view path ) +bool jsonNestsTooDeep( std::string_view bytes ) noexcept +constexpr std::uint32_t kMaxYamlNestDepth = 64u +SpanTierBatch spanTiersOfFiles( std::span<const std::string> diskPaths, bool useMemo ) +inline bool hasMainGuard( std::string_view source ) +inline void runParseWorker( ParsePoolShared& sh, unsigned t ) +inline SliceScan sliceScanDefinition( const std::string& src, const Symbol& sym, SliceFam fam, const ::TSLanguage* grammar, std::string_view varName ) +struct LintRule +enum class DisclosureWhy : std::uint8_t +inline std::string buildMemoryStopAttr( const IngestResult& ing, bool json ) +struct AstQuerySpec +… [63 more display lines; full output is 9784 bytes on 1 raw line(s)] +````` + +## `./build/ripwire . --for="tree-sitter parse of a source file" --auto-bodies` + +*Opt OUT of compact conceptual serving: restore the rank-first auto walk (bundle="auto").* + +**wall time: 1.02s** + +````` + + + + +inline bool topLevelEvidence( std::string_view source, const TSLanguage* language, const Predicate& predicate ) +The crawl's per-file byte ceiling. A text file larger than this is skipped: at this size it is o…constexpr std::size_t kDefaultMaxFileBytes = 4u * 1024u * 1024u +TSTree* parseTree( TSParser* parser, std::string_view src ) + +Exercise every registered grammar and its embedded query, reporting loaded and expected totalsinline DoctorGrammarProbe doctorProbeGrammars() +struct FileHealth +enum class AstWalk : std::uint8_t +inline RevSide sliceAtRev( const std::string& root, const std::string& sha, const std::string& rel, const Symbol& sym, SliceFam fam, const ::TSLanguage* grammar…struct SliceScan +constexpr std::array<LangEntry, 53> kLangTable = +inline bool astroFenceTailIsBlank( std::string_view src, std::size_t from, std::size_t to ) noexcept +constexpr std::uint32_t kMaxKotlinStringNestDepth = 128u +std::vector<LocalNameFact> collectGatedLocalNames( std::string_view defBytes, std::uint32_t defStartLine, Lang lang ) +inline bool hasPhantomScopeSeparator( TSNode qualified ) noexcept +inline ModuleScan scanModule( std::string_view source, std::string_view path ) +bool jsonNestsTooDeep( std::string_view bytes ) noexcept +constexpr std::uint32_t kMaxYamlNestDepth = 64u +SpanTierBatch spanTiersOfFiles( std::span<const std::string> diskPaths, bool useMemo ) +inline bool hasMainGuard( std::string_view source ) +inline void runParseWorker( ParsePoolShared& sh, unsigned t ) +inline SliceScan sliceScanDefinition( const std::string& src, const Symbol& sym, SliceFam fam, const ::TSLanguage* grammar, std::string_view varName ) +struct LintRule +enum class DisclosureWhy : std::uint8_t +inline std::string buildMemoryStopAttr( const IngestResult& ing, bool json ) +struct AstQuerySpec +… [90 more display lines; full output is 13065 bytes on 55 raw line(s)] +````` + +## `./build/ripwire . --for="quality delta acks ledger rubber stamp"` + +*Doc-mention surfacing (default ON): a markdown doc naming a top-resolved symbol in a backtick rides in below that symbol — the legend's [doc mentions: …] clause says it fired.* + +````` + + + + +inline QualityDeltaOutcome computeQualityDelta( const std::string& root ) +std::optional<int> refuseForeignAckSelection( const rw::Config& cfg, const rw::quality::Scope& scope, const std::vector<rw::quality::Regression>& outOfSc … [line truncated: 14 more bytes on this line] + +runQualityViews was NOT a dispatch chain — it held two branches, one of which was 298 lines. T…std::optional<int> runQualityDelta( const MainDispatch& d ) + +H10 (capture-audit 2026-09-04): the --quality-ack run that has NOTHING to accept. It used to re-…int ackNothingToAccept( const std::string& acksFile, const gtl::btree_map<std::string, rw::quality::AckRecord>& acks, const rw::quality::Scope& scope, std::size… +struct DeltaBasis +inline const char* kAcksFile = ".ripwire_quality_acks" +DirtyPinVerdict inspectDirtyBaselinePin( const MainDispatch& d, const std::string& acksFile ) +struct IdentityHealing +inline std::pair<std::string, std::string> qualityDeltaJson( const std::string& root ) +inline bool writeAckRecords( const std::string& path, const gtl::btree_map<std::string, AckRecord>& acks ) +std::optional<int> resolveDeltaBasis( const MainDispatch& d, const std::string& baselineFile, RefPairDelta& refs, DeltaBasis& out ) +inline std::string staleAcksXml( const std::vector<StaleAck>& staleAcks, EscapeFn esc ) +struct QualityDeltaOutcome +inline bool atomicWriteFile( const std::string& path, const std::string& blob ) +inline std::string qualityAcksPath( const std::string& root ) +inline constexpr const char* kAtStampLegend = "<!-- at= is the git commit these numbers were computed at +inline std::string gitHeadSha( const std::string& root ) +inline gtl::btree_map<std::string, AckRecord> readAckRecords( const std::string& path, std::size_t& badLines ) +inline rw::pathguard::NoFollowRead openRegularFileStream( std::string_view what, const std::string& path ) +inline std::string renderAckRecords( const gtl::btree_map<std::string, AckRecord>& acks ) +int runQualityBaselinePin( const MainDispatch& d, const std::string& baselineFile, const std::string& acksFile ) +inline Result computeDmm( const std::string& root, std::string_view spec, const IngestResult& workingIng, const std::vector<std::string>& excludes, std::size_t maxFileBytes … [line truncated: 6 more bytes on this line] +inline std::string qualityBaselinePath( const std::string& root ) +inline constexpr const char* kQdSchemeLegend = "A THIRD re-filing, git-independent: on 2026-08-25 the per-symbol quality key stopped being a " "canonical-id hash (which degraded to a BA … [line truncated: 62 more bytes on this line] +… [52 more display lines; full output is 9762 bytes on 1 raw line(s)] +````` + +## `./build/ripwire . --for="quality delta acks ledger rubber stamp" --no-doc-mention` + +*The same task with doc-mention surfacing OFF — the contrast the flag exists for (no [doc mentions] clause, one fewer row).* + +**wall time: 1.01s** + +````` + + + + +inline QualityDeltaOutcome computeQualityDelta( const std::string& root ) +std::optional<int> refuseForeignAckSelection( const rw::Config& cfg, const rw::quality::Scope& scope, const std::vector<rw::quality::Regression>& outOfSc … [line truncated: 14 more bytes on this line] + +runQualityViews was NOT a dispatch chain — it held two branches, one of which was 298 lines. T…std::optional<int> runQualityDelta( const MainDispatch& d ) + +H10 (capture-audit 2026-09-04): the --quality-ack run that has NOTHING to accept. It used to re-…int ackNothingToAccept( const std::string& acksFile, const gtl::btree_map<std::string, rw::quality::AckRecord>& acks, const rw::quality::Scope& scope, std::size… +struct DeltaBasis +inline const char* kAcksFile = ".ripwire_quality_acks" +DirtyPinVerdict inspectDirtyBaselinePin( const MainDispatch& d, const std::string& acksFile ) +struct IdentityHealing +inline std::pair<std::string, std::string> qualityDeltaJson( const std::string& root ) +inline bool writeAckRecords( const std::string& path, const gtl::btree_map<std::string, AckRecord>& acks ) +std::optional<int> resolveDeltaBasis( const MainDispatch& d, const std::string& baselineFile, RefPairDelta& refs, DeltaBasis& out ) +inline std::string staleAcksXml( const std::vector<StaleAck>& staleAcks, EscapeFn esc ) +struct QualityDeltaOutcome +inline bool atomicWriteFile( const std::string& path, const std::string& blob ) +inline std::string qualityAcksPath( const std::string& root ) +inline constexpr const char* kAtStampLegend = "<!-- at= is the git commit these numbers were computed at +inline std::string gitHeadSha( const std::string& root ) +inline gtl::btree_map<std::string, AckRecord> readAckRecords( const std::string& path, std::size_t& badLines ) +inline rw::pathguard::NoFollowRead openRegularFileStream( std::string_view what, const std::string& path ) +inline std::string renderAckRecords( const gtl::btree_map<std::string, AckRecord>& acks ) +int runQualityBaselinePin( const MainDispatch& d, const std::string& baselineFile, const std::string& acksFile ) +inline Result computeDmm( const std::string& root, std::string_view spec, const IngestResult& workingIng, const std::vector<std::string>& excludes, std::size_t maxFileBytes … [line truncated: 6 more bytes on this line] +inline std::string qualityBaselinePath( const std::string& root ) +inline constexpr const char* kQdSchemeLegend = "A THIRD re-filing, git-independent: on 2026-08-25 the per-symbol quality key stopped being a " "canonical-id hash (which degraded to a BA … [line truncated: 62 more bytes on this line] +… [52 more display lines; full output is 9724 bytes on 1 raw line(s)] +````` + +## `./build/ripwire . --safe-delete=rankGraphTeleport` + +*"Can I delete this?" — callers + transitive impact + every use site + how much of the radius is tested, composed in ONE call; risk= names what was found, never a verdict.* + +````` + + + + + + + + + +````` + +## `./build/ripwire . --safe-delete=DoesNotExist` + +*Unknown-symbol refusal shape for --safe-delete.* + +**exit code: 1** + +````` +(empty) +````` + +stderr: + +````` +ripwire: --safe-delete symbol not found: DoesNotExist +````` + +## `./build/ripwire . --handoff` + +*The continuation packet for the NEXT session: disk truth (branch/sha, changed symbols, blast radius, tests) + labeled suggestions. Recorded against a CLEAN tree.* + +**wall time: 1.57s** + +````` + + + + + + + + + + + + + +````` + +## `./build/ripwire . --handoff --token-budget=1200` + +*The same packet under a hard ceiling: heuristic rows drop tail-first (withheld= disclosed), verified rows never drop.* + +**wall time: 1.53s** + +````` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +… [415 more display lines; full output is 44441 bytes on 1 raw line(s)] +````` + +## `./build/ripwire . --no-ignore --top-k=3` + +*Crawl paths the repo's own .gitignore covers (default honours it and discloses ignored_files=/ignored_dirs= only when it dropped anything — this repo's crawl drops nothing, so the header is identical to the default map's; --skipped's ignore_mode= says which rule applied).* + +````` + + + + + + + + + + + + + + +````` + +## `./build/ripwire . --no-stable --top-k=3` + +*--no-stable outside --mcp: what the flag does (or says) when there is no stable-by-default ordering to opt out of.* + +````` + + + + + + + + + + + + + + +````` + +stderr: + +````` +ripwire: --no-stable is read only by --mcp/--listen (it opts out of the stable ordering the server turns on) — it changed nothing here; the CLI map orders important-first unless you pass --order=stable +````` + +## `./build/ripwire . --run-trace="cat /aux/asan_trace_now.txt; exit 1"` + +*EXEC-MODE --from-trace: run a command, and on a non-zero exit map its captured output onto indexed symbols in the same call — the whole fix-loop entry. ripwire exits 4 here because the wrapped command failed, which is the signal, not an incident.* + +**exit code: 4** + +````` + + + + + + const&, float) src/graph.h:7926 + #1 0x102f3e884 in rw::rankGraph(Graph const&, float) src/graph.h:7967 + #2 0x102e11f30 in runDefaultMap(MainDispatch const&) src/main.cpp:1634 + #3 0x102e01a44 in main src/main.cpp:3999 + #4 0x1a2b3c0dc in start+0x9dc (dyld:arm64e+0x60dc)]]> pw = biasPrior( g, p ); + const std::size_t N = pw.size(); + std::vector teleport( pw.begin(), pw.end() ); + std::vector rankDouble( N, 0.0 ); + PageRankRun run{}; // an N == 0 graph never enters the kernel: { 0, converged } — see PageRankRun + if( N ) + { + double teleportMass = 0.0; + for( const double value : teleport ) + { + teleportMass += value; + } + if( teleportMass > 0.0 ) + { + const double inverseMass = 1.0 / teleportMass; + for( double& value : teleport ) + { + value *= inverseMass; + } + } + run = pageRankDouble( g.inEdges, g.wOutDeg, teleport, rankDouble, PageRankConfig{ .alpha = double( alpha ) } ); + } + std::vector r( N, 0.f ); + std::transform( rankDouble.begin(), rankDouble.end(), r.begin(), []( double value ) { return float( value ); } ); + return { std::move( r ), run.iterationCount, run.hasConverged }; +}]]>inline std::vector<float> biasPrior( const Graph& g, const std::vector<float>& p )#define PROFILE_SCOPE_DESCRIBE( desc )#define PROFILE_SCOPE_DESCR … [line truncated: 524 more bytes on this line] +````` + +## `./build/ripwire . --run-trace="true"` + +*A command that exits 0: a minimal success record (exit, measured duration, disclosed output tail) and NO bundle — nothing failed, nothing to map.* + +````` + + + + +````` + +## `./build/ripwire . --run-trace="sleep 30" --run-timeout=2` + +*A command still running at the cap: its process group is killed and the run reports timed_out=1 — an honest timeout, never an empty success.* + +**exit code: 4** — **wall time: 2.58s** + +````` + + + + +````` + +stderr: + +````` +ripwire: --run-trace: TIMEOUT — the command exceeded the 2 s cap; its process group was killed +````` + +## `./build/ripwire . --run-timeout=5` + +*--run-timeout alone is refused loudly (it only modifies --run-trace).* + +**exit code: 1** + +````` +(empty) +````` + +stderr: + +````` +ripwire: --run-timeout=SECONDS modifies --run-trace — pass it too (e.g. ripwire --run-trace="make -j" --run-timeout=60) +````` + + +--- + +# assess quality — the wider lens family + +## `./build/ripwire . --quality-panel` + +*THE single wide-angle quality read: six families in one pass, an eligible/ranked shortlist rather than a firehose.* + +**wall time: 4.01s** + +````` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +… [185 more display lines; full output is 15643 bytes on 1 raw line(s)] +````` + +## `./build/ripwire . --biggest-first --limit=8` + +*Per-function size ranking, largest Halstead volume/token entropy/lines first — a size proxy, not a readability order (withdrawn, docs/EVALS.md §8) — a RANKING lens, not a grade.* + +````` + + + + + + + + + + + +````` + +## `./build/ripwire . --comment-coherence --limit=8` + +*Functions WITH a doc comment, most name-restating first: c_coeff (high = the comment repeats the name) and cic (Jaccard of comment vs identifier vocabulary), both reported, never collapsed.* + +````` + + + + + + + + + + + +````` + +## `./build/ripwire . --context-ratio --limit=8` + +*The local-reasoning lens: to understand this symbol, how much must you know that is NOT in front of you (ent_ratio= edge share, read_ratio= token-weighted).* + +````` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +… [21 more display lines; full output is 11200 bytes on 1 raw line(s)] +````` + +## `./build/ripwire . --nonlocal-state --limit=8` + +*Per function, the non-local MUTABLE state it can reach (transitively), most writes first — unsound by construction, and the legend says where.* + +**wall time: 2.36s** + +````` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +… [41 more display lines; full output is 7594 bytes on 1 raw line(s)] +````` + +## `./build/ripwire . --ensemble --limit=8` + +*The family join: per function, which of four orthogonal evidence families fire, ranked by how many agree.* + +**wall time: 2.10s** + +````` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +… [35 more display lines; full output is 7386 bytes on 1 raw line(s)] +````` + +## `./build/ripwire . --field-affinity` + +*The cache-locality lens over every aggregate: fields READ TOGETHER but declared FAR APART (split-line / straddle findings, Chilimbi separation weight) — advice only, never a rewrite.* + +**wall time: 1.31s** + +````` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +… [513 more display lines; full output is 46141 bytes on 1 raw line(s)] +````` + +## `./build/ripwire . --field-affinity=Symbol` + +*The same lens narrowed to ONE struct — the one --layout=Symbol shows the offsets for.* + +**wall time: 1.23s** + +````` + + + + + + + + + + + + + + + + + + + + + + + + + + +````` + +## `./build/ripwire . --naming-consistency --limit=8` + +*The corpus's OWN case-convention vote per (language, kind) group; off-convention names get a mechanical propose= (a suggestion, never a blind rename).* + +````` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +… [13 more display lines; full output is 4347 bytes on 1 raw line(s)] +````` + +## `./build/ripwire . --naming-calibration` + +*Score the naming-* rules against this repo's own rename history: proxy=old/(old+new) per rule, 0.50 = chance; read pairs= (sample size) first.* + +**wall time: 9.15s** + +````` + + + + + + + + + + + +

+

+

+

+

+

+

+

+

+

+

+

+

+

+

+

+… [131 more display lines; full output is 14137 bytes on 1 raw line(s)] +````` + +## `./build/ripwire . --lint --naming-locals` + +*The opt-in --lint modifier: naming predicates over LOCAL variable names too, C/C++ only, only inside functions already past a size/complexity gate.* + +**wall time: 2.18s** + +````` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +… [639 more display lines; full output is 72669 bytes on 1 raw line(s)] +````` + +## `./build/ripwire . --lint-catalog` + +*The built-in rule registry — one row per rule with sev=/category=/rationale/lang=/since=; no corpus needed.* + +````` + + +a C-style cast — cppcoreguidelines-pro-type-cstyle-cast prefers the explicit static_cast/const_cast/reinterpret_cast +a goto statement — cppcoreguidelines-avoid-goto +a do/while loop shape (on Swift, its do/catch block shares the same grammar node and also matches) +a call to an unbounded C string function (strcpy/strcat/sprintf/gets) +a call to a broken hash or cipher (MD5/SHA1/MD4/RC4) +a doubly-parenthesized expression — readability-redundant-parentheses +an if-body that is just `;` — bugprone-suspicious-semicolon +a C-style typedef struct/union where `using` is preferred +a non-trivial numeric literal inside a function body, outside a const/constexpr init +a catch block with an empty body +x = x — almost always a copy-paste bug +a function body over 80 lines +brace nesting depth over 4 inside a function body +a bare `return;` mixed with a value-returning return in the same function +a statement after an unconditional return/break/continue/throw/raise in the same block +a 1-2 letter Function/Method/Var name — visible far beyond any tiny scope [Beniamini/Hofmeister] +more than 5 split tokens in one name [Butler; AlSuhaibani] +foo1/foo2/... digit-suffix siblings sharing a base name in one scope [Butler] +internal consecutive underscores, or a C-family reserved __x/_X form [Butler] +snake_case and camelCase mixed inside one name [Butler] +an is/has/can/should/was-prefixed name whose KNOWN return type is not bool-like [LAPD A2] +a set-prefixed name whose KNOWN return type is not void-like [LAPD A3] +a co-visible pair within edit distance <=2, reordered tokens, or a bare/digit-suffixed twin [Namesake] +every split subtoken is corpus-ubiquitous (BM25 idf) on a body past the size floor — fires only at the low end [Sparck Jones 1972] +the comma operator inside an expression (never a for-header comma) [Gopstein FSE 2017] +++/-- evaluated inside a larger expression (never a whole statement) [Gopstein FSE 2017] +an assignment whose VALUE is consumed (a condition, an argument, ...) [Gopstein FSE 2017] +a conditional expression inside a conditional expression [Gopstein FSE 2017] +… [12 more display lines; full output is 7764 bytes on 1 raw line(s)] +````` + +## `./build/ripwire . --legend-dict` + +*The session legend dictionary the MCP server serves as ripwire://legend-dict/full — one definition per line, headed by its dictv= version; no corpus needed. =roster lists the completeness attributes it defines.* + +````` +ripwire legend dictionary ripwire.dict/v1 dictv=593769ab691623d8 entries=806 +: the answer's rows come first; its root keeps only task= changed= from= to=, and this LAST child carries every other root attribute unchanged (schema= included); legend="ref": a definition is sent once per session (this dictionary's core, or the first answer that ne … [line truncated: 83 more bytes on this line] +schema=ripwire.KEY/v1: the line ripwire.KEY/v1 below reads the answer's rows +window: shown= total= capped= has_more= next_offset= offset= limit= page a list (capped=1 cut; next_offset= pastes as offset=) +X_capped= (any attribute ending _capped): 1 = cut +under ref, est_tokens= and over_ceiling= price the answer with its inline legend: an upper bound +under ref, a group of n <= 8 runner-less rows prints as its n single rows, each run_unknown=1 +ripwire.map/v1 : ranked symbol map: groups rows (k= rank), resolved callees; the header comment is data +ripwire.map-diff/v1 : the ranked map anchored at at=: what the diff touched, the map's row vocabulary +ripwire.metrics/v1 : the ranked map with per-symbol metrics: in/out, cx/ccx, loc, params, nest, humps/deep, locals, cbo, amp, tested, ev +ripwire.around/v1 : call neighbourhood of of=: depth= hops, fanout= kept per hop; absent rows lie outside that boundary +ripwire.query/v1 : lexical-rank map for the query term, the map's row vocabulary +ripwire.pack-signatures/v1 : the ranked map plus signature rows +ripwire.pack-top-n/v1 : the ranked map plus bodies of the top-N symbols +ripwire.skipped/v1 : why the index lacks a file ; indexed but unvouched ; census +ripwire.notes/v1 : field notes by target: holds ; the kept count comment: notes= rows, targets= rows, dangling= targets matching nothing indexed (listed, surfaced nowhere) +ripwire.lego/v1 : ONE interface/base type: , its method contract, every implementor +ripwire.expand/v1 : full bodies: of ; resolved callees +ripwire.expand-file/v1 : the file's own text: ; per scoped symbol; full id = p::sc::n +ripwire.pack-task/v1 : one-call task bundle for task= under budget_tokens=: ranking, ranked but over 1 hop out (of_top= ranked rows) > with callees > 1-hop from the bodies (o … [line truncated: 105 more bytes on this line] +ripwire.from-trace/v1 : trace frames mapped to indexed symbols, innermost first; the innermost in-corpus body included +ripwire.exemplar/v1 : the best-in-class instance of kind= for the task, chosen by role: , to imitate +ripwire.pack-task/v1 : N minimally overlapping agent bundles carved along call-graph communities plus one shared core; each wraps a ; bundle role=core|partition i= symbols= modules= bytes= tokens=: one agent's ctx, symbols= ids assigned, bytes= its size; tokens= = est_t … [line truncated: 345 more bytes on this line] +ripwire.callers/v1 : 1-hop CALLERS of of= (defs= matched, count= distinct symbols): ; hop_tested=/hop_untested= +ripwire.callees/v1 : 1-hop CALLEES of of= (defs= matched, count= distinct symbols): +ripwire.uses/v1 : resolvable use-sites of of=: +ripwire.impact/v1 : transitive blast radius of of=: reach set, importers; defs= matched, reaches= their transitive callers, radius_tested= non-tests an indexed test reaches, radius_untested= the rest; importers= files that #include/import a def's file +ripwire.path/v1 : one DIRECTED call path from= to to=, each a hop; reachable=0 hops=0 when none +ripwire.connect/v1 : minimal joining subgraph: groups, terminals, joins, edges, +ripwire.at/v1 : enclosing-definition chain at p=:l=: sym= innermost, chain= outermost-first, spans +… [777 more display lines; full output is 80949 bytes on 807 raw line(s)] +````` + +## `./build/ripwire . --lint --lint-select=cache-` + +*Run ONLY one rule family; the root carries selected="K of N" so a filtered zero is never confusable with an unfiltered one.* + +**wall time: 1.72s** + +````` + + + + + + + + + + +vector<std::vector<double>> +nodes[ perm[i] ] +nodes[ perm[ i + 1 ] ] +p = p->next +map +byName[ symN[i] ] +vector<std::vector<std::vector<std::uint32_t>>> +vector<std::vector<std::uint32_t>> +m[ symN[id] ] +vector<std::vector<double>> +m[ in.keys[ i ] ] +map +vector<std::vector<std::vector<std::uint32_t>>> +vector<std::vector<std::uint32_t>> +m[ symN[id] ] +map +m[ g_pool[ g_keyOf[i] ] ] +g_pool[ g_readOf[i] ] +g_pool[ g_readOf[i] ] +m[ g_pool[i] ] +… [533 more display lines; full output is 64511 bytes on 1 raw line(s)] +````` + +## `./build/ripwire . --lint --lint-ignore=naming-,cache-` + +*DROP two families, applied after selection; the raw select=/ignore= you passed rides on the root.* + +**wall time: 1.75s** + +````` + + + + + + + + + + + + + + + + + + + + + + + +0.5 +1023u +main (90 lines) +20 +delta < 0.02 && delta > -0.02 +0.02 +… [727 more display lines; full output is 66106 bytes on 1 raw line(s)] +````` + +## `./build/ripwire . --lint --lint-select=goto --lint-max-per-rule=4` + +*A rule that spends its own match budget (here 4, set BELOW the default 5000 so the floor shows on this repo): goto carries count_capped="1", its count= is a FLOOR, and the root names the call that counts the rest — findings_next= re-runs only the floored rules under a 10x budget.* + +**wall time: 2.47s** + +````` + + + +goto done; +goto epilogue; +goto epilogue; +goto epilogue; + +````` + +## `./build/ripwire . --lint --lint-select=goto --lint-max-per-rule=40` + +*Pasting that findings_next=: the same rule under a 10x budget counts its true total — no count_capped=, no findings_next=.* + +**wall time: 1.93s** + +````` + + + +goto done; +goto epilogue; +goto epilogue; +goto epilogue; +goto epilogue; +goto epilogue; +goto epilogue; +goto epilogue; +goto epilogue; +goto epilogue; +goto epilogue; +goto epilogue; +goto epilogue; +goto done; +goto done; + +````` + +## `./build/ripwire . --lint --lint-select=cach-` + +*An unresolvable PREFIX refuses (exit 1) with a did-you-mean from a real edit distance (one character off cache-).* + +**exit code: 1** — **wall time: 1.77s** + +````` +(empty) +````` + +stderr: + +````` +ripwire: --lint-select: 'cach-' matches no rule or family (did you mean 'cache-'?) — see --lint-catalog for the full registry +````` + +## `./build/ripwire . --lint --lint-select=nosuchfamily` + +*A PREFIX with no near miss at all: the refusal points at --lint-catalog instead of guessing.* + +**exit code: 1** — **wall time: 1.78s** + +````` +(empty) +````` + +stderr: + +````` +ripwire: --lint-select: 'nosuchfamily' matches no rule or family — see --lint-catalog for the full registry +````` + +## `./build/ripwire . --lint --sarif` + +*The SAME findings as SARIF 2.1.0 (what github/codeql-action/upload-sarif consumes) — pure re-serialization, results count == the native run's.* + +**wall time: 1.76s** + +````` +{"version":"2.1.0","$schema":"https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json","runs":[{"tool":{"driver":{"name":"ripwire","rules":[{"id":"c-style-cast","shortDescription":{"text":"c-style-cast"},"properties":{"builtin":true,"capped":false,"applicable": … [line truncated: 7 more bytes on this line] +{"id":"goto","shortDescription":{"text":"goto"},"properties":{"builtin":true,"capped":false,"applicable":true}}, +{"id":"do-while","shortDescription":{"text":"do-while"},"properties":{"builtin":true,"capped":false,"applicable":true}}, +{"id":"unsafe-c-fn","shortDescription":{"text":"unsafe-c-fn"},"properties":{"builtin":true,"capped":false,"applicable":true}}, +{"id":"weak-crypto","shortDescription":{"text":"weak-crypto"},"properties":{"builtin":true,"capped":false,"applicable":true}}, +{"id":"redundant-parens","shortDescription":{"text":"redundant-parens"},"properties":{"builtin":true,"capped":false,"applicable":true}}, +{"id":"suspicious-semicolon","shortDescription":{"text":"suspicious-semicolon"},"properties":{"builtin":true,"capped":false,"applicable":true}}, +{"id":"typedef-over-using","shortDescription":{"text":"typedef-over-using"},"properties":{"builtin":true,"capped":false,"applicable":true}}, +{"id":"magic-number","shortDescription":{"text":"magic-number"},"properties":{"builtin":true,"capped":true,"applicable":true}}, +{"id":"empty-catch","shortDescription":{"text":"empty-catch"},"properties":{"builtin":true,"capped":false,"applicable":true}}, +{"id":"self-assign","shortDescription":{"text":"self-assign"},"properties":{"builtin":true,"capped":false,"applicable":true}}, +{"id":"large-function","shortDescription":{"text":"large-function"},"properties":{"builtin":true,"capped":false,"applicable":true}}, +{"id":"deep-nesting","shortDescription":{"text":"deep-nesting"},"properties":{"builtin":true,"capped":false,"applicable":true}}, +{"id":"inconsistent-return","shortDescription":{"text":"inconsistent-return"},"properties":{"builtin":true,"capped":false,"applicable":true}}, +{"id":"unreachable-code","shortDescription":{"text":"unreachable-code"},"properties":{"builtin":true,"capped":false,"applicable":true}}, +{"id":"naming-short","shortDescription":{"text":"naming-short"},"properties":{"builtin":true,"capped":false,"applicable":true}}, +{"id":"naming-wordy","shortDescription":{"text":"naming-wordy"},"properties":{"builtin":true,"capped":false,"applicable":true}}, +{"id":"naming-series","shortDescription":{"text":"naming-series"},"properties":{"builtin":true,"capped":false,"applicable":true}}, +{"id":"naming-underscore","shortDescription":{"text":"naming-underscore"},"properties":{"builtin":true,"capped":false,"applicable":true}}, +{"id":"naming-case","shortDescription":{"text":"naming-case"},"properties":{"builtin":true,"capped":false,"applicable":true}}, +{"id":"naming-predicate","shortDescription":{"text":"naming-predicate"},"properties":{"builtin":true,"capped":false,"applicable":true}}, +{"id":"naming-setter","shortDescription":{"text":"naming-setter"},"properties":{"builtin":true,"capped":false,"applicable":true}}, +{"id":"naming-confusable","shortDescription":{"text":"naming-confusable"},"properties":{"builtin":true,"capped":false,"applicable":true}}, +{"id":"naming-uninformative","shortDescription":{"text":"naming-uninformative"},"properties":{"builtin":true,"capped":false,"applicable":true}}, +{"id":"atom-comma-operator","shortDescription":{"text":"atom-comma-operator"},"properties":{"builtin":true,"capped":false,"applicable":true}}, +{"id":"atom-embedded-crement","shortDescription":{"text":"atom-embedded-crement"},"properties":{"builtin":true,"capped":false,"applicable":true}}, +{"id":"atom-assign-as-value","shortDescription":{"text":"atom-assign-as-value"},"properties":{"builtin":true,"capped":false,"applicable":true}}, +{"id":"atom-nested-ternary","shortDescription":{"text":"atom-nested-ternary"},"properties":{"builtin":true,"capped":false,"applicable":true}}, +{"id":"atom-implicit-predicate","shortDescription":{"text":"atom-implicit-predicate"},"properties":{"builtin":true,"capped":false,"applicable":true}}, +{"id":"atom-octal-literal","shortDescription":{"text":"atom-octal-literal"},"properties":{"builtin":true,"capped":false,"applicable":true}}, +… [10455 more display lines; full output is 1464271 bytes on 1 raw line(s)] +````` + +Parsed summary of the same SARIF (past the display cut): + +````` +sarif 2.1.0 rules= 39 results= 5223 +````` + +## `./build/ripwire . --lint --sarif --limit=5` + +*SARIF is always the FULL result set: paging alongside it refuses loudly.* + +**exit code: 1** + +````` +(empty) +````` + +stderr: + +````` +ripwire: --sarif always emits the full result set — drop --limit=N/--offset=M +````` + +## `./build/ripwire . --dmm` + +*The Delta Maintainability Model scalar for the WORKING TREE vs HEAD — recorded against a CLEAN tree (the sandbox section shows a real delta). UNAVAILABLE is a stated reason, never 0 or 1.* + +**wall time: 2.84s** + +````` + + +

+

+ +````` + +## `./build/ripwire . --dmm=HEAD` + +*The per-commit scalar: HEAD vs its first parent, with the three separately actionable sub-scores.* + +**wall time: 6.63s** + +````` + + +

+

+ +````` + +## `./build/ripwire . --dmm=HEAD~3..HEAD` + +*The range form: tree HEAD vs tree HEAD~3.* + +**wall time: 6.50s** + +````` + + +

+

+ +````` + +## `./build/ripwire . --cochange --cochange-groups` + +*Modularity-violation GROUPS instead of pairs: "X co-changes with {A,B,C}, none of which it depends on" — a greedy cover, disclosed as greedy.* + +**wall time: 1.36s** + +````` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +… [773 more display lines; full output is 51080 bytes on 1 raw line(s)] +````` + +## `./build/ripwire . --cochange --cochange-recur=2` + +*Only pairs whose co-change RECURS in 2+ sub-windows of the mined window (sub_windows= is the denominator) — a one-off sprint stops reading like a structural defect.* + +**wall time: 1.27s** + +````` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +````` + +## `./build/ripwire . --html=/aux/map2.html --color-by=community` + +*The HTML graph with the initial colour mode set to community (the page embeds all five modes and keeps a live selector).* + +**wall time: 1.37s** + +````` +(empty) +````` + +Artifact written: + +````` + 159219 /aux/map2.html +````` + +## `./build/ripwire . --index-out=/aux/ci_index` + +*CI generate-and-exit: cold-parse and write BOTH committable cache families (lean + rich), no map on stdout.* + +**wall time: 3.57s** + +````` +(empty) +````` + +stderr: + +````` +ripwire: --index-out wrote /aux/ci_index.lean.ripwirecache (23065349 bytes, lean family) +ripwire: --index-out wrote /aux/ci_index.rich.ripwirecache (48813356 bytes, rich family) +````` + +Artifact written: + +````` + 23065349 /aux/ci_index.lean.ripwirecache + 48813356 /aux/ci_index.rich.ripwirecache + 71878705 total +````` + +## `./build/ripwire . --cache=/aux/ci_index.lean.ripwirecache --top-k=3` + +*Consume the lean artifact in a PR job: restore-equivalence, never blob-byte-identity.* + +````` + + + + + + + + + + + + + + +````` + +## `./build/ripwire . --pin-census=/aux/pin_census.tsv --top-k=3` + +*Eval-only: a per-call-site census of WHICH mechanism resolved each call, and the canonical id of every surviving target.* + +````` + + + + + + + + + + + + + + +````` + +Artifact written: + +````` +# ripwire pin-census v3 C=kind\tmech\tpre\tpost\tflags\tcaller_id\tcallee\ttargets(|-sep)\tline +# v3 field escape: every id and callee field is escaped, so no separator occurs inside one. A backslash is +# written \\, TAB \t, LF \n, CR \r, every other control byte 0x00-0x1f and the targets separator | as \xHH +# (lowercase hex). Split lines on LF, fields on TAB and targets on |, THEN decode. Columns are unchanged +# from v2 and an id without those bytes is spelled exactly as in v2; a C++ out-of-line template member +# whose template-argument list spans lines keeps its line break as \n instead of splitting the row. +# line is the 1-based call-site line in the caller's file (v2, appended LAST so v1 readers are unchanged): +# the key a SCIP occurrence joins on, so a coverage loss can be classified per site instead of guessed. + 122387 /aux/pin_census.tsv +````` + +## `./build/ripwire . --plan-lint=test/planlintfix/wave.md` + +*The house PLAN/DESIGN format's STRUCTURE check — never semantics; exit 2 when a card or ledger row gates.* + +**exit code: 2** + +````` + + + + + + + + + + + + + +````` + +## `./build/ripwire . --plan-lint=test/planlintfix/wave_ledger.md` + +*The ledger-shaped fixture through the same check (exit 2: one gating card).* + +**exit code: 2** + +````` + + + + + + + +````` + +## `./build/ripwire . --doctor --agent=claude` + +*--doctor plus a LIVE integration inspection for one agent: PATH binary, installed-skill manifest parity, hook executability, MCP wiring — read-only, fixed repair commands, never config contents; exit 1 when any check fails.* + +**exit code: 1** — **wall time: 2.91s** + +````` + + + + + + + + + + + + + +````` + +## `./build/ripwire . --doctor --agent=nosuch` + +*Other --agent values refuse.* + +**exit code: 1** + +````` +(empty) +````` + +stderr: + +````` +ripwire: unsupported --agent value 'nosuch' (supported: codex, claude) +````` + + +--- + +# the dirty-tree verbs (throwaway clone, NOT the read-only repo) + +Everything below runs with `cwd` = the throwaway clone at `/dirty` (`git clone --local` of this repo, then one deliberate regression in `src/infra/sortutil.h`). The read-only repo is never touched. The binary is the same `build/ripwire`, addressed absolutely. + +## `./build/ripwire . --situ` + +*Situational report for a real diff: blast radius + tests + co-change + forgotten co-change partners.* + +**wall time: 4.18s** + +````` +ripwire situational-awareness — 1 changed file(s), 13 symbols in them +root: . +at: f1e5c2e76+dirty + [1] blast radius: 225 symbols across 51 files transitively depend on these changes (showing 8 of 51 files — shown=8 total=51 capped=1 prcontext_cap=20 (--pr-context's own per-file list is cut at 20 too); next: --situ --limit=51) + counts_floor=1 graph_ambiguous=12498 graph_unresolved=12552 graph_unindexed=255 (files no grammar in this build could read at all) (the map header's own gauges) — every count above is a FLOOR, never a total: call edges are name-based, so dynamic dispatch, callbacks and macros can be missin … [line truncated: 46 more bytes on this line] + src/mcpverbs.h (33 dependent symbols) + src/slice.h (30 dependent symbols) + src/quality.h (10 dependent symbols) + src/verbs_for.h (10 dependent symbols) + src/verbs_navigate.h (10 dependent symbols) + src/resolve.h (9 dependent symbols) + src/serialize.h (9 dependent symbols) + src/main.cpp (7 dependent symbols) + [2] tests to run (5) order=evidence: [changed] you edited it, [partner] named after a changed file, then hops asc (1 = direct); "(n): a, b" = n runner-less files sharing that evidence; a (run: …) is relative to root: + test/verify_radix.cpp [hops=1] (run: bash test/greptiercheck.sh) + test/adaptivecutshapefix/adaptive_cut_shape_test.cpp [hops=2] (run: bash test/adaptivecutshapecheck.sh) + test/includeprecise_unit.cpp [hops=2] (run: bash test/includeprecisecheck.sh) + test/verify_csr.cpp [hops=2] (run: bash test/a9disclosurecheck.sh) + test/rustimport_unit.cpp [hops=4] (run: bash test/rustimportprecisecheck.sh) + script_gates_unmodelled=743 — test/*.sh gates never appear above: script-to-binary edges are not call edges (a path count) + [3] co-change — usually edited with these but NOT in your diff (0) window="18mo@HEAD" commits="4174": + (none — 4174 commits were mined and none co-edited a file outside your diff) + next: --test-gate +````` + +## `./build/ripwire . --test-gate` + +*The pre-PR gate with real obligations — exit 4 when tests-to-run or untested blast radius is non-empty.* + +**exit code: 4** — **wall time: 3.35s** + +````` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +````` + +## `./build/ripwire . --quality-delta` + +*Every row carries p="file:line", the gating rows are marked gating="1" and now bar= (the threshold each numeric row is judged against), and the exit-2 refusal prints a naming line on stderr; a gating row carries a pasteable next=.* + +**exit code: 2** — **wall time: 19.98s** + +````` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +… [127 more display lines; full output is 16474 bytes on 1 raw line(s)] +````` + +stderr: + +````` +ripwire: no ./.ripwire_quality_baseline — auto-comparing the working tree vs git HEAD (commit the baseline with --quality-baseline to pin it) +ripwire: --quality-delta gating: 5 preexisting-worse major finding(s); first: api-surface src/infra/sortutil.h::rw::sortutil::nonNegativeFloatDescKey at src/infra/sortutil.h:109 (was=1 now=2) +````` + +## `./build/ripwire . --quality-delta --legend=full` + +*The same gating report under --legend=full — same rows, same exit 2; the default compact comment (schema="ripwire.quality-delta/v1", the shape an agent's edit loop should run) becomes ~4 KB of prose legend.* + +**exit code: 2** — **wall time: 9.87s** + +````` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +… [127 more display lines; full output is 21352 bytes on 1 raw line(s)] +````` + +stderr: + +````` +ripwire: no ./.ripwire_quality_baseline — auto-comparing the working tree vs git HEAD (commit the baseline with --quality-baseline to pin it) +ripwire: --quality-delta gating: 5 preexisting-worse major finding(s); first: api-surface src/infra/sortutil.h::rw::sortutil::nonNegativeFloatDescKey at src/infra/sortutil.h:109 (was=1 now=2) +````` + +## `./build/ripwire . --quality-delta --json` + +*The same findings as JSON (one of the CI/scripting verbs --json supports) — same exit 2 as the XML form.* + +**exit code: 2** — **wall time: 9.92s** + +````` +{"baseline":"git-HEAD","regressions":9,"minor":1,"acked":0,"stale":145,"preexisting-worse":6,"new-symbol":3,"gating":5,"register-macro-excluded":62,"api-new-surface":2,"at":"f1e5c2e76+dirty","renames":58,"rename_window_commits":400,"acked_by_rename":0,"acked_by_content":0,"renames_window_truncated": … [line truncated: 211 more bytes on this line] +{"kind":"complexity","sym":"src/infra/sortutil.h::rw::sortutil::lessByScoreDescId","was":1,"now":67,"p":"src/infra/sortutil.h:49","gating":true}, +{"kind":"dead-code","sym":"src/infra/sortutil.h::rw::sortutil::nonNegativeFloatAscKeyCopy","p":"src/infra/sortutil.h:119","origin":"new-symbol"}, +{"kind":"dead-code","sym":"src/infra/sortutil.h::rw::sortutil::sortScoredIdsWithOptions","p":"src/infra/sortutil.h:129","origin":"new-symbol"}, +{"kind":"duplication","members":"src/infra/sortutil.h::rw::sortutil::nonNegativeFloatAscKeyCopy | src/infra/sortutil.h::rw::sortutil::nonNegativeFloatDescKey","tokens":59,"p":"src/infra/sortutil.h:119","gating":true}, +{"kind":"nesting","sym":"src/infra/sortutil.h::rw::sortutil::lessByScoreDescId","was":1,"now":6,"p":"src/infra/sortutil.h:49","gating":true}, +{"kind":"new-clone-of-reused-helper","sym":"src/infra/sortutil.h::rw::sortutil::nonNegativeFloatAscKeyCopy | src/infra/sortutil.h::rw::sortutil::nonNegativeFloatDescKey","was":0,"now":4,"p":"src/infra/sortutil.h:119","gating":true}, +{"kind":"params","sym":"src/infra/sortutil.h::rw::sortutil::sortScoredIdsWithOptions","was":0,"now":8,"p":"src/infra/sortutil.h:129","origin":"new-symbol"}, +{"kind":"verbosity","sym":"src/infra/sortutil.h::rw::sortutil::lessByScoreDescId","was":8,"now":47,"p":"src/infra/sortutil.h:49","sev":"minor"}], +"sa":[{"kind":"api-surface","key":"085e3d408c2c4a35","why":"target-gone"}, +{"kind":"api-surface","key":"155d74d341a49f3c","why":"target-gone"}, +{"kind":"api-surface","key":"1a15386c2d1e47af","why":"target-gone"}, +{"kind":"api-surface","key":"298e798c7f075715","why":"target-gone"}, +{"kind":"api-surface","key":"56acdf9b5c314a14","why":"target-gone"}, +{"kind":"api-surface","key":"5a07390012b46e06","why":"target-gone"}, +{"kind":"api-surface","key":"6eef859578c8c376","why":"target-gone"}, +{"kind":"api-surface","key":"6f2394762f82a855","why":"target-gone"}, +{"kind":"api-surface","key":"7f2c3eefdf6e512e","why":"target-gone"}, +{"kind":"api-surface","key":"802e513731103806","why":"target-gone"}, +{"kind":"api-surface","key":"b6a24afef32a68a8","why":"target-gone"}, +{"kind":"api-surface","key":"c923e661c197b265","why":"target-gone"}, +{"kind":"api-surface:new-symbol","key":"1926b0d9e94541a0","why":"target-gone"}, +{"kind":"api-surface:new-symbol","key":"1d3814ba687a4aef","why":"target-gone"}, +{"kind":"api-surface:new-symbol","key":"1db76028879244ef","why":"target-gone"}, +{"kind":"api-surface:new-symbol","key":"36e39c7ab0fc1686","why":"target-gone"}, +{"kind":"api-surface:new-symbol","key":"4bce64bd920de0c3","why":"target-gone"}, +{"kind":"api-surface:new-symbol","key":"61f0e361ef7dee27","why":"target-gone"}, +{"kind":"api-surface:new-symbol","key":"79625906f9f71ad0","why":"target-gone"}, +{"kind":"api-surface:new-symbol","key":"8ecb190954dce18a","why":"target-gone"}, +{"kind":"api-surface:new-symbol","key":"995375dfa4e63104","why":"target-gone"}, +… [124 more display lines; full output is 15115 bytes on 1 raw line(s)] +````` + +stderr: + +````` +ripwire: no ./.ripwire_quality_baseline — auto-comparing the working tree vs git HEAD (commit the baseline with --quality-baseline to pin it) +ripwire: --quality-delta gating: 5 preexisting-worse major finding(s); first: api-surface src/infra/sortutil.h::rw::sortutil::nonNegativeFloatDescKey at src/infra/sortutil.h:109 (was=1 now=2) +````` + +## `./build/ripwire . --quality-delta --quality-ack --ack-only=zzznope` + +*NEW FLAG: --ack-only matching nothing REFUSES rather than falling back to acking everything.* + +**exit code: 1** — **wall time: 6.41s** + +````` +(empty) +````` + +stderr: + +````` +ripwire: no ./.ripwire_quality_baseline — auto-comparing the working tree vs git HEAD (commit the baseline with --quality-baseline to pin it) +ripwire: ack provenance backfill — 0 clone row(s) reconstructed from the current tree, 218 re-derived, 15 left UNVERIFIED (their group was not found here — a floor, not proof it is gone), 57 left legacy (member set not found cloning here — a floor, not proof it is gone), 1315 ineligible (no cu … [line truncated: 200 more bytes on this line] +ripwire: --ack-only=zzznope matched none of the 9 finding(s) — nothing written +````` + +## `./build/ripwire . --quality-delta --quality-ack --ack-only=api-surface` + +*NEW FLAG: ack only the api-surface findings — a per-finding ratchet instead of a rubber stamp.* + +**wall time: 6.42s** + +````` +(empty) +````` + +stderr: + +````` +ripwire: no ./.ripwire_quality_baseline — auto-comparing the working tree vs git HEAD (commit the baseline with --quality-baseline to pin it) +ripwire: ack provenance backfill — 0 clone row(s) reconstructed from the current tree, 218 re-derived, 15 left UNVERIFIED (their group was not found here — a floor, not proof it is gone), 57 left legacy (member set not found cloning here — a floor, not proof it is gone), 1315 ineligible (no cu … [line truncated: 200 more bytes on this line] +ripwire: acknowledged 1 of 9 finding(s) (8 left UNACKED by --ack-only, 0 already acked) → ./.ripwire_quality_acks +````` + +## `./build/ripwire . --quality-delta` + +*Re-run after the partial ack: acked=3, the rest still gate (exit 2).* + +**exit code: 2** — **wall time: 9.67s** + +````` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +… [126 more display lines; full output is 16177 bytes on 1 raw line(s)] +````` + +stderr: + +````` +ripwire: no ./.ripwire_quality_baseline — auto-comparing the working tree vs git HEAD (commit the baseline with --quality-baseline to pin it) +ripwire: --quality-delta gating: 4 preexisting-worse major finding(s); first: complexity src/infra/sortutil.h::rw::sortutil::lessByScoreDescId at src/infra/sortutil.h:49 (was=1 now=67) +````` + +## `./build/ripwire . --ack-only=gating` + +*--ack-only WITHOUT --quality-ack REFUSES loudly (exit 1, the pairing named) — it used to be silently ignored.* + +**exit code: 1** + +````` +(empty) +````` + +stderr: + +````` +ripwire: --ack-only=SUBSTR narrows --quality-ack — pass both (e.g. ripwire

--quality-delta --ack-only=contract-change --quality-ack="reason") +````` + +## `./build/ripwire . --edit-check=nonNegativeFloatDescKey` + +*A real contract-change: was=1 now=2 params, with the call sites that are now provably incompatible, and a pasteable next= (--uses=SYM) on the root.* + +**wall time: 1.29s** + +````` + + + + + + +````` + +## `./build/ripwire . --edit-check=nonNegativeFloatDescKey --legend=full` + +*The same verdict under --legend=full: the default's one compact comment (the post-edit reflex at its cheapest) becomes ~5.7 KB of prose legend, every row identical.* + +**wall time: 1.29s** + +````` + + + + + + +````` + +## `./build/ripwire . --pr-context` + +*The review-evidence bundle with an actual changed file.* + +**wall time: 3.15s** + +````` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +… [88 more display lines; full output is 8517 bytes on 1 raw line(s)] +````` + +## `./build/ripwire . --map-diff --top-k=5` + +*The map re-ranked with a teleport toward the changed file (changed=1 here, not 0).* + +**wall time: 1.97s** + +````` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +````` + +## `./build/ripwire . --clones` + +*The duplicated helper the sandbox edit introduced shows up as a clone group.* + +**wall time: 2.05s** + +````` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +… [306 more display lines; full output is 16116 bytes on 1 raw line(s)] +````` + +## `./build/ripwire . --stray-content=zz-orphan` + +*CHANGED: a ref with NO merge base with HEAD now reports v="unknown" ok="0" in its own bucket — the absence of an answer, never a claim it is merged. (The sandbox carries a deliberately parentless branch built with `git commit-tree`; a shallow CI clone puts every ref here.)* + +**wall time: 1.41s** + +````` + + + + + +````` + +stderr: + +````` +[math degraded] crossref: no merge-base for ref (shallow clone or unrelated history?) — verdict is unknown, not merged (crossref.h:1173, RefPlumbing rw::crossref::probeRefBase(const std::string &, const RefInfo &, const std::string &) — logged once per site) +````` + +## `./build/ripwire . --stray-content=zz-orphan --plan` + +*CHANGED: --plan surfaces those same refs as an row rather than silently dropping them.* + +**wall time: 2.03s** + +````` + + + + +````` + +stderr: + +````` +[math degraded] crossref: no merge-base for ref (shallow clone or unrelated history?) — verdict is unknown, not merged (crossref.h:1173, RefPlumbing rw::crossref::probeRefBase(const std::string &, const RefInfo &, const std::string &) — logged once per site) +````` + +## `./build/ripwire . --dmm` + +*The DMM scalar on a REAL delta: the sandbox edit grew one unit past the nesting/complexity thresholds and added an 8-parameter one, so dmm is low and the three sub-scores say which property moved.* + +**wall time: 3.49s** + +````` + + +

+

+ +````` + +## `./build/ripwire . --quality-delta --scope=src/graph.h` + +*OWNERSHIP partition for a shared tree: every regression here lives in src/infra/, so under a scope naming src/graph.h they ALL print under with a do-not-ack banner and never gate — scoped-out-gating= says how many would have.* + +**wall time: 9.91s** + +````` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +… [129 more display lines; full output is 16444 bytes on 1 raw line(s)] +````` + +stderr: + +````` +ripwire: no ./.ripwire_quality_baseline — auto-comparing the working tree vs git HEAD (commit the baseline with --quality-baseline to pin it) +````` + +## `./build/ripwire . --quality-delta --quality-ack --scope=src/graph.h --ack-only=api-surface` + +*The rubber-stamp guard: an --ack-only that names an OUT-OF-SCOPE row refuses (exit 1) and writes nothing.* + +**exit code: 1** — **wall time: 7.51s** + +````` +(empty) +````` + +stderr: + +````` +ripwire: no ./.ripwire_quality_baseline — auto-comparing the working tree vs git HEAD (commit the baseline with --quality-baseline to pin it) +ripwire: --ack-only=api-surface selects 1 finding(s) OUT OF SCOPE for --scope=src/graph.h — refusing, and writing nothing at all: + api-surface src/infra/sortutil.h::rw::sortutil::nonNegativeFloatDescKey at src/infra/sortutil.h:109 + those rows belong to whoever is editing those paths. Acking them here writes their debt into a committed ledger under YOUR + reason string, which is how a per-finding ratchet becomes a rubber stamp. Narrow the pattern, or widen the scope if they really are yours. +````` + +## `./build/ripwire . --handoff` + +*The continuation packet with a REAL diff: verified changed symbols + blast radius + tests-to-run, then the heuristic rows.* + +**wall time: 3.35s** + +````` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +````` + +## `./build/ripwire . --note-add="lessByScoreDescId: keep this branch-free — it sits inside the PageRank sort comparator"` + +*Pin a field note (write-side memory) to a symbol; committed to .ripwire_notes in the sandbox. The BARE name is resolved through the same resolver the read verbs use and stored as the canonical id — the rewrite is echoed on stderr, because a silent one is not a disclosure.* + +````` +src/infra/sortutil.h::rw::sortutil::lessByScoreDescId 2026-10-08 keep this branch-free — it sits inside the PageRank sort comparator f1e5c2e76f0a8c29149cc56d865c6627d76b4c24 integration/train-26b +````` + +stderr: + +````` +ripwire: --note-add: tip: notes that say "chose X over Y because Z" surface better — consider adding the why +ripwire: --note-add: target 'lessByScoreDescId' canonicalised to 'src/infra/sortutil.h::rw::sortutil::lessByScoreDescId' — that is the id --for/--expand key notes by +````` + +## `./build/ripwire . --notes` + +*The note is listed under the canonical id, dangling="0" — i.e. it will actually surface. (Before the H1 fix the bare name was stored verbatim and read dangling="1": recorded, and surfaced nowhere.)* + +````` + + + + + + + + + + + + + + + + gate scripts' claim (~line 1305) is NOT enforced — the derived-vs-stated sibling loop here covers docs/EVALS.md only. It drifted 407→451 unnoticed (fixed 2026-08-23). To close: grep both files ('file:line:' parsing) in the gateCountClaims arm.]]> + + + + +````` + +## `./build/ripwire . --note-add="gitOneLine: which one?"` + +*Two definitions carry this name, so the write REFUSES rather than pick one: a note keys ONE canonical id, and an ambiguous selector is refused, never silently narrowed. Every candidate is named, with a runnable retry.* + +**exit code: 1** + +````` +(empty) +````` + +stderr: + +````` +ripwire: --note-add: tip: notes that say "chose X over Y because Z" surface better — consider adding the why +ripwire: --note-add: target 'gitOneLine' is ambiguous — it matches 2 definitions in 2 distinct contracts, and a note keys ONE canonical id (it would surface on one of them and look absent on the rest). Qualify one: ./src/handoff.h:gitOneLine, ./src/quality.h:gitOneLine — e.g. --note-add="./src/h … [line truncated: 33 more bytes on this line] +````` + +## `./build/ripwire . --note-add="lessByScoreDescIdd: typo"` + +*A name that resolves to nothing is refused with the read verbs' own did-you-mean — never written as a dead note.* + +**exit code: 1** + +````` +(empty) +````` + +stderr: + +````` +ripwire: --note-add: tip: notes that say "chose X over Y because Z" surface better — consider adding the why +ripwire: --note-add: target not found: lessByScoreDescIdd (did you mean 'lessByScoreDescId'?) — a note keys the canonical id a read verb resolves; to note a FILE instead, pass a path (one with a '/' or an extension), which may name a file that does not exist yet +````` + +## `./build/ripwire . --note-add="src/infra/sortutil.h::rw::sortutil::lessByScoreDescId: chose the flat two-branch compare over the nested ladder because the comparator sits inside the PageRank sort"` + +*The same symbol addressed by its CANONICAL id: already canonical, so nothing is rewritten and both notes land on ONE target.* + +````` +src/infra/sortutil.h::rw::sortutil::lessByScoreDescId 2026-10-08 chose the flat two-branch compare over the nested ladder because the comparator sits inside the PageRank sort f1e5c2e76f0a8c29149cc56d865c6627d76b4c24 integration/train-26b +````` + +## `./build/ripwire . --expand=lessByScoreDescId --top-k=0` + +*Both notes riding along with the symbol's body — the elements follow the body, past the display cut, so they are extracted below.* + +````` + + + + + + + + + + + + + +````` + +## `./build/ripwire . --insert-after-symbol=lessByScoreDescId --edit-payload=/aux/payload_note.h` + +*Insert immediately AFTER one uniquely-resolved definition; replaced_bytes=0 because the insert verbs never overwrite. The receipt carries the folded post-edit verification (lines=, edit_check, tests_to_run) so the loop closes in one call.* + +**wall time: 3.26s** + +````` +{"applied":"insert_after_symbol","symbol":"lessByScoreDescId","file":"src/infra/sortutil.h","span":{"start":2375,"end":2463},"lines":{"start":56,"end":58},"replaced_bytes":0,"old_file_bytes":10824,"new_file_bytes":10912,"file_eol":"lf","eol_normalized":false,"trailing_newline_folded":true,"separator … [line truncated: 652 more bytes on this line] +"graph_ambiguous":12495,"graph_unresolved":12552,"graph_unindexed":255,"counts_floor":true},"tests_to_run":[{"p":"test/verify_radix.cpp","hops":1,"run":"bash test/greptiercheck.sh"}, +{"p":"test/adaptivecutshapefix/adaptive_cut_shape_test.cpp","hops":2,"run":"bash test/adaptivecutshapecheck.sh"}, +{"p":"test/includeprecise_unit.cpp","hops":2,"run":"bash test/includeprecisecheck.sh"}, +{"p":"test/verify_csr.cpp","hops":2,"run":"bash test/a9disclosurecheck.sh"}, +{"p":"test/rustimport_unit.cpp","hops":4,"run":"bash test/rustimportprecisecheck.sh"}], +"order":"evidence","partners":0,"run_first":1,"tests":5,"script_gates_unmodelled":743,"graph_ambiguous":12495,"graph_unresolved":12552,"graph_unindexed":255,"counts_floor":true,"next":"bash test/greptiercheck.sh"} +````` + +stderr: + +````` +ripwire edit: applied atomically; receipt carries region, blob_sha, edit_check, tests_to_run; next: bash test/greptiercheck.sh +````` + +## `./build/ripwire . --insert-after-symbol=lessByScoreDescId --edit-payload=/aux/payload_note.h --no-post-check` + +*The opt-out: the same insert with the folded verification skipped — lines= still rides (it is free), edit_check/tests_to_run do not, and the two pasteable commands stay on stderr.* + +**wall time: 1.79s** + +````` +{"applied":"insert_after_symbol","symbol":"lessByScoreDescId","file":"src/infra/sortutil.h","span":{"start":2375,"end":2463},"lines":{"start":56,"end":58},"replaced_bytes":0,"old_file_bytes":10912,"new_file_bytes":11000,"file_eol":"lf","eol_normalized":false,"trailing_newline_folded":true,"separator … [line truncated: 572 more bytes on this line] +````` + +stderr: + +````` +ripwire edit: applied atomically; receipt carries region, blob_sha (post-check skipped); next: --edit-check=src/infra/sortutil.h:lessByScoreDescId +````` + +## `./build/ripwire . --insert-before-symbol=nonNegativeFloatDescKey --edit-payload=/aux/payload_note.h --edit-target-file=src/infra/sortutil.h` + +*Insert BEFORE, with --edit-target-file pinning which same-named definition (here unambiguous — the disambiguator is simply honoured).* + +**wall time: 4.29s** + +````` +{"applied":"insert_before_symbol","symbol":"nonNegativeFloatDescKey","file":"src/infra/sortutil.h","span":{"start":2895,"end":2983},"lines":{"start":74,"end":75},"replaced_bytes":0,"old_file_bytes":11000,"new_file_bytes":11088,"file_eol":"lf","eol_normalized":false,"trailing_newline_folded":false,"s … [line truncated: 755 more bytes on this line] +{"n":"benchAdaptive","p":"bench/bench_radix_ab.cpp:157","l":[162]}, +{"n":"radixSortNonNegativeFloatsDesc","p":"src/infra/sortutil.h:105","l":[114]}, +{"n":"radixSortByScoreDescId","p":"src/infra/sortutil.h:120","l":[180]}], +"graph_ambiguous":12495,"graph_unresolved":12552,"graph_unindexed":255,"counts_floor":true},"tests_to_run":[{"p":"test/verify_radix.cpp","hops":1,"run":"bash test/greptiercheck.sh"}, +{"p":"test/adaptivecutshapefix/adaptive_cut_shape_test.cpp","hops":2,"run":"bash test/adaptivecutshapecheck.sh"}, +{"p":"test/includeprecise_unit.cpp","hops":2,"run":"bash test/includeprecisecheck.sh"}, +{"p":"test/verify_csr.cpp","hops":2,"run":"bash test/a9disclosurecheck.sh"}, +{"p":"test/rustimport_unit.cpp","hops":4,"run":"bash test/rustimportprecisecheck.sh"}], +"order":"evidence","partners":0,"run_first":1,"tests":5,"script_gates_unmodelled":743,"graph_ambiguous":12495,"graph_unresolved":12552,"graph_unindexed":255,"counts_floor":true,"next":"--uses=src/infra/sortutil.h:nonNegativeFloatDescKey"} +````` + +stderr: + +````` +ripwire edit: applied atomically; receipt carries region, blob_sha, edit_check, tests_to_run; next: --uses=src/infra/sortutil.h:nonNegativeFloatDescKey +````` + +## `./build/ripwire . --replace-symbol-body=DoesNotExist --edit-payload=/aux/payload_note.h` + +*An unknown TARGET refuses and leaves every file byte-identical.* + +**exit code: 1** — **wall time: 1.78s** + +````` +(empty) +````` + +stderr: + +````` +ripwire: --replace-symbol-body: symbol 'DoesNotExist' not found +````` + +## `./build/ripwire . --edit-plan=/aux/edit_plan.json --dry-run` + +*A versioned multi-edit TRANSACTION preflighted without writing: the receipt shows what each op would read and touch.* + +**wall time: 1.87s** + +Input file: + +````` +{ + "version": 1, + "edits": [ + { + "op": "insert_before_symbol", + "target": "nonNegativeFloatDescKey", + "payload": "plan_note.h" + } + ] +} +````` + +````` +{"schema":"ripwire.edit-plan/v1","mode":"dry-run","edits":1,"files":1,"callers_union":4,"graph_ambiguous":12495,"graph_unresolved":12552,"graph_unindexed":255,"counts_floor":true,"atomic_scope":"per-file","rollback_on_write_error":true,"recheck_before_each_write":true,"multifile_crash_atomic":false, … [line truncated: 345 more bytes on this line] +````` + +## `./build/ripwire . --edit-plan=/aux/edit_plan.json --apply` + +*The same plan committed: per-file locks, re-verify-before-write, atomic rename, rollback on a later failure.* + +**wall time: 3.80s** + +````` +{"schema":"ripwire.edit-plan/v1","mode":"apply","edits":1,"files":1,"callers_union":4,"graph_ambiguous":12495,"graph_unresolved":12552,"graph_unindexed":255,"counts_floor":true,"applied":1,"atomic_files":1,"atomic_scope":"per-file","rollback_on_write_error":true,"recheck_before_each_write":true,"mul … [line truncated: 529 more bytes on this line] +{"n":"benchAdaptive","p":"bench/bench_radix_ab.cpp:157","l":[162]}, +{"n":"radixSortNonNegativeFloatsDesc","p":"src/infra/sortutil.h:107","l":[116]}, +{"n":"radixSortByScoreDescId","p":"src/infra/sortutil.h:122","l":[182]}], +"graph_ambiguous":12495,"graph_unresolved":12552,"graph_unindexed":255,"counts_floor":true}}]} +````` + +## `./build/ripwire . --edit-plan=/aux/edit_plan.json` + +*Neither --dry-run nor --apply: the mode is explicit, so this refuses.* + +**exit code: 1** + +````` +(empty) +````` + +stderr: + +````` +ripwire: --edit-plan requires exactly one of --dry-run or --apply +````` + +## `./build/ripwire . --quality-delta` + +*After the agent's edits: the complexity/nesting rows on lessByScoreDescId are gone (the replace undid them), the rest still gate (exit 2).* + +**exit code: 2** — **wall time: 12.77s** + +````` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +… [123 more display lines; full output is 15541 bytes on 1 raw line(s)] +````` + +stderr: + +````` +ripwire: no ./.ripwire_quality_baseline — auto-comparing the working tree vs git HEAD (commit the baseline with --quality-baseline to pin it) +ripwire: --quality-delta gating: 2 preexisting-worse major finding(s); first: duplication src/infra/sortutil.h::rw::sortutil::nonNegativeFloatAscKeyCopy | src/infra/sortutil.h::rw::sortutil::nonNegativeFloatDescKey at src/infra/sortutil.h:88 (was=0 now=59) +````` + +## `./build/ripwire . --quality-baseline` + +*REFUSES, exit 1: this sandbox tree is already regressed, and pinning here would swallow that debt into the floor so every later delta read clean. It names how many gating findings it would absorb, the first of them, and the way forward.* + +**exit code: 1** — **wall time: 7.42s** + +````` +(empty) +````` + +stderr: + +````` +ripwire: --quality-baseline: this tree already holds 2 gating finding(s) against HEAD — pinning here would absorb + them into the floor, and every later --quality-delta would read clean. First: duplication src/infra/sortutil.h::rw::sortutil::nonNegativeFloatAscKeyCopy | src/infra/sortutil.h::rw::sortutil::nonNegativeFloatDescKey (was=0 now=59) + Commit the tree first, or pass --allow-dirty to pin anyway (the sidecar then records the 2 absorbed, and every + report against it carries baseline_absorbed="2"). +````` + +## `./build/ripwire . --quality-baseline --allow-dirty` + +*The consent form: pin anyway. The sidecar is stamped with the dirty pin and the absorbed count, so the fact outlives the process that knew it.* + +**wall time: 10.88s** + +````` +(empty) +````` + +stderr: + +````` +ripwire: --quality-baseline --allow-dirty: pinned with 2 gating finding(s) ABSORBED into the floor (stamped in the sidecar; every --quality-delta against it carries baseline_absorbed="2") +ripwire: wrote ./.ripwire_quality_baseline (snapshot of 24758 per-symbol rows, 25560 indexed symbols total) +````` + +Artifact written: + +````` + 4195904 .ripwire_quality_baseline +# ripwire quality baseline v6 — regenerate with --quality-baseline; do not hand-edit +producer 8569518cb60b7c1a0c8a57175e169d7bfeab286033b8825969bdb1f593c3096e +head f1e5c2e76f0a8c29149cc56d865c6627d76b4c24 +dirty 1 +absorbed 2 +ccx d19d7c13cbc7 2 +ccx 1f7edbff929e5 10 +ccx 6a732f6699bdb 0 +ccx 7d8862be4e +````` + +## `./build/ripwire . --quality-delta` + +*Against that sidecar the same tree reads regressions=0 — but baseline_absorbed= is on the root, so this green means clean SINCE THE PIN, never clean. A baseline is a floor YOU chose, and it belongs BEFORE the change.* + +**wall time: 9.26s** + +````` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +… [118 more display lines; full output is 14425 bytes on 1 raw line(s)] +````` + + +--- + +# the MCP dialect — the same verbs over stdio JSON-RPC (one-shot exchange, not a persistent server) + +## `printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize"}' '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' | ./build/ripwire --mcp` + +*initialize + tools/list: the manifest an agent host loads at session start — every verb's name, description and input schema.* + +````` +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-11-25","serverInfo":{"name":"ripwire","version":"1.0"},"capabilities":{"tools":{},"resources":{}},"instructions":"Map before reading files. Start a new task with explore; use from_trace for an error; use impact plus uses before changing a sym … [line truncated: 738 more bytes on this line] +{"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"analyze","description":"Architecture map for a directory: signatures and the call graph for the top symbols. Use when landing cold in a repo or subdir, before reading files; for a task-scoped inventory use 'for', for one symbol's neighborhood find_ … [line truncated: 742 more bytes on this line] +{"name":"rank_by","description":"The SAME architecture map 'analyze' serves, ranked by a different signal instead of plain PageRank. rank_by = pagerank (default, omit it — the CLI's own unbiased --rank-by=pagerank; 'analyze' can rank differently on a tree with uncommitted changes, where it biases … [line truncated: 1077 more bytes on this line] +{"name":"find_symbol","description":"A symbol's 1-hop neighborhood: the symbol (with a fetch_body handle) plus direct callers (calledBy) and callees (calls). Full transitive reach: 'impact'. Read/write/import sites, not just calls: 'uses'. JSON {symbol, calledBy, calls, defs, count, hop_tested, hop_ … [line truncated: 1181 more bytes on this line] +{"name":"find_referencing_symbols","description":"Direct (1-hop) callers of a symbol, each with a fetch_body handle. For the full transitive blast radius use 'impact', for read/write/import sites 'uses'. JSON {symbol, calledBy, defs, count, hop_tested, hop_untested, declined_calls, counts_floor}; ca … [line truncated: 945 more bytes on this line] +{"name":"grep","description":"Trigram literal search; each hit annotated with its enclosing symbol (which function/class it is in). Hits carry text; enclosing rows a fetch_body handle. pattern = the literal; in=any lifts the span tiering (default: tightest non-empty tier; rest rides as suppressed_co … [line truncated: 837 more bytes on this line] +{"name":"cochange","description":"Files that historically change together with this file — the co-edit partners. Fowler's Shotgun Surgery as change coupling. dep_capable=false means neither side could carry one (sh/md/json/binary), so surprising is undefined rather than informative. file = the fil … [line truncated: 700 more bytes on this line] +{"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 = … [line truncated: 790 more bytes on this line] +{"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 HEA … [line truncated: 1297 more bytes on this line] +{"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. An @FILE:LINE line-seed rebinds to the innermost definition enclosing that line and answers for it, disclosing the rebound name as 'sym' … [line truncated: 625 more bytes on this line] +{"name":"for","description":"Task-lens ranked, signatures-only inventory of the building blocks most relevant to a task (cx=complexity, in=reuse-count). task = the task in plain words; budget_tokens = an optional ceiling (the CLI --for --token-budget). The header carries route= (which ranker answere … [line truncated: 1053 more bytes on this line] +{"name":"lego","description":"Interface-to-impls view for ONE named interface/base: its method contract plus EVERY implementor (own-language only), each with file path. Use when implementing against a KNOWN interface (contrast 'for', which sprays top interfaces for a task). type = interface/base nam … [line truncated: 657 more bytes on this line] +{"name":"owners","description":"Bus-factor: recency-weighted (6-month half-life) author ownership per file. symbol = optional, restricts to the file that defines it; limit/offset page the rows. at= is the commit these numbers were computed at (+dirty = the working tree differed). An @FILE:LINE line- … [line truncated: 879 more bytes on this line] +{"name":"replace_symbol_body","description":"Replace a symbol's ENTIRE definition (signature through closing brace) with new_body — splices over the full def span, preserving every byte outside it verbatim; new_body must be a complete, well-formed definition. One trailing newline folds (trailing_n … [line truncated: 1323 more bytes on this line] +{"name":"insert_before_symbol","description":"Insert text immediately BEFORE a symbol's definition (its first byte); padded to the file's own blank-line seam (separator_padded=N). Same refusal contract as replace_symbol_body (not found / ambiguous / stale index / symlink / concurrent write, file unc … [line truncated: 877 more bytes on this line] +{"name":"insert_after_symbol","description":"Insert text immediately AFTER a symbol's definition (past its final byte, which is preserved exactly); padded to the file's own blank-line seam (separator_padded=N); one trailing newline folds (trailing_newline_folded). Same refusal contract as replace_sy … [line truncated: 878 more bytes on this line] +{"name":"fetch_body","description":"Full (or partial-range) source of a symbol's definition, addressed by the stable `handle` a read verb attached to it (bodies on request, not by default). start_line/end_line are optional, 1-based, INCLUSIVE and BODY-RELATIVE (line 1 = the def's first line), clampe … [line truncated: 1049 more bytes on this line] +{"name":"exemplar","description":"BEFORE writing a function / method / class / struct / interface / variable, get the repo's single best-in-class instance of that kind to imitate — signature AND full body. chosen by ROLE, NEVER by text similarity to your task: candidates are first filtered to cogn … [line truncated: 1375 more bytes on this line] +{"name":"quality_delta","description":"Your PR self-check, run every time you think a change is DONE — pairs with the CLI-only --test-gate (names the tests to run + the untested blast radius; not MCP-exposed) to form the two-step pre-PR gate. Reports ONLY what your working tree made WORSE vs basel … [line truncated: 867 more bytes on this line] +{"name":"quality_baseline","description":"PIN the quality floor: writes .ripwire_quality_baseline stamped with the current git HEAD sha, snapshotting complexity / duplication / dead-code / API surface. Call once at the start of non-trivial work, then quality_delta compares against this pinned floor … [line truncated: 445 more bytes on this line] +{"name":"impact","description":"IS IT SAFE TO CHANGE X? — the TRANSITIVE blast radius of a symbol via calls, nearest first (d= hop depth), then PageRank. Use before modifying or deleting a symbol; it beats find_referencing_symbols (direct callers only), and 'uses' catches the read/write/import sit … [line truncated: 1325 more bytes on this line] +{"name":"uses","description":"The STATICALLY RESOLVABLE use-sites of a symbol, not just calls: role (call | read | write | import | extends), file:line, and enclosing symbol. Use to see the footprint before renaming or changing a name — find_referencing_symbols and impact follow only calls. extern … [line truncated: 1146 more bytes on this line] +{"name":"affected","description":"WHICH TESTS TO RUN for a change — test files that transitively reach the changed files/symbols, ranked by evidence (edited > partner-named > hop distance). files = changed files and/or symbols, comma-separated: each item is tried as an indexed PATH pattern first, … [line truncated: 1238 more bytes on this line] +{"name":"path_between","description":"Does A REACH B, and HOW? — the shortest directed CALL path between two symbols, hop-by-hop. reachable=\"0\" hops=\"0\" is a valid 'not reachable' answer — call edges are name-based, so a missing dynamic/callback edge can hide a real path. Named path_between … [line truncated: 709 more bytes on this line] +{"name":"connect","description":"When a task touches 2..16 named symbols, returns the minimal subgraph RELATING them - terminals, the fewest joining intermediaries (with signatures), and call edges in true direction - finding the shared-caller joins a directed path_between cannot; unrelated symbols … [line truncated: 950 more bytes on this line] +{"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 gr … [line truncated: 1933 more bytes on this line] +{"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 a … [line truncated: 1226 more bytes on this line] +{"name":"edit_check","description":"Just edited a symbol? Did its CONTRACT (param count + publicness) change vs git HEAD, and which 1-hop callers are NOW INCOMPATIBLE with the new arity by fixed-arity evidence (not a guess — every folded definition disagrees)? This is call sites worth OPENING, not … [line truncated: 1908 more bytes on this line] +{"name":"whereis","description":"WHERE DOES THIS CONTENT LIVE? Which branch's tree defines or mentions a symbol, HEAD first, with on-head=0 naming the case this verb exists for: content that lives only on a branch (a finished fix stranded on 1 of 30 refs). Each distinct blob is read once (content-ad … [line truncated: 1387 more bytes on this line] +{"name":"stray_content","description":"Per branch: the lines its own divergent work AUTHORED (vs its merge-base with HEAD) that the live line does NOT have. Four verdicts (unmerged+superseded+merged+unknown=refs): v=unmerged is genuinely absent; v=superseded means the live line re-implemented the wo … [line truncated: 1123 more bytes on this line] +{"name":"flags","description":"WHAT IS BUILT BUT DARK here — the answer to 'why don't I see feature X?'. Harvests all three gate patterns (ifndef/define header gates, CMake option(), getenv reads) with each gate's kind, DEFAULT, the size of the code it guards, and its read sites. When a name is bo … [line truncated: 1434 more bytes on this line] +{"name":"doc_drift","description":"WHICH OF THIS REPO'S DOC CLAIMS ARE NOW FALSE. Verifies the CHECKABLE anchors in every markdown file against the live index and returns ONLY the ones that no longer hold: file:line refs (missing-file / past-eof / line-moved), backticked symbol mentions (undefined), … [line truncated: 1167 more bytes on this line] +{"name":"slice","description":"WHERE IS THIS VARIABLE DEFINED AND USED inside one function — NAME-BASED intra-procedural def-use rows of one variable inside ONE uniquely-resolved definition (the ARISE slicer, arXiv:2605.03117). symbol alone lists the sliceable locals to pick from; add var (or spel … [line truncated: 1722 more bytes on this line] +{"name":"batch","description":"ONE-TURN CONTEXT SWEEP: answer up to 16 heterogeneous READ sub-queries in a single call (the deterministic $0 counterpart of a parallel-search agent). queries = array over the SAME path, in EITHER grammar: {verb, ...args} objects, or the CLI --batch file's own \"verb:a … [line truncated: 2040 more bytes on this line] +````` + +The manifest, summarised (name / description bytes / required args) — what the host pays in context every session: + +````` +tools= 33 manifest_bytes= 46964 (~tokens at 4 bytes/token: 11741 ) +batch desc_bytes= 1235 schema_bytes= 962 required=['queries'] +explore desc_bytes= 1322 schema_bytes= 774 required=['task'] +edit_check desc_bytes= 1180 schema_bytes= 889 required=['symbol'] +slice desc_bytes= 953 schema_bytes= 932 required=['symbol'] +flags desc_bytes= 881 schema_bytes= 720 required=[] +whereis desc_bytes= 701 schema_bytes= 851 required=['symbol'] +exemplar desc_bytes= 954 schema_bytes= 591 required=[] +replace_symbol_body desc_bytes= 865 schema_bytes= 610 required=['symbol', 'new_body'] +impact desc_bytes= 821 schema_bytes= 666 required=['symbol'] +situational_awareness desc_bytes= 865 schema_bytes= 581 required=[] +affected desc_bytes= 790 schema_bytes= 610 required=['files'] +from_trace desc_bytes= 829 schema_bytes= 559 required=['trace'] +find_symbol desc_bytes= 752 schema_bytes= 590 required=['symbol'] +doc_drift desc_bytes= 728 schema_bytes= 604 required=[] +uses desc_bytes= 626 schema_bytes= 682 required=['symbol'] +stray_content desc_bytes= 678 schema_bytes= 604 required=[] +rank_by desc_bytes= 758 schema_bytes= 481 required=[] +for desc_bytes= 445 schema_bytes= 777 required=['task'] +fetch_body desc_bytes= 623 schema_bytes= 588 required=['handle'] +connect desc_bytes= 535 schema_bytes= 580 required=['symbols'] +find_referencing_symbols desc_bytes= 505 schema_bytes= 590 required=['symbol'] +insert_after_symbol desc_bytes= 458 schema_bytes= 577 required=['symbol', 'text'] +insert_before_symbol desc_bytes= 455 schema_bytes= 578 required=['symbol', 'text'] +owners desc_bytes= 425 schema_bytes= 625 required=[] +quality_delta desc_bytes= 833 schema_bytes= 193 required=[] +grep desc_bytes= 445 schema_bytes= 562 required=['pattern'] +memory_recall desc_bytes= 447 schema_bytes= 500 required=['task'] +path_between desc_bytes= 357 schema_bytes= 506 required=['from', 'to'] +cochange desc_bytes= 388 schema_bytes= 476 required=['file'] +analyze desc_bytes= 546 schema_bytes= 318 required=[] +lego desc_bytes= 360 schema_bytes= 467 required=['type'] +mentions desc_bytes= 305 schema_bytes= 486 required=['symbol'] +quality_baseline desc_bytes= 407 schema_bytes= 193 required=[] +````` + +## `printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize"}' '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' | ./build/ripwire --mcp --mcp-tools=core` + +*--mcp-tools=core: the server lists only the core profile's tools (explore, batch, from_trace, impact, uses, fetch_body, edit_check, quality_delta), so a host that loads every schema at session start pays only for those.* + +````` +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-11-25","serverInfo":{"name":"ripwire","version":"1.0"},"capabilities":{"tools":{},"resources":{}},"instructions":"Map before reading files. Start a new task with explore; use from_trace for an error; use impact plus uses before changing a sym … [line truncated: 955 more bytes on this line] +{"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"fetch_body","description":"Full (or partial-range) source of a symbol's definition, addressed by the stable `handle` a read verb attached to it (bodies on request, not by default). start_line/end_line are optional, 1-based, INCLUSIVE and BODY-RELAT … [line truncated: 1092 more bytes on this line] +{"name":"quality_delta","description":"Your PR self-check, run every time you think a change is DONE — pairs with the CLI-only --test-gate (names the tests to run + the untested blast radius; not MCP-exposed) to form the two-step pre-PR gate. Reports ONLY what your working tree made WORSE vs basel … [line truncated: 867 more bytes on this line] +{"name":"impact","description":"IS IT SAFE TO CHANGE X? — the TRANSITIVE blast radius of a symbol via calls, nearest first (d= hop depth), then PageRank. Use before modifying or deleting a symbol; it beats find_referencing_symbols (direct callers only), and 'uses' catches the read/write/import sit … [line truncated: 1325 more bytes on this line] +{"name":"uses","description":"The STATICALLY RESOLVABLE use-sites of a symbol, not just calls: role (call | read | write | import | extends), file:line, and enclosing symbol. Use to see the footprint before renaming or changing a name — find_referencing_symbols and impact follow only calls. extern … [line truncated: 1146 more bytes on this line] +{"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 gr … [line truncated: 1933 more bytes on this line] +{"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 a … [line truncated: 1226 more bytes on this line] +{"name":"edit_check","description":"Just edited a symbol? Did its CONTRACT (param count + publicness) change vs git HEAD, and which 1-hop callers are NOW INCOMPATIBLE with the new arity by fixed-arity evidence (not a guess — every folded definition disagrees)? This is call sites worth OPENING, not … [line truncated: 1908 more bytes on this line] +{"name":"batch","description":"ONE-TURN CONTEXT SWEEP: answer up to 16 heterogeneous READ sub-queries in a single call (the deterministic $0 counterpart of a parallel-search agent). queries = array over the SAME path, in EITHER grammar: {verb, ...args} objects, or the CLI --batch file's own \"verb:a … [line truncated: 2040 more bytes on this line] +````` + +The core manifest, summarised (name / description bytes / required args): + +````` +tools= 8 manifest_bytes= 13982 (~tokens at 4 bytes/token: 3495 ) +batch desc_bytes= 1235 schema_bytes= 962 required=['queries'] +explore desc_bytes= 1322 schema_bytes= 774 required=['task'] +edit_check desc_bytes= 1180 schema_bytes= 889 required=['symbol'] +impact desc_bytes= 821 schema_bytes= 666 required=['symbol'] +from_trace desc_bytes= 829 schema_bytes= 559 required=['trace'] +uses desc_bytes= 626 schema_bytes= 682 required=['symbol'] +fetch_body desc_bytes= 623 schema_bytes= 588 required=['handle'] +quality_delta desc_bytes= 833 schema_bytes= 193 required=[] +````` + +## `printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize"}' '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"for","arguments":{"path":".","task":"pagerank power iteration"}}}' | ./build/ripwire --mcp` + +*MCP `for`: always bundle=sigs (never the CLI's compact route), the same ranked signatures as --for.* + +**wall time: 1.30s** + +````` +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-11-25","serverInfo":{"name":"ripwire","version":"1.0"},"capabilities":{"tools":{},"resources":{}},"instructions":"Map before reading files. Start a new task with explore; use from_trace for an error; use impact plus uses before changing a sym … [line truncated: 738 more bytes on this line] +{"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":". window: shown= total= capped= has_more= next_offset= offset= limit= (ca … [line truncated: 19102 more bytes on this line] +"_index":"[index: files=2688 symbols=25558 hash=3e4b5e31]","_fresh":"ok"}} +{"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":""; + +inline const char* implementorsFloorLegendComment( const IngestResult& ing, const std::vector>& graphImplementors, NodeId focus ) +{ + const NodeId one[1] = { focus }; + return implementorFloors( ing, graphImplementors, std::span( one ) )[0] ? kImplementorsFloorLegendComment : ""; +} + +} // namespace rw diff --git a/src/crossref.h b/src/crossref.h index 05e88df39..ad0d29b46 100644 --- a/src/crossref.h +++ b/src/crossref.h @@ -97,6 +97,8 @@ #include "infra/jsonesc.h" // shSingleQuote #include "serialize.h" // escapeXml #include "pageview.h" // §P8: pageWindow / pageDisclosure — the shared --limit/--offset contract +#include "nextverb.h" // nextAttrXml / nextFlag — the follow-up of the default listing +#include "compactlegend.h" // compactDeliveredBytesOrWritten — the default listing is chosen on the bytes the compact dialect delivers #include "workspace.h" // wsdetail::segmentsOf #include "filter.h" // §P11.5: rw::pathTierOf — the shared source/test/doc ORDERING tier #include "infra/Diagnostics.h" // ASSUME / DISCLOSE @@ -141,6 +143,24 @@ constexpr std::uint32_t kMaxRefs = 512; // refusal bound — a swee constexpr std::size_t kStrayFilesPerRef = 12; constexpr std::size_t kWhereisHits = 60; +// WHICH ROWS a --whereis answer lists (--whereis-listing=, MCP `listing`). A where-defined question is answered by +// the kind="def" rows; the kind="ref" rows (8-59 per answer on the comparison-table cells, ~8 KB) supplied no gold +// item there. So the default lists the definitions and COUNTS the references in one element whose +// next= lists exactly those rows (Refs). All is the whole hit list, the pre-listing answer and the uncapped variant +// a lean answer is graded against (owner rule: a capped variant is graded against the uncapped one). +// Defs every kind="def" row; the kind="ref" rows counted. Degrades to All (no listing=, no ) when the hit +// list holds no ref row (nothing to elide) or no def row (the mentions ARE the answer then). +// Refs the kind="ref" rows only, in the hit list's order: the page. +// All every row. +// ShorterOfDefsAll the DEFAULT (no flag, no MCP `listing`): complete answers first. The Defs page when it SHOWS MORE +// definitions than the All page under the shared row cap (a capped All page can list fewer: HEAD's references fill +// the cap before the branch definitions arrive; review D1), whatever its bytes; when both pages show the same +// definitions, the Defs page only when it is STRICTLY shorter in bytes (review B1: on a symbol with one or two +// refs the Defs page pays more for its element, listing= and their legend readings than the ref rows it +// elides), else the All page. Measured, never guessed: whereisServedListing renders both. An explicit defs is +// served as asked. +enum class WhereisListing : std::uint8_t { Defs, Refs, All, ShorterOfDefsAll }; + // ── blob facts (the per-sha memo payload) ──────────────────────────────────────────────────────────────── // Everything this module needs to know about one blob, reduced from its bytes as they stream past and kept @@ -2820,6 +2840,186 @@ inline void writeWhereisFate( std::FILE* out, const WhereResult& res, const XmlE gitoracle::writeNameFate( out, res.sym, res.fate, ex ); } +// ── the listing and the hoist (lean-answers lane) ──────────────────────────────────────────────────────── + +// The rows one --whereis answer lists (see WhereisListing): indices into WhereResult::hits in the list's own order, +// the root's listing= value ("" when every hit is listed), and how many kind="ref" rows the element +// stands for (0 = no such element). +struct ListedHits +{ + std::vector rows; + std::string_view attr; + std::size_t refsElided = 0; +}; + +inline ListedHits listedHits( const WhereResult& res, WhereisListing listing ) +{ + EXPECTS( listing != WhereisListing::ShorterOfDefsAll, "the default is resolved to Defs or All (whereisServedListing) before the rows are picked" ); + const std::size_t defs = std::size_t( std::count_if( res.hits.begin(), res.hits.end(), []( const WhereHit& h ) { return h.isDef; } ) ); + const std::size_t refs = res.hits.size() - defs; + ListedHits out; + // Defs with nothing to elide (no ref row) IS the whole list; Defs with no def row would list nothing but a count, + // and then the mentions are the answer (a name the index does not define, a literal): both print every hit. + const bool lean = listing == WhereisListing::Defs && defs > 0 && refs > 0; + if( !lean && listing != WhereisListing::Refs ) + { + out.rows.resize( res.hits.size() ); + for( std::size_t i = 0; i < res.hits.size(); ++i ) { out.rows[ i ] = i; } + return out; + } + const bool wantDefs = lean; + for( std::size_t i = 0; i < res.hits.size(); ++i ) + { + if( res.hits[ i ].isDef == wantDefs ) { out.rows.push_back( i ); } + } + out.attr = wantDefs ? std::string_view( "defs" ) : std::string_view( "refs" ); + out.refsElided = wantDefs ? refs : 0; + ENSURES( out.rows.size() == ( wantDefs ? defs : refs ), "a listing holds exactly the rows of its kind" ); + return out; +} + +// HEAD's committer date, read off the first row on HEAD's commit (every such row carries the same date: the scan +// stamps a ref's rows with its tip's date). "" when no row is on HEAD's commit (a branch-only answer, hits="0"). +inline std::string whereisHeadDate( const WhereResult& res ) +{ + const std::string_view head = std::string_view( res.headSha ).substr( 0, 9 ); + for( const WhereHit& h : res.hits ) + { + if( !head.empty() && std::string_view( h.tip ).substr( 0, 9 ) == head ) + { + return h.date; + } + } + return {}; +} + +// Whether a row's tip= and date= are exactly the root's at= (as printed: 9 hex, without +dirty) and head_date=, so the +// row may omit both and a reader restores them losslessly. Compared on the PRINTED 9-hex spelling, the bytes a reader holds. +inline bool whereisRowOnHeadCommit( const WhereHit& h, std::string_view headSha, std::string_view headDate ) noexcept +{ + const std::string_view head = headSha.substr( 0, 9 ); + return !head.empty() && !headDate.empty() && std::string_view( h.tip ).substr( 0, 9 ) == head && h.date == headDate; +} + +// The listing a validated --whereis-listing= / MCP `listing` value names; "" (absent) is the default, defs when it +// lists more definitions than all, else the shorter of the two (WhereisListing::ShorterOfDefsAll). Both surfaces refuse any other value before they get here. +inline WhereisListing whereisListingOf( std::string_view v ) noexcept +{ + return v == "refs" ? WhereisListing::Refs : v == "all" ? WhereisListing::All + : v == "defs" ? WhereisListing::Defs : WhereisListing::ShorterOfDefsAll; +} + +// The : the same symbol, listing=refs — exactly the kind="ref" rows the default listing counted. +inline std::string whereisRefsNext( const WhereResult& res ) +{ + return nextFlag( "--whereis=", res.sym ) + " --whereis-listing=refs"; +} + +// The page a --whereis answer prints: the listed rows and the window over them (writeWhereisPage's own reading of its +// arguments, handed to the two writers below so neither re-derives it). +struct WhereisPageView +{ + const ListedHits& listed; + PageWindow page; + int pageLimit = 0; + int pageOffset = 0; +}; + +// The open tag (writeWhereisPage). +inline void writeWhereisRoot( std::FILE* out, const WhereResult& res, const WhereisPageView& v, const std::string& headDate, + const XmlEscaper& ex ) +{ + const ListedHits& listed = v.listed; + const PageWindow& hitPage = v.page; + const std::size_t listedCount = listed.rows.size(); + const int pageLimit = v.pageLimit; + const int pageOffset = v.pageOffset; + char pab[ kPageDisclosureCap ]; + // §A7(iii): refs_scanned=, not refs=. --stray-content and --abi both spell the MATCHED set refs=; this one + // counted every branch the sweep READ (73 here, matched or not) under the same attribute name — one noun, + // two meanings, across sibling verbs an agent reads together. + // T1: the claim is scan-exhaustiveness AND listing-wholeness — an uncut page over a clean scan. Appended + // LAST (after at=) so no existing attribute-adjacency assertion can break on it, the same placement rule + // the graph verbs' floor marker follows. When either half fails, NOTHING is added: the truncation + // vocabulary above already covers every partial shape, and complete-equals-zero would be noise. + // A dotted selector was searched as a literal the question did not mean: exhaustive over that literal, but not + // an answer to "where is Class.method", so it never claims (its selector-note below says why). + // listing= (WhereisListing): the claim reads the LISTED rows — under listing=defs every def row on this page (the + // ref rows are counted exactly by , never silently gone); under listing=refs every ref row. + const bool completeClaim = res.scanExhaustive && hitPage.begin == 0 && hitPage.end == listedCount && res.dottedRetry.empty(); + // H14/M6: refs_scanned="80" under a ref-name filter is a total for the FILTER, not for the repo (the + // audit measured 80 filtered vs 189 unfiltered) — so the filter is named beside the number it bounds. + const std::string whFilterAttr = res.filter.empty() ? std::string() : ( " filter=\"" + ex( res.filter ) + "\"" ); + // The overlay's two marks, both absent on a clean checkout (so its answer is byte-identical to before): +dirty + // on the stamp whenever a differing path was seen, and worktree= naming what the overlay managed. + static constexpr std::string_view kWorktreeAttr[] = { "", " worktree=\"read\"", " worktree=\"partial\"", " worktree=\"unlisted\"" }; + const bool dirty = res.worktree == WorktreeOverlay::Read || res.worktree == WorktreeOverlay::Partial; + // THE HOIST (lossless): a checkout row on HEAD's own commit repeats tip= (= at= without +dirty) and date= (HEAD's + // committer date) on every row. Those rows omit both, and the root says the date ONCE (head_date=) — only when a + // printed row actually omitted it, so a page of branch rows alone is unchanged. Placed before at= so the at= / + // worktree= / complete= adjacency every existing assertion reads stays byte-stable. + bool anyHoist = false; + for( std::size_t k = hitPage.begin; k < hitPage.end; ++k ) + { + anyHoist = anyHoist || whereisRowOnHeadCommit( res.hits[ listed.rows[ k ] ], res.headSha, headDate ); + } + const std::string listingAttr = listed.attr.empty() ? std::string() : ( " listing=\"" + std::string( listed.attr ) + "\"" ); + const std::string headDateAttr = anyHoist ? ( " head_date=\"" + ex( headDate ) + "\"" ) : std::string(); + rw::emitTo( out, "", + ex( res.sym ).c_str(), res.onHead ? 1 : 0, res.refsScanned, res.distinctBlobs, res.hits.size(), + res.headLabelsFromIndex ? "index" : "lexical", whFilterAttr.c_str(), + pageDisclosure( pab, sizeof( pab ), hitPage.end - hitPage.begin, listedCount, hitPage.end, + pageLimit, pageOffset, true ), + listingAttr.c_str(), headDateAttr.c_str(), + res.headSha.c_str(), dirty ? "+dirty" : "", + kWorktreeAttr[ std::size_t( res.worktree ) ], + completeClaim ? " complete=\"1\"" : "" ); + +} + +// The rows of the page, its remainder and the element (writeWhereisPage). +inline void writeWhereisRows( std::FILE* out, const WhereResult& res, const ListedHits& listed, const PageWindow& hitPage, + const std::string& headDate, const XmlEscaper& ex ) +{ + const std::size_t listedCount = listed.rows.size(); + // The contract, restated because it was false here: shown + dropped == the listed rows, ALWAYS. The + // count must be taken against the CAP, not against a loop variable `shown++ >= cap` has already pushed to + // cap+1 — that off-by-one under-reported every drop by exactly one row (81 hits, 60 shown, "20" dropped), + // and at exactly cap+1 hits `size > shown` went false, so the element vanished and a row disappeared + // unmarked. Nothing is dropped without a number is a headline claim; keep it arithmetically true. + std::size_t shownCount = 0; + for( std::size_t k = hitPage.begin; k < hitPage.end; ++k ) + { + const WhereHit& h = res.hits[ listed.rows[ k ] ]; + ++shownCount; + if( whereisRowOnHeadCommit( h, res.headSha, headDate ) ) + { + rw::emitTo( out, "", + ex( h.ref ).c_str(), ex( h.path ).c_str(), + h.line, h.isDef ? "def" : "ref", h.testLocal ? " test_local=\"1\"" : "", ex( h.text ).c_str() ); + continue; + } + rw::emitTo( out, "", + ex( h.ref ).c_str(), h.tip.c_str(), ex( h.date ).c_str(), ex( h.path ).c_str(), + h.line, h.isDef ? "def" : "ref", h.testLocal ? " test_local=\"1\"" : "", ex( h.text ).c_str() ); + } + ASSUME( shownCount == hitPage.end - hitPage.begin ); + // = the LISTED rows AFTER this page, so shown + more == the listed rows from this page's offset + // on. Un-paginated that is the historic "listed rows minus the 60 printed"; paged it is what the NEXT page holds + // (and next_offset= on the root says where to ask for it). + if( hitPage.end < listedCount ) + { + rw::emitTo( out, "", listedCount - hitPage.end ); + } + // The elided ref rows, COUNTED and one pasteable call away: count= is exact (every kind="ref" row of the hit list, + // whatever page this is), and next= lists exactly them. Not a element: is this listing's own + // page remainder, and a reader (or a gate) that reads " 0 ) + { + rw::emitTo( out, "", listed.refsElided, nextAttrXml( whereisRefsNext( res ) ) ); + } +} + // Contract-level defect: this verb said hits="2560" and printed 60, and // --limit/--offset were accepted and ignored, so a paging loop over it never advanced and never ended. // `pageLimit`/`pageOffset` (0 = un-paginated) window the hit list, which is already deterministically @@ -2830,7 +3030,8 @@ inline void writeWhereisFate( std::FILE* out, const WhereResult& res, const XmlE // // Paging lives in its own entry point rather than as two defaulted parameters on writeWhereis() so the // un-paginated contract — the one the MCP `whereis` verb calls — keeps its exact three-argument shape. -inline void writeWhereisPage( std::FILE* out, const WhereResult& res, std::size_t maxHits, int pageLimit, int pageOffset ) +inline void writeWhereisListedPage( std::FILE* out, const WhereResult& res, std::size_t maxHits, int pageLimit, int pageOffset, + const ListedHits& listed ) { std::vector esc; const XmlEscaper ex = [ & ]( std::string_view s ) { return std::string( escapeXml( s, esc ) ); }; @@ -2839,9 +3040,10 @@ inline void writeWhereisPage( std::FILE* out, const WhereResult& res, std::size_ // SIZE_MAX under --detail); --offset skips whole rows and clamps at the end, so offset-past-the-end is // an empty page rather than an out-of-range read. maxHits can be SIZE_MAX, which pageWindow's int limit // cannot carry — clamp the "no explicit --limit" arm to the row count instead of overflowing it. - const int rowCap = pageLimit > 0 ? pageLimit - : ( maxHits >= res.hits.size() ? int( res.hits.size() ) : int( maxHits ) ); - const PageWindow hitPage = pageWindow( res.hits.size(), rowCap, pageOffset ); + const std::size_t listedCount = listed.rows.size(); + const int rowCap = pageLimit > 0 ? pageLimit + : ( maxHits >= listedCount ? int( listedCount ) : int( maxHits ) ); + const PageWindow hitPage = pageWindow( listedCount, rowCap, pageOffset ); rw::emitRaw( out, "", out ); - char pab[ kPageDisclosureCap ]; - // §A7(iii): refs_scanned=, not refs=. --stray-content and --abi both spell the MATCHED set refs=; this one - // counted every branch the sweep READ (73 here, matched or not) under the same attribute name — one noun, - // two meanings, across sibling verbs an agent reads together. - // T1: the claim is scan-exhaustiveness AND listing-wholeness — an uncut page over a clean scan. Appended - // LAST (after at=) so no existing attribute-adjacency assertion can break on it, the same placement rule - // the graph verbs' floor marker follows. When either half fails, NOTHING is added: the truncation - // vocabulary above already covers every partial shape, and complete-equals-zero would be noise. - // A dotted selector was searched as a literal the question did not mean: exhaustive over that literal, but not - // an answer to "where is Class.method", so it never claims (its selector-note below says why). - const bool completeClaim = res.scanExhaustive && hitPage.begin == 0 && hitPage.end == res.hits.size() && res.dottedRetry.empty(); - // H14/M6: refs_scanned="80" under a ref-name filter is a total for the FILTER, not for the repo (the - // audit measured 80 filtered vs 189 unfiltered) — so the filter is named beside the number it bounds. - const std::string whFilterAttr = res.filter.empty() ? std::string() : ( " filter=\"" + ex( res.filter ) + "\"" ); - // The overlay's two marks, both absent on a clean checkout (so its answer is byte-identical to before): +dirty - // on the stamp whenever a differing path was seen, and worktree= naming what the overlay managed. - static constexpr std::string_view kWorktreeAttr[] = { "", " worktree=\"read\"", " worktree=\"partial\"", " worktree=\"unlisted\"" }; - const bool dirty = res.worktree == WorktreeOverlay::Read || res.worktree == WorktreeOverlay::Partial; - rw::emitTo( out, "", - ex( res.sym ).c_str(), res.onHead ? 1 : 0, res.refsScanned, res.distinctBlobs, res.hits.size(), - res.headLabelsFromIndex ? "index" : "lexical", whFilterAttr.c_str(), - pageDisclosure( pab, sizeof( pab ), hitPage.end - hitPage.begin, res.hits.size(), hitPage.end, - pageLimit, pageOffset, true ), - res.headSha.c_str(), dirty ? "+dirty" : "", - kWorktreeAttr[ std::size_t( res.worktree ) ], - completeClaim ? " complete=\"1\"" : "" ); - + const std::string headDate = whereisHeadDate( res ); + writeWhereisRoot( out, res, WhereisPageView{ listed, hitPage, pageLimit, pageOffset }, headDate, ex ); writeWhereisSelectorNotes( out, res, ex ); // The history lane, when it was asked for: what the probe did, then this symbol's own verdict. @@ -2942,35 +3139,72 @@ inline void writeWhereisPage( std::FILE* out, const WhereResult& res, std::size_ // row is printed unless the index itself already proved the symbol is defined on HEAD. writeWhereisFate( out, res, ex ); - // The contract, restated because it was false here: shown + dropped == hits=, ALWAYS. The count - // must be taken against the CAP, not against a loop variable `shown++ >= cap` has already pushed to - // cap+1 — that off-by-one under-reported every drop by exactly one row (81 hits, 60 shown, "20" dropped), - // and at exactly cap+1 hits `size > shown` went false, so the element vanished and a row disappeared - // unmarked. Nothing is dropped without a number is a headline claim; keep it arithmetically true. - std::size_t shownCount = 0; - for( std::size_t hitIndex = hitPage.begin; hitIndex < hitPage.end; ++hitIndex ) + writeWhereisRows( out, res, listed, hitPage, headDate, ex ); + rw::emitRaw( out, "" ); +} + +// THE DEFAULT, MEASURED (review B1, then D1). First the definitions: the Defs page is served whenever it SHOWS more +// definitions than the All page under the row cap (a capped All page can list fewer), whatever its bytes. Only when both +// pages show the same definitions do the bytes decide. The Defs page pays a fixed overhead — listing=, the element +// and, under the compact legend, their readings — that eliding one or two short ref rows does not repay: on 24 of 58 +// sampled symbols the Defs page was LONGER than the All page while listing fewer rows. So both pages are rendered and +// Defs is served only when it is STRICTLY shorter both as written (the full legend, whose prose is the same text on +// both pages, so this compares the payload) and as the compact dialect delivers it (its per-answer readings ride only +// the page that carries their attribute, so a reading the All page's ref rows trigger can tip either way). A tie serves All: equal +// bytes, more rows. Both pages are rendered UNPAGED over the same row cap, so the choice is a fact of the answer, not +// of --limit/--offset: every page of one answer lists the same rows. A render that fails serves All (the whole answer). +inline ListedHits whereisServedListing( const WhereResult& res, std::size_t maxHits, WhereisListing listing ) +{ + if( listing != WhereisListing::ShorterOfDefsAll ) { - const WhereHit& h = res.hits[ hitIndex ]; - ++shownCount; - rw::emitTo( out, "", - ex( h.ref ).c_str(), h.tip.c_str(), ex( h.date ).c_str(), ex( h.path ).c_str(), - h.line, h.isDef ? "def" : "ref", h.testLocal ? " test_local=\"1\"" : "", ex( h.text ).c_str() ); + return listedHits( res, listing ); } - ASSUME( shownCount == hitPage.end - hitPage.begin ); - // = the rows AFTER this page, so shown + more == the rows from this page's offset on. - // Un-paginated that is the historic "hits= minus the 60 printed"; paged it is what the NEXT page holds - // (and next_offset= on the root says where to ask for it). - if( hitPage.end < res.hits.size() ) + ListedHits defs = listedHits( res, WhereisListing::Defs ); + if( defs.attr.empty() ) { - rw::emitTo( out, "", res.hits.size() - hitPage.end ); + return defs; // Defs already degraded to every hit (no ref row, or no def row): nothing to compare } - rw::emitRaw( out, "" ); + ListedHits all = listedHits( res, WhereisListing::All ); + // COMPLETE ANSWERS FIRST (orchestrator ruling 2026-10-08, review D1). Under the shared row cap the All page can list + // FEWER definitions than the Defs page: HEAD's ref rows fill the cap before the branch definitions arrive. Both + // lists are in hit-list order and Defs is the def subsequence, so the definitions the All page shows are a PREFIX + // of the Defs page's: comparing the counts compares the sets. More definitions wins whatever its bytes; only equal + // sets fall to the bytes rule below. + const auto defsShown = [ & ]( const ListedHits& l ) + { + const std::size_t n = std::min( l.rows.size(), maxHits ); + return std::size_t( std::count_if( l.rows.begin(), l.rows.begin() + std::ptrdiff_t( n ), + [ & ]( std::size_t i ) { return res.hits[ i ].isDef; } ) ); + }; + const std::size_t defsOnDefsPage = defsShown( defs ); + const std::size_t defsOnAllPage = defsShown( all ); + ENSURES( defsOnDefsPage >= defsOnAllPage, "the All page's shown definitions are a prefix of the Defs page's" ); + if( defsOnDefsPage > defsOnAllPage ) + { + return defs; + } + const Rendered defsPage = renderToString( [ & ]( std::FILE* f ) { writeWhereisListedPage( f, res, maxHits, 0, 0, defs ); } ); + const Rendered allPage = renderToString( [ & ]( std::FILE* f ) { writeWhereisListedPage( f, res, maxHits, 0, 0, all ); } ); + const bool defsShorter = defsPage.ok && allPage.ok && defsPage.text.size() < allPage.text.size() + && compactDeliveredBytesOrWritten( defsPage.text, "whereis" ) + < compactDeliveredBytesOrWritten( allPage.text, "whereis" ); + return defsShorter ? std::move( defs ) : std::move( all ); +} + +// One --whereis answer page: the listing it serves (whereisServedListing), then that page. +inline void writeWhereisPage( std::FILE* out, const WhereResult& res, std::size_t maxHits, int pageLimit, int pageOffset, + WhereisListing listing ) +{ + // The LISTED rows (WhereisListing): indices into res.hits, in the hit list's own order, so a listing is a + // subsequence of the whole list and its rows print byte-identically to the same rows of listing=all. + const ListedHits listed = whereisServedListing( res, maxHits, listing ); + writeWhereisListedPage( out, res, maxHits, pageLimit, pageOffset, listed ); } // The un-paginated form — unchanged contract, for callers that want the whole (capped) listing. inline void writeWhereis( std::FILE* out, const WhereResult& res, std::size_t maxHits ) { - writeWhereisPage( out, res, maxHits, 0, 0 ); + writeWhereisPage( out, res, maxHits, 0, 0, WhereisListing::All ); } }} // namespace rw::crossref diff --git a/src/defectshape.h b/src/defectshape.h new file mode 100644 index 000000000..728d71cdd --- /dev/null +++ b/src/defectshape.h @@ -0,0 +1,3115 @@ +#pragma once + +// defectshape.h — the scanners behind --quality-delta's defect-shape kind: four shapes of a REAL defect that +// review keeps finding by hand and that a deterministic syntactic check finds at write time. +// +// format-arity C++: a literal std::format / format_to / format_to_n / print / println, fmt::format / +// format_to / format_to_n / print / println, rw::emitTo / rw::formatTo format string whose +// replacement fields do not match its arguments. Python: a "literal".format( ... ). +// std::format_string rejects too FEW arguments at compile time and accepts too MANY +// silently; Python raises on too few at run time and ignores too many. +// utf8-cut C++: a byte cap on display text — a cut (resize / erase / substr( 0, N )) plus an appended +// ellipsis on the same string, the cut length being the bound of a size comparison on that +// string or a cap-named constant — or an ellipsis appended once a single-char push loop +// reaches a size cap, with no UTF-8 boundary back-off anywhere in the function. +// dedup-first C++: std::unique / std::ranges::unique with a lambda predicate over a type that carries a +// severity-like field (a bounded name token sev / severity / priority / prio), where neither +// the predicate nor a sort before it in the function reads that field: the FIRST row of a +// run survives, not the most severe. +// vacuous-assert Bash test scripts: an ABSENCE assertion (grep -q PAT whose match is the failure branch) +// read off a command whose failure is never checked, so a crash reads as PASS. +// +// Each scanner returns SITES: a facet, the byte the offending text starts at (the caller anchors it on the +// innermost enclosing definition, or on the file), and the offending text NORMALIZED (comments dropped, +// whitespace runs collapsed). The normalized text is the site's identity across a change: --quality-delta +// reports a site only when its text is not among the baseline's sites of that facet, so an untouched, moved +// or renamed defect is not new, and an edited one is. +// +// PRECISION BEFORE RECALL. Every test below answers "not this shape" when it cannot decide — a format held in +// a named constant or a macro, a pack expansion, a named predicate, a producer this file never assigns — and +// each of those is a stated miss, never a finding. The legend names the floors. + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "infra/Diagnostics.h" +#include "infra/namesplit.h" // the ONE ASCII identifier-character pair and the quote-pair strip +#include "infra/sortutil.h" // svLess: the string_view order a comparator lambda may use (portablebuildcheck #6c) + +namespace rw::defectshape +{ + +enum class Facet : std::uint8_t +{ + FormatArity = 0, + Utf8Cut = 1, + DedupFirst = 2, + VacuousAssert = 3, +}; +inline constexpr std::size_t kFacetCount = 4; +inline constexpr std::array kFacetNames = { "format-arity", "utf8-cut", "dedup-first", "vacuous-assert" }; + +inline constexpr std::string_view facetName( Facet f ) noexcept +{ + return kFacetNames[ static_cast( f ) ]; +} + +// The one facet whose rows gate, on ANY origin: a placeholder/argument mismatch on a literal format has no +// legitimate intent (an argument the format never prints is dropped output; a field with no argument is a +// compile error or a run-time exception), so it is a defect, not debt. The other three are report-only. +inline constexpr bool facetGatesOnAnyOrigin( Facet f ) noexcept +{ + return f == Facet::FormatArity; +} + +struct Site +{ + Facet facet = Facet::FormatArity; + std::uint32_t startByte = 0; + std::string text; +}; + +// A definition's byte span [begin, end) in the scanned file — the function bodies the per-function rules +// (utf8-cut, dedup-first) and the Bash scopes (vacuous-assert) read. +struct Span +{ + std::uint32_t begin = 0; + std::uint32_t end = 0; +}; + +namespace detail +{ + +// identifiers are read ASCII-only, the house rule (namesplit.h): a non-ASCII byte outside a literal becomes a +// one-byte punctuator, which no shape below matches. +using rw::namesplit::isIdentChar; +using rw::namesplit::isIdentStart; + +inline bool isDigit( char c ) noexcept +{ + return c >= '0' && c <= '9'; +} + +inline bool isSpace( char c ) noexcept +{ + return std::isspace( static_cast( c ) ) != 0; +} + +enum class TokKind : std::uint8_t +{ + Ident, + Number, + String, + Char, + Punct, +}; + +// One token of a C-family or Python source: [begin, end) in the file. A String token spans its prefix and +// quotes; contentBegin/contentEnd are the bytes between the quotes (between the raw delimiters for a C++ raw +// string, between the triple quotes for a Python long string). +struct Tok +{ + TokKind kind = TokKind::Punct; + std::uint32_t begin = 0; + std::uint32_t end = 0; + std::uint32_t contentBegin = 0; + std::uint32_t contentEnd = 0; +}; + +inline std::string_view tokText( std::string_view src, const Tok& t ) noexcept +{ + return src.substr( t.begin, t.end - t.begin ); +} + +inline std::string_view tokContent( std::string_view src, const Tok& t ) noexcept +{ + return src.substr( t.contentBegin, t.contentEnd - t.contentBegin ); +} + +inline bool tokIs( std::string_view src, const Tok& t, std::string_view s ) noexcept +{ + return tokText( src, t ) == s; +} + +// The longest punctuator at `i` from a small fixed table (longest first), else one byte. +inline std::uint32_t punctLen( std::string_view s, std::size_t i ) noexcept +{ + static constexpr std::array kPunct = { + "<<=", ">>=", "<=>", "->*", "...", "::", "->", "++", "--", "<<", ">>", "<=", ">=", "==", "!=", "&&", "||", + "+=", "-=", "*=", "/=", "%=", "&=", "|=", "^=", "**", ":=", + }; + for( std::string_view p : kPunct ) + { + if( s.substr( i, p.size() ) == p ) + { + return static_cast( p.size() ); + } + } + return 1; +} + +// Skip a quoted run starting at the opening quote s[i]; returns the index one past the closing quote (or the +// end of the line for an unterminated literal). Backslash escapes the next byte. +inline std::size_t skipQuoted( std::string_view s, std::size_t i, char q ) noexcept +{ + bool escaped = false; + for( std::size_t j = i + 1; j < s.size(); ++j ) + { + if( s[j] == '\n' ) + { + return j; // unterminated on its line: stop there rather than swallow the file + } + if( !escaped && s[j] == q ) + { + return j + 1; + } + escaped = !escaped && s[j] == '\\'; + } + return s.size(); +} + +// What lexCpp saw of the preprocessor, for the format-arity skips (rv-cr-qd-kinds-068 F1/F2): the byte each +// live directive starts at, and the names this file #defines (function-like or object-like). +struct CppPreproc +{ + std::vector directives; // the '#' byte of each directive outside an #if 0 region, ascending + std::vector macros; // every name a #define in this file defines; sorted, unique +}; + +// The identifier at s[i..] after spaces and tabs; advances i past it (empty when there is none). +inline std::string_view ppWord( std::string_view s, std::size_t& i ) noexcept +{ + while( i < s.size() && ( s[i] == ' ' || s[i] == '\t' ) ) + { + ++i; + } + const std::size_t b = i; + while( i < s.size() && isIdentChar( s[i] ) ) + { + ++i; + } + return s.substr( b, i - b ); +} + +// `#if 0` (nothing but a comment after the 0): the region up to its own #else / #elif / #endif is dead code, +// never compiled, so nothing in it is judged (F3). Returns the start of the line holding the directive that +// ends the region (the caller lexes that directive as usual), or s.size() when the region never closes. +inline std::size_t skipIfZeroRegion( std::string_view s, std::size_t lineEnd ) noexcept +{ + int depth = 0; + for( std::size_t ls = lineEnd; ls < s.size(); ) + { + std::size_t j = ls; + while( j < s.size() && ( s[j] == ' ' || s[j] == '\t' ) ) + { + ++j; + } + if( j < s.size() && s[j] == '#' ) + { + ++j; + const std::string_view w = ppWord( s, j ); + if( w == "if" || w == "ifdef" || w == "ifndef" ) + { + ++depth; + } + else if( w == "endif" || w == "else" || w == "elif" || w == "elifdef" || w == "elifndef" ) + { + if( depth == 0 ) + { + return ls; + } + depth -= ( w == "endif" ) ? 1 : 0; + } + } + const std::size_t nl = s.find( '\n', ls ); + ls = ( nl == std::string_view::npos ) ? s.size() : nl + 1; + } + return s.size(); +} + +// The directive at s[hash] is `#if 0`, alone on its line but for a comment. +inline bool isIfZero( std::string_view s, std::size_t hash ) noexcept +{ + std::size_t j = hash + 1; + if( ppWord( s, j ) != "if" ) + { + return false; + } + while( j < s.size() && ( s[j] == ' ' || s[j] == '\t' ) ) + { + ++j; + } + if( j >= s.size() || s[j] != '0' ) + { + return false; + } + ++j; + while( j < s.size() && ( s[j] == ' ' || s[j] == '\t' || s[j] == '\r' ) ) + { + ++j; + } + return j >= s.size() || s[j] == '\n' || ( s[j] == '/' && j + 1 < s.size() && ( s[j + 1] == '/' || s[j + 1] == '*' ) ); +} + +// C++ tokens with comments and preprocessor directives dropped. A directive is skipped whole (continuation +// lines included): a format inside a macro body is never judged — its arguments are whatever the expansion +// site passes. An `#if 0` region is dropped whole (F3). `pp`, when given, receives each directive's byte and +// the names this file #defines. +inline std::vector lexCpp( std::string_view s, CppPreproc* pp = nullptr ) +{ + std::vector out; + std::size_t i = 0; + bool lineStart = true; + while( i < s.size() ) + { + const char c = s[i]; + if( c == '\n' ) + { + lineStart = true; + ++i; + continue; + } + if( isSpace( c ) ) + { + ++i; + continue; + } + if( c == '/' && i + 1 < s.size() && s[i + 1] == '/' ) + { + while( i < s.size() && s[i] != '\n' ) + { + ++i; + } + continue; + } + if( c == '/' && i + 1 < s.size() && s[i + 1] == '*' ) + { + const std::size_t e = s.find( "*/", i + 2 ); + i = ( e == std::string_view::npos ) ? s.size() : e + 2; + continue; + } + if( c == '#' && lineStart ) + { + const std::size_t hash = i; + if( pp != nullptr ) + { + pp->directives.push_back( static_cast( hash ) ); + std::size_t j = hash + 1; + if( ppWord( s, j ) == "define" ) + { + const std::string_view name = ppWord( s, j ); + if( !name.empty() ) + { + pp->macros.emplace_back( name ); + } + } + } + // the directive runs to the first newline a backslash does not continue + while( i < s.size() && !( s[i] == '\n' && ( i == 0 || s[i - 1] != '\\' ) ) ) + { + ++i; + } + if( isIfZero( s, hash ) ) + { + i = skipIfZeroRegion( s, i < s.size() ? i + 1 : i ); + } + continue; + } + lineStart = false; + const std::uint32_t b = static_cast( i ); + if( isIdentStart( c ) ) + { + std::size_t j = i; + while( j < s.size() && isIdentChar( s[j] ) ) + { + ++j; + } + const std::string_view word = s.substr( i, j - i ); + const bool isStrPrefix = word == "L" || word == "u" || word == "U" || word == "u8" || word == "R" || word == "LR" || word == "uR" + || word == "UR" || word == "u8R"; + if( isStrPrefix && j < s.size() && s[j] == '"' ) + { + if( word.back() == 'R' ) + { + const std::size_t open = s.find( '(', j + 1 ); + if( open != std::string_view::npos && open - j - 1 <= 16 ) + { + const std::string close = ")" + std::string( s.substr( j + 1, open - j - 1 ) ) + "\""; + const std::size_t e = s.find( close, open + 1 ); + const std::size_t endAt = ( e == std::string_view::npos ) ? s.size() : e + close.size(); + const std::size_t cEnd = ( e == std::string_view::npos ) ? s.size() : e; + out.push_back( { TokKind::String, b, static_cast( endAt ), static_cast( open + 1 ), static_cast( cEnd ) } ); + i = endAt; + continue; + } + } + const std::size_t e = skipQuoted( s, j, '"' ); + out.push_back( { TokKind::String, b, static_cast( e ), static_cast( j + 1 ), static_cast( e > j + 1 ? e - 1 : j + 1 ) } ); + i = e; + continue; + } + if( isStrPrefix && word.back() != 'R' && j < s.size() && s[j] == '\'' ) + { + const std::size_t e = skipQuoted( s, j, '\'' ); + out.push_back( { TokKind::Char, b, static_cast( e ), b, static_cast( e ) } ); + i = e; + continue; + } + out.push_back( { TokKind::Ident, b, static_cast( j ), b, static_cast( j ) } ); + i = j; + continue; + } + if( isDigit( c ) || ( c == '.' && i + 1 < s.size() && isDigit( s[i + 1] ) ) ) + { + std::size_t j = i + 1; + while( j < s.size() && ( isIdentChar( s[j] ) || s[j] == '.' || s[j] == '\'' + || ( ( s[j] == '+' || s[j] == '-' ) && ( s[j - 1] == 'e' || s[j - 1] == 'E' || s[j - 1] == 'p' || s[j - 1] == 'P' ) ) ) ) + { + ++j; + } + out.push_back( { TokKind::Number, b, static_cast( j ), b, static_cast( j ) } ); + i = j; + continue; + } + if( c == '"' ) + { + const std::size_t e = skipQuoted( s, i, '"' ); + out.push_back( { TokKind::String, b, static_cast( e ), b + 1, static_cast( e > i + 1 ? e - 1 : i + 1 ) } ); + i = e; + continue; + } + if( c == '\'' ) + { + const std::size_t e = skipQuoted( s, i, '\'' ); + out.push_back( { TokKind::Char, b, static_cast( e ), b, static_cast( e ) } ); + i = e; + continue; + } + const std::uint32_t n = punctLen( s, i ); + out.push_back( { TokKind::Punct, b, b + n, b, b + n } ); + i += n; + } + if( pp != nullptr ) + { + std::sort( pp->macros.begin(), pp->macros.end() ); + pp->macros.erase( std::unique( pp->macros.begin(), pp->macros.end() ), pp->macros.end() ); + } + return out; +} + +// One Python string literal: its prefix starts at `b`, its opening quote sits at `q`. Single-quoted literals end at +// their quote or the line's end; a backslash escapes the next byte for the scan (raw literals included). +inline Tok lexPythonString( std::string_view s, std::uint32_t b, std::size_t q ) +{ + const char quote = s[q]; + const bool triple = s.substr( q, 3 ) == std::string( 3, quote ); + const std::size_t cb = q + ( triple ? 3 : 1 ); + std::size_t j = cb; + while( j < s.size() && !( triple ? s.substr( j, 3 ) == std::string( 3, quote ) : ( s[j] == quote || s[j] == '\n' ) ) ) + { + j += ( s[j] == '\\' ) ? 2 : 1; + } + const std::size_t ce = std::min( j, s.size() ); + const std::size_t end = std::min( s.size(), j + ( j < s.size() && s[j] != '\n' ? ( triple ? 3 : 1 ) : 0 ) ); + return { TokKind::String, b, static_cast( end ), static_cast( cb ), static_cast( ce ) }; +} + +// Python tokens with comments dropped. A newline inside brackets is not a token (an implicit concat inside +// parentheses spans lines), and neither is a backslash-continued one; a newline that ENDS a logical line (outside +// every bracket) is one "\n" token, so two statements' adjacent literals are never read as one concat +// (X = "a {}" on one line, "{}".format( 1 ) on the next). +inline std::vector lexPython( std::string_view s ) +{ + std::vector out; + std::size_t i = 0; + int depth = 0; + const auto isPrefixChar = []( char c ) { return c == 'r' || c == 'R' || c == 'b' || c == 'B' || c == 'u' || c == 'U' || c == 'f' || c == 'F'; }; + while( i < s.size() ) + { + const char c = s[i]; + if( c == '\n' && depth == 0 ) + { + if( !out.empty() && !tokIs( s, out.back(), "\n" ) ) + { + const std::uint32_t nl = static_cast( i ); + out.push_back( { TokKind::Punct, nl, nl + 1, nl, nl + 1 } ); // a logical line ends + } + ++i; + continue; + } + if( isSpace( c ) || c == '\\' ) + { + // a backslash-continued newline is skipped with its backslash: one logical line + i += ( c == '\\' && s.substr( i + 1, 1 ) == "\n" ) ? 2 : ( c == '\\' && s.substr( i + 1, 2 ) == "\r\n" ) ? 3 : 1; + continue; + } + if( c == '#' ) + { + while( i < s.size() && s[i] != '\n' ) + { + ++i; + } + continue; + } + const std::uint32_t b = static_cast( i ); + std::size_t q = i; + while( q < s.size() && q - i < 2 && isPrefixChar( s[q] ) ) + { + ++q; + } + if( q < s.size() && ( s[q] == '"' || s[q] == '\'' ) && ( q == i || !isIdentChar( i > 0 ? s[i - 1] : ' ' ) ) ) + { + out.push_back( lexPythonString( s, b, q ) ); + i = out.back().end; + continue; + } + if( isIdentStart( c ) ) + { + std::size_t j = i; + while( j < s.size() && isIdentChar( s[j] ) ) + { + ++j; + } + out.push_back( { TokKind::Ident, b, static_cast( j ), b, static_cast( j ) } ); + i = j; + continue; + } + if( isDigit( c ) ) + { + std::size_t j = i + 1; + while( j < s.size() && ( isIdentChar( s[j] ) || s[j] == '.' ) ) + { + ++j; + } + out.push_back( { TokKind::Number, b, static_cast( j ), b, static_cast( j ) } ); + i = j; + continue; + } + const std::uint32_t n = punctLen( s, i ); + out.push_back( { TokKind::Punct, b, b + n, b, b + n } ); + if( n == 1 ) + { + depth += ( c == '(' || c == '[' || c == '{' ) ? 1 : 0; + depth -= ( ( c == ')' || c == ']' || c == '}' ) && depth > 0 ) ? 1 : 0; + } + i += n; + } + return out; +} + +// The tokens [from, to] joined by single spaces: a site's identity text, immune to reformatting. +inline std::string joinTokens( std::string_view src, const std::vector& toks, std::size_t from, std::size_t to ) +{ + std::string out; + for( std::size_t k = from; k <= to && k < toks.size(); ++k ) + { + if( !out.empty() ) + { + out.push_back( ' ' ); + } + out.append( tokText( src, toks[k] ) ); + } + return out; +} + +// The index of the bracket that closes the one at `open` (round, square or curly; the others nest inside), +// or toks.size() when unbalanced. +inline std::size_t matchClose( std::string_view src, const std::vector& toks, std::size_t open ) noexcept +{ + int depth = 0; + for( std::size_t k = open; k < toks.size(); ++k ) + { + if( toks[k].kind != TokKind::Punct ) + { + continue; + } + const char c = src[ toks[k].begin ]; + if( toks[k].end - toks[k].begin != 1 ) + { + continue; + } + if( c == '(' || c == '[' || c == '{' ) + { + ++depth; + } + else if( c == ')' || c == ']' || c == '}' ) + { + --depth; + if( depth == 0 ) + { + return k; + } + if( depth < 0 ) + { + return toks.size(); + } + } + } + return toks.size(); +} + +// A call's arguments: token index ranges [first, last] between the parentheses at `open` and `close`, split at +// top-level commas. An empty argument (a trailing comma) is kept as first > last. +struct ArgRange +{ + std::size_t first = 0; + std::size_t last = 0; + bool empty() const noexcept { return first > last; } +}; + +inline std::vector splitArgs( std::string_view src, const std::vector& toks, std::size_t open, std::size_t close ) +{ + std::vector out; + if( close == open + 1 ) + { + return out; + } + int depth = 0; + std::size_t start = open + 1; + for( std::size_t k = open + 1; k < close; ++k ) + { + if( toks[k].kind != TokKind::Punct || toks[k].end - toks[k].begin != 1 ) + { + continue; + } + const char c = src[ toks[k].begin ]; + if( c == '(' || c == '[' || c == '{' ) + { + ++depth; + } + else if( c == ')' || c == ']' || c == '}' ) + { + --depth; + } + else if( c == ',' && depth == 0 ) + { + out.push_back( { start, k - 1 } ); + start = k + 1; + } + } + out.push_back( { start, close - 1 } ); + return out; +} + +// ─── replacement-field grammars ───────────────────────────────────────────────────────────────────────── + +// What a format string asks of its argument list. ok=false: the string is not one this check can judge +// (mixed automatic and manual numbering, a lone brace, a named field where the family has none) — skipped. +struct FieldUse +{ + bool ok = false; + std::uint32_t autoFields = 0; + std::vector indices; // manual positional indices, one per use + std::vector names; // Python named fields +}; + +// The arg-id at s[i..]: digits → a manual index, an identifier → a name, empty → automatic. Advances i. +// Returns false on anything else. +inline bool readArgId( std::string_view s, std::size_t& i, FieldUse& use, bool allowNames, bool pythonFieldName ) +{ + const std::size_t b = i; + if( i < s.size() && isDigit( s[i] ) ) + { + std::uint32_t v = 0; + while( i < s.size() && isDigit( s[i] ) ) + { + if( v > 100000 ) + { + return false; // not a real argument index — refuse rather than wrap + } + v = v * 10 + static_cast( s[i] - '0' ); + ++i; + } + use.indices.push_back( v ); + } + else if( i < s.size() && isIdentStart( s[i] ) ) + { + if( !allowNames ) + { + return false; + } + while( i < s.size() && isIdentChar( s[i] ) ) + { + ++i; + } + use.names.emplace_back( s.substr( b, i - b ) ); + } + else + { + ++use.autoFields; + } + if( pythonFieldName ) + { + // attribute / index accessors after the arg name: .attr and [key], neither consumes an argument + while( i < s.size() && ( s[i] == '.' || s[i] == '[' ) ) + { + if( s[i] == '[' ) + { + const std::size_t e = s.find( ']', i ); + if( e == std::string_view::npos ) + { + return false; + } + i = e + 1; + } + else + { + ++i; + while( i < s.size() && isIdentChar( s[i] ) ) + { + ++i; + } + } + } + } + return true; +} + +// The std::format / fmt / Python str.format replacement-field grammar, over the literal's content as written +// (Python's \N{...} is skipped; a literal with an escape that spells a brace never reaches here — see +// escapeSpellsBrace). `python` selects the Python field-name and conversion syntax. +inline FieldUse parseFields( std::string_view s, bool python ) +{ + FieldUse use; + std::size_t i = 0; + while( i < s.size() ) + { + const char c = s[i]; + if( python && c == '\\' && i + 1 < s.size() && s[i + 1] == 'N' && i + 2 < s.size() && s[i + 2] == '{' ) + { + const std::size_t e = s.find( '}', i + 3 ); + if( e == std::string_view::npos ) + { + return use; + } + i = e + 1; + continue; + } + if( c == '}' ) + { + if( i + 1 < s.size() && s[i + 1] == '}' ) + { + i += 2; + continue; + } + return use; // a lone '}' — not a format this check can judge + } + if( c != '{' ) + { + ++i; + continue; + } + if( i + 1 < s.size() && s[i + 1] == '{' ) + { + i += 2; + continue; + } + ++i; + if( !readArgId( s, i, use, python, python ) ) + { + return use; + } + if( python && i < s.size() && s[i] == '!' ) + { + i += 2; // !r / !s / !a + } + if( i < s.size() && s[i] == ':' ) + { + ++i; + // the spec: nested replacement fields (dynamic width / precision) each consume an argument + while( i < s.size() && s[i] != '}' ) + { + if( s[i] == '{' ) + { + ++i; + if( !readArgId( s, i, use, python, python ) || i >= s.size() || s[i] != '}' ) + { + return use; + } + } + ++i; + } + } + if( i >= s.size() || s[i] != '}' ) + { + return use; + } + ++i; + } + use.ok = !( use.autoFields > 0 && !use.indices.empty() ); // mixed numbering is an error this check does not judge + return use; +} + +// True when `use` against `positional` arguments (and, for Python, the keyword names `kw`) is a mismatch. +inline bool fieldsMismatch( const FieldUse& use, std::uint32_t positional, const std::vector& kw ) +{ + bool bad = false; + if( !use.indices.empty() ) + { + std::vector seen( positional, 0 ); + for( std::uint32_t ix : use.indices ) + { + if( ix >= positional ) + { + bad = true; // a field with no argument + } + else + { + seen[ix] = 1; + } + } + bad = bad || std::find( seen.begin(), seen.end(), char( 0 ) ) != seen.end(); // an argument no field prints + } + else + { + bad = use.autoFields != positional; + } + for( const std::string& n : use.names ) + { + bad = bad || std::find( kw.begin(), kw.end(), n ) == kw.end(); + } + for( const std::string& k : kw ) + { + bad = bad || std::find( use.names.begin(), use.names.end(), k ) == use.names.end(); + } + return bad; +} + +// ─── format-arity, C++ ────────────────────────────────────────────────────────────────────────────────── + +// The format family: (namespace, name) → the index the format argument sits at, and whether a leading +// stdout / stderr argument shifts it (the print overloads that take a FILE*). A leading std::locale(...) +// argument shifts every family by one. Nothing else moves the index: a non-literal at that position is +// skipped, never searched past (the literal there may be an ARGUMENT, as in std::format( kFmt, "{}" )). +struct FormatFamily +{ + std::string_view ns; + std::string_view name; + std::uint8_t fmtIndex; + bool streamShift; +}; +inline constexpr std::array kFormatFamilies = { { + { "std", "format", 0, false }, { "std", "format_to", 1, false }, { "std", "format_to_n", 2, false }, + { "std", "print", 0, true }, { "std", "println", 0, true }, { "fmt", "format", 0, false }, + { "fmt", "format_to", 1, false }, { "fmt", "format_to_n", 2, false }, { "fmt", "print", 0, true }, + { "fmt", "println", 0, true }, { "rw", "emitTo", 1, false }, { "rw", "formatTo", 2, false }, +} }; + +inline const FormatFamily* formatFamilyAt( std::string_view src, const std::vector& toks, std::size_t k ) noexcept +{ + if( k < 2 || toks[k].kind != TokKind::Ident || !tokIs( src, toks[k - 1], "::" ) || toks[k - 2].kind != TokKind::Ident ) + { + return nullptr; + } + if( k >= 3 && ( tokIs( src, toks[k - 3], "." ) || tokIs( src, toks[k - 3], "->" ) ) ) + { + return nullptr; + } + if( k >= 4 && tokIs( src, toks[k - 3], "::" ) && toks[k - 4].kind == TokKind::Ident && !tokIs( src, toks[k - 4], "return" ) + && !tokIs( src, toks[k - 4], "co_return" ) && !tokIs( src, toks[k - 4], "throw" ) ) + { + return nullptr; // other::std::format names some other namespace's std, not the family; ::std::format still is + } + const std::string_view ns = tokText( src, toks[k - 2] ); + const std::string_view name = tokText( src, toks[k] ); + for( const FormatFamily& f : kFormatFamilies ) + { + if( f.ns == ns && f.name == name ) + { + return &f; + } + } + return nullptr; +} + +inline bool isAllCapsIdent( std::string_view w ) noexcept +{ + if( w.size() < 2 || !( w[0] >= 'A' && w[0] <= 'Z' ) ) + { + return false; + } + return std::all_of( w.begin(), w.end(), []( char c ) { return ( c >= 'A' && c <= 'Z' ) || isDigit( c ) || c == '_'; } ); +} + +inline bool inSortedNames( const std::vector* names, std::string_view w ) +{ + if( names == nullptr ) + { + return false; + } + const auto it = std::lower_bound( names->begin(), names->end(), w, []( const std::string& a, std::string_view b ) { return rw::sortutil::svLess( a, b ); } ); + return it != names->end() && *it == w; +} + +// The macro names an argument is judged against (F1): this file's #defines (CppPreproc::macros) and the +// tree's — every macro definition the index holds in a C-family file (the C++ grammar indexes function-like +// macros; the C grammar both kinds). Either list may be absent. +struct MacroNames +{ + const std::vector* file = nullptr; + const std::vector* tree = nullptr; +}; + +// F1: an identifier outside every bracket of the argument [first, last] that may be a macro expanding to an +// argument LIST — one this file #defines (function-like or object-like), a FUNCTION-LIKE macro the tree defines (the +// tree list is SymKind::Macro, which holds function-like macros only), or an ALL_CAPS one followed by '(' (SRC_LOC(), +// VEC3( v )) whose definition is not visible. A member or qualified name (x.f(, ns::F() is never a macro. Floor (stated +// on the legend and in the CHANGELOG): a lower-case object-like macro defined only in ANOTHER file is not seen. +inline bool argMayBeMacroList( std::string_view src, const std::vector& toks, const ArgRange& a, const MacroNames& macros ) +{ + int depth = 0; + for( std::size_t k = a.first; k <= a.last; ++k ) + { + const Tok& t = toks[k]; + if( t.kind == TokKind::Punct && t.end - t.begin == 1 ) + { + const char c = src[ t.begin ]; + depth += ( c == '(' || c == '[' || c == '{' ) ? 1 : ( c == ')' || c == ']' || c == '}' ) ? -1 : 0; + } + const bool qualified = k > a.first && ( tokIs( src, toks[k - 1], "::" ) || tokIs( src, toks[k - 1], "." ) || tokIs( src, toks[k - 1], "->" ) ); + if( depth != 0 || t.kind != TokKind::Ident || qualified ) + { + continue; + } + const std::string_view w = tokText( src, t ); + const bool called = k < a.last && tokIs( src, toks[k + 1], "(" ); + if( inSortedNames( macros.file, w ) || inSortedNames( macros.tree, w ) || ( called && isAllCapsIdent( w ) ) ) + { + return true; + } + } + return false; +} + +// An argument whose count is unknowable here: a pack expansion, __VA_ARGS__, an all-caps identifier alone (an +// object-like macro may expand to a comma list), more '<' than '>' (a template argument list the comma split +// may have cut: std::pair{} splits as "std::pair{}"), or an identifier that may be a +// macro expanding to an argument list (argMayBeMacroList, F1). A lone '>' is a comparison (n > 0) and does not +// make the count unknowable. +inline bool argCountUnknowable( std::string_view src, const std::vector& toks, const ArgRange& a, const MacroNames& macros ) +{ + if( a.empty() ) + { + return true; + } + if( tokIs( src, toks[a.last], "..." ) ) + { + return true; + } + if( a.first == a.last && toks[a.first].kind == TokKind::Ident && isAllCapsIdent( tokText( src, toks[a.first] ) ) ) + { + return true; + } + int angle = 0; + for( std::size_t k = a.first; k <= a.last; ++k ) + { + if( tokIs( src, toks[k], "__VA_ARGS__" ) ) + { + return true; + } + angle += tokIs( src, toks[k], "<" ) ? 1 : 0; + angle -= tokIs( src, toks[k], ">" ) ? 1 : 0; + } + return angle > 0 || argMayBeMacroList( src, toks, a, macros ); +} + +// True when a backslash escape in `content` (an ordinary, non-raw literal) spells a brace: \x7b, \173, \u007d, +// \N{LEFT CURLY BRACKET}, a C++23 delimited \x{..} / \o{..} / \u{..} (never decoded, assumed to). The field +// grammar reads a literal as WRITTEN, so such a literal's fields are not knowable here — the call is skipped. +// Python's \xHH takes exactly two digits; C++'s takes every hex digit that follows. +inline bool escapeSpellsBrace( std::string_view content, bool python ) noexcept +{ + const auto hexVal = []( char c ) -> int + { + if( c >= '0' && c <= '9' ) { return c - '0'; } + if( c >= 'a' && c <= 'f' ) { return c - 'a' + 10; } + if( c >= 'A' && c <= 'F' ) { return c - 'A' + 10; } + return -1; + }; + for( std::size_t i = 0; i + 1 < content.size(); ++i ) + { + if( content[i] != '\\' ) + { + continue; + } + const char e = content[i + 1]; + std::uint32_t v = 0x110000; // "no code point" unless an escape below decodes one + std::size_t j = i + 2; + std::size_t maxLen = 0; + int base = 16; + if( ( e == 'x' || e == 'o' || e == 'u' || e == 'N' ) && j < content.size() && content[j] == '{' ) + { + if( e != 'N' || !python ) + { + return true; // a delimited escape (C++23 \x{..}, \o{..}, \u{..}, \N{..}) + } + const std::size_t close = content.find( '}', j ); + std::string name( content.substr( j + 1, close == std::string_view::npos ? 0 : close - j - 1 ) ); + std::ranges::transform( name, name.begin(), []( char ch ) { return static_cast( std::toupper( static_cast( ch ) ) ); } ); + if( name == "LEFT CURLY BRACKET" || name == "RIGHT CURLY BRACKET" ) + { + return true; + } + ++i; + continue; + } + if( e == 'x' ) + { + maxLen = python ? 2 : 8; + } + else if( e == 'u' ) + { + maxLen = 4; + } + else if( e == 'U' ) + { + maxLen = 8; + } + else if( e >= '0' && e <= '7' ) + { + j = i + 1; + maxLen = 3; + base = 8; + } + else + { + ++i; // \\, \", \n …: one escaped byte, never a brace + continue; + } + std::uint32_t acc = 0; + std::size_t n = 0; + while( j < content.size() && n < maxLen ) + { + const int d = hexVal( content[j] ); + if( d < 0 || d >= base ) + { + break; + } + acc = acc * static_cast( base ) + static_cast( d ); + ++j; + ++n; + } + if( n > 0 ) + { + v = acc; + } + if( v == 0x7B || v == 0x7D ) + { + return true; + } + i = j - 1; + } + return false; +} + +// A C++ string token is raw when its prefix (the bytes before the quote) holds an R. +inline bool isRawCppLiteral( std::string_view src, const Tok& t ) noexcept +{ + return src.substr( t.begin, t.contentBegin - t.begin ).find( 'R' ) != std::string_view::npos; +} + +// The argument's tokens are one or more adjacent string literals → their concatenated content; else empty +// optional-by-flag (a named constant, a macro piece such as PRIu64, a runtime_format wrapper). +inline bool literalContent( std::string_view src, const std::vector& toks, const ArgRange& a, std::string& out ) +{ + out.clear(); + if( a.empty() ) + { + return false; + } + for( std::size_t k = a.first; k <= a.last; ++k ) + { + if( toks[k].kind != TokKind::String ) + { + return false; + } + if( !isRawCppLiteral( src, toks[k] ) && escapeSpellsBrace( tokContent( src, toks[k] ), false ) ) + { + return false; // "\x7b\x7d" is "{}": a field the as-written grammar cannot see — skipped, never guessed + } + out.append( tokContent( src, toks[k] ) ); + } + return true; +} + +// F2: a preprocessor directive between the call's parentheses — the tokens of every #if arm are concatenated +// here, so the argument count is no arm's count. Skipped. +inline bool callHoldsDirective( const std::vector& directives, std::uint32_t open, std::uint32_t close ) noexcept +{ + const auto it = std::lower_bound( directives.begin(), directives.end(), open ); + return it != directives.end() && *it < close; +} + +inline void scanFormatArityCpp( std::string_view src, const std::vector& toks, const CppPreproc& pp, const std::vector* treeMacros, + std::vector& out ) +{ + std::string fmt; + const MacroNames macros{ &pp.macros, treeMacros }; + for( std::size_t k = 2; k + 1 < toks.size(); ++k ) + { + const FormatFamily* fam = formatFamilyAt( src, toks, k ); + if( fam == nullptr || !tokIs( src, toks[k + 1], "(" ) ) + { + continue; + } + const std::size_t close = matchClose( src, toks, k + 1 ); + if( close >= toks.size() || callHoldsDirective( pp.directives, toks[k + 1].begin, toks[close].begin ) ) + { + continue; + } + const std::vector args = splitArgs( src, toks, k + 1, close ); + std::size_t fi = fam->fmtIndex; + if( fi < args.size() && fam->streamShift && args[fi].first == args[fi].last + && ( tokIs( src, toks[ args[fi].first ], "stdout" ) || tokIs( src, toks[ args[fi].first ], "stderr" ) ) ) + { + ++fi; + } + if( fi < args.size() && !args[fi].empty() && args[fi].first + 3 <= args[fi].last && tokIs( src, toks[ args[fi].first ], "std" ) + && tokIs( src, toks[ args[fi].first + 1 ], "::" ) && tokIs( src, toks[ args[fi].first + 2 ], "locale" ) ) + { + ++fi; + } + if( fi >= args.size() || !literalContent( src, toks, args[fi], fmt ) ) + { + continue; + } + // every argument, the ones before the format too: a macro there may shift the format's index + bool unknowable = false; + for( std::size_t a = 0; a < args.size(); ++a ) + { + unknowable = unknowable || ( a != fi && argCountUnknowable( src, toks, args[a], macros ) ); + } + const FieldUse use = parseFields( fmt, false ); + if( unknowable || !use.ok ) + { + continue; + } + const std::uint32_t positional = static_cast( args.size() - fi - 1 ); + if( fieldsMismatch( use, positional, {} ) ) + { + out.push_back( { Facet::FormatArity, toks[k - 2].begin, joinTokens( src, toks, k - 2, close ) } ); + } + } +} + +// ─── format-arity, Python "literal".format( ... ) ─────────────────────────────────────────────────────── + +inline bool pyStringPrefixOk( std::string_view src, const Tok& t ) noexcept +{ + for( std::uint32_t p = t.begin; p < t.contentBegin; ++p ) + { + const char c = src[p]; + if( c == 'f' || c == 'F' || c == 'b' || c == 'B' ) + { + return false; // an f-string has no argument list; bytes have no .format + } + } + return true; +} + +inline bool pyStringIsRaw( std::string_view src, const Tok& t ) noexcept +{ + const std::string_view prefix = src.substr( t.begin, t.contentBegin - t.begin ); + return prefix.find( 'r' ) != std::string_view::npos || prefix.find( 'R' ) != std::string_view::npos; +} + +// A keyword before '(' makes the parentheses a grouping (return ( "a" "b" ).format(…)), not a call. +inline bool isPythonKeyword( std::string_view w ) noexcept +{ + static constexpr std::array kKw = { "return", "yield", "in", "not", "and", "or", "if", "else", "elif", "while", "assert", + "await", "lambda", "is", "for" }; + return std::find( kKw.begin(), kKw.end(), w ) != kKw.end(); +} + +inline void scanFormatArityPython( std::string_view src, const std::vector& toks, std::vector& out ) +{ + std::string fmt; + for( std::size_t k = 1; k + 2 < toks.size(); ++k ) + { + if( !tokIs( src, toks[k], "." ) || !tokIs( src, toks[k + 1], "format" ) || !tokIs( src, toks[k + 2], "(" ) ) + { + continue; + } + // the receiver: adjacent literals right before the dot, or a parenthesised group holding only literals + std::size_t firstLit = k; + std::size_t lastLit = k - 1; + std::size_t siteFrom = k; + if( toks[k - 1].kind == TokKind::String ) + { + firstLit = k - 1; + while( firstLit > 0 && toks[firstLit - 1].kind == TokKind::String ) + { + --firstLit; + } + siteFrom = firstLit; + } + else if( tokIs( src, toks[k - 1], ")" ) ) + { + std::size_t open = k - 1; + while( open > 0 && toks[open - 1].kind == TokKind::String ) + { + --open; + } + if( open == 0 || !tokIs( src, toks[open - 1], "(" ) || open > k - 2 ) + { + continue; + } + const std::size_t paren = open - 1; + if( paren > 0 && ( ( toks[paren - 1].kind == TokKind::Ident && !isPythonKeyword( tokText( src, toks[paren - 1] ) ) ) + || tokIs( src, toks[paren - 1], ")" ) || tokIs( src, toks[paren - 1], "]" ) ) ) + { + continue; // a call's parentheses, not a grouping + } + firstLit = open; + lastLit = k - 2; + siteFrom = paren; + } + if( firstLit > lastLit ) + { + continue; + } + fmt.clear(); + bool prefixOk = true; + for( std::size_t p = firstLit; p <= lastLit; ++p ) + { + prefixOk = prefixOk && pyStringPrefixOk( src, toks[p] ); + const std::string_view content = tokContent( src, toks[p] ); + if( pyStringIsRaw( src, toks[p] ) ) + { + // a raw literal has no escapes: r"\N{a}" is a backslash, an N and the field {a} — the \N{...} skip in + // parseFields must not see a backslash here + const std::size_t from = fmt.size(); + fmt.append( content ); + std::replace( fmt.begin() + static_cast( from ), fmt.end(), '\\', ' ' ); + } + else + { + prefixOk = prefixOk && !escapeSpellsBrace( content, true ); // "\x7b\x7d" is "{}": not judged + fmt.append( content ); + } + } + const std::size_t close = matchClose( src, toks, k + 2 ); + if( !prefixOk || close >= toks.size() ) + { + continue; + } + std::vector args = splitArgs( src, toks, k + 2, close ); + if( !args.empty() && args.back().empty() ) + { + args.pop_back(); // a trailing comma + } + std::uint32_t positional = 0; + std::vector kw; + bool unknowable = false; + for( const ArgRange& a : args ) + { + if( a.empty() || tokIs( src, toks[a.first], "*" ) || tokIs( src, toks[a.first], "**" ) ) + { + unknowable = true; + break; + } + if( a.first < a.last && toks[a.first].kind == TokKind::Ident && tokIs( src, toks[a.first + 1], "=" ) ) + { + kw.emplace_back( tokText( src, toks[a.first] ) ); + } + else + { + ++positional; + } + } + const FieldUse use = parseFields( fmt, true ); + if( unknowable || !use.ok ) + { + continue; + } + if( fieldsMismatch( use, positional, kw ) ) + { + out.push_back( { Facet::FormatArity, toks[siteFrom].begin, joinTokens( src, toks, siteFrom, close ) } ); + } + } +} + +// ─── utf8-cut, C++ ────────────────────────────────────────────────────────────────────────────────────── + +// An ellipsis literal: an ordinary or u8 literal whose content, spaces trimmed, is "..." or U+2026 (written +// raw or as \xE2\x80\xA6). A wide / u16 / u32 literal holds code units or points: a cut cannot split one. +inline bool isEllipsisLiteral( std::string_view src, const Tok& t ) noexcept +{ + if( t.kind != TokKind::String ) + { + return false; + } + const std::string_view pre = src.substr( t.begin, t.contentBegin - t.begin ); + if( pre != "\"" && pre != "u8\"" ) + { + return false; + } + std::string_view c = tokContent( src, t ); + while( !c.empty() && c.front() == ' ' ) + { + c.remove_prefix( 1 ); + } + while( !c.empty() && c.back() == ' ' ) + { + c.remove_suffix( 1 ); + } + return c == "..." || c == "\xE2\x80\xA6" || c == "\\xE2\\x80\\xA6" || c == "\\xe2\\x80\\xa6" || c == "\\u2026"; +} + +// A UTF-8 boundary back-off or a utf8-aware helper anywhere in the function exempts it (a precision choice: +// the guard is not proven to cover the cut): the 0xC0 / 0x80 continuation mask, or a call whose name has a +// utf8 token. +inline bool hasUtf8Guard( std::string_view src, const std::vector& toks, std::size_t from, std::size_t to ) noexcept +{ + bool c0 = false; + bool c80 = false; + for( std::size_t k = from; k < to; ++k ) + { + const std::string_view w = tokText( src, toks[k] ); + if( toks[k].kind == TokKind::Number ) + { + c0 = c0 || w == "0xC0" || w == "0xc0" || w == "0xC0u" || w == "0xc0u" || w == "0xC0U"; + c80 = c80 || w == "0x80" || w == "0x80u" || w == "0x80U"; + } + if( toks[k].kind == TokKind::Ident && ( w.find( "utf8" ) != std::string_view::npos || w.find( "Utf8" ) != std::string_view::npos + || w.find( "UTF8" ) != std::string_view::npos ) ) + { + return true; + } + } + return c0 && c80; +} + +// V . size ( ) / V . length ( ) at k: the receiver name, or empty. +inline std::string_view sizeCallReceiver( std::string_view src, const std::vector& toks, std::size_t k ) noexcept +{ + if( k + 4 < toks.size() && toks[k].kind == TokKind::Ident && tokIs( src, toks[k + 1], "." ) + && ( tokIs( src, toks[k + 2], "size" ) || tokIs( src, toks[k + 2], "length" ) ) && tokIs( src, toks[k + 3], "(" ) && tokIs( src, toks[k + 4], ")" ) ) + { + return tokText( src, toks[k] ); + } + return {}; +} + +inline bool isCompareOp( std::string_view src, const Tok& t ) noexcept +{ + static constexpr std::array kCompare = { ">", ">=", "<", "<=", "==" }; + return std::find( kCompare.begin(), kCompare.end(), tokText( src, t ) ) != kCompare.end(); +} + +// Name tokens of an identifier (camelCase and snake_case boundaries), lower-cased. +inline std::vector nameTokens( std::string_view w ) +{ + std::vector out; + std::string cur; + for( std::size_t i = 0; i < w.size(); ++i ) + { + const char c = w[i]; + if( c == '_' || isDigit( c ) ) + { + if( !cur.empty() ) + { + out.push_back( cur ); + cur.clear(); + } + continue; + } + const bool upper = c >= 'A' && c <= 'Z'; + if( upper && !cur.empty() && !( i > 0 && w[i - 1] >= 'A' && w[i - 1] <= 'Z' ) ) + { + out.push_back( cur ); + cur.clear(); + } + cur.push_back( upper ? static_cast( c - 'A' + 'a' ) : c ); + } + if( !cur.empty() ) + { + out.push_back( cur ); + } + return out; +} + +inline bool isCapName( std::string_view w ) +{ + for( const std::string& t : nameTokens( w ) ) + { + if( t == "cap" || t == "max" || t == "limit" || t == "budget" ) + { + return true; + } + } + return false; +} + +// Is the length text `n` (the tokens of a cut's length argument) the bound of a size comparison on `v` +// within [from, to), or a single cap-named identifier? +inline bool lengthIsSizeBound( std::string_view src, const std::vector& toks, std::size_t from, std::size_t to, std::string_view v, + const std::string& n ) +{ + for( std::size_t k = from; k < to; ++k ) + { + if( sizeCallReceiver( src, toks, k ) != v ) + { + continue; + } + // V.size() OP M / M OP V.size(), where the cut length N is M, or M less the ellipsis width (1 to 3 bytes: + // resize( 117 ) under size() > 120 leaves room for "...") + const auto boundMatches = [ & ]( const Tok& m ) + { + const std::string_view mt = tokText( src, m ); + if( mt == n || n == std::string( mt ) + " - 1" || n == std::string( mt ) + " - 2" || n == std::string( mt ) + " - 3" ) + { + return true; + } + long nv = 0; + long mv = 0; + const auto plain = []( std::string_view d, long& out ) + { + const auto r = std::from_chars( d.data(), d.data() + d.size(), out ); + return !d.empty() && d.size() <= 9 && r.ec == std::errc() && r.ptr == d.data() + d.size(); + }; + if( m.kind != TokKind::Number || !plain( n, nv ) || !plain( mt, mv ) ) + { + return false; // not two plain decimal lengths + } + return mv >= nv && mv - nv <= 3; + }; + if( k + 6 < to && isCompareOp( src, toks[k + 5] ) && boundMatches( toks[k + 6] ) ) + { + return true; + } + if( k >= 2 && isCompareOp( src, toks[k - 1] ) && boundMatches( toks[k - 2] ) ) + { + return true; + } + } + return n.find( ' ' ) == std::string::npos && isCapName( n ); +} + +// Does `V += ELL` / `V.append( ELL )` occur in [from, to)? Returns the index of the statement's first token, +// or `to`. +inline std::size_t ellipsisAppendOn( std::string_view src, const std::vector& toks, std::size_t from, std::size_t to, std::string_view v ) noexcept +{ + for( std::size_t k = from; k + 2 < to; ++k ) + { + if( toks[k].kind != TokKind::Ident || tokText( src, toks[k] ) != v || ( k > 0 && ( tokIs( src, toks[k - 1], "." ) || tokIs( src, toks[k - 1], "->" ) ) ) ) + { + continue; + } + if( tokIs( src, toks[k + 1], "+=" ) && isEllipsisLiteral( src, toks[k + 2] ) ) + { + return k; + } + if( k + 4 < to && tokIs( src, toks[k + 1], "." ) && tokIs( src, toks[k + 2], "append" ) && tokIs( src, toks[k + 3], "(" ) && isEllipsisLiteral( src, toks[k + 4] ) ) + { + return k; + } + } + return to; +} + +// The single-argument text of the call whose '(' is at `open`, or empty when it has more than one argument. +inline std::string singleArgText( std::string_view src, const std::vector& toks, std::size_t open ) +{ + const std::size_t close = matchClose( src, toks, open ); + if( close >= toks.size() ) + { + return {}; + } + const std::vector args = splitArgs( src, toks, open, close ); + if( args.size() != 1 || args[0].empty() ) + { + return {}; + } + return joinTokens( src, toks, args[0].first, args[0].last ); +} + +// substr( 0 , N ) at the '(' `open`: N's text, or empty. +inline std::string substrZeroLength( std::string_view src, const std::vector& toks, std::size_t open ) +{ + const std::size_t close = matchClose( src, toks, open ); + if( close >= toks.size() ) + { + return {}; + } + const std::vector args = splitArgs( src, toks, open, close ); + if( args.size() != 2 || args[0].empty() || args[1].empty() || args[0].first != args[0].last || !tokIs( src, toks[ args[0].first ], "0" ) ) + { + return {}; + } + return joinTokens( src, toks, args[1].first, args[1].last ); +} + +inline void scanUtf8CutFunction( std::string_view src, const std::vector& toks, std::size_t from, std::size_t to, std::vector& out ) +{ + if( hasUtf8Guard( src, toks, from, to ) ) + { + return; + } + for( std::size_t k = from; k + 3 < to; ++k ) + { + if( toks[k].kind != TokKind::Ident || !tokIs( src, toks[k + 1], "." ) || ( k > 0 && ( tokIs( src, toks[k - 1], "." ) || tokIs( src, toks[k - 1], "->" ) ) ) ) + { + continue; + } + const std::string_view v = tokText( src, toks[k] ); + const std::string_view method = tokText( src, toks[k + 2] ); + if( !tokIs( src, toks[k + 3], "(" ) ) + { + continue; + } + const std::size_t close = matchClose( src, toks, k + 3 ); + if( close >= to ) + { + continue; + } + // (1) V.resize( N ) / V.erase( N ), then V += ELL / V.append( ELL ) later in the function + if( method == "resize" || method == "erase" ) + { + const std::string n = singleArgText( src, toks, k + 3 ); + if( n.empty() || !lengthIsSizeBound( src, toks, from, to, v, n ) ) + { + continue; + } + const std::size_t ell = ellipsisAppendOn( src, toks, close + 1, to, v ); + if( ell < to ) + { + const std::size_t ellEnd = std::min( to - 1, ell + 2 + ( tokIs( src, toks[ell + 1], "." ) ? 3 : 0 ) ); + out.push_back( { Facet::Utf8Cut, toks[ell].begin, joinTokens( src, toks, k, close ) + " ; " + joinTokens( src, toks, ell, ellEnd ) } ); + } + continue; + } + if( method != "substr" ) + { + continue; + } + // (2) V.substr( 0, N ) + ELL, as an expression + const std::string n = substrZeroLength( src, toks, k + 3 ); + if( n.empty() || !lengthIsSizeBound( src, toks, from, to, v, n ) ) + { + continue; + } + if( close + 2 < to && tokIs( src, toks[close + 1], "+" ) && isEllipsisLiteral( src, toks[close + 2] ) ) + { + out.push_back( { Facet::Utf8Cut, toks[k].begin, joinTokens( src, toks, k, close + 2 ) } ); + continue; + } + // (3) W = V.substr( 0, N ) (or a declaration of W), then W += ELL later + if( k >= 2 && tokIs( src, toks[k - 1], "=" ) && toks[k - 2].kind == TokKind::Ident ) + { + const std::string_view w = tokText( src, toks[k - 2] ); + const std::size_t ell = ellipsisAppendOn( src, toks, close + 1, to, w ); + if( ell < to ) + { + const std::size_t ellEnd = std::min( to - 1, ell + 2 + ( tokIs( src, toks[ell + 1], "." ) ? 3 : 0 ) ); + out.push_back( { Facet::Utf8Cut, toks[ell].begin, joinTokens( src, toks, k - 2, close ) + " ; " + joinTokens( src, toks, ell, ellEnd ) } ); + } + } + } + // (4) a single-char push loop: V.push_back( c ) somewhere, and `if( V.size() OP CAP ) { … V += ELL … }` + for( std::size_t k = from; k + 2 < to; ++k ) + { + if( !tokIs( src, toks[k], "if" ) || !tokIs( src, toks[k + 1], "(" ) ) + { + continue; + } + const std::string_view v = sizeCallReceiver( src, toks, k + 2 ); + if( v.empty() || k + 7 >= to || !isCompareOp( src, toks[k + 7] ) ) + { + continue; + } + const std::size_t condClose = matchClose( src, toks, k + 1 ); + if( condClose + 1 >= to || !tokIs( src, toks[condClose + 1], "{" ) ) + { + continue; + } + const std::size_t blockClose = matchClose( src, toks, condClose + 1 ); + const std::size_t ell = ellipsisAppendOn( src, toks, condClose + 2, std::min( blockClose, to ), v ); + if( blockClose >= to || ell >= blockClose ) + { + continue; + } + bool pushes = false; + for( std::size_t p = from; p + 3 < to && !pushes; ++p ) + { + pushes = tokText( src, toks[p] ) == v && tokIs( src, toks[p + 1], "." ) && tokIs( src, toks[p + 2], "push_back" ) && tokIs( src, toks[p + 3], "(" ); + } + if( pushes ) + { + const std::size_t ellEnd = std::min( to - 1, ell + 2 + ( tokIs( src, toks[ell + 1], "." ) ? 3 : 0 ) ); + out.push_back( { Facet::Utf8Cut, toks[ell].begin, joinTokens( src, toks, k, condClose ) + " { " + joinTokens( src, toks, ell, ellEnd ) } ); + } + } +} + +// ─── dedup-first, C++ ─────────────────────────────────────────────────────────────────────────────────── + +inline bool isSeverityName( std::string_view field ) +{ + for( const std::string& t : nameTokens( field ) ) + { + if( t == "sev" || t == "severity" || t == "priority" || t == "prio" ) + { + return true; + } + } + return false; +} + +// The member fields named in `struct T { … }` / `class T { … }` bodies in this file: the severity-like ones. +// A member is an identifier followed by '=', ';', '{' or '[' at the body's top level. +inline std::vector severityFieldsOf( std::string_view src, const std::vector& toks, std::string_view type ) +{ + std::vector out; + for( std::size_t k = 0; k + 2 < toks.size(); ++k ) + { + if( !( tokIs( src, toks[k], "struct" ) || tokIs( src, toks[k], "class" ) ) || tokText( src, toks[k + 1] ) != type ) + { + continue; + } + std::size_t open = k + 2; + while( open < toks.size() && !tokIs( src, toks[open], "{" ) && !tokIs( src, toks[open], ";" ) ) + { + ++open; + } + if( open >= toks.size() || !tokIs( src, toks[open], "{" ) ) + { + continue; + } + const std::size_t close = matchClose( src, toks, open ); + int depth = 0; + for( std::size_t m = open + 1; m + 1 < close && close < toks.size(); ++m ) + { + if( tokIs( src, toks[m], "{" ) || tokIs( src, toks[m], "(" ) ) + { + ++depth; + } + else if( tokIs( src, toks[m], "}" ) || tokIs( src, toks[m], ")" ) ) + { + --depth; + } + else if( depth == 0 && toks[m].kind == TokKind::Ident + && ( tokIs( src, toks[m + 1], "=" ) || tokIs( src, toks[m + 1], ";" ) || tokIs( src, toks[m + 1], "{" ) || tokIs( src, toks[m + 1], "[" ) ) + && isSeverityName( tokText( src, toks[m] ) ) ) + { + out.emplace_back( tokText( src, toks[m] ) ); + } + } + } + return out; +} + +// Does any `. FIELD` / `-> FIELD` for a field in `fields` occur in [from, to]? +inline bool readsAnyField( std::string_view src, const std::vector& toks, std::size_t from, std::size_t to, const std::vector& fields ) +{ + for( std::size_t k = from + 1; k <= to && k < toks.size(); ++k ) + { + if( ( tokIs( src, toks[k - 1], "." ) || tokIs( src, toks[k - 1], "->" ) ) + && std::find( fields.begin(), fields.end(), tokText( src, toks[k] ) ) != fields.end() ) + { + return true; + } + } + return false; +} + +// The element type of a lambda `[ … ]( const T& a, … )` starting at `lb`: T, or empty (auto, a builtin, not a +// lambda). Returns the index of the lambda's closing '}' in `bodyEnd`. +inline std::string_view lambdaElementType( std::string_view src, const std::vector& toks, std::size_t lb, std::size_t& bodyEnd ) +{ + bodyEnd = toks.size(); + if( lb >= toks.size() || !tokIs( src, toks[lb], "[" ) ) + { + return {}; + } + const std::size_t capClose = matchClose( src, toks, lb ); + if( capClose + 1 >= toks.size() || !tokIs( src, toks[capClose + 1], "(" ) ) + { + return {}; + } + const std::size_t parClose = matchClose( src, toks, capClose + 1 ); + std::size_t body = parClose + 1; + while( body < toks.size() && !tokIs( src, toks[body], "{" ) && !tokIs( src, toks[body], ";" ) && !tokIs( src, toks[body], ")" ) ) + { + ++body; + } + if( body >= toks.size() || !tokIs( src, toks[body], "{" ) ) + { + return {}; + } + bodyEnd = matchClose( src, toks, body ); + // the first parameter: the identifier right before '&' / '*' / the parameter name + std::string_view type; + for( std::size_t k = capClose + 2; k < parClose && !tokIs( src, toks[k], "," ); ++k ) + { + if( toks[k].kind != TokKind::Ident ) + { + continue; + } + const std::string_view w = tokText( src, toks[k] ); + if( w == "const" || w == "volatile" || w == "std" || w == "struct" ) + { + continue; + } + if( type.empty() ) + { + type = w; + } + else if( k + 1 < parClose && tokIs( src, toks[k - 1], "::" ) ) + { + type = w; // a qualified type: keep its last segment + } + } + if( type == "auto" || type == "int" || type == "long" || type == "unsigned" || type == "char" || type == "double" || type == "float" || type == "bool" + || type == "size_t" ) + { + return {}; + } + return type; +} + +inline void scanDedupFirstFunction( std::string_view src, const std::vector& toks, std::size_t from, std::size_t to, std::vector& out ) +{ + for( std::size_t k = from + 2; k + 1 < to; ++k ) + { + if( !tokIs( src, toks[k], "unique" ) || !tokIs( src, toks[k - 1], "::" ) || !tokIs( src, toks[k + 1], "(" ) ) + { + continue; + } + const bool ranges = tokIs( src, toks[k - 2], "ranges" ); + if( !ranges && !tokIs( src, toks[k - 2], "std" ) ) + { + continue; + } + const std::size_t close = matchClose( src, toks, k + 1 ); + if( close >= to ) + { + continue; + } + const std::vector args = splitArgs( src, toks, k + 1, close ); + const std::size_t predAt = ranges ? ( args.size() == 2 ? 1 : 2 ) : 2; + if( predAt >= args.size() || args[predAt].empty() ) + { + continue; // no predicate: whole values compare + } + std::size_t lambdaEnd = 0; + const std::string_view type = lambdaElementType( src, toks, args[predAt].first, lambdaEnd ); + if( type.empty() || lambdaEnd > close ) + { + continue; // a named predicate, an auto / builtin element: skipped, never guessed + } + const std::vector sev = severityFieldsOf( src, toks, type ); + if( sev.empty() || readsAnyField( src, toks, args[predAt].first, lambdaEnd, sev ) ) + { + continue; + } + // a sort before the unique, in this function, whose arguments read the severity field (either direction) + bool sorted = false; + for( std::size_t s = from; s < k && !sorted; ++s ) + { + const std::string_view w = tokText( src, toks[s] ); + if( ( w == "sort" || w == "stable_sort" ) && s + 1 < k && tokIs( src, toks[s + 1], "(" ) ) + { + const std::size_t sc = matchClose( src, toks, s + 1 ); + sorted = sc < k && readsAnyField( src, toks, s + 1, sc, sev ); + } + } + if( !sorted ) + { + out.push_back( { Facet::DedupFirst, toks[k - 2].begin, joinTokens( src, toks, k - ( ranges ? 4 : 2 ), close ) } ); + } + } +} + +// ─── vacuous-assert, Bash test scripts ────────────────────────────────────────────────────────────────── + +// One logical line of a shell script: continuation lines joined, comments dropped, heredoc bodies and the +// tails of multi-line quotes removed (they are data, not code). +struct ShLine +{ + std::uint32_t startByte = 0; + std::string text; +}; + +inline std::vector shLogicalLines( std::string_view s ) +{ + std::vector out; + std::vector heredocEnds; // pending terminators, in order + bool heredocStrip = false; + char openQuote = 0; // a quote left open at the end of the previous physical line + std::size_t i = 0; + ShLine cur; + bool haveCur = false; + while( i < s.size() ) + { + std::size_t e = s.find( '\n', i ); + if( e == std::string_view::npos ) + { + e = s.size(); + } + std::string_view phys = s.substr( i, e - i ); + const std::uint32_t physStart = static_cast( i ); + i = e + 1; + if( !heredocEnds.empty() ) + { + std::string_view t = phys; + while( heredocStrip && !t.empty() && t.front() == '\t' ) + { + t.remove_prefix( 1 ); + } + if( t == heredocEnds.front() ) + { + heredocEnds.erase( heredocEnds.begin() ); + } + continue; + } + // code bytes of this physical line: quotes tracked, comments dropped + std::string code; + std::size_t k = 0; + if( openQuote != 0 ) + { + const std::size_t close = phys.find( openQuote ); + if( close == std::string_view::npos ) + { + continue; // the whole line is inside a multi-line literal + } + k = close + 1; + openQuote = 0; + code.append( "\"\"" ); + } + char q = 0; + for( ; k < phys.size(); ++k ) + { + const char c = phys[k]; + if( q == 0 && c == '#' && ( k == 0 || isSpace( phys[k - 1] ) || phys[k - 1] == ';' ) ) + { + break; + } + if( c == '\\' && q != '\'' && k + 1 < phys.size() ) + { + code.push_back( c ); + code.push_back( phys[k + 1] ); + ++k; + continue; + } + if( q == 0 && ( c == '\'' || c == '"' ) ) + { + q = c; + } + else if( q == c ) + { + q = 0; + } + code.push_back( c ); + } + if( q != 0 ) + { + openQuote = q; // a literal that runs on: the rest of it is data + } + // heredoc operators on this line (not here-strings) + for( std::size_t h = code.find( "<<" ); h != std::string::npos; h = code.find( "<<", h + 2 ) ) + { + if( h + 2 < code.size() && code[h + 2] == '<' ) + { + h += 1; + continue; + } + std::size_t p = h + 2; + const bool strip = p < code.size() && code[p] == '-'; + p += strip ? 1 : 0; + while( p < code.size() && code[p] == ' ' ) + { + ++p; + } + std::string word; + while( p < code.size() && !isSpace( code[p] ) && code[p] != ';' && code[p] != ')' && code[p] != '|' && code[p] != '&' && code[p] != '>' ) + { + if( code[p] != '\'' && code[p] != '"' && code[p] != '\\' ) + { + word.push_back( code[p] ); + } + ++p; + } + // a terminator is a word ( << 2 inside $(( )) is a shift, not a heredoc ) + if( !word.empty() && isIdentStart( word[0] ) && std::all_of( word.begin(), word.end(), []( char ch ) { return isIdentChar( ch ); } ) ) + { + heredocEnds.push_back( word ); + heredocStrip = strip; + } + } + const bool continues = !code.empty() && code.back() == '\\' && openQuote == 0; + if( continues ) + { + code.pop_back(); + } + if( !haveCur ) + { + cur = ShLine{ physStart, {} }; + haveCur = true; + } + cur.text.append( code ); + if( continues && heredocEnds.empty() ) + { + cur.text.push_back( ' ' ); + continue; + } + out.push_back( std::move( cur ) ); + cur = ShLine{}; + haveCur = false; + } + if( haveCur ) + { + out.push_back( std::move( cur ) ); + } + return out; +} + +// A shell word or operator, quote- and $( )-aware. +struct ShTok +{ + std::string text; + bool op = false; +}; + +inline std::vector shTokens( std::string_view l ) +{ + std::vector out; + std::size_t i = 0; + while( i < l.size() ) + { + const char c = l[i]; + if( isSpace( c ) ) + { + ++i; + continue; + } + if( l.substr( i, 2 ) == "&&" || l.substr( i, 2 ) == "||" || l.substr( i, 2 ) == ";;" ) + { + out.push_back( { std::string( l.substr( i, 2 ) ), true } ); + i += 2; + continue; + } + if( c == '|' || c == ';' || c == '&' || ( c == '!' && ( i + 1 >= l.size() || isSpace( l[i + 1] ) ) ) ) + { + out.push_back( { std::string( 1, c ), true } ); + ++i; + continue; + } + std::string w; + int paren = 0; + char q = 0; + while( i < l.size() ) + { + const char d = l[i]; + if( q == 0 && paren == 0 && ( isSpace( d ) || d == '|' || d == ';' || d == '&' ) ) + { + break; + } + if( d == '\\' && q != '\'' && i + 1 < l.size() ) + { + w.push_back( d ); + w.push_back( l[i + 1] ); + i += 2; + continue; + } + // A "$( … )" capture's inner quotes are balanced, so plain toggling keeps the word whole; an UNQUOTED + // $( … ) with spaces inside is held together by the paren depth. + if( q == 0 && d == '$' && i + 1 < l.size() && l[i + 1] == '(' ) + { + ++paren; + w.append( "$(" ); + i += 2; + continue; + } + if( q == 0 && paren > 0 && d == '(' ) + { + ++paren; + } + else if( q == 0 && paren > 0 && d == ')' ) + { + --paren; + } + else if( q == 0 && ( d == '\'' || d == '"' ) ) + { + q = d; + } + else if( q != 0 && d == q ) + { + q = 0; + } + w.push_back( d ); + ++i; + } + out.push_back( { std::move( w ), false } ); + } + return out; +} + +using ShLineToks = std::vector>; // every logical line's tokens, computed once per script + +inline std::string_view unquoted( std::string_view w ) noexcept +{ + return rw::namesplit::stripQuotePair( w ); +} + +// "$VAR" / "${VAR}" / $VAR → VAR, else empty. +inline std::string_view varRef( std::string_view w ) noexcept +{ + w = unquoted( w ); + if( w.size() < 2 || w[0] != '$' ) + { + return {}; + } + w.remove_prefix( 1 ); + if( !w.empty() && w.front() == '{' && w.back() == '}' ) + { + w = w.substr( 1, w.size() - 2 ); + } + if( w.empty() || !isIdentStart( w[0] ) ) + { + return {}; + } + for( char c : w ) + { + if( !isIdentChar( c ) ) + { + return {}; + } + } + return w; +} + +enum class Reporter : std::uint8_t +{ + None, + Pass, + Fail, +}; + +// A reporter is named by its tokens (ok / pass vs no / fail …), or is an echo / printf of PASS / FAIL text, +// an exit / return with a nonzero status, or a fail-flag assignment. +inline Reporter reporterOf( const std::vector& t, std::size_t k, const std::vector>& defined ) +{ + while( k < t.size() && ( ( !t[k].op && ( t[k].text == "{" || t[k].text == "then" || t[k].text == "else" ) ) || ( t[k].op && t[k].text == ";" ) ) ) + { + ++k; // the branch's opening words and the line breaks (joined as ';') before its first command + } + if( k >= t.size() || t[k].op ) + { + return Reporter::None; + } + const std::string& w = t[k].text; + for( const auto& [ name, r ] : defined ) + { + if( name == w && r != Reporter::None ) + { + return r; + } + } + if( w == "echo" || w == "printf" ) + { + for( std::size_t a = k + 1; a < t.size() && !t[a].op; ++a ) + { + std::string_view u = unquoted( t[a].text ); + while( !u.empty() && ( u.front() == ' ' || u.front() == '[' ) ) + { + u.remove_prefix( 1 ); + } + if( u.starts_with( "FAIL" ) ) + { + return Reporter::Fail; + } + if( u.starts_with( "PASS" ) || u.starts_with( "ok " ) ) + { + return Reporter::Pass; + } + } + return Reporter::None; + } + if( ( w == "exit" || w == "return" ) && k + 1 < t.size() && !t[k + 1].op && t[k + 1].text != "0" ) + { + return Reporter::Fail; + } + if( w.find( '=' ) != std::string::npos ) + { + const std::string lhs = w.substr( 0, w.find( '=' ) ); + const std::string rhs = w.substr( w.find( '=' ) + 1 ); + for( const std::string& tk : nameTokens( lhs ) ) + { + if( ( tk == "fail" || tk == "failed" || tk == "failures" || tk == "status" ) && rhs != "0" ) + { + return Reporter::Fail; + } + } + return Reporter::None; + } + for( const std::string& tk : nameTokens( w ) ) + { + if( tk == "ok" || tk == "pass" || tk == "passed" || tk == "good" ) + { + return Reporter::Pass; + } + if( tk == "no" || tk == "fail" || tk == "failed" || tk == "bad" || tk == "die" ) + { + return Reporter::Fail; + } + } + return Reporter::None; +} + +// The grep -q assertion of one pipeline [b, e) of tokens: its producer and polarity. +struct GrepPipe +{ + bool ok = false; + bool negated = false; // a leading `!` + bool direct = false; // the producer is a command (not a variable's text) + std::string var; // the variable whose text is grepped, when not direct +}; + +inline bool isQuietGrep( const std::vector& t, std::size_t b, std::size_t e, std::size_t& operands ) +{ + if( b >= e || t[b].op || t[b].text != "grep" ) + { + return false; + } + bool quiet = false; + operands = 0; + bool afterDashE = false; + for( std::size_t k = b + 1; k < e; ++k ) + { + const std::string& w = t[k].text; + if( afterDashE ) + { + ++operands; + afterDashE = false; + continue; + } + if( w == "--quiet" || w == "--silent" ) + { + quiet = true; + } + else if( w == "-e" ) + { + afterDashE = true; + } + else if( w.size() > 1 && w[0] == '-' && w[1] != '-' ) + { + quiet = quiet || w.find( 'q' ) != std::string::npos; + } + else if( w == "--" ) + { + continue; + } + else if( w.rfind( "<<<", 0 ) == 0 || w.rfind( "2>", 0 ) == 0 || w.rfind( ">", 0 ) == 0 ) + { + continue; + } + else + { + ++operands; + } + } + return quiet; +} + +// Is the command at [b, e) a RUN UNDER TEST? Past `cd DIR &&`, environment assignments and the wrappers env / +// nice / timeout / command / exec / time / stdbuf, the command must be spelled through a variable ("$BIN") +// or a path. Not judged (stated floors): a text utility or builtin over a file — its input's provenance is +// not on this line — and a function the script defines, which may wrap a run or only read a variable. +inline bool isRunUnderTest( const std::vector& t, std::size_t b, std::size_t e ) +{ + std::size_t k = b; + while( k < e ) + { + const std::string& w = t[k].text; + if( !t[k].op && w == "cd" ) + { + while( k < e && !( t[k].op && t[k].text == "&&" ) ) + { + ++k; + } + ++k; + continue; + } + if( !t[k].op && ( w == "env" || w == "nice" || w == "timeout" || w == "command" || w == "exec" || w == "time" || w == "stdbuf" + || ( !w.empty() && w[0] == '-' ) || ( !w.empty() && isDigit( w[0] ) ) + || ( w.find( '=' ) != std::string::npos && w[0] != '$' && w[0] != '"' && w[0] != '\'' ) ) ) + { + ++k; + continue; + } + break; + } + if( k >= e || t[k].op ) + { + return false; + } + const std::string_view head = unquoted( t[k].text ); + return !head.empty() && ( head.front() == '$' || head.find( '/' ) != std::string_view::npos ); +} + +inline GrepPipe grepPipe( const std::vector& t, std::size_t b, std::size_t e ) +{ + GrepPipe g; + while( b < e && !t[b].op && ( t[b].text == "{" || t[b].text == "(" ) ) + { + ++b; // a brace group or subshell around the pipe: { echo "$V" | grep -q P && … ; } + } + if( b < e && t[b].op && t[b].text == "!" ) + { + g.negated = true; + ++b; + } + // stage boundaries + std::vector stages{ b }; + for( std::size_t k = b; k < e; ++k ) + { + if( t[k].op && t[k].text == "|" ) + { + stages.push_back( k + 1 ); + } + } + const std::size_t last = stages.back(); + std::size_t operands = 0; + if( !isQuietGrep( t, last, e, operands ) ) + { + return g; + } + if( stages.size() == 1 ) + { + // grep -q PAT <<< "$V" reads a variable; grep -q PAT FILE reads a file (not this shape) + for( std::size_t k = last + 1; k < e; ++k ) + { + if( t[k].text == "<<<" && k + 1 < e ) + { + g.var = std::string( varRef( t[k + 1].text ) ); + } + else if( t[k].text.rfind( "<<<", 0 ) == 0 ) + { + g.var = std::string( varRef( std::string_view( t[k].text ).substr( 3 ) ) ); + } + } + g.ok = !g.var.empty(); + return g; + } + if( operands > 1 ) + { + return g; // the last grep reads files, not the pipe + } + const std::size_t p0 = stages.front(); + const std::size_t p0e = stages[1] - 1; + if( p0 >= p0e ) + { + return g; + } + const std::string& cmd = t[p0].text; + if( cmd == "printf" || cmd == "echo" ) + { + // printf '%s' "$V" / echo "$V" — the text of one variable; anything else printed is not a run's output + std::string_view v; + std::size_t vars = 0; + for( std::size_t k = p0 + 1; k < p0e; ++k ) + { + const std::string_view r = varRef( t[k].text ); + if( !r.empty() ) + { + v = r; + ++vars; + } + else if( t[k].text.find( "$(" ) != std::string::npos ) + { + const std::vector inner = shTokens( std::string_view( t[k].text ).substr( t[k].text.find( "$(" ) + 2 ) ); + std::size_t innerEnd = 0; + while( innerEnd < inner.size() && !( inner[innerEnd].op && inner[innerEnd].text == "|" ) ) + { + ++innerEnd; + } + if( !isRunUnderTest( inner, 0, innerEnd ) ) + { + return g; // printf "$( wrapper … )": not a run under test + } + g.direct = true; + } + } + if( g.direct ) + { + g.ok = true; + return g; + } + if( vars != 1 ) + { + return g; + } + g.var = std::string( v ); + g.ok = true; + return g; + } + if( !isRunUnderTest( t, p0, p0e ) ) + { + return g; // a direct pipe judges only a run under test + } + g.direct = true; + g.ok = true; + return g; +} + +// The shape of one list: `PIPE && R1 || R2`. Returns the grep pipe and the two reporters. +struct AndOr +{ + GrepPipe pipe; + Reporter onTrue = Reporter::None; + Reporter onFalse = Reporter::None; +}; + +inline bool andOrShape( const std::vector& t, const std::vector>& defined, AndOr& out ) +{ + std::size_t andAt = t.size(); + std::size_t orAt = t.size(); + for( std::size_t k = 0; k < t.size(); ++k ) + { + if( t[k].op && t[k].text == "&&" && andAt == t.size() ) + { + andAt = k; + } + else if( t[k].op && t[k].text == "||" && andAt != t.size() && orAt == t.size() ) + { + orAt = k; + } + else if( t[k].op && ( t[k].text == ";" || t[k].text == ";;" ) && andAt == t.size() ) + { + return false; + } + } + if( andAt == t.size() || orAt == t.size() ) + { + return false; + } + out.pipe = grepPipe( t, 0, andAt ); + out.onTrue = reporterOf( t, andAt + 1, defined ); + out.onFalse = reporterOf( t, orAt + 1, defined ); + return out.pipe.ok; +} + +// Is a list an ABSENCE assertion (the grep matching is the failure branch)? Or a REQUIREMENT on the same +// text (a match is the pass branch, or no match is the failure branch)? +enum class Polarity : std::uint8_t +{ + None, + Absence, + Requirement, +}; + +inline Polarity polarityOf( bool negated, Reporter onMatch, Reporter onNoMatch ) noexcept +{ + if( negated ) + { + std::swap( onMatch, onNoMatch ); + } + if( onMatch == Reporter::Fail && onNoMatch == Reporter::Pass ) + { + return Polarity::Absence; + } + if( onMatch == Reporter::Pass || onNoMatch == Reporter::Fail ) + { + return Polarity::Requirement; + } + return Polarity::None; +} + +// Every grep assertion in a script: the line, its pipe, its polarity. +struct ShAssert +{ + std::size_t line = 0; + GrepPipe pipe; + Polarity pol = Polarity::None; + bool sameCapture = false; // a guard-only requirement (shGuardRequirements): it guards only the capture that reaches it +}; + +inline std::vector shAssertions( const ShLineToks& lt, const std::vector>& defined ) +{ + std::vector out; + for( std::size_t li = 0; li < lt.size(); ++li ) + { + const std::vector& t = lt[li]; + if( t.empty() ) + { + continue; + } + // case arm: `pattern) LIST ;;` — read the list after the arm's ')' + std::size_t from = 0; + if( !t[0].op && t[0].text.size() > 1 && t[0].text.back() == ')' && t[0].text.find( "$(" ) == std::string::npos ) + { + from = 1; + } + if( from >= t.size() ) + { + continue; // a case pattern alone on its line: no list to read + } + if( !t[from].op && ( t[from].text == "elif" || t[from].text == "while" ) ) + { + continue; // an elif / while condition is not judged (a stated floor) + } + if( !t[from].op && t[from].text == "if" ) + { + std::size_t condEnd = from + 1; + while( condEnd < t.size() && !( t[condEnd].op && ( t[condEnd].text == ";" || t[condEnd].text == "&&" || t[condEnd].text == "||" ) ) + && !( !t[condEnd].op && t[condEnd].text == "then" ) ) + { + ++condEnd; + } + const GrepPipe g = grepPipe( t, from + 1, condEnd ); + if( !g.ok || ( condEnd < t.size() && t[condEnd].op && ( t[condEnd].text == "&&" || t[condEnd].text == "||" ) ) ) + { + continue; + } + // the whole if … fi, lines joined with ';': its top-level then / elif / else, nested ifs skipped + std::vector st; + for( std::size_t j = li; j < lt.size() && j < li + 80; ++j ) + { + st.insert( st.end(), lt[j].begin(), lt[j].end() ); + st.push_back( { ";", true } ); + } + std::size_t thenAt = st.size(), elseAt = st.size(); + bool hasElif = false; + int depth = 0; + for( std::size_t k = 0; k < st.size(); ++k ) + { + if( st[k].op ) + { + continue; + } + const std::string& w = st[k].text; + if( w == "if" ) + { + ++depth; + } + else if( w == "fi" ) + { + if( --depth == 0 ) + { + break; + } + } + else if( depth == 1 && w == "then" && thenAt == st.size() ) + { + thenAt = k; + } + else if( depth == 1 && w == "elif" ) + { + hasElif = true; + } + else if( depth == 1 && w == "else" ) + { + elseAt = k; + } + } + if( thenAt == st.size() || hasElif ) + { + continue; // no then found, or an elif chain (its branches are not judged) + } + const Reporter onThen = reporterOf( st, thenAt + 1, defined ); + const Reporter onElse = elseAt < st.size() ? reporterOf( st, elseAt + 1, defined ) : Reporter::None; + out.push_back( { li, g, polarityOf( g.negated, onThen, onElse ) } ); + continue; + } + std::vector list( t.begin() + static_cast( from ), t.end() ); + AndOr ao; + if( andOrShape( list, defined, ao ) ) + { + out.push_back( { li, ao.pipe, polarityOf( ao.pipe.negated, ao.onTrue, ao.onFalse ) } ); + continue; + } + // `PIPE || R` / `PIPE && R` alone + for( std::size_t k = 0; k < list.size(); ++k ) + { + if( list[k].op && ( list[k].text == "||" || list[k].text == "&&" ) ) + { + const GrepPipe g = grepPipe( list, 0, k ); + const Reporter r = reporterOf( list, k + 1, defined ); + if( g.ok && r != Reporter::None ) + { + const bool onMatch = list[k].text == "&&"; + out.push_back( { li, g, polarityOf( g.negated, onMatch ? r : Reporter::None, onMatch ? Reporter::None : r ) } ); + } + break; + } + } + } + return out; +} + +// Requirements the absence scan does not read as assertions but that still GUARD a capture (rv-cr-qd-kinds-068 +// §1c, the kind's own rule: a crash empties the text, so a positive on that text fails the script): an if / elif +// condition of &&-joined greps on the text whose then-branch passes (if echo "$V" | grep -q A && echo "$V" | +// grep -qF B; then ok), and a grep on the text through a function the script defines (rows "$V" | grep -q P && +// ok). Requirements only — never an absence row — and each guards only the SAME capture (the assignment of the +// variable that reaches it is the one that reaches the absence; a reassigned variable is a different run). Not +// read as guards (a crash can still pass): a condition with || in it, a helper the script does not define, a +// helper given more than one variable. +inline GrepPipe guardPipe( const std::vector& t, std::size_t b, std::size_t e, const std::vector>& defined ) +{ + GrepPipe g = grepPipe( t, b, e ); + if( g.ok ) + { + return g; + } + GrepPipe h; + std::size_t s = b; + while( s < e && !t[s].op && ( t[s].text == "{" || t[s].text == "(" ) ) + { + ++s; + } + if( s < e && t[s].op && t[s].text == "!" ) + { + h.negated = true; + ++s; + } + if( s >= e || t[s].op + || std::none_of( defined.begin(), defined.end(), [ & ]( const std::pair& d ) { return d.first == t[s].text; } ) ) + { + return h; + } + std::size_t p0e = s + 1; + while( p0e < e && !( t[p0e].op && t[p0e].text == "|" ) ) + { + ++p0e; + } + if( p0e >= e ) + { + return h; + } + std::string_view v; + std::size_t vars = 0; + for( std::size_t k = s + 1; k < p0e; ++k ) + { + const std::string_view r = varRef( t[k].text ); + if( !r.empty() ) + { + v = r; + ++vars; + } + else if( t[k].text.find( "$(" ) != std::string::npos ) + { + return h; + } + } + std::size_t last = p0e + 1; + for( std::size_t k = p0e + 1; k < e; ++k ) + { + if( t[k].op && t[k].text == "|" ) + { + last = k + 1; + } + } + std::size_t operands = 0; + if( vars != 1 || !isQuietGrep( t, last, e, operands ) || operands > 1 ) + { + return h; + } + h.var = std::string( v ); + h.ok = true; + return h; +} + +inline std::vector shGuardRequirements( const ShLineToks& lt, const std::vector>& defined ) +{ + std::vector out; + const auto keep = [ & ]( std::size_t li, const GrepPipe& g, Reporter onMatchSide, Reporter onOther ) + { + if( g.ok && !g.direct && !g.var.empty() && polarityOf( g.negated, onMatchSide, onOther ) == Polarity::Requirement ) + { + out.push_back( { li, g, Polarity::Requirement, true } ); + } + }; + for( std::size_t li = 0; li < lt.size(); ++li ) + { + const std::vector& t = lt[li]; + if( t.empty() || t[0].op ) + { + continue; + } + if( t[0].text == "if" || t[0].text == "elif" ) + { + std::vector st; + for( std::size_t j = li; j < lt.size() && j < li + 80; ++j ) + { + st.insert( st.end(), lt[j].begin(), lt[j].end() ); + st.push_back( { ";", true } ); + } + std::size_t thenAt = 1; + while( thenAt < st.size() && !( !st[thenAt].op && st[thenAt].text == "then" ) ) + { + ++thenAt; + } + std::size_t condEnd = thenAt; + while( condEnd > 1 && st[condEnd - 1].op && st[condEnd - 1].text == ";" ) + { + --condEnd; + } + const bool hasOr = std::any_of( st.begin() + 1, st.begin() + static_cast( condEnd ), + []( const ShTok& w ) { return w.op && ( w.text == "||" || w.text == ";" ); } ); + if( thenAt >= st.size() || hasOr ) + { + continue; + } + // the else reporter only for an `if` with no elif chain (an elif's else is the chain's) + Reporter onElse = Reporter::None; + if( t[0].text == "if" ) + { + std::size_t elseAt = st.size(); + bool hasElif = false; + int depth = 0; + for( std::size_t k = 0; k < st.size(); ++k ) + { + if( st[k].op ) + { + continue; + } + const std::string& w = st[k].text; + if( w == "if" ) + { + ++depth; + } + else if( w == "fi" && --depth == 0 ) + { + break; + } + else if( depth == 1 && w == "elif" ) + { + hasElif = true; + } + else if( depth == 1 && w == "else" ) + { + elseAt = k; + } + } + onElse = ( !hasElif && elseAt < st.size() ) ? reporterOf( st, elseAt + 1, defined ) : Reporter::None; + } + const Reporter onThen = reporterOf( st, thenAt + 1, defined ); + std::size_t cb = 1; + for( std::size_t k = 1; k <= condEnd; ++k ) + { + if( k == condEnd || ( st[k].op && st[k].text == "&&" ) ) + { + keep( li, guardPipe( st, cb, k, defined ), onThen, onElse ); + cb = k + 1; + } + } + continue; + } + // `HELPER "$V" | grep -q P && R1 || R2` and `… && R` / `… || R`: the helper spelling grepPipe does not read + std::size_t andAt = t.size(), orAt = t.size(); + for( std::size_t k = 0; k < t.size(); ++k ) + { + if( t[k].op && t[k].text == "&&" && andAt == t.size() && orAt == t.size() ) + { + andAt = k; + } + else if( t[k].op && t[k].text == "||" && orAt == t.size() ) + { + orAt = k; + } + else if( t[k].op && ( t[k].text == ";" || t[k].text == ";;" ) ) + { + break; + } + } + const std::size_t pipeEnd = std::min( andAt, orAt ); + if( pipeEnd == t.size() ) + { + continue; + } + const GrepPipe g = guardPipe( t, 0, pipeEnd, defined ); + const Reporter onTrue = andAt < t.size() && andAt == pipeEnd ? reporterOf( t, andAt + 1, defined ) : Reporter::None; + const Reporter onFalse = orAt < t.size() ? reporterOf( t, orAt + 1, defined ) : Reporter::None; + keep( li, g, onTrue, onFalse ); + } + return out; +} + +// The innermost span (function) holding `byte`, or spans.size() for top-level code. +inline std::size_t scopeOf( const std::vector& spans, std::uint32_t byte ) noexcept +{ + std::size_t best = spans.size(); + std::uint32_t bestLen = UINT32_MAX; + for( std::size_t k = 0; k < spans.size(); ++k ) + { + if( byte >= spans[k].begin && byte < spans[k].end && spans[k].end - spans[k].begin < bestLen ) + { + best = k; + bestLen = spans[k].end - spans[k].begin; + } + } + return best; +} + +// The assignment of `v` that reaches line `li` within its scope: the last such line before it, or npos. +inline std::size_t assignmentOf( const ShLineToks& lt, const std::vector& scope, std::size_t li, std::string_view v ) +{ + const std::string eq = std::string( v ) + "="; + for( std::size_t back = li; back > 0; --back ) + { + const std::size_t k = back - 1; // walk upward without an unsigned wrap at line 0 + if( scope[k] != scope[li] ) + { + continue; + } + const std::vector& t = lt[k]; + for( std::size_t w = 0; w < t.size(); ++w ) + { + if( !t[w].op && t[w].text.rfind( eq, 0 ) == 0 ) + { + return k; + } + } + } + return std::string::npos; +} + +// Is the text of `v` required somewhere in the absence's scope: a presence test (-n / -z / ${#v}), a case on +// it, or a requirement assertion (a match is the pass branch, or no match the failure branch) on it? +inline bool contentRequired( const std::vector& lines, const ShLineToks& lt, const std::vector& scope, + const std::vector& asserts, std::size_t absenceLine, std::string_view v ) +{ + const std::string q1 = "\"$" + std::string( v ) + "\""; + const std::string q2 = "\"${" + std::string( v ) + "}\""; + const std::string q3 = "$" + std::string( v ); + const std::string q4 = "${#" + std::string( v ) + "}"; + for( std::size_t k = 0; k < lines.size(); ++k ) + { + if( scope[k] != scope[absenceLine] || k == absenceLine ) + { + continue; + } + const std::string& l = lines[k].text; + for( const char* test : { "-n ", "-z " } ) + { + for( std::size_t p = l.find( test ); p != std::string::npos; p = l.find( test, p + 1 ) ) + { + const std::string_view rest = std::string_view( l ).substr( p + 3 ); + if( rest.starts_with( q1 ) || rest.starts_with( q2 ) || ( rest.starts_with( q3 ) && ( rest.size() == q3.size() || !isIdentChar( rest[q3.size()] ) ) ) ) + { + return true; + } + } + } + if( l.find( q4 ) != std::string::npos ) + { + return true; + } + const std::vector& ct = lt[k]; + if( ct.size() >= 3 && ct[0].text == "case" && varRef( ct[1].text ) == v ) + { + return true; + } + } + for( const ShAssert& a : asserts ) + { + if( a.line != absenceLine && scope[a.line] == scope[absenceLine] && a.pol == Polarity::Requirement && !a.pipe.direct && a.pipe.var == v + && ( !a.sameCapture || assignmentOf( lt, scope, a.line, v ) == assignmentOf( lt, scope, absenceLine, v ) ) ) + { + return true; + } + } + return false; +} + +// Is the variable's capture guarded — its failure checked or its content required somewhere in scope? +inline bool captureGuarded( const std::vector& lines, const ShLineToks& lt, const std::vector& scope, + const std::vector& asserts, std::size_t absenceLine, std::size_t assignLine, std::string_view v, bool errexit ) +{ + const std::string eq = std::string( v ) + "="; + const std::vector& t = lt[assignLine]; + bool plainAssign = false; + std::size_t at = t.size(); + for( std::size_t w = 0; w < t.size(); ++w ) + { + if( !t[w].op && t[w].text.rfind( eq, 0 ) == 0 ) + { + at = w; + plainAssign = w == 0 || ( t[w - 1].op ) || t[w - 1].text == "if" || t[w - 1].text == "elif" || t[w - 1].text == "while"; + break; + } + } + // if ! V="$( … )"; then / while V=… — the rc decides a branch + if( at > 0 && at < t.size() && ( t[at - 1].text == "if" || t[at - 1].text == "elif" || t[at - 1].text == "while" || ( t[at - 1].op && t[at - 1].text == "!" ) ) ) + { + return true; + } + // V="$( … )" || … / && … on the capture, or an rc read right after it on the same line + for( std::size_t w = at + 1; w < t.size(); ++w ) + { + if( t[w].op && ( t[w].text == "||" || t[w].text == "&&" ) ) + { + return true; + } + if( t[w].text.find( "$?" ) != std::string::npos || t[w].text.find( "PIPESTATUS" ) != std::string::npos ) + { + return true; + } + } + // the next line in scope reads $? / PIPESTATUS + for( std::size_t k = assignLine + 1; k < lines.size(); ++k ) + { + if( scope[k] != scope[assignLine] ) + { + continue; + } + if( lines[k].text.find( "$?" ) != std::string::npos || lines[k].text.find( "PIPESTATUS" ) != std::string::npos ) + { + return true; + } + break; + } + // errexit aborts the script on a plain capture's failure (local / declare / export mask the rc) + if( errexit && plainAssign ) + { + return true; + } + // a presence test, a case on the text, or a requirement on the text anywhere in scope — on the variable + // itself or on one DERIVED from it (W="$( printf '%s' "$V" | … )" then a check on W): a check that W is + // non-empty or carries a pattern is a check that V had content. + if( contentRequired( lines, lt, scope, asserts, absenceLine, v ) ) + { + return true; + } + const std::string ref1 = "\"$" + std::string( v ) + "\""; + for( std::size_t k = 0; k < lines.size(); ++k ) + { + if( scope[k] != scope[absenceLine] || k == absenceLine || lt[k].empty() || lt[k][0].op ) + { + continue; + } + const std::string& w0 = lt[k][0].text; + const std::size_t eqAt = w0.find( '=' ); + if( eqAt == std::string::npos || eqAt == 0 || w0.find( "$(" ) == std::string::npos ) + { + continue; + } + const std::string_view rhs = std::string_view( w0 ).substr( eqAt + 1 ); + const std::size_t cap = rhs.find( "$(" ); + if( cap == std::string_view::npos ) + { + continue; + } + const std::string_view in = rhs.substr( cap + 2 ); + std::size_t b = 0; + while( b < in.size() && isSpace( in[b] ) ) + { + ++b; + } + if( ( in.substr( b ).starts_with( "printf" ) || in.substr( b ).starts_with( "echo" ) ) && in.find( ref1 ) != std::string_view::npos ) + { + const std::string w = w0.substr( 0, eqAt ); + if( w != v && contentRequired( lines, lt, scope, asserts, absenceLine, w ) ) + { + return true; + } + } + } + return false; +} + +// Does the absence's own line read an rc ($?, or a variable named like rc / status) — `if [ "$RC" = 0 ] && ! …`? +inline bool lineReadsRc( std::string_view l ) +{ + if( l.find( "$?" ) != std::string_view::npos || l.find( "PIPESTATUS" ) != std::string_view::npos ) + { + return true; + } + for( std::size_t p = l.find( '$' ); p != std::string_view::npos; p = l.find( '$', p + 1 ) ) + { + std::size_t q = p + 1; + if( q < l.size() && l[q] == '{' ) + { + ++q; + } + std::size_t e = q; + while( e < l.size() && isIdentChar( l[e] ) ) + { + ++e; + } + for( const std::string& tk : nameTokens( l.substr( q, e - q ) ) ) + { + if( tk == "rc" || tk == "status" || tk == "ret" || tk == "exit" ) + { + return true; + } + } + } + return false; +} + +// Script-defined reporter functions, classified by name tokens, else by body signal (prints FAIL / sets a +// failure flag → fail; prints PASS → pass). +inline std::vector> shDefinedReporters( std::string_view src, const std::vector& lines, const ShLineToks& lt, + const std::vector& spans ) +{ + std::vector> out; + for( std::size_t li = 0; li < lines.size(); ++li ) + { + const ShLine& l = lines[li]; + const std::vector& t = lt[li]; + if( t.empty() || t[0].op ) + { + continue; + } + std::string name; + if( t[0].text == "function" && t.size() > 1 ) + { + name = t[1].text; + } + else if( t[0].text.size() > 2 && t[0].text.find( "()" ) != std::string::npos && t[0].text.find( '$' ) == std::string::npos ) + { + name = t[0].text.substr( 0, t[0].text.find( "()" ) ); + } + else if( t.size() > 1 && t[1].text.rfind( "()", 0 ) == 0 ) + { + name = t[0].text; + } + while( !name.empty() && name.back() == '(' ) + { + name.pop_back(); + } + if( name.empty() || !std::all_of( name.begin(), name.end(), []( char c ) { return isIdentChar( c ) || c == '-'; } ) ) + { + continue; + } + std::vector probe{ { name, false } }; + Reporter r = reporterOf( probe, 0, {} ); + if( r == Reporter::None ) + { + const std::size_t s = scopeOf( spans, l.startByte ); + const std::string_view body = s < spans.size() ? src.substr( spans[s].begin, spans[s].end - spans[s].begin ) : std::string_view( l.text ); + if( body.find( "FAIL" ) != std::string_view::npos || body.find( "fail=1" ) != std::string_view::npos ) + { + r = Reporter::Fail; + } + else if( body.find( "PASS" ) != std::string_view::npos ) + { + r = Reporter::Pass; + } + } + out.emplace_back( name, r ); + } + return out; +} + +// errexit state at each logical line: a top-level `set -e…` / `set -o errexit` turns it on, `set +e` off. +inline std::vector shErrexit( const std::vector& lines, const ShLineToks& lt ) +{ + std::vector out( lines.size(), 0 ); + bool on = false; + if( !lines.empty() && lines[0].text.rfind( "#!", 0 ) == 0 ) + { + on = lines[0].text.find( " -e" ) != std::string::npos; + } + for( std::size_t k = 0; k < lines.size(); ++k ) + { + const std::vector& t = lt[k]; + for( std::size_t w = 0; w + 1 < t.size(); ++w ) + { + if( t[w].op || t[w].text != "set" ) + { + continue; + } + for( std::size_t a = w + 1; a < t.size() && !t[a].op; ++a ) + { + const std::string& f = t[a].text; + if( f.size() > 1 && f[0] == '-' && f[1] != 'o' && f.find( 'e' ) != std::string::npos ) + { + on = true; + } + else if( f.size() > 1 && f[0] == '+' && f.find( 'e' ) != std::string::npos ) + { + on = false; + } + else if( f == "-o" && a + 1 < t.size() && t[a + 1].text == "errexit" ) + { + on = true; + } + } + } + out[k] = on ? 1 : 0; + } + return out; +} + +inline std::string collapseSpaces( std::string_view s ) +{ + std::string out; + bool space = false; + for( char c : s ) + { + if( isSpace( c ) ) + { + space = !out.empty(); + continue; + } + if( space ) + { + out.push_back( ' ' ); + space = false; + } + out.push_back( c ); + } + return out; +} + +inline std::vector scanVacuousAssertBash( std::string_view src, const std::vector& spans ) +{ + std::vector out; + const std::vector lines = shLogicalLines( src ); + if( lines.empty() ) + { + return out; + } + std::vector scope( lines.size() ); + ShLineToks lt( lines.size() ); + for( std::size_t k = 0; k < lines.size(); ++k ) + { + scope[k] = scopeOf( spans, lines[k].startByte ); + lt[k] = shTokens( lines[k].text ); + } + const std::vector> defined = shDefinedReporters( src, lines, lt, spans ); + const std::vector asserts = shAssertions( lt, defined ); + const std::vector errexit = shErrexit( lines, lt ); + std::vector guards = asserts; // what guards a capture: every assertion plus the guard-only requirements + { + const std::vector extra = shGuardRequirements( lt, defined ); + guards.insert( guards.end(), extra.begin(), extra.end() ); + } + for( const ShAssert& a : asserts ) + { + if( a.pol != Polarity::Absence || lineReadsRc( lines[a.line].text ) ) + { + continue; + } + bool vacuous = a.pipe.direct; + if( !vacuous ) + { + // follow a derived capture (V="$( printf '%s' "$W" | … )") back to its source, up to three steps: the + // absence is vacuous only when the chain ends in a capture of a RUN UNDER TEST and no step of it is + // guarded (its failure checked, or its content required). + std::string var = a.pipe.var; + bool guard = false; + bool runSource = false; + for( int step = 0; step < 3 && !guard && !runSource; ++step ) + { + const std::size_t as = assignmentOf( lt, scope, a.line, var ); + if( as == std::string::npos ) + { + break; + } + const std::string& al = lines[as].text; + const std::size_t eq = al.find( var + "=" ); + const std::string_view rhs = std::string_view( al ).substr( eq + var.size() + 1 ); + if( rhs.find( "$(" ) == std::string_view::npos ) + { + break; // a literal or a copy: not a run under test + } + const std::vector inner = shTokens( rhs.substr( rhs.find( "$(" ) + 2 ) ); + std::size_t innerEnd = 0; + while( innerEnd < inner.size() && !( inner[innerEnd].op && inner[innerEnd].text == "|" ) ) + { + ++innerEnd; + } + guard = captureGuarded( lines, lt, scope, guards, a.line, as, var, errexit[as] != 0 ); + if( isRunUnderTest( inner, 0, innerEnd ) ) + { + runSource = true; + break; + } + std::string next; + if( !inner.empty() && ( inner[0].text == "printf" || inner[0].text == "echo" ) ) + { + for( std::size_t w = 1; w < inner.size() && !inner[w].op; ++w ) + { + const std::string_view r = varRef( inner[w].text ); + if( !r.empty() ) + { + next = std::string( r ); + } + } + } + if( next.empty() ) + { + break; // a text utility over a file, or a wrapper function: not judged + } + var = next; + } + vacuous = runSource && !guard; + } + if( vacuous ) + { + out.push_back( { Facet::VacuousAssert, lines[a.line].startByte, collapseSpaces( lines[a.line].text ) } ); + } + } + return out; +} + +} // namespace detail + +// ─── the per-language entry points ────────────────────────────────────────────────────────────────────── + +// C++: format-arity over the whole file; utf8-cut and dedup-first inside each function span. A site found +// in nested spans is reported once (the scan of each span is independent; the caller anchors by byte). +// `treeMacros`: the macro names the index holds across the tree (sorted, unique), for format-arity's F1 skip. +inline std::vector scanCpp( std::string_view src, const std::vector& fnSpans, const std::vector* treeMacros = nullptr ) +{ + std::vector out; + detail::CppPreproc pp; + const std::vector toks = detail::lexCpp( src, &pp ); + detail::scanFormatArityCpp( src, toks, pp, treeMacros, out ); + for( const Span& sp : fnSpans ) + { + const auto first = std::lower_bound( toks.begin(), toks.end(), sp.begin, []( const detail::Tok& t, std::uint32_t b ) { return t.begin < b; } ); + const auto last = std::lower_bound( toks.begin(), toks.end(), sp.end, []( const detail::Tok& t, std::uint32_t b ) { return t.begin < b; } ); + const std::size_t from = static_cast( first - toks.begin() ); + const std::size_t to = static_cast( last - toks.begin() ); + if( from >= to ) + { + continue; + } + detail::scanUtf8CutFunction( src, toks, from, to, out ); + detail::scanDedupFirstFunction( src, toks, from, to, out ); + } + std::sort( out.begin(), out.end(), []( const Site& a, const Site& b ) { return a.startByte != b.startByte ? a.startByte < b.startByte : a.text < b.text; } ); + out.erase( std::unique( out.begin(), out.end(), []( const Site& a, const Site& b ) { return a.facet == b.facet && a.startByte == b.startByte && a.text == b.text; } ), + out.end() ); + return out; +} + +inline std::vector scanPython( std::string_view src ) +{ + std::vector out; + detail::scanFormatArityPython( src, detail::lexPython( src ), out ); + return out; +} + +// Bash, for a test-script path only (the caller decides the path): `fnSpans` are the script's function +// definitions, the scopes a capture and its guards are matched in. +inline std::vector scanBashTestScript( std::string_view src, const std::vector& fnSpans ) +{ + return detail::scanVacuousAssertBash( src, fnSpans ); +} + +// A cheap byte prefilter: can this C++ file hold any of the three C++ shapes at all? +inline bool cppMayHoldShapes( std::string_view src ) noexcept +{ + static constexpr std::array kNeedles = { "format", "print", "emitTo", "unique", "...", "\xE2\x80\xA6", "u2026" }; + // "format" also covers formatTo; the escaped ellipsis "\xE2\x80\xA6" is found through its "xE2" bytes below + return std::any_of( kNeedles.begin(), kNeedles.end(), [ & ]( std::string_view n ) { return src.find( n ) != std::string_view::npos; } ) + || src.find( "xE2" ) != std::string_view::npos; +} + +} // namespace rw::defectshape diff --git a/src/didyoumean.h b/src/didyoumean.h index 3794492d8..ec143c4fc 100644 --- a/src/didyoumean.h +++ b/src/didyoumean.h @@ -216,7 +216,7 @@ inline std::string nearestAggregateName( const IngestResult& ing, std::string_vi { const std::string_view best = nearestSymbolNameWhere( ing, typed, []( const Symbol& s ) { - return s.kind == SymKind::Struct || s.kind == SymKind::Class + return isStructOrNamedType( s.kind ) || s.kind == SymKind::Class || s.kind == SymKind::Interface; } ); return best.empty() || best == typed ? std::string() : ( " (did you mean '" + std::string( best ) + "'?)" ); diff --git a/src/extentsuspect.h b/src/extentsuspect.h index bb3a2c9fe..c8550ce65 100644 --- a/src/extentsuspect.h +++ b/src/extentsuspect.h @@ -83,7 +83,8 @@ inline std::string_view extentSuspectReasons( std::uint8_t bits ) noexcept } // ── the two membership tables the rules key on (declarative, one lookup shape) ────────────────────────────── -inline constexpr std::array kExtentClassKinds = { SymKind::Class, SymKind::Struct, SymKind::Interface }; +inline constexpr std::array kExtentClassKinds = { SymKind::Class, SymKind::Struct, SymKind::Interface, + SymKind::NamedType, SymKind::Alias, SymKind::FuncType }; // the Struct split (model.h isStructOrNamedType) inline constexpr std::array kHeadRuleLangs = { Lang::C, Lang::Cpp, Lang::ObjC }; // R3's C family template diff --git a/src/fieldaffinity.h b/src/fieldaffinity.h index 928991214..ab895ab8d 100644 --- a/src/fieldaffinity.h +++ b/src/fieldaffinity.h @@ -325,7 +325,7 @@ inline std::vector modelAllAggregates( layout::ModelCtx& ctx, std::s for( const Symbol& s : ctx.ing.symbols ) { - if( s.kind != SymKind::Struct && s.kind != SymKind::Class && s.kind != SymKind::Interface ) + if( !isStructOrNamedType( s.kind ) && s.kind != SymKind::Class && s.kind != SymKind::Interface ) { continue; } diff --git a/src/filter.h b/src/filter.h index 008f6a03e..65ae45dff 100644 --- a/src/filter.h +++ b/src/filter.h @@ -881,6 +881,30 @@ inline std::vector docNoiseSymbolMultipliers( const IngestResult& ing, st return mul; } +// ── CODE ABOVE DOCS (--for and MCP `for`, routed path only; serialize.h reorderDocsAfterCode; gate forsigspancheck (R)) ── +// Markdown headings that repeat a question's words sat ABOVE the code that defines the answer in the ranked rows. A +// question about CODE now shows the code rows of its SHOWN set first and the doc rows after them — the same rows, only +// reordered: the score order and the byte ladder still choose which rows are shown, so the reorder never evicts a doc +// row. A question that ASKS about docs keeps the plain score order: a docs cue (kDocsQuestionCues), a change or +// translation cue (the doc-noise lists above: those questions are about prose too), or a named doc file. A false cue +// is the cheap side to be wrong on — it restores the old order. Under an explicit ceiling TIGHTER than the default signature +// share, or a body ceiling (serialize.h explicitCeilingTighterThanDefault) the reorder and its uncharged note do not apply: the answer is the plain +// score-order one, so est_tokens <= the budget keeps holding. RIPWIRE_NO_DOCS_AFTER_CODE=1 is the A/B handle (the +// RIPWIRE_NO_DOC_MENTION precedent): the same answer in plain score order, which the gate compares the row SET against. +inline constexpr DocNoiseCue kDocsQuestionCues[] = { { "doc", true }, { "readme", true }, { "guide", true }, { "tutorial", true }, + { "manual", true }, { "markdown", false }, { "wiki", false }, { "faq", false }, + { "howto", false } }; +inline bool taskAsksAboutDocs( std::string_view task ) +{ + const std::string lowerTask = queryshape::detail::lowerAscii( task ); + if( lowerTask.find( ".md" ) != std::string::npos || lowerTask.find( ".rst" ) != std::string::npos ) + { + return true; // a named doc file (the B8 mention anchor lifts it; this rule must not push it back down) + } + return taskHasCue( lowerTask, kDocsQuestionCues ) || taskHasCue( lowerTask, kChangeQuestionCues ) + || taskHasCue( lowerTask, kTranslationQuestionCues ); +} + // Order a file-id list by a per-id KEY descending, PATH ascending as the tiebreak — the shared // "most-consequential-first, and deterministically so" ordering the first-screen verbs need (§P11.7 // --pr-context by blast radius, §P11.8 --tree by best-symbol rank). Templated on the key because one of diff --git a/src/flipimpact.h b/src/flipimpact.h index 448aa5483..cb00fe138 100644 --- a/src/flipimpact.h +++ b/src/flipimpact.h @@ -71,7 +71,7 @@ #include "serialize.h" // escapeXml #include "testmap.h" // M21(b): TestRunnerIndex / runAttrDisclosed — the ONE run= hint the tests_to_run family shares #include "pageview.h" // §P8: pageWindow / effectiveRowCap / secondaryCutAttrs — the ONE paging contract -#include "nextverb.h" // P3: nextAttrXml / kNextAttrMaxBytes — the ONE pasteable follow-up +#include "nextverb.h" // P3: nextAttrXml — the ONE pasteable follow-up #include "infra/Diagnostics.h" // DISCLOSE #include "btree.hpp" // gtl::btree_map — sorted iteration (house rule: never std::map) diff --git a/src/forpage.h b/src/forpage.h index 192902ba1..859149442 100644 --- a/src/forpage.h +++ b/src/forpage.h @@ -41,7 +41,7 @@ #include "lexical.h" // LexTermEvidence — the term masks + df the BM25 pass already accumulated #include "model.h" -#include "nextverb.h" // nextFlag / nextAttrXml / kNextAttrMaxBytes — the ONE next= spelling +#include "nextverb.h" // nextFlag / nextAttrXml — the ONE next= spelling #include "pageview.h" // pageWindow / pageDisclosure — the shared --limit/--offset vocabulary #include "serialize.h" // escapeXml, lensRowPath, ctxRootOpen, radixSortByScoreDescId #include "testmap.h" // splitChangedFilesOfSymbols — the ONE "distinct files of a symbol set" walk (--affected's) diff --git a/src/gitmine.h b/src/gitmine.h index 44352c90d..cee6a5b88 100644 --- a/src/gitmine.h +++ b/src/gitmine.h @@ -450,7 +450,7 @@ inline std::string sinceLogArgs( const SinceScope& scope, const char* fallbackSi // repo 43 of 1028 paths moved, ALL upward, none downward, and 2 appeared for the first time. // // Cost: 0.103 s → 0.148 s for the whole-history walk on this repo (1133 commits, 133 merges) — one popen, -// and the expensive walk is memoized (quality::gitCoChangeAndChurnCached; its cache SCHEME is bumped with +// and the expensive walk is memoized (quality::gitRawCommitStreamCached; its cache SCHEME is bumped with // this change, since a warm blob written by a merge-blind binary would otherwise keep serving the old stream). // // `-c` after `log` is git log's combined-diff option; it is NOT the `git -c core.quotepath=false` config @@ -1550,7 +1550,7 @@ inline std::int64_t approxMonthsAgoEpoch( unsigned months ) // The RAW, per-commit (epoch, changed-paths) stream from ONE `git log --name-only` walk // over `coSince`: the parsed-but-UNRESOLVED form gitCoChangeAndChurn needs. Split out of that function so -// it can be MEMOIZED (quality::gitCoChangeAndChurnCached) independent of any particular ingest's fileId +// it can be MEMOIZED (quality::gitRawCommitStreamCached) independent of any particular ingest's fileId // space — paths stay raw repo-relative strings here; resolveCommitStream (below) resolves them against a // CALLER-supplied `ing` fresh on every call. Committed-history-only by construction (git log never sees // working-tree state), which is exactly what makes this cacheable: two calls with the SAME (repo, coSince) @@ -1629,7 +1629,7 @@ inline std::string gitWindowBoundarySha( const std::string& root, const std::str // Resolve a RawCommitStream against the CURRENT `ing` (+ `onlyRoot`) into the same (sets, churn) shape // gitCoChangeAndChurn has always returned — pure in-memory work (basename/suffix matching + a churn tally), -// no subprocess. Split out (Y2) so the cached path (quality::gitCoChangeAndChurnCached) and the uncached +// no subprocess. Split out (Y2) so the cached path (quality::resolveHistoryWalk) and the uncached // path (gitCoChangeAndChurn, below) share ONE resolver instead of two copies that could drift. inline std::vector> resolveCommitStream( const RawCommitStream& raw, const IngestResult& ing, std::size_t maxFiles, @@ -1704,7 +1704,7 @@ inline std::vector> resolveCommitStream( // // This UNCACHED form is now a thin raw-walk + resolve composition (below) — kept as-is for // any caller that doesn't want the memoized path. The two rich-verb call sites (main.cpp's --metrics/--for -// amp=/churn= computation) go through quality::gitCoChangeAndChurnCached instead, which memoizes exactly +// amp=/churn= computation) go through quality::gitRawCommitStreamCached + resolveHistoryWalk instead, which memoize exactly // the expensive part (gitLogNameOnlyRaw) this function also calls. inline std::vector> gitCoChangeAndChurn( const std::string& root, const IngestResult& ing, const char* coSince, std::size_t maxFiles, diff --git a/src/gitstamp.h b/src/gitstamp.h index 1dd34b9c6..8a8f0f41f 100644 --- a/src/gitstamp.h +++ b/src/gitstamp.h @@ -111,7 +111,7 @@ inline std::string stampAt( const std::string& root ) // retrieval path is contractually git-free on a git-less corpus (test/nongitqmetricscheck.sh: "rich // retrieval on a non-git root must not spawn git merely to degrade"), and stampAt then paid TWO popens to // learn there was no repo. hasEnclosingGitRepo is the same filesystem probe (stat for .git, walking up) - // that quality::gitCoChangeAndChurnCached already gates its own walk on: it costs no subprocess, changes + // that quality::gitRawCommitStreamCached already gates its own walk on: it costs no subprocess, changes // no answer — on a non-git root the result was always "" — and makes the property hold for EVERY stamped // verb rather than for whichever one a gate happened to be watching. if( !rw::hasEnclosingGitRepo( root ) ) diff --git a/src/graph.h b/src/graph.h index 78e9c778d..e8eb7b1bc 100644 --- a/src/graph.h +++ b/src/graph.h @@ -7,7 +7,7 @@ #include "elixir_resolve.h" // lexical module/name/arity resolution; reuses cached Binding records #include "filter.h" // isTestPath — for the Q2 tested= post-pass #include "pageview.h" // LB-H: kImportReachRowCap — the import tier's display cap lives with the rest of the truncation vocabulary -#include "nextverb.h" // cut-fix E: nextFlag / nextAttrXml / kNextAttrMaxBytes — the import tier's importers_next= +#include "nextverb.h" // cut-fix E: nextFlag / nextAttrXml — the import tier's importers_next= #include "graphlegend.h" // M15: graphGaugeAttrXml/Json + kGraphCountFloorAttrXml/Json — graphCountFloorAttrXml( g ) below #include "lintrules.h" // §P9.4: langOfPath / dependencyCapable — the file-language classification // restrictDependencyHealth() needs (owns the extension table, kept in sync @@ -19,6 +19,7 @@ #include "prconverge.h" // W2-F: RankDisclosure — the power iteration's own account, carried with its result #include "smallvec.h" // rw::SmallVec — THE ONE ALIAS (src/smallvec.h picks the implementation) #include "resolve.h" // P2-D one-hop type narrowing (Rule 1: class membership) — applied before the name-based fallback +#include "receiverevidence.h" // FE-B: typed receivers RESOLVE; a call nothing proves keeps its rows marked via="name" #include "scipoverlay.h" // SCIP precision overlay (data struct only; parser lives in scip.h) #include "pincensus.h" // eval-only per-call-site decision census (--pin-census); inert unless armed #include "externalnames.h" // Phase 5: the committed builtin/stdlib tables behind the external-name veto @@ -62,6 +63,9 @@ struct Graph // the export (JS/TS); read, not chosen by name // Empty ⇒ no overlay, no FFI/import edge and nothing split: every edge // uniquely resolved, so the whole attribute is absent (omit-at-confident). + std::vector outNameOnly; // FE-B: per-out-edge HEDGE bit, parallel to outTargets — 1 = every call site that bound this + // edge did so by NAME ALONE (no receiver evidence: receiverevidence.h), rendered via="name" + // on every surface; 0 = at least one site proved it. Empty ⇒ no edge is name-only. std::size_t scipDocsSeen = 0; // # SCIP documents consumed (0 unless --scip); honesty summary std::size_t scipEdgesPinned = 0; // # (from,to) edges the SCIP index pinned; honesty summary std::vector> implementors; // base-class id → derived class ids (inheritance/Lego view) @@ -173,6 +177,80 @@ struct Graph // emits nothing — the map is byte-identical either way. }; +// FE-B: is the call edge from → to NAME-ONLY, over the raw out-CSR and its parallel hedge bits — bound by name alone at +// every site that bound it, so every surface renders it via="name". false for an edge that does not exist and for empty +// hedge bits (a graph with no name-only edge at all). edgeNameOnly( Graph ) below and serialize.h's block share it. +inline bool edgeNameOnlyInCsr( const std::vector& outOff, const std::vector& outTargets, + const std::vector& nameOnly, NodeId from, NodeId to ) noexcept +{ + if( nameOnly.empty() || std::size_t( from ) + 1 >= outOff.size() ) + { + return false; + } + EXPECTS( nameOnly.size() == outTargets.size(), "the hedge bit is parallel to outTargets (buildGraph allocates it per edge)" ); + const auto b = outTargets.begin() + outOff[ from ]; + const auto e = outTargets.begin() + outOff[ std::size_t( from ) + 1 ]; + const auto it = std::lower_bound( b, e, to ); // ascending within a source (buildGraph sorts by (from, to)) + return it != e && *it == to && nameOnly[ std::size_t( it - outTargets.begin() ) ] != 0; +} +inline bool edgeNameOnly( const Graph& g, NodeId from, NodeId to ) noexcept +{ + EXPECTS( g.outNameOnly.empty() || g.outNameOnly.size() == g.outTargets.size(), "buildGraph allocates the hedge bits per edge, or none" ); + return edgeNameOnlyInCsr( g.outOff, g.outTargets, g.outNameOnly, from, to ); +} + +// FE-B: does any hop of a directed call path (path[i-1] → path[i]) run through a name-only edge? --path and MCP +// path_between ask it once to decide their via="name" legend sentence (one spelling for both transports). +inline bool pathHasNameOnlyHop( const Graph& g, std::span path ) noexcept +{ + return std::adjacent_find( path.begin(), path.end(), [ & ]( NodeId from, NodeId to ) { return edgeNameOnly( g, from, to ); } ) != path.end(); +} + +// FE-B: does the call edge from → to exist at all (the out-CSR is ascending within a source) +inline bool edgeExists( const Graph& g, NodeId from, NodeId to ) noexcept +{ + if( std::size_t( from ) + 1 >= g.outOff.size() ) + { + return false; + } + const auto b = g.outTargets.begin() + g.outOff[ from ]; + const auto e = g.outTargets.begin() + g.outOff[ std::size_t( from ) + 1 ]; + return std::binary_search( b, e, to ); +} + +// FE-B: a neighbour row of a selector that matched several definitions is via="name" iff EVERY edge joining it to one +// of them is name-only — one proven edge makes the row proven. `towardMatches`: the row is a CALLER (row → match); +// else a callee (match → row). false when no edge joins them, and on a graph with no name-only edge. +inline bool rowNameOnly( const Graph& g, std::span matches, NodeId row, bool towardMatches ) noexcept +{ + if( g.outNameOnly.empty() ) + { + return false; + } + bool any = false; + for( const NodeId m : matches ) + { + const NodeId from = towardMatches ? row : m; + const NodeId to = towardMatches ? m : row; + if( !edgeExists( g, from, to ) ) + { + continue; + } + if( !edgeNameOnly( g, from, to ) ) + { + return false; + } + any = true; + } + return any; +} + +// The via="name" spelling every XML row of a name-only edge carries (" via=\"name\"", 11 bytes), "" otherwise. +inline const char* viaNameAttr( bool nameOnly ) noexcept +{ + return nameOnly ? " via=\"name\"" : ""; +} + // The prov= VOCABULARY, spelled once. serialize's XML and serializeJson's JSON must emit identical // words for identical edges — test/mcpclidiffcheck.sh is the gate that says so — and two ternary // chains over the same integers is precisely how two dialects drift apart. Returns "" for 0 (a @@ -262,7 +340,7 @@ inline bool namespaceCompatible( RefRole role, SymKind kind ) noexcept case RefRole::Type: case RefRole::Extends: { - return kind == SymKind::Class || kind == SymKind::Struct || kind == SymKind::Interface; + return kind == SymKind::Class || isStructOrNamedType( kind ) || kind == SymKind::Interface; } case RefRole::Macro: { @@ -1156,9 +1234,9 @@ inline FnPtrBindTables buildFnPtrBindTables( const IngestResult& ing ) }; for( const Binding& b : ing.bindings ) { - if( b.kind == LocalBindKind::Type || b.kind == LocalBindKind::ParamType || b.var.empty() || b.typeName.empty() ) + if( b.kind == LocalBindKind::Type || b.kind == LocalBindKind::ParamType || isReceiverEvidenceKind( b.kind ) || b.var.empty() || b.typeName.empty() ) { - continue; // a var→TYPE fact (declared or parameter) is never a fn-pointer binding + continue; // a var→TYPE fact (declared or parameter, or FE-B's receiver evidence) is never a fn-pointer binding } const std::string& tgt = ( b.typeName == kFnBindClobberTarget ) ? emptyTarget : b.typeName; if( b.fromSymbol != kNoNode ) @@ -1266,7 +1344,7 @@ inline JavaFieldOwnerGroups buildJavaFieldOwnerGroups( const IngestResult& ing ) const Symbol& owner = ing.symbols[ b.fromSymbol ]; if( owner.lang != Lang::Java || ( owner.kind != SymKind::Class && owner.kind != SymKind::Interface - && owner.kind != SymKind::Struct ) ) + && !isStructOrNamedType( owner.kind ) ) ) { continue; } @@ -1357,9 +1435,9 @@ inline FieldNarrowTables buildFieldNarrowTables( const IngestResult& ing, const // Every record, an assignment's included: localNameEvidence keeps an assignment out of Rule 2b's declared bit (the // member it assigns stays typed, which is what assignmentNamesNoClass guarded here), while Rule 2c still reads it as // proof the token is a variable (test/fieldnarrowcheck.sh arm v3). - if( b.fromSymbol == kNoNode || b.var.empty() ) + if( b.fromSymbol == kNoNode || b.var.empty() || isReceiverEvidenceKind( b.kind ) ) { - continue; + continue; // FE-B's receiver evidence names fields and aliases the other bindings already record (or never declare) } key.clear(); Narrower::appendUint( key, b.fromSymbol ); @@ -1496,7 +1574,7 @@ inline HashMap> buildJavaTypeMembers( const PROFILE_SCOPE_DESCRIBE( "buildGraph/2e: Java Class::method members (issue #74)" ); HashMap> members; const auto isJavaType = []( const Symbol& s ) { - return s.lang == Lang::Java && ( s.kind == SymKind::Class || s.kind == SymKind::Struct || s.kind == SymKind::Interface ); + return s.lang == Lang::Java && ( s.kind == SymKind::Class || isStructOrNamedType( s.kind ) || s.kind == SymKind::Interface ); }; if( std::ranges::none_of( ing.symbols, isJavaType ) ) { @@ -2551,7 +2629,7 @@ struct BuiltinMethodGate static bool isClassLike( const Symbol& s ) noexcept { - return s.kind == SymKind::Class || s.kind == SymKind::Struct || s.kind == SymKind::Interface + return s.kind == SymKind::Class || isStructOrNamedType( s.kind ) || s.kind == SymKind::Interface || ( s.lang == Lang::Ruby && s.kind == SymKind::Other ); // a Ruby module, as classNameSet counts it } @@ -2996,6 +3074,25 @@ struct RubySelfReach return memo.emplace( self, std::move( reach ) ).first->second; } + // FE-B: does self's lookup PROVE candidate `c` — a class or module the tree defines, in reach, and a reach no + // delegator or method_missing opens (an open reach admits everything, which proves nothing) + bool proves( const IngestResult& ing, const std::string& self, NodeId c ) const + { + const Reach& reach = reachOf( self ); + if( reach.open ) + { + return false; + } + const auto q = qualifiedOwner.find( c ); + const std::string& owner = q != qualifiedOwner.end() ? q->second : ing.symbols[ c ].scope; + if( owner.empty() ) + { + return !reach.external; // a top-level def: a private method of Object, reached while no outside ancestor answers first + } + const bool known = q != qualifiedOwner.end() || classNames.find( owner ) != classNames.end(); + return known && reach.names.find( owner ) != reach.names.end(); + } + // The candidates of `ids` self's lookup can reach, into `out` (cleared first); true when any was left out. A candidate // owned by no Ruby class or module the tree defines is reachable; a top-level def (a private method of Object) only // while every ancestor in reach is in the tree — an out-of-tree one (ActionController::Base) is found first by Ruby. @@ -3131,6 +3228,11 @@ struct RubyTopSelf return !call || kind == Kind::None ? nullptr : kind == Kind::Main ? &kMain : &kView; } static bool owns( const std::string& self ) noexcept { return self == "

" || self == ""; } + // FE-B: a class's self is RubySelfReach's proof; outside any class nothing is proven (a top-level script's call) + bool proves( const std::string& self, NodeId c ) const + { + return !owns( self ) && reach.proves( ing, self, c ); + } // The candidates of `ids` self reaches, into `out` (cleared first); true when any was left out. A class's self is // RubySelfReach's to read; for "
" and "", a candidate owned by no Ruby class or module the tree defines is @@ -3568,7 +3670,7 @@ inline bool rubyIsConstantName( std::string_view name ) noexcept inline std::string_view rubyConstantReceiver( const Reference& r ) noexcept { const bool constant = r.lang == Lang::Ruby && r.role == RefRole::Call && r.recv == RecvKind::NamedVar && r.qualifier.empty() - && rubyIsConstantName( r.recvVar ) && r.recvVar.find( kRubyTypedRecvSep ) == std::string::npos; + && rubyIsConstantName( r.recvVar ) && !rubyTypedRecvOf( r ); const std::string_view written = !constant ? std::string_view {} : r.fieldName.empty() ? std::string_view( r.recvVar ) : std::string_view( r.fieldName ); return written.substr( written.starts_with( "::" ) ? 2 : 0 ); } @@ -4527,7 +4629,10 @@ inline void collectFileClassEvidence( const IngestResult& ing, const std::vector } for( const Reference& r : ing.references ) { - if( !r.isDocLink ) // a backtick mention in prose is not code evidence + // a backtick mention in prose is not code evidence; a read/write USE-site is recorded only by the rich ingest (--uses, + // MCP), so reading it here made the same call bind on MCP and decline on the CLI map — evidence must not depend on + // which ingest ran (FE-B found it through mcpclidiffcheck's stanza on a Python `set().update()` beside a method) + if( !r.isDocLink && r.role != RefRole::Read && r.role != RefRole::Write ) { note( r.fileId, r.calleeName ); note( r.fileId, r.recvVar ); @@ -4536,7 +4641,10 @@ inline void collectFileClassEvidence( const IngestResult& ing, const std::vector } for( const Binding& b : ing.bindings ) { - noteBindingClasses( gate, fileGated, b ); + if( !isReceiverEvidenceKind( b.kind ) ) // FE-B's receiver evidence is read by ReceiverEvidence alone + { + noteBindingClasses( gate, fileGated, b ); + } } for( const auto& [ importKey, bound ] : jsImports.targets ) // key "#" (jsImportKey) { @@ -4668,6 +4776,7 @@ struct FalseEdgeRules std::vector goDotImport; // fileId → the file has `import . "…"` std::vector goModules; // every in-tree go.mod's module path std::vector goUnderModule; // fileId → a go.mod sits at or above the file, inside the root + GoPackagePaths goPackages; // fileId → its package's import paths (receiverevidence.h) HashMap jsVocabulary; // jsModuleVocabulary, built only when a JS alias exists HashMap rustOutsideUse; // "#name": a Rust `use` of a path outside the crate names it std::vector> functionsByFile; // fileId → its functions/methods sorted by sigStartByte (enclosingFunction) @@ -4719,14 +4828,14 @@ struct FalseEdgeRules // the functions defined earlier in that body. A METHOD's bare call does not reach its siblings (that needs `self.`). bool callerOwnClass( const Symbol& caller, const Symbol& cand ) const { - const bool callerIsClass = caller.kind == SymKind::Class || caller.kind == SymKind::Struct || caller.kind == SymKind::Interface; + const bool callerIsClass = caller.kind == SymKind::Class || isStructOrNamedType( caller.kind ) || caller.kind == SymKind::Interface; return callerIsClass && !cand.scope.empty() && cand.scope == caller.name; } bool cannotReach( const Reference& r, const Symbol& caller, const Symbol& cand, std::string_view callerDir ) const { if( r.lang == Lang::C ) { - return cand.kind == SymKind::Class || cand.kind == SymKind::Struct || cand.kind == SymKind::Interface; + return cand.kind == SymKind::Class || isStructOrNamedType( cand.kind ) || cand.kind == SymKind::Interface; } if( memberLike( cand ) && !callerOwnClass( caller, cand ) ) { @@ -4811,29 +4920,7 @@ struct FalseEdgeRules return false; } // the innermost function/method of the same file whose span strictly holds `id`'s span; kNoNode when none - NodeId enclosingFunction( NodeId id ) const - { - const Symbol& inner = ing.symbols[ id ]; - if( inner.fileId >= functionsByFile.size() ) - { - return kNoNode; - } - NodeId best = kNoNode; - for( NodeId c : functionsByFile[ inner.fileId ] ) - { - const Symbol& s = ing.symbols[ c ]; - if( s.sigStartByte > inner.sigStartByte ) - { - break; // sorted by start: nothing later can hold it - } - const bool holds = c != id && s.endByte >= inner.endByte && ( s.sigStartByte < inner.sigStartByte || s.endByte > inner.endByte ); - if( holds ) - { - best = c; // later starts are more inner - } - } - return best; - } + NodeId enclosingFunction( NodeId id ) const { return innermostEnclosingFn( ing, functionsByFile, id ); } // Rule (1) for a receiverless call, per language. Nothing dropped → Keep: the ladder decides exactly as before. What // is left when something was dropped is narrowed by the language's own visibility where the extractor records it; @@ -5066,24 +5153,20 @@ inline std::string goModulePathOf( std::string_view text ) while( !line.empty() && ( line.front() == ' ' || line.front() == '\t' ) ) { line.remove_prefix( 1 ); } if( !line.starts_with( "module" ) || line.size() < 7 || ( line[ 6 ] != ' ' && line[ 6 ] != '\t' ) ) { continue; } line.remove_prefix( 7 ); - line = line.substr( 0, line.find( "//" ) ); // the comment goes FIRST: `module x // c` and `module "x" // c` are x - while( !line.empty() && ( line.front() == ' ' || line.front() == '\t' ) ) { line.remove_prefix( 1 ); } - while( !line.empty() && ( line.back() == ' ' || line.back() == '\t' || line.back() == '\r' ) ) { line.remove_suffix( 1 ); } - if( line.size() >= 2 && line.front() == '"' && line.back() == '"' ) { line = line.substr( 1, line.size() - 2 ); } - return std::string( line ); + // The comment goes FIRST: `module x // c` and `module "x" // c` are x. goModToken (resolve.h) then reads the + // path as the go.mod grammar spells it — bare, an interpreted string or a raw (backquoted) one — the reader the + // replace lines below and resolve.h's replace aliases share, so the three spellings cannot drift apart. + return std::string( goModToken( line.substr( 0, line.find( "//" ) ) ) ); } return {}; } -// The module paths a go.mod puts in the tree: its `module` line, and every `replace X => ./local` (or ../, /) left side — -// a module replaced by a LOCAL directory is this tree's code under another path (a multi-root workspace's sibling root). -inline std::vector goModuleTreePaths( std::string_view text ) +// A go.mod's LOCAL replaces, as ( module path, directory as written ): every `replace X => ./local` (or ../, /) — a +// module replaced by a local directory is this tree's code under another path (a multi-root workspace's sibling root). +// Both the one-line form and the lines of a `replace ( … )` block; a version on either side is dropped. +inline std::vector> goLocalReplaces( std::string_view text ) { - std::vector paths; - if( std::string m = goModulePathOf( text ); !m.empty() ) - { - paths.push_back( std::move( m ) ); - } + std::vector> out; for( std::size_t at = 0; at < text.size(); ) { std::size_t end = text.find( '\n', at ); @@ -5100,29 +5183,173 @@ inline std::vector goModuleTreePaths( std::string_view text ) { continue; } - const std::string_view left = trimWs( line.substr( 0, arrow ) ); - const std::string_view target = trimWs( line.substr( arrow + 2 ) ); + // either side may be quoted (`"X" => "./x"`, a raw `X`); goModToken also drops a version: `X v1.2.3 => ./x` + const std::string_view left = goModToken( line.substr( 0, arrow ) ); + const std::string_view target = goModToken( line.substr( arrow + 2 ) ); if( !left.empty() && !target.empty() && ( target.front() == '.' || target.front() == '/' ) ) { - paths.emplace_back( left.substr( 0, left.find_first_of( " \t" ) ) ); // drop a version: `X v1.2.3 => ./x` + out.emplace_back( left, target ); // goModToken dropped the quotes and a version on each side } } + return out; +} + +// The module paths a go.mod puts in the tree: its `module` line, and the left side of every local replace. +inline std::vector goModuleTreePaths( std::string_view text ) +{ + std::vector paths; + if( std::string m = goModulePathOf( text ); !m.empty() ) + { + paths.push_back( std::move( m ) ); + } + for( auto& [ module, dir ] : goLocalReplaces( text ) ) + { + paths.push_back( std::move( module ) ); + } return paths; } +// FE-B: root-relative directory `base` joined with a go.mod replace target `rel` (`./third/lib`, `../svc`); nullopt +// when the target is absolute or climbs above the root (no root-relative file can sit under it). +inline std::optional goJoinRelDir( std::string_view base, std::string_view rel ) +{ + if( rel.starts_with( '/' ) ) + { + return std::nullopt; + } + std::vector parts; + for( std::string_view p : { base, rel } ) + { + while( !p.empty() ) + { + const std::size_t slash = p.find( '/' ); + const std::string_view seg = p.substr( 0, slash ); + p = slash == std::string_view::npos ? std::string_view{} : p.substr( slash + 1 ); + if( seg == ".." ) + { + if( parts.empty() ) + { + return std::nullopt; + } + parts.pop_back(); + } + else if( !seg.empty() && seg != "." ) + { + parts.push_back( seg ); + } + } + } + std::string joined; + for( std::string_view p : parts ) + { + joined.append( joined.empty() ? 0 : 1, '/' ).append( p ); + } + return joined; +} + +// FE-B: a Go package's import path — its go.mod's module path, then the package directory below the go.mod's directory +// (`example.com/qm` + `quoted/lib` under `quoted` → `example.com/qm/lib`); "" when the go.mod has no module line. +inline std::string goPackageImportPath( std::string_view modulePath, std::string_view modDir, std::string_view pkgDir ) +{ + if( modulePath.empty() ) + { + return {}; + } + std::string_view below = pkgDir.substr( std::min( modDir.size(), pkgDir.size() ) ); + below.remove_prefix( below.starts_with( '/' ) ? 1 : 0 ); + std::string path( modulePath ); + if( !below.empty() ) + { + path.append( 1, '/' ).append( below ); + } + return path; +} + +// The go.mod files collectGoModules reads, once per disk directory: each one's tree paths (empty: no go.mod there), its +// own `module` line, and (FE-B) every local replace as ( root-relative replaced directory, module path ). +struct GoModReads +{ + HashMap> treePaths; + HashMap moduleLine; + std::vector> replacedDirs; + + // the tree paths of the go.mod in disk directory `disk` (root-relative `modDir`), read on first use + const std::vector& read( const std::string& disk, std::string_view modDir ) + { + auto [ it, fresh ] = treePaths.try_emplace( disk, std::vector{} ); + if( fresh ) + { + if( const std::optional text = docparse::detail::readWholeFile( disk + "/go.mod" ) ) + { + it->second = goModuleTreePaths( *text ); + moduleLine.try_emplace( disk, goModulePathOf( *text ) ); + for( auto& [ module, dir ] : goLocalReplaces( *text ) ) + { + if( std::optional under = goJoinRelDir( modDir, dir ) ) + { + replacedDirs.emplace_back( std::move( *under ), std::move( module ) ); + } + } + } + } + return it->second; + } +}; + +// FE-B: record `fileId`'s package import path, its go.mod found in disk directory `disk` (root-relative `modDir`) +inline void noteGoPackagePath( const IngestResult& ing, FalseEdgeRules& rules, const GoModReads& reads, const std::string& disk, + std::uint32_t fileId, std::string_view modDir ) +{ + if( const auto ml = reads.moduleLine.find( disk ); ml != reads.moduleLine.end() ) + { + rules.goPackages.note( rules.goPackages.nearest, fileId, goPackageImportPath( ml->second, modDir, includerDir( rootRelPath( ing, fileId ) ) ) ); + } +} + +// FE-B: every Go file under a locally replaced directory (the deepest one when replaces nest) gets the replaced module's +// path plus the directory below it — `replace github.com/up/lib => ./third/lib` makes third/lib/x.go `github.com/up/lib` +inline void noteGoReplacedPaths( const IngestResult& ing, FalseEdgeRules& rules, const std::vector& isGoFile, + const std::vector>& replacedDirs ) +{ + for( std::uint32_t f = 0; f < isGoFile.size() && !replacedDirs.empty(); ++f ) + { + if( isGoFile[ f ] == 0 ) + { + continue; + } + const std::string_view pkgDir = includerDir( rootRelPath( ing, f ) ); + const std::pair* best = nullptr; + for( const auto& rd : replacedDirs ) + { + if( ( rd.first.empty() || pathIsUnder( pkgDir, rd.first ) ) && ( best == nullptr || rd.first.size() > best->first.size() ) ) + { + best = &rd; + } + } + if( best != nullptr ) + { + rules.goPackages.note( rules.goPackages.replaced, f, goPackageImportPath( best->second, best->first, pkgDir ) ); + } + } +} + // FE-A's Go module census: for every Go file, the go.mod at or above its directory INSIDE the root (read once per // directory); every module path found joins goModules. A file with none above it is unknown (goUnderModule 0). inline void collectGoModules( const IngestResult& ing, FalseEdgeRules& rules ) { rules.goUnderModule.assign( ing.files.size(), 0 ); - HashMap> modOfDir; // disk directory → its go.mod's tree paths (empty: no go.mod) - HashMap seenModule; + rules.goPackages.nearest.assign( ing.files.size(), std::string{} ); + rules.goPackages.replaced.assign( ing.files.size(), std::string{} ); + GoModReads reads; + HashMap seenModule; + std::vector isGoFile( ing.files.size(), 0 ); for( const Symbol& s : ing.symbols ) { if( s.lang != Lang::Go || s.fileId >= ing.files.size() || rules.goUnderModule[ s.fileId ] != 0 ) { continue; } + isGoFile[ s.fileId ] = 1; std::string_view rel = rootRelPath( ing, s.fileId ); std::string disk = diskPath( ing, s.fileId ); // walk up as many directories as the root-relative path has, never above the root @@ -5135,18 +5362,13 @@ inline void collectGoModules( const IngestResult& ing, FalseEdgeRules& rules ) break; } disk.resize( diskCut ); - auto [ it, fresh ] = modOfDir.try_emplace( disk, std::vector{} ); - if( fresh ) - { - if( const std::optional text = docparse::detail::readWholeFile( disk + "/go.mod" ) ) - { - it->second = goModuleTreePaths( *text ); - } - } - if( !it->second.empty() ) + const std::string_view modDir = relCut == std::string_view::npos ? std::string_view{} : rel.substr( 0, relCut ); + const std::vector& paths = reads.read( disk, modDir ); + if( !paths.empty() ) { rules.goUnderModule[ s.fileId ] = 1; - for( const std::string& m : it->second ) + noteGoPackagePath( ing, rules, reads, disk, s.fileId, modDir ); + for( const std::string& m : paths ) { if( seenModule.try_emplace( m, '\0' ).second ) { @@ -5159,10 +5381,12 @@ inline void collectGoModules( const IngestResult& ing, FalseEdgeRules& rules ) { break; // the root itself was the last directory read } - rel = rel.substr( 0, relCut ); + rel = modDir; } } - std::sort( rules.goModules.begin(), rules.goModules.end() ); // determinism: discovery order follows symbol order + std::sort( rules.goModules.begin(), rules.goModules.end() ); // determinism: discovery order follows symbol order + std::sort( reads.replacedDirs.begin(), reads.replacedDirs.end() ); // ... and so would a tie between two replaces of one dir + noteGoReplacedPaths( ing, rules, isGoFile, reads.replacedDirs ); } // FE-A: the binding facts — every ModuleAlias (Go dot imports apart), every name a JS/TS import or alias binds, and every @@ -5652,6 +5876,7 @@ inline Graph buildGraph( const IngestResult& ing, const ScipOverlay* scip = null // whose per-reference tier work dominates the growth cascade by orders of // magnitude. A guessed reserve would be a made-up number in a hot struct. std::vector rule3Out; // reused Rule-3 output buffer (candidates from the single included file) + Rule3FileMemo rule3Memo; // Rule 3's answer per (caller file, byName entry): one walk per name per file, not per call std::string qkey; // reused "qualifier::name" buffer for the E#4 canonical lookup (no per-ref alloc) std::vector locShare; // reused per-candidate localityRank memo (computed once per tier, below) @@ -5673,7 +5898,7 @@ inline Graph buildGraph( const IngestResult& ing, const ScipOverlay* scip = null g.rubyBasesUnscoped = rubyBases.disclosure.unscoped; // ruby_bases_unscoped= (absent at zero) { const auto isClassLikeK = []( SymKind k ) noexcept - { return k == SymKind::Class || k == SymKind::Struct || k == SymKind::Interface; }; + { return k == SymKind::Class || isStructOrNamedType( k ) || k == SymKind::Interface; }; PROFILE_SCOPE_DESCRIBE( "buildGraph/2h: CHA-lite inheritance name graph" ); for( const Reference& ir : ing.references ) { @@ -5726,6 +5951,107 @@ inline Graph buildGraph( const IngestResult& ing, const ScipOverlay* scip = null const ClassIdentity classIds = buildClassIdentity( ing, chaUp ); // Rule 2's class identity: nesting, owners, real inheritance (resolve.h) std::vector filtScratch; // reused per-call survivor buffer for CHA-lite / arity filtering rw::SmallVec reachScratch; // reused per-call buffer: the candidates a call can reach by name (reachableByName) + // FE-B (receiverevidence.h): the receiver-evidence tables, the per-edge hedge sets and the reused per-call buffers. + ReceiverEvidence recvEv( ing, chaUp, classNames ); + { + PROFILE_SCOPE_DESCRIBE( "buildGraph/2i: receiver-evidence tables" ); + recvEv.localNames = &fieldNarrow.localNameSet; + recvEv.goPackages = &falseEdges.goPackages; + recvEv.build(); + } + HashMap nameOnlyEdges; // (from<<32|to) of an edge some call bound by NAME ALONE (no receiver evidence) + HashMap provenEdges; // … and of an edge some call bound WITH evidence: a proven site wins the merged row + std::vector provedScratch; // reused: the name-ladder candidates the language's own lookup proves + std::vector memberRule3; // reused: Rule 3's included-file candidates for a MEMBER call — a hedge tier, never a proof + // FE-B: which of a call's name-ladder candidates the language's own lookup PROVES (receiverevidence.h, point 2). A + // member call's receiver must name the candidate's class (this/self/cls and the class's bases, super and the bases, + // a class-name receiver) or its module (a JS/TS/Go/Python module alias); a bare call is an implicit receiver's own + // class or a free function in scope where the receiver is implicit, and elsewhere a definition the caller's scope + // sees — its own file, a file it imports, Go's package directory — reached by a name that is not a local or a + // parameter of the caller (a call THROUGH one holds whatever was passed in). + const auto ownerIsClass = [ & ]( NodeId c ) -> std::string_view + { + const std::string& owner = recvEv.ownerClass[ c ]; + return ( !owner.empty() && classNames.contains( owner ) ) ? std::string_view( owner ) : std::string_view{}; + }; + const auto ladderProves = [ & ]( const Reference& ref, NodeId c ) -> bool + { + const Symbol& cs = ing.symbols[ c ]; + if( isMemberCallRef( ref ) ) + { + const ReceiverEvidence::Chain ch = ReceiverEvidence::chainOf( ref ); + if( !ch.valid || !ch.path.empty() || !ch.ctor.empty() ) + { + return false; // a chained or constructed receiver the resolve rule could not type proves nothing here + } + const std::string_view owner = ownerIsClass( c ); + if( isThisRoot( ch.root ) || ( ref.lang == Lang::Python && ch.root == "cls" ) ) + { + const std::string_view callerCls = recvEv.callerClass( ref.fromSymbol ); + if( !callerCls.empty() ) + { + return !owner.empty() && recvEv.inCone( callerCls, owner ) && recvEv.onSide( ref, c, recvEv.callerIsStatic( ref.fromSymbol ) ); + } + return owner.empty() && cs.fileId == ref.fileId && cs.kind == SymKind::Method; // an object literal's own member + } + if( isSuperRoot( ref, ch.root ) ) + { + const std::string_view callerCls = recvEv.callerClass( ref.fromSymbol ); + return !callerCls.empty() && !owner.empty() && owner != callerCls && recvEv.inCone( callerCls, owner ) + && recvEv.onSide( ref, c, recvEv.callerIsStatic( ref.fromSymbol ) ); + } + if( recvEv.isLocalName( ref.fromSymbol, ch.root ) || externalVeto.hasLocal( ref, ch.root ) ) + { + return false; // a parameter or local spelled like the module alias hides it (`alias = make(); alias.run()`) + } + if( recvEv.moduleNames( ref, ch.root, c ) ) + { + return true; + } + if( ref.lang == Lang::Python && ref.recv == RecvKind::NamedVar ) + { + bindKey.clear(); Narrower::appendUint( bindKey, ref.fileId ); bindKey.push_back( '#' ); bindKey.append( ref.recvVar ); + if( const auto ait = extVeto.importBindFile.find( bindKey ); ait != extVeto.importBindFile.end() && ait->second == cs.fileId ) + { + return true; + } + } + const std::string_view cls = recvEv.unalias( ref.fileId, ch.root ); + return !owner.empty() && recvEv.namesClass( ref, ch.root, cls ) && recvEv.inCone( cls, owner ) // a class-name receiver: the class or a base, + && recvEv.onSide( ref, c, /*classSide=*/true ); // its CLASS side + } + if( implicitReceiverLang( ref.lang ) ) + { + const std::string_view owner = ownerIsClass( c ); + if( owner.empty() ) + { + return true; // a free / top-level function + } + const std::string_view callerCls = recvEv.callerClass( ref.fromSymbol ); + return !callerCls.empty() && recvEv.inCone( callerCls, owner ); + } + if( ref.lang == Lang::Rust || ref.lang == Lang::C ) + { + return true; // FE-A's rules already keep a receiverless call to what its scope reaches + } + if( falseEdges.isLocalOfCaller( ref ) ) + { + return false; + } + if( cs.fileId == ref.fileId ) + { + return !localDefOutOfReach( ing, c, ref ); + } + if( ref.fileId < fileIncludes.size() && std::binary_search( fileIncludes[ ref.fileId ].begin(), fileIncludes[ ref.fileId ].end(), symFileId[ c ] ) ) + { + return true; + } + if( ref.lang == Lang::Go ) + { + return fileDir[ cs.fileId ] == fileDir[ ref.fileId ]; // one package is one directory + } + return ( ref.lang == Lang::TypeScript || ref.lang == Lang::JavaScript ) && rootRelPath( ing, cs.fileId ).ends_with( ".d.ts" ); + }; rw::SmallVec rubyReachable; // reused per-call buffer: the candidates a Ruby call to self can reach (RubySelfReach) // ---- census arming + the ORACLE side (eval-only; src/pincensus.h) ------------------------------ @@ -6209,6 +6535,20 @@ inline Graph buildGraph( const IngestResult& ing, const ScipOverlay* scip = null fieldTypeNarrowed = narrowed; } const bool receiverTypeNarrowed = narrowed && !narrowedBeforeReceiverRules; // Rule 2, 2c or 2b chose the candidates (S6-C reads it) + // FE-B receiver evidence (receiverevidence.h, point 1): a receiver whose class the source states — `this`/`super` + // in a class, a typed parameter or local, a constructed receiver, a class-name receiver, a chain of stated field + // classes, Python's `feed = parser.feed` — resolves to that class's member or the shallowest base defining it. + if( !scipPinned && !canonical && !narrowed && recvEv.narrow( r ) ) + { + for( NodeId c : recvEv.found ) + { + if( langCompatible( ing.symbols[ c ].lang, r.lang ) && sameRoot( c, r.fileId ) ) + { + cand.push_back( c ); + } + } + narrowed = !cand.empty(); + } // Python Rule 2d (module-ALIAS receiver narrow, issue #287): `alias.m(...)` where `alias` is bound in // the caller's file by `import X as alias` / `import X` / `from pkg import X as alias` to a module X // that resolves to EXACTLY ONE indexed file (ExternalVetoTables::importBindFile — Step-A, plus the @@ -6290,6 +6630,15 @@ inline Graph buildGraph( const IngestResult& ing, const ScipOverlay* scip = null && reachableByName( ing, *baseIds, r, reachScratch ) && !( r.role == RefRole::Call && r.qualifier.empty() && bindingTier.empty() && externalVeto.isExternalBound( r ) ); const rw::SmallVec* nameIds = localsYield ? &reachScratch : baseIds; + // FE-B: for a MEMBER call the include graph says which file the caller imports, never what its receiver is — the + // included file's definitions join the name-only tier below instead of deciding the call. + // a QUALIFIED call (`A::m()`, `Storage::reset()`) names its scope: that is the canonical tier's evidence axis, + // not a receiver's, so a qualifier the canonical tier could not place keeps the ladder exactly as before + const bool hedgeEligible = !scipPinned && !canonical && receiverHedgeLang( r.lang ) && r.role == RefRole::Call && r.qualifier.empty(); + // A language's OWN method lookup that cut the candidates to what the receiver can reach (Ruby's RubySelfReach, PR #373) + // sets this: its survivors are proven by that lookup, never name-only. No language sets it on this branch yet. + bool languageLookupProved = false; + memberRule3.clear(); // Ruby: Rule 3 and the ladder choose only among what self's method lookup can reach (RubySelfReach), on the side // self stands on when that is known — a narrow to an unreachable namesake must not hide a reachable one. const RubyCut rubyCut = !scipPinned && !canonical && !narrowed && nameIds != nullptr && rubySelf != nullptr @@ -6313,14 +6662,22 @@ inline Graph buildGraph( const IngestResult& ing, const ScipOverlay* scip = null } if( !scipPinned && !canonical && !narrowed && it != byName.end() ) { - if( narrower.rule3IncludeFile( *nameIds, r.fileId, rule3Out, localsYield ? 1u : 2u ) - || ( localsYield && narrower.rule3IncludeFile( *baseIds, r.fileId, rule3Out ) ) ) + // memoized when the list asked about IS the byName entry (no per-call survivor list in play): same answer, asked once + const auto rule3 = [ & ]( const rw::SmallVec& ids, std::size_t minCands ) + { + return ( &ids == &it->second && minCands == 2 ) ? rule3Memo.narrow( narrower, ids, r.fileId, rule3Out ) + : narrower.rule3IncludeFile( ids, r.fileId, rule3Out, minCands ); + }; + if( rule3( *nameIds, localsYield ? 1u : 2u ) || ( localsYield && rule3( *baseIds, 2u ) ) ) { + // an implicit receiver's bare call is the same question: the included file's definition must be one the + // language's lookup reaches (a free function, the class's own cone) — decided with the ladder's proof below + std::vector& into = ( hedgeEligible && ( isMemberCallRef( r ) || implicitReceiverLang( r.lang ) ) ) ? memberRule3 : cand; for( NodeId c : rule3Out ) { if( langCompatible( ing.symbols[c].lang, r.lang ) ) { - cand.push_back( c ); + into.push_back( c ); } } narrowed = !cand.empty(); @@ -6459,6 +6816,83 @@ inline Graph buildGraph( const IngestResult& ing, const ScipOverlay* scip = null continue; } + // ---- FE-B: PROVEN or NAME-ONLY (receiverevidence.h, point 2). A call no rule above answered binds by name; the + // candidates the language's own lookup proves are its answer (the rest never were candidates of that lookup), and + // with none proven the call is NAME-ONLY: the tier below keeps every same-file and same-directory candidate (and a + // member call's included-file ones) instead of one locality pick, each edge marked via="name" — D1 holds a single + // candidate to the same rule, because one candidate is not evidence. + bool nameOnly = false; + bool nameOnlyAnyFile = false, nameOnlyAnyDir = false; // which rung the classic ladder would have stopped at (rank weight) + // TRIAL (#373): the candidates Ruby's own lookup PROVES are the call's answer, like any other proof + if( hedgeEligible && !narrowed && rubySelf != nullptr && !cand.empty() ) + { + provedScratch.clear(); + for( NodeId c : cand ) + { + if( rubyTop.proves( *rubySelf, c ) ) + { + provedScratch.push_back( c ); + } + } + if( !provedScratch.empty() ) + { + cand.swap( provedScratch ); + languageLookupProved = true; + } + } + if( hedgeEligible && !narrowed && !languageLookupProved && !cand.empty() && r.lang != Lang::Elixir ) + { + provedScratch.clear(); + if( !memberRule3.empty() && !isMemberCallRef( r ) ) + { + // an implicit receiver's Rule 3 answer, kept where the lookup proves it — Rule 3's own decision, as before + for( NodeId c : memberRule3 ) + { + if( ladderProves( r, c ) ) + { + provedScratch.push_back( c ); + } + } + if( !provedScratch.empty() ) + { + cand.swap( provedScratch ); + narrowed = true; + } + } + if( !narrowed ) + { + provedScratch.clear(); + for( NodeId c : cand ) + { + if( ladderProves( r, c ) ) + { + provedScratch.push_back( c ); + } + } + if( provedScratch.empty() ) + { + nameOnly = true; + } + else if( provedScratch.size() < cand.size() ) + { + cand.swap( provedScratch ); + } + } + // a name-only MEMBER call never reaches a free function where the language has no way to (Python, Go, C++, + // Rust, Ruby, Java, C#, Swift: a function is reached through a member only as a method, or through a module + // alias, which proves itself above) — JS/TS object properties, Kotlin extensions and C function-pointer fields + // can hold one, so those keep it. Nothing left: no in-repo definition can answer the call (external=). + if( nameOnly && isMemberCallRef( r ) && memberNeverReachesFreeFunction( r.lang ) ) + { + std::erase_if( cand, [ & ]( NodeId c ) { return ownerIsClass( c ).empty() && ing.symbols[ c ].kind == SymKind::Function; } ); + std::erase_if( memberRule3, [ & ]( NodeId c ) { return ownerIsClass( c ).empty() && ing.symbols[ c ].kind == SymKind::Function; } ); + if( cand.empty() ) + { + disposition = vetoExternal( r ); + continue; + } + } + } // ---- tier ladder (the name-based fallback) — SKIPPED when SCIP pinned this site, and for Rule 2's class-identity CLAIM (a type fact, not a locality guess) const bool identityClaim = narrowed && narrower.identityClaimFor( r ); if( !scipPinned && ( r.lang == Lang::Elixir || identityClaim ) ) { tier = cand; } @@ -6506,6 +6940,47 @@ inline Graph buildGraph( const IngestResult& ing, const ScipOverlay* scip = null continue; } } + else if( nameOnly ) + { + // FE-B: every local candidate, hedged — the same file, the same directory, a member call's included file + bool anyFile = false, anyDir = false; + const std::uint32_t rdir = fileDir[ r.fileId ]; + for( NodeId c : cand ) + { + const bool file = ing.symbols[ c ].fileId == r.fileId; + const bool dir = fileDir[ ing.symbols[ c ].fileId ] == rdir; + if( file || dir || std::find( memberRule3.begin(), memberRule3.end(), c ) != memberRule3.end() ) + { + tier.push_back( c ); + anyFile = anyFile || file; + anyDir = anyDir || dir; + } + } + for( NodeId c : memberRule3 ) // the included file's candidates, even where reachableByName set them aside + { + if( std::find( tier.begin(), tier.end(), c ) == tier.end() ) + { + tier.push_back( c ); + } + } + tierConf = anyFile ? 1.0f : anyDir ? 0.5f : 0.2f; + nameOnlyAnyFile = anyFile; + nameOnlyAnyDir = anyDir; + if( tier.empty() ) + { + if( cand.size() == 1 && !localsYield ) + { + tier = cand; // a unique global, hedged + } + else + { + ++g.declinedOut[ r.fromSymbol ]; + internDeclinedList( g, declinedListsByHash, cand ); + disposition = CallDisposition::Declined; + continue; + } + } + } else { for( NodeId c : cand ) @@ -6660,7 +7135,7 @@ inline Graph buildGraph( const IngestResult& ing, const ScipOverlay* scip = null // declaring the member is a genuine split (the shape Rule 2c also keeps whole), not a locality race. if( !scipPinned && !bindingPinned && r.lang != Lang::Elixir && tier.size() > 1 && !ing.symbols[ r.fromSymbol ].scope.empty() && r.recv != RecvKind::FieldOfThis && r.recv != RecvKind::FieldOfVar && r.recv != RecvKind::SuperObj && !identityClaim - && !isJsTsLitRecv( r.recv ) && r.recv != RecvKind::JavaTypeCandidate ) + && !isJsTsLitRecv( r.recv ) && r.recv != RecvKind::JavaTypeCandidate && !nameOnly ) // FE-B: a name-only call keeps every candidate { const std::string& callerCanon = g.localityKey[ r.fromSymbol ]; // == canonId here (the caller is scoped) const std::size_t localityCap = receiverLocalityCap( r, receiverTypeNarrowed, ing.files[ ing.symbols[ r.fromSymbol ].fileId ] ); @@ -6691,6 +7166,9 @@ inline Graph buildGraph( const IngestResult& ing, const ScipOverlay* scip = null // another — rubygems' composed_set.rb). Widening tier 1 past the caller would invent a // cross-file edge the SAME-FILE tier already outranked, and `other.each` on a second instance // of the caller's own class is a genuine self-loop, so the honest nothing stands. + // FE-B narrows that floor to calls WITH receiver evidence: a NAME-ONLY call never reaches this + // block (its tier keeps every same-file and same-directory candidate, receiverevidence.h), so + // there the cross-file candidate IS listed — hedged via="name", never as a confident edge. const std::size_t sh = ( c == r.fromSymbol ) ? 0 : localityRank( callerCanon, g.localityKey[c], ( r.recv == RecvKind::None && r.qualifier.empty() ) || r.recv == RecvKind::ThisObj, localityCap ); // path-scoped even for a free function locShare.push_back( sh ); if( sh > bestShare ) @@ -6756,6 +7234,22 @@ inline Graph buildGraph( const IngestResult& ing, const ScipOverlay* scip = null gateRefused.push_back( c ); } } + if( anyReal && anyAdmitted && nameOnly ) + { + // FE-B: a name-only call's tier is WIDER than the ladder's pick (every same-file and same-directory candidate), + // so "the decision stands" would hand a builtin-named call every class's method in reach. A CLASS-owned + // target still needs the gate's evidence; a module or object-literal member (no class to look for) stays. + std::erase_if( tier, [ & ]( NodeId c ) + { + if( c == r.fromSymbol ) + { + return false; + } + const std::uint32_t owner = builtinGate.ownerOf( c ); + return owner != BuiltinMethodGate::kNoClass && owner != BuiltinMethodGate::kNested + && builtinGate.judge( r, c, chaCones ) != BuiltinMethodGate::Verdict::Admit; + } ); + } if( anyReal && !anyAdmitted ) { if( gateRefused.empty() ) @@ -6876,7 +7370,28 @@ inline Graph buildGraph( const IngestResult& ing, const ScipOverlay* scip = null } } } - const float base = conf / float( nReal ); // split over real (non-self) targets + // FE-B: a name-only tier LISTS every candidate in reach, but RANK keeps the classic ladder's pick — the rung it would + // have stopped at (same file, else same directory) shares the call's weight as before, and the extra hedged + // candidates ride at weight ZERO: a guess is listed, not let to move PageRank. Zero, not a fraction: PageRank divides + // by each caller's weighted out-degree, so a caller whose ONLY out-edges are extras would push its whole mass through + // any positive weight — measured on this repository, where each `empty()` member forwarding to a container's + // `.empty()` became a clique of same-directory `empty` methods and their rank rose ~6x. At zero such a caller stays + // dangling, exactly as the classic ladder left it (its rung held only the caller: a recursion, no edge). + const std::uint32_t rdirE = fileDir[ r.fileId ]; + const auto classic = [ & ]( NodeId to ) -> bool + { + if( !nameOnly || ( !nameOnlyAnyFile && !nameOnlyAnyDir ) ) + { + return true; + } + return nameOnlyAnyFile ? ing.symbols[ to ].fileId == r.fileId : fileDir[ ing.symbols[ to ].fileId ] == rdirE; + }; + std::size_t nClassic = 0; + for( NodeId c : tier ) + { + nClassic += ( c != r.fromSymbol && classic( c ) ) ? 1u : 0u; + } + const float base = nClassic > 0 ? conf / float( nClassic ) : 0.f; // split over the classic rung's real (non-self) targets // a qualified written type decided this site by its last name — Rule 2 or 2b narrowed on it, or CHA-lite pruned by it — so every edge it // commits is prov="final-segment" (resolve.h finalSegmentTypeAt, fieldFinalSegmentAt); never a class-identity CLAIM, whose one class was verified const bool finalSegmentType = ( ( receiverTypeNarrowed || censusCone ) && !identityClaim && narrower.finalSegmentTypeAt( r ) ) @@ -6889,7 +7404,7 @@ inline Graph buildGraph( const IngestResult& ing, const ScipOverlay* scip = null } const std::uint64_t ekey = ( std::uint64_t( r.fromSymbol ) << 32 ) | to; EdgeAcc& e = acc[ ekey ]; - e.confSum += base; + e.confSum += classic( to ) ? base : 0.f; e.nref += 1; if( splitPick ) { @@ -6907,6 +7422,7 @@ inline Graph buildGraph( const IngestResult& ing, const ScipOverlay* scip = null { finalSegmentEdges[ekey] = 1; // remember (from,to) for prov="final-segment" } + ( nameOnly ? nameOnlyEdges : provenEdges )[ ekey ] = 1; // FE-B: a proven site wins the merged row (Graph::outNameOnly) } disposition = CallDisposition::Bound; } @@ -6966,6 +7482,12 @@ inline Graph buildGraph( const IngestResult& ing, const ScipOverlay* scip = null g.outProv.assign( edges.size(), 0u ); } const EdgeProvenanceSets provSets{ bindingEdges, importEdges, splitEdges, finalSegmentEdges }; + // FE-B: the hedge bit, allocated only when some edge is name-only (empty ⇒ every edge proven, nothing marked anywhere) + const auto hedged = [ & ]( std::uint64_t k ) { return nameOnlyEdges.contains( k ) && !provenEdges.contains( k ); }; + if( std::any_of( edges.begin(), edges.end(), [ & ]( const E& e ) { return hedged( ( std::uint64_t( e.from ) << 32 ) | e.to ); } ) ) + { + g.outNameOnly.assign( edges.size(), 0u ); + } { std::vector cur( g.outOff.begin(), g.outOff.begin() + N ); for( const E& e : edges ) @@ -6982,6 +7504,11 @@ inline Graph buildGraph( const IngestResult& ing, const ScipOverlay* scip = null { g.outProv[ pos ] = edgeProvenance( scip && scip->isPrecise( e.from, e.to ), provSets, ( std::uint64_t( e.from ) << 32 ) | e.to ); } + if( !g.outNameOnly.empty() ) + { + ASSUME( pos < g.outNameOnly.size(), "outNameOnly is allocated one slot per flattened edge" ); + g.outNameOnly[ pos ] = hedged( ( std::uint64_t( e.from ) << 32 ) | e.to ) ? 1u : 0u; + } } } @@ -7177,7 +7704,7 @@ inline Graph buildGraph( const IngestResult& ing, const ScipOverlay* scip = null for( NodeId typeId : it->second ) { const SymKind k = ing.symbols[ typeId ].kind; - if( k != SymKind::Class && k != SymKind::Struct ) + if( k != SymKind::Class && !isStructOrNamedType( k ) ) { continue; } @@ -8827,7 +9354,7 @@ inline FieldUseAnswer collectFieldUseSites( const IngestResult& ing, FieldId fie return {}; } const Symbol& e = ing.symbols[ encl ]; - const bool classLike = e.kind == SymKind::Class || e.kind == SymKind::Struct || e.kind == SymKind::Interface; + const bool classLike = e.kind == SymKind::Class || isStructOrNamedType( e.kind ) || e.kind == SymKind::Interface; return classLike ? std::string_view( e.name ) : std::string_view( e.scope ); }; const auto fieldTypeOf = [ & ]( std::string_view owner, std::string_view member ) -> std::string_view @@ -9030,7 +9557,7 @@ inline std::string memberSelectorUnservedRefusal( const IngestResult& ing, std:: const std::string_view owner = spec.substr( 0, dot ); for( const Symbol& s : ing.symbols ) { - const bool classLike = s.kind == SymKind::Class || s.kind == SymKind::Struct || s.kind == SymKind::Interface; + const bool classLike = s.kind == SymKind::Class || isStructOrNamedType( s.kind ) || s.kind == SymKind::Interface; const bool fieldsLang = s.lang == Lang::Cpp || s.lang == Lang::C || s.lang == Lang::Python; // queries/{c,cpp,python}/tags.scm carry @definition.field if( classLike && s.name == owner && !fieldsLang ) { @@ -9335,7 +9862,7 @@ inline QMetrics computeQMetrics( const IngestResult& ing, const Graph& g ) std::string key; for( const Symbol& s : ing.symbols ) { - const bool isClassKind = ( s.kind == SymKind::Class || s.kind == SymKind::Struct || s.kind == SymKind::Interface ); + const bool isClassKind = ( s.kind == SymKind::Class || isStructOrNamedType( s.kind ) || s.kind == SymKind::Interface ); if( !isClassKind ) { continue; @@ -9736,8 +10263,11 @@ struct ImportTier // cut-fix E: `sym` is the answer's own selector. A cut tier was a DEAD-END cut (docs/research/answer-completeness.md // §1.3): counted, but naming no call that serves the rest, although one exists — the tier is sized by limit, so // `--impact=SYM --limit=` lists all of it. That call rides as importers_next= (the root's next= is taken -// by --safe-delete), only on a cut, and not when the invocation would pass kNextAttrMaxBytes (a hint that pastes -// wrong is worse than none — forpage.h's rule). Both surfaces spell the CLI flag, as their root next= already does. +// by --safe-delete), only on a cut, and IN FULL whatever its length (knob-honesty-068): it used to be dropped past +// kNextAttrMaxBytes, so a long selector's cut tier lost its only continuation with no marker — the silent drop the +// 2026-09-25 ruling (nextverb.h: next= is never truncated or dropped for length) removed from every other producer. +// The invocation is never truncated, so it never pastes wrong. Both surfaces spell the CLI flag, as their root next= +// already does. Gate: impactimportcheck.sh #9d (a 130 B call). inline void sizeImportTier( ImportTier& t, int pageLimit, std::string_view sym = {} ) { t.shown = std::min( t.files.size(), std::size_t( rw::effectiveRowCap( pageLimit, rw::kImportReachRowCap ) ) ); @@ -9745,11 +10275,7 @@ inline void sizeImportTier( ImportTier& t, int pageLimit, std::string_view sym = t.next.clear(); if( t.capped && !sym.empty() ) { - std::string inv = rw::nextFlag( "--impact=", sym ) + " --limit=" + std::to_string( t.files.size() ); - if( inv.size() <= rw::kNextAttrMaxBytes ) - { - t.next = std::move( inv ); - } + t.next = rw::nextFlag( "--impact=", sym ) + " --limit=" + std::to_string( t.files.size() ); } // Emitted UNCONDITIONALLY, zero included: an absent importers= reads as "this build cannot measure it", // and a shown_ without its capped= is the missing-attribute ambiguity pageview.h rule 3 forbids. @@ -10102,6 +10628,52 @@ inline std::vector transitiveCallersDepth( const Graph& g, std::span transitiveCallers( const Graph& g, std::span seeds ) { return transitiveCallersDepth( g, seeds, nullptr ); } +// FE-B (ruling D3): the callers that reach a seed through an ALL-PROVEN path — every edge on it bound with receiver +// evidence (Graph::outNameOnly 0). An --impact row outside this set is reached only through some name-only edge and +// inherits via="name"; a row with ANY all-proven path stays plain, at whatever depth. Indexed by node id; EMPTY (no row +// hedged) on a graph with no name-only edge, so such a corpus pays one emptiness test. +inline std::vector provenCallerReach( const Graph& g, std::span seeds ) +{ + std::vector proven; + if( g.outNameOnly.empty() ) + { + return proven; + } + const std::size_t N = g.wOutDeg.size(); + std::vector q; + proven.assign( N, 0 ); + for( NodeId s : seeds ) + { + if( s < N && !proven[s] ) + { + proven[s] = 1; + q.push_back( s ); + } + } + const auto* ro = g.inEdges.rowOffsets(); + const auto* ci = g.inEdges.colIndices(); + for( std::size_t head = 0; head < q.size(); ++head ) + { + const NodeId u = q[ head ]; + for( std::uint32_t k = ro[u]; k < ro[u + 1]; ++k ) + { + const NodeId c = ci[k]; + if( c < N && !proven[c] && !edgeNameOnly( g, c, u ) ) + { + proven[c] = 1; + q.push_back( c ); + } + } + } + ENSURES( proven.size() == N && q.size() <= N, "each node is enqueued at most once, by the mark" ); + return proven; +} +// the --impact row's hedge: reached, but by no all-proven path +inline bool impactRowNameOnly( const std::vector& proven, NodeId n ) noexcept +{ + return !proven.empty() && n < proven.size() && proven[n] == 0; +} + // ── Depth-labelled --impact (0.6.5) — the hop depth the walk above already records, put on the listing ────── // A flat blast radius cannot tell a direct caller from a four-hop dependent, and a page window cut over a // PageRank-only order drops rows of every depth at once, so a capped answer had no clean boundary. Both diff --git a/src/graphlegend.h b/src/graphlegend.h index b35e988e2..ce8475cf6 100644 --- a/src/graphlegend.h +++ b/src/graphlegend.h @@ -352,8 +352,25 @@ inline constexpr std::string_view kForRouteCodeLegend = // bytes verbatim AND the session was already served them, so a clause spelled twice would silently stop being // dropped (the answer stays honest — it just keeps the clause inline). Byte-identical to the inline spellings. // No "--" in any of them: they ride inside an XML comment (G4). +// lean-answers lane: the zero-elision readings (an absent cx=/ccx=/in= is 0 on a --for lens row), one per dialect. Each +// is EXEMPT from the sig ledger (forZeroNoteBytes): spelling the reading must never cost the answer a row, so the rows +// kept are exactly the rows kept before the elision. Each is checked verbatim inside the clause that carries it. +inline constexpr std::string_view kForZeroAbsentCompactNote = " (absent cx/ccx/in = 0)"; +inline constexpr std::string_view kForZeroAbsentFullNote = ", each absent when 0;"; +inline constexpr std::string_view kForZeroAbsentMcpNote = "; an absent cx/ccx/in is 0"; +// The bytes of those readings a header carries — what its sig ledger leaves uncharged. +inline std::size_t forZeroNoteBytes( std::string_view header ) noexcept +{ + std::size_t n = 0; + for( const std::string_view note : { kForZeroAbsentCompactNote, kForZeroAbsentFullNote, kForZeroAbsentMcpNote } ) + { + n += header.find( note ) != std::string_view::npos ? note.size() : 0u; + } + return n; +} inline constexpr std::string_view kMcpForBuildingBlocksLegend = - ": reusable building blocks (cx=complexity, in=reuse-count) — prefer composing/reusing these over reimplementing"; + ": reusable building blocks (cx=complexity, in=reuse-count; an absent cx/ccx/in is 0) — prefer composing/reusing these over reimplementing"; +static_assert( kMcpForBuildingBlocksLegend.find( kForZeroAbsentMcpNote ) != std::string_view::npos, "the MCP for zero reading is spelled once" ); inline constexpr std::string_view kMcpForBundleSigsLegend = "; bundle=sigs: signatures only in this bundle, no inline bodies — fetch a symbol's full body with the fetch_body verb"; inline constexpr std::string_view kMcpForLensColumnsLegend = @@ -618,11 +635,16 @@ inline constexpr const char* kValueRefsCalleesLegend = inline constexpr const char* kValueRefsReachLegend = "value_refs=N (absent when 0) counts rows: SYM is USED AS A VALUE at bind= (stored into into= or passed), matched by name. It is not a proven call: reaches= and impact_reaches= exclude them, and a caller that runs it through that slot is not in the radius. is their window; : the enclosing symbol, the binding site, where it lands, functions that may call through it. "; inline constexpr const char* kValueRefsSafeDeleteLegend = - "value_refs=N (absent when 0): SYM is USED AS A VALUE N times (a table, field or argument holds it; matched by name, not a proven call). Each such site (a decorator row aside: a fact about the definition) is in uses=; any row keeps dead_code_candidate at 0 and risk off none-found: deleting SYM breaks the table even though no call reaches it. / list them. "; + "value_refs=N (absent when 0): SYM is USED AS A VALUE N times (a table, field or argument holds it; matched by name, not a proven call). Each such site (a decorator row aside: a fact about the definition) is in uses=; any row keeps risk off none-found, and any row made outside SYM itself keeps dead_code_candidate at 0: deleting SYM breaks the table even though no call reaches it (a function that only stores ITSELF, timer_set( tick ) inside tick, can still be a candidate, as a recursive self-call is no caller). / list them. "; inline constexpr const char* kToValueRefsLegend = "to_value_refs=N (absent when 0): to= is USED AS A VALUE N times (stored or passed, matched by name); a run through such a slot is not a proven call and is no hop here: the callers verb on to= lists the binding sites and called_by=. "; inline constexpr const char* kUsesValueRoleLegend = "role=\"value\" (reference-as-value round): the function is USED AS A VALUE there, stored into a table, field or variable or passed as an argument, matched by name with the callers verb's own visibility rules. It is not a proven call; the callers verb shows where the value lands and who may call through it. A decorator row is a fact about the definition, not a site: the callers verb lists it, this verb does not. "; +// The value walk's depth cut (ingest_valuerefs.h kVrMaxDepth, valuerefs.h valueRefsDepthAttrXml): rides EVERY answer +// that carries value_refs_depth_capped=, whether value_refs= is there or not — a zero beside it is the floor it names. +inline constexpr const char* kValueRefsDepthLegend = + "value_refs_depth_capped=N (absent when 0): N indexed files of this language family (every family on the dead-code list) nest deeper than the value-reference walk's 512-level cap (a long else-if chain, a deep expression), and a function used as a value below that depth is not seen: value_refs= (absent or not) is a floor there, and a reading built on it (dead_code_candidate=, risk=none-found, the dead-code list) may miss a table that holds the function. value_refs_depth_at= is the first cut, file:line: read the code there for value uses. "; +inline const char* valueRefsDepthLegend( bool on ) noexcept { return on ? kValueRefsDepthLegend : ""; } inline const char* usesValueRoleLegend( bool on ) noexcept { return on ? kUsesValueRoleLegend : ""; } inline const char* valueRefsLegend( bool on, bool callersSide ) noexcept { @@ -854,6 +876,83 @@ inline constexpr const char* kModScopeLegend = "t=\"modscope\" is a row for a file's MODULE SCOPE, named : the statements outside every named definition, which is where a top-level call and an anonymous callback body's calls live. It is a CALLER, not a function — nothing in the source can name it, so it never appears as a callee, and it has no body, so expanding it returns none. A file that has no such call has no such row. "; inline const char* modScopeLegend( bool on ) noexcept { return on ? kModScopeLegend : ""; } +// FE-B (test/receiverevidencecheck.sh): the reading of via="name" — ONE wording for every surface that marks a row with it +// (--callees/--callers, --impact, --path, --connect's , the rows of --expand and --for, and MCP's "via":"name"). +// Gated like modScopeLegend: an answer carrying no such row pays 0 bytes. What it does NOT mean is said in the sentence, +// because the attribute reads as a verdict and is not one. +inline constexpr const char* kViaNameLegend = + "via=\"name\" on a row: the target was matched by name alone, because nothing (self/this, a construction, a type annotation, " + "an import, the class's bases) proves the receiver is that class; every by-name candidate in reach is listed (same file and " + "directory, else a lone definition), and any one of them, or none (a builtin or an outside object), may be the real target. " + "It does NOT mean the edge is false, and a row without it is evidence-bound, not type-checked. "; +// G4: no double hyphen — viaNameLegend( …, impact = true ) lands inside the --impact legend's XML comment on the CLI and MCP +// (CodeRabbit 5468003465: "An --impact row" made a --legend=full answer with a via="name" row invalid XML; test/xmlwellformed.sh). +inline constexpr const char* kViaNameImpactClause = + "An impact row reached only through such an edge carries it too; one that some all-proven path reaches stays plain. "; +inline constexpr const char* kViaNameColumnarClause = "format=columnar carries it as the column (1 = via=\"name\"). "; +inline std::string viaNameLegend( bool present, bool impact = false, bool columnar = false ) +{ + if( !present ) + { + return {}; + } + std::string out = kViaNameLegend; + if( impact ) + { + out += kViaNameImpactClause; + } + if( columnar ) + { + out += kViaNameColumnarClause; + } + return out; +} +// …and as --for's two header strips spell a present-only clause (see kForCompactModScopeClause below): the verb's FIRST-SCREEN +// legend must define every attribute its payload can carry, so --for (and --exemplar) define it there, on an over-approximated +// bit (serialize.h namesOnlyOutAny), and tell the section not to repeat it. +inline constexpr std::string_view kForCompactViaNameClause = + "; c via=name: that callee matched by name alone, receiver unproven (NOT a claim the edge is false)"; +// The full dialect's spelling: the same three facts as kViaNameLegend (the mechanism, the listing, the NOT sentence) in the +// fewest bytes, because every byte of --for's header is un-charged by its token ladder and its tight rungs carry ~0 +// headroom (see kForRootRelPathsLegendShort's measurement in verbs_for.h). ~190 B against kViaNameLegend's ~430 B. +inline constexpr std::string_view kForViaNameClause = + " via=\"name\" on a row: that callee matched by name alone (nothing proves the receiver's class); every by-name " + "candidate in reach is listed. It does NOT mean the edge is false."; +inline std::string forViaNameClause() +{ + return std::string( kForViaNameClause ); +} +// …as its own comment node, for the surfaces whose legend is written beside the rows (a / block's ) +inline std::string viaNameLegendComment() +{ + return std::string( ""; +} +// …and the MAP's spelling, as its own comment: the same three facts as kViaNameLegend in the fewest bytes (a map prints the +// most hedged rows of any answer, and its --max-tokens fit pays for every legend byte in rows), plus x=, the count a merged +// row carries (serialize.h writeMapCalleeRows), and where each merged candidate is listed. No `--` inside an XML comment: +// the flag is named callees=. +inline constexpr std::string_view kMapViaNameLegend = + ""; +// …and MCP's spelling: the same sentence as the `via_note` key of an answer one of whose entries carries "via":"name". +inline std::string viaNameNoteJson() +{ + std::string text = kViaNameLegend; + for( std::size_t at = text.find( "via=\"name\"" ); at != std::string::npos; at = text.find( "via=\"name\"", at ) ) + { + text.replace( at, 10, "\\\"via\\\":\\\"name\\\"" ); // via="name" -> \"via\":\"name\" inside the JSON string + at += 16; + } + if( const std::size_t at = text.find( "on a row" ); at != std::string::npos ) + { + text.replace( at, 8, "on an entry" ); + } + if( !text.empty() && text.back() == ' ' ) + { + text.pop_back(); + } + return ",\"via_note\":\"" + text + "\""; +} + // The same fact in --for's two dialects, which are prose strips joined by ';' and ':' rather than XML comment // clauses — one constant each so the wording cannot drift from kModScopeLegend above. Present-only, on that // verb's own `modScopePresent` bit (verbs_for.h), like every other clause in those two strips. diff --git a/src/htmlexport.h b/src/htmlexport.h index d7e3d2ced..9ae5ed294 100644 --- a/src/htmlexport.h +++ b/src/htmlexport.h @@ -2208,6 +2208,9 @@ inline constexpr const char* kSymShapes[] = { "cross", // Other "bar", // ModuleScope — a file's module scope holds statements, it is not callable: it shares // Section's bar rather than borrowing the function circle it is not. + "square", // NamedType — a type, like class/struct/interface + "square", // Alias + "square", // FuncType }; inline constexpr std::size_t kSymShapeCount = sizeof( kSymShapes ) / sizeof( kSymShapes[0] ); static_assert( kSymShapeCount == kSymKindCount, diff --git a/src/infra/fieldid.h b/src/infra/fieldid.h index 561ade969..8f71256f9 100644 --- a/src/infra/fieldid.h +++ b/src/infra/fieldid.h @@ -70,7 +70,7 @@ enum class NodeField : std::uint8_t Alias, Alternative, Argument, Arguments, Attribute, Body, Captures, Condition, Consequence, Constructor, Declaration, Declarator, DefaultValue, Definition, Directive, - Field, Function, Initializer, Key, Left, + Expression, Field, Function, Initializer, Key, Left, Method, ModuleName, Name, Object, Operand, Operator, Parameter, Parameters, Path, Pattern, Property, Receiver, Right, Scope, Source, Subject, @@ -99,7 +99,7 @@ inline constexpr std::array kNodeFieldNames = { { NodeField::Captures, "captures", 8 }, { NodeField::Condition, "condition", 9 }, { NodeField::Consequence, "consequence", 11 }, { NodeField::Constructor, "constructor", 11 }, { NodeField::Declaration, "declaration", 11 }, { NodeField::Declarator, "declarator", 10 }, { NodeField::DefaultValue, "default_value", 13 }, { NodeField::Definition, "definition", 10 }, { NodeField::Directive, "directive", 9 }, - { NodeField::Field, "field", 5 }, { NodeField::Function, "function", 8 }, { NodeField::Initializer, "initializer", 11 }, + { NodeField::Expression, "expression", 10 }, { NodeField::Field, "field", 5 }, { NodeField::Function, "function", 8 }, { NodeField::Initializer, "initializer", 11 }, { NodeField::Key, "key", 3 }, { NodeField::Left, "left", 4 }, { NodeField::Method, "method", 6 }, { NodeField::ModuleName, "module_name", 11 }, { NodeField::Name, "name", 4 }, { NodeField::Object, "object", 6 }, { NodeField::Operand, "operand", 7 }, { NodeField::Operator, "operator", 8 }, { NodeField::Parameter, "parameter", 9 }, diff --git a/src/infra/profilePmc.h b/src/infra/profilePmc.h index 869fed7a4..e588109d0 100644 --- a/src/infra/profilePmc.h +++ b/src/infra/profilePmc.h @@ -79,7 +79,8 @@ struct Snapshot #endif #if PROFILE_PMC_VERBOSE - #define PMC_DIAG( ... ) rw::emitRaw( stderr, "prof::pmc: " __VA_ARGS__ ) + // `fmt` is a std::format string (rw::emitTo's spelling, `{}` not `%s`); the prefix is concatenated onto the literal. + #define PMC_DIAG( fmt, ... ) rw::emitTo( stderr, "prof::pmc: " fmt __VA_OPT__( , ) __VA_ARGS__ ) #else #define PMC_DIAG( ... ) ( (void) 0 ) #endif @@ -238,7 +239,7 @@ inline bool resolve_event( kpep_db* db, const char* alias, kpep_event** out ) no // try the alias verbatim first (some DBs expose the friendly name directly) if( g_api.kpep_db_event( db, alias, out ) == 0 && *out ) { - PMC_DIAG( "resolved '%s' -> '%s' (verbatim)\n", alias, alias ); + PMC_DIAG( "resolved '{}' -> '{}' (verbatim)\n", alias, alias ); return true; } @@ -258,7 +259,7 @@ inline bool resolve_event( kpep_db* db, const char* alias, kpep_event** out ) no } if( g_api.kpep_db_event( db, probe, out ) == 0 && *out ) { - PMC_DIAG( "resolved '%s' -> '%s'\n", alias, probe ); + PMC_DIAG( "resolved '{}' -> '{}'\n", alias, probe ); return true; } } @@ -275,6 +276,8 @@ struct PerfState kpep_db* db = nullptr; kpep_config* cfg = nullptr; uint32_t classes = 0; + int prev_forced = 0; // kpc_force_all_ctrs_get() before we took the counters: what release() restores + bool forced = false; // we took the counters (force_all_ctrs_set succeeded): release() owes them back uint32_t counter_count = 0; // raw counters per thread read unsigned event_count = 0; // logical events we configured @@ -286,12 +289,33 @@ struct PerfState inline PerfState g_perf; inline std::once_flag g_once; +// Frees the kpep db/config when the arming sequence ends, however it ends: the kpc words and slot map it needs +// were copied out into g_perf by then. +struct ConfigFreer +{ + ~ConfigFreer() + { + if( g_perf.cfg ) + { + g_api.kpep_config_free( g_perf.cfg ); + g_perf.cfg = nullptr; + } + if( g_perf.db ) + { + g_api.kpep_db_free( g_perf.db ); + g_perf.db = nullptr; + } + } +}; + // global, one-time: load the ABI, build the config, arm the counters inline void ensure_global_init() noexcept { std::call_once( g_once, []() noexcept { - PMC_DIAG( "init: euid=%u (root needed unless entitled)\n", unsigned( geteuid() ) ); + const ConfigFreer freeConfig; // every `return` below is a degrade or the end of arming: the kpep objects are dead weight either way + + PMC_DIAG( "init: euid={} (root needed unless entitled)\n", unsigned( geteuid() ) ); if( !load_api() ) { @@ -326,7 +350,7 @@ inline void ensure_global_init() noexcept kpep_event* ev = nullptr; if( !resolve_event( g_perf.db, kDefaultSelection[ i ], &ev ) ) { - PMC_DIAG( "skip '%s' (not in this core's DB)\n", kDefaultSelection[ i ] ); + PMC_DIAG( "skip '{}' (not in this core's DB)\n", kDefaultSelection[ i ] ); continue; } @@ -334,7 +358,7 @@ inline void ensure_global_init() noexcept const int rc = g_api.kpep_config_add_event( g_perf.cfg, &ev, 0, &err ); if( rc != 0 ) { - PMC_DIAG( "skip '%s' (add rc=%d err=%u; PMC budget?)\n", kDefaultSelection[ i ], rc, err ); + PMC_DIAG( "skip '{}' (add rc={} err={}; PMC budget?)\n", kDefaultSelection[ i ], rc, err ); continue; } @@ -382,13 +406,13 @@ inline void ensure_global_init() noexcept // arm: take ownership of all counters, push the config, start counting. // The force/set calls are the privileged ones — they fail without root or // the entitlement, or if another tool (Instruments) holds the counters. - int prev = 0; - g_api.kpc_force_all_ctrs_get( &prev ); + g_api.kpc_force_all_ctrs_get( &g_perf.prev_forced ); if( g_api.kpc_force_all_ctrs_set( 1 ) != 0 ) { PMC_DIAG( "FAIL kpc_force_all_ctrs_set (privilege? counters busy?)\n" ); return; } + g_perf.forced = true; // from here release() must hand the counters back, whichever later step fails if( g_api.kpc_set_config( g_perf.classes, regs ) != 0 ) { @@ -403,10 +427,29 @@ inline void ensure_global_init() noexcept } g_perf.ok = true; - PMC_DIAG( "OK: %u events armed, %u raw counters\n", g_perf.event_count, g_perf.counter_count ); + PMC_DIAG( "OK: {} events armed, {} raw counters\n", g_perf.event_count, g_perf.counter_count ); } ); } +// Teardown: stop counting and hand the counters back (kpc_force_all_ctrs_set to what it was before we took +// them), so a privileged run does not leave the PMU forced and armed after exit. Called once, after the exit +// report has rendered (profileScope.h Reporter); counting for the calling thread stops with it, every other +// thread's flag dies with the thread. active() is false afterwards, so a late read() returns zeros. +inline void release() noexcept +{ + if( g_perf.ok ) + { + g_perf.ok = false; + g_api.kpc_set_counting( 0 ); + g_api.kpc_set_thread_counting( 0 ); + } + if( g_perf.forced ) + { + g_perf.forced = false; + g_api.kpc_force_all_ctrs_set( g_perf.prev_forced ); + } +} + // per-thread: enable counting for the calling thread (each profiled thread does // this once, from profileScope.h's per-thread registration) inline void ensure_thread_counting() noexcept @@ -439,7 +482,7 @@ ALWAYS_INLINE Snapshot read() noexcept if( !logged ) { logged = true; - PMC_DIAG( "first read: rc=%d cc=%u raw[0..2]=%llu,%llu,%llu\n", rc, g_perf.counter_count, + PMC_DIAG( "first read: rc={} cc={} raw[0..2]={},{},{}\n", rc, g_perf.counter_count, (unsigned long long) raw[ 0 ], (unsigned long long) raw[ 1 ], (unsigned long long) raw[ 2 ] ); } #endif @@ -627,7 +670,7 @@ inline bool open_group( ThreadCounters& tc, const unsigned* tableIndices, unsign const long fd = sys_perf_event_open( &attr, 0, -1, groupFd, 0 ); if( fd < 0 ) { - PMC_DIAG( "open '%s' failed (errno=%d) — column dropped%s\n", desc.alias, errno, isLeader ? ", leadership passes to the next event that opens" : "" ); + PMC_DIAG( "open '{}' failed (errno={}) — column dropped{}\n", desc.alias, errno, isLeader ? ", leadership passes to the next event that opens" : "" ); continue; // graceful per-event skip, leader included — same rule as the kperf side } @@ -647,7 +690,7 @@ inline bool arm_and_verify( ThreadCounters& tc, GroupRead* out ) noexcept if( ::ioctl( tc.fds[ 0 ], PERF_EVENT_IOC_RESET, PERF_IOC_FLAG_GROUP ) != 0 || ::ioctl( tc.fds[ 0 ], PERF_EVENT_IOC_ENABLE, PERF_IOC_FLAG_GROUP ) != 0 ) { - PMC_DIAG( "group reset/enable ioctl failed (errno=%d)\n", errno ); + PMC_DIAG( "group reset/enable ioctl failed (errno={})\n", errno ); return false; } @@ -660,7 +703,7 @@ inline bool arm_and_verify( ThreadCounters& tc, GroupRead* out ) noexcept const ssize_t got = ::read( tc.fds[ 0 ], out, sizeof( *out ) ); if( got < ssize_t( 3 * sizeof( std::uint64_t ) ) || out->nr != tc.fd_count ) { - PMC_DIAG( "group verify read got=%zd nr=%llu (want %u fds) — over PMU budget?\n", + PMC_DIAG( "group verify read got={} nr={} (want {} fds) — over PMU budget?\n", got, ( unsigned long long ) ( got > 0 ? out->nr : 0 ), tc.fd_count ); return false; } @@ -680,7 +723,7 @@ inline bool map_value_indices( ThreadCounters& tc, const GroupRead& probe ) noex std::uint64_t id = 0; if( ::ioctl( tc.fds[ slot ], PERF_EVENT_IOC_ID, &id ) != 0 ) { - PMC_DIAG( "PERF_EVENT_IOC_ID failed for slot %u (errno=%d)\n", slot, errno ); + PMC_DIAG( "PERF_EVENT_IOC_ID failed for slot {} (errno={})\n", slot, errno ); return false; } @@ -696,7 +739,7 @@ inline bool map_value_indices( ThreadCounters& tc, const GroupRead& probe ) noex } if( !found ) { - PMC_DIAG( "id %llu for slot %u missing from group read\n", ( unsigned long long ) id, slot ); + PMC_DIAG( "id {} for slot {} missing from group read\n", ( unsigned long long ) id, slot ); return false; } } @@ -735,7 +778,7 @@ inline void select_and_arm_first_thread( ThreadCounters& tc ) noexcept g_perf.event_count = openedCount; g_perf.ok = true; tc.ok = true; - PMC_DIAG( "OK: %u events armed (pinned group)\n", openedCount ); + PMC_DIAG( "OK: {} events armed (pinned group)\n", openedCount ); return; } @@ -765,7 +808,7 @@ inline void select_and_arm_first_thread( ThreadCounters& tc ) noexcept candidates[ candidateCount++ ] = openedRows[ i ]; } } - PMC_DIAG( "dropped '%s', retrying with %u events\n", kEvents[ openedRows[ dropIndex ] ].alias, candidateCount ); + PMC_DIAG( "dropped '{}', retrying with {} events\n", kEvents[ openedRows[ dropIndex ] ].alias, candidateCount ); } tc.close_all(); @@ -806,6 +849,15 @@ inline void ensure_thread_counting() noexcept } } +// Teardown. The group's fds are the whole of this backend's footprint and ThreadCounters closes them when its +// thread exits; this closes the CALLING thread's now (the main thread's thread_local is destroyed before the +// exit report's static destructor runs), and ends the "active" claim so a late read() returns zeros. +inline void release() noexcept +{ + t_counters.close_all(); + g_perf.ok = false; +} + // hot path: one grouped read() syscall returns every counter for the calling thread ALWAYS_INLINE Snapshot read() noexcept { @@ -841,6 +893,7 @@ namespace pmc { inline void ensure_thread_counting() noexcept {} +inline void release() noexcept {} ALWAYS_INLINE Snapshot read() noexcept { return {}; } inline bool active() noexcept { return false; } inline unsigned event_count() noexcept { return 0; } diff --git a/src/infra/profileScope.h b/src/infra/profileScope.h index d6fb9c614..48768eda0 100644 --- a/src/infra/profileScope.h +++ b/src/infra/profileScope.h @@ -520,6 +520,9 @@ struct Reporter { #if PROFILE_AUTO_REPORT report(); +#endif +#if PROFILE_PMC + prof::pmc::release(); // after the report: hand the HW counters back #endif registry().teardown(); } diff --git a/src/ingest_binds.h b/src/ingest_binds.h index 958b17442..273a7332f 100644 --- a/src/ingest_binds.h +++ b/src/ingest_binds.h @@ -68,8 +68,11 @@ struct RecvShape std::string var; std::string field; bool viaArrow = false; - bool member = false; // FE-A: Go/JS/TS/Rust — the callee is a member access's field (model.h Reference::memberCall) + bool member = false; // FE-A: Go/JS/TS/Rust — the callee is a member access's field (model.h Reference::memberCall); + // FE-B adds Java, Kotlin, C# and Swift, whose member calls recorded no shape at all std::string root; // FE-A: that access's receiver-chain root identifier, "" when not an identifier + std::string path; // FE-B: the member names between root and the callee, '.'-joined (model.h Reference::memberPath) + std::string ctor; // FE-B: the class a constructed receiver names (model.h Reference::memberCtor) }; // FE-A (model.h Reference::memberRoot): the ROOT identifier of a Go/JS/TS/Rust receiver chain — `crypto` for @@ -107,6 +110,158 @@ inline std::string memberChainRoot( TSNode recv, std::string_view src ) return {}; } +// FE-B (model.h Reference::memberPath / memberCtor): one receiver chain, walked from the call's immediate receiver down to +// its root, for the languages whose member calls carry no RecvKind (JS/TS, Go, Java, Kotlin, C#, Swift). `root` keeps +// memberChainRoot's answer for JS/TS and Go (FE-A reads it) and adds `super` (JS/TS/Java/Kotlin/Swift; C#'s `base`), +// `this`/`self` spellings of the other languages, which FE-A never sees. `path` collects the member names passed on +// the way down, in source order; `ctor` is the class a constructed receiver names (`new X( … )`, Go `X{ … }` or +// `&X{ … }`), final segment, with root "". Any other node (a call, a subscript, a literal) ends the walk with root "" +// and an empty path. Bounded by the chain's depth like memberChainRoot. +struct MemberChain +{ + std::string root; + std::string path; + std::string ctor; +}; + +inline std::string_view finalTypeSegment( TSNode typeNode, std::string_view src ) +{ + for( int guard = 0; guard < 8 && !ts_node_is_null( typeNode ); ++guard ) + { + const char* t = ts_node_type( typeNode ); + if( kindIs( t, "type_identifier" ) || kindIs( t, "identifier" ) || kindIs( t, "simple_identifier" ) ) + { + return pattern::nodeText( typeNode, src ); + } + if( kindIs( t, "qualified_type" ) ) // Go `pkg.T` + { + typeNode = fieldChild( typeNode, NodeField::Name ); + } + else if( kindIs( t, "pointer_type" ) || kindIs( t, "generic_type" ) || kindIs( t, "parenthesized_type" ) ) + { + typeNode = ts_node_named_child_count( typeNode ) > 0 ? ts_node_named_child( typeNode, ts_node_named_child_count( typeNode ) - 1 ) : TSNode{}; + if( !ts_node_is_null( typeNode ) && kindIs( t, "generic_type" ) ) + { + typeNode = ts_node_named_child( ts_node_parent( typeNode ), 0 ); // `Foo`: the name, never the argument list + } + } + else if( kindIs( t, "member_expression" ) || kindIs( t, "scoped_type_identifier" ) || kindIs( t, "nested_identifier" ) ) + { + const std::uint32_t n = ts_node_named_child_count( typeNode ); + typeNode = n > 0 ? ts_node_named_child( typeNode, n - 1 ) : TSNode{}; + if( !ts_node_is_null( typeNode ) && kindIs( ts_node_type( typeNode ), "property_identifier" ) ) + { + return pattern::nodeText( typeNode, src ); + } + } + else + { + return {}; + } + } + return {}; +} + +// the class a constructed receiver names, final segment; "" when `node` constructs nothing this rule reads +inline std::string_view constructedClass( TSNode node, Lang lang, std::string_view src ) +{ + const char* t = ts_node_type( node ); + if( ( lang == Lang::JavaScript || lang == Lang::TypeScript ) && kindIs( t, "new_expression" ) ) + { + return finalTypeSegment( fieldChild( node, NodeField::Constructor ), src ); + } + if( ( lang == Lang::Java || lang == Lang::CSharp ) && kindIs( t, "object_creation_expression" ) ) + { + return finalTypeSegment( fieldChild( node, NodeField::Type ), src ); + } + if( lang == Lang::Go && kindIs( t, "composite_literal" ) ) + { + return finalTypeSegment( fieldChild( node, NodeField::Type ), src ); + } + if( lang == Lang::Go && kindIs( t, "unary_expression" ) && ts_node_named_child_count( node ) == 1 ) + { + const TSNode operand = ts_node_named_child( node, 0 ); + return kindIs( ts_node_type( operand ), "composite_literal" ) ? finalTypeSegment( fieldChild( operand, NodeField::Type ), src ) : std::string_view{}; + } + return {}; +} + +inline MemberChain memberChainOf( TSNode recv, Lang lang, std::string_view src ) +{ + MemberChain out; + std::vector names; // collected receiver-first (innermost member last), reversed below + TSNode node = recv; + for( int depth = 0; depth < 64 && !ts_node_is_null( node ); ++depth ) + { + const char* t = ts_node_type( node ); + if( kindIs( t, "parenthesized_expression" ) && ts_node_named_child_count( node ) == 1 ) + { + node = ts_node_named_child( node, 0 ); + continue; + } + if( kindIs( t, "identifier" ) || kindIs( t, "this" ) || kindIs( t, "self" ) || kindIs( t, "package_identifier" ) || kindIs( t, "super" ) + || kindIs( t, "simple_identifier" ) || kindIs( t, "this_expression" ) || kindIs( t, "super_expression" ) || kindIs( t, "self_expression" ) + || kindIs( t, "base_expression" ) ) + { + out.root = std::string( pattern::nodeText( node, src ) ); + break; + } + TSNode next{}; + TSNode name{}; + if( kindIs( t, "member_expression" ) ) // JS/TS + { + next = fieldChild( node, NodeField::Object ); name = fieldChild( node, NodeField::Property ); + } + else if( kindIs( t, "selector_expression" ) ) // Go + { + next = fieldChild( node, NodeField::Operand ); name = fieldChild( node, NodeField::Field ); + } + else if( kindIs( t, "field_access" ) ) // Java + { + next = fieldChild( node, NodeField::Object ); name = fieldChild( node, NodeField::Field ); + } + else if( kindIs( t, "member_access_expression" ) ) // C# + { + next = fieldChild( node, NodeField::Expression ); name = fieldChild( node, NodeField::Name ); + } + else if( kindIs( t, "navigation_expression" ) && ts_node_named_child_count( node ) == 2 ) // Kotlin / Swift + { + next = ts_node_named_child( node, 0 ); + const TSNode suffix = ts_node_named_child( node, 1 ); + name = ts_node_named_child_count( suffix ) > 0 ? ts_node_named_child( suffix, ts_node_named_child_count( suffix ) - 1 ) : TSNode{}; + } + else + { + const std::string_view ctor = constructedClass( node, lang, src ); + if( ctor.empty() ) + { + return MemberChain{}; // a call, subscript or literal: no root identifier and no path + } + out.ctor = std::string( ctor ); + break; + } + if( ts_node_is_null( next ) || ts_node_is_null( name ) ) + { + return MemberChain{}; + } + names.push_back( pattern::nodeText( name, src ) ); + node = next; + } + if( out.root.empty() && out.ctor.empty() ) + { + return MemberChain{}; + } + for( auto it = names.rbegin(); it != names.rend(); ++it ) + { + if( !out.path.empty() ) + { + out.path.push_back( '.' ); + } + out.path.append( *it ); + } + return out; +} + // FE-A: the member-access node kind and its receiver field for the languages whose receiverOf records no shape (Go, JS/TS, // Rust; and C, whose only member call is a call through a function-pointer field). inline TSNode memberOnlyReceiver( TSNode parent, Lang lang ) noexcept @@ -134,6 +289,28 @@ inline TSNode memberOnlyReceiver( TSNode parent, Lang lang ) noexcept { return fieldChild( parent, NodeField::Argument ); // `ops->open( x )`: a call through a function-pointer FIELD } + // FE-B: the implicit-receiver languages whose member calls recorded no shape, so a bare `flush()` and `w.flush()` + // were one reference. A receiver here makes the call a member call; its absence keeps it bare (an implicit this). + if( lang == Lang::Java && kindIs( t, "method_invocation" ) ) + { + return fieldChild( parent, NodeField::Object ); // null for a bare `flush()` + } + if( lang == Lang::CSharp && kindIs( t, "member_access_expression" ) ) + { + return fieldChild( parent, NodeField::Expression ); + } + if( lang == Lang::CSharp && kindIs( t, "member_binding_expression" ) ) + { + const TSNode cond = ts_node_parent( parent ); // `w?.Bump()`: the conditional access holds the receiver + return ( !ts_node_is_null( cond ) && kindIs( ts_node_type( cond ), "conditional_access_expression" ) && ts_node_named_child_count( cond ) > 0 ) + ? ts_node_named_child( cond, 0 ) : TSNode{}; + } + if( ( lang == Lang::Kotlin || lang == Lang::Swift ) && kindIs( t, "navigation_suffix" ) ) + { + const TSNode nav = ts_node_parent( parent ); + return ( !ts_node_is_null( nav ) && kindIs( ts_node_type( nav ), "navigation_expression" ) && ts_node_named_child_count( nav ) > 0 ) + ? ts_node_named_child( nav, 0 ) : TSNode{}; + } return TSNode{}; } @@ -623,6 +800,15 @@ inline bool rubyNamingChild( TSNode n, const char* t, TSNode c ) noexcept return kindIs( t, "setter" ) || kindIs( t, "alias" ) || kindIs( t, "undef" ); } +// FE-B on #373: a Ruby receiver the code builds, typed in the reference's own fields (model.h rubyTypedRecvOf) +inline void rubyTypeReceiver( RawRef& ref, std::string_view type, std::string_view via, bool factory ) +{ + ref.recvVar.clear(); + ref.memberCtor.assign( type ); + ref.memberVia.assign( via ); + ref.memberFactory = factory; +} + // ─── Ruby's TYPED receiver (test/rubytypedrecvcheck.sh, parser version 132) ─────────────────────────────────────────── // A receiver the code BUILDS is an instance of one class, and Ruby's method lookup on that class decides which `def` a call // on it reaches (graph.h rubyTypedReceiver). What builds one is read from what the expression IS: `Const.new( … )` and the @@ -633,7 +819,7 @@ inline bool rubyNamingChild( TSNode n, const char* t, TSNode c ) noexcept // binding in its scope builds the same one, or the let/subject RSpec runs for that name there — the innermost definition, // a nested group's override included — where a group with no subject of its own has its parent's, and at the top // `described_class.new`. RubyBareCallWalk reads all of it on the walk it already makes, beside the locals it tracks, and -// typeReceivers writes each type into its call reference (model.h rubyTypedRecvToken). Floors (test/rubytypedrecvcheck.sh): +// typeReceivers writes each type into its call reference (rubyTypeReceiver above; model.h rubyTypedRecvOf). Floors (test/rubytypedrecvcheck.sh): // control flow is not read (one binding of another shape anywhere in the scope, a parameter or a block parameter // included, leaves the local untyped); instance variables, return values and a shared context's lets are not typed. // RSpec's own builders are read the same way (test/rubyrspectargetcheck.sh, parser version 133): inside an example @@ -1201,7 +1387,7 @@ inline void RubyBareCallWalk::typeReceivers( std::size_t firstRef, std::size_t e { if( const RubyTypedSite* site = siteOf( refs_[ i ] ) ) { - refs_[ i ].recvVar = rubyTypedRecvToken( site->type.type, site->type.via, site->type.factory ); + rubyTypeReceiver( refs_[ i ], site->type.type, site->type.via, site->type.factory ); } } } @@ -1326,7 +1512,7 @@ inline void typeRubyJbuilderLocal( std::uint32_t fileId, std::vector& re { if( refs[ i ].role == RefRole::Call && refs[ i ].recv == RecvKind::NamedVar && refs[ i ].recvVar == kJbuilderLocal ) { - refs[ i ].recvVar = rubyTypedRecvToken( kJbuilderTemplate, kJbuilderLocal, false ); + rubyTypeReceiver( refs[ i ], kJbuilderTemplate, kJbuilderLocal, false ); } } } @@ -2200,7 +2386,15 @@ inline RecvShape receiverOf( TSNode nameNode, Lang lang, std::string_view src ) } RecvShape member; // recv stays None (non-literal TS/JS, every Go/Rust member call); FE-A marks the shape member.member = true; - member.root = memberChainRoot( obj, src ); + if( lang == Lang::Rust || lang == Lang::C ) + { + member.root = memberChainRoot( obj, src ); // FE-A's root only: no receiver-evidence rule reads these two + return member; + } + MemberChain chain = memberChainOf( obj, lang, src ); // FE-B: root + path + constructed class + member.root = std::move( chain.root ); + member.path = std::move( chain.path ); + member.ctor = std::move( chain.ctor ); return member; } if( !isMemberAccessNode( ts_node_type( parent ), lang ) ) @@ -3887,12 +4081,24 @@ inline void resolvePendingFnBindDecls( std::uint32_t fileId, Lang lang, FnBindGa // stream (streamSideCaptures below) — the pass used to own an identical walk of its own, which is what the // fusion removed. Its state outlives a single node (the L3 clobber sweep needs the whole file's positives), // so it rides in a context the driver holds by reference; bindsFinalize spends it when the stream ends. +// FE-B (review B4b): one type parameter a generic declares (``, `[T any]`, `[T]`) and the declaration it belongs to +// (its owner's bytes). A written type that spells one of these inside the owner is the parameter, never a same-named class. +struct TypeParamScope +{ + std::string_view name; + std::uint32_t start = 0; + std::uint32_t end = 0; + + bool declares( std::string_view spelled, std::uint32_t at ) const noexcept { return name == spelled && start <= at && at < end; } +}; + struct BindCtx { std::uint32_t fileId = 0; Lang lang {}; std::string_view src; std::vector* binds = nullptr; + std::vector typeParams; // FE-B: filled as the pre-order walk meets each parameter list // L3 fn-pointer buffers. Positives collect here (not straight into binds) so the end-of-walk clobber // sweep can ask "does this var have a fn binding in this file?" — a clobbering assignment @@ -3904,6 +4110,1239 @@ struct BindCtx bool cFamilyFn = false; }; +// ── FE-B receiver-evidence capture (model.h LocalBindKind::RecvType / MemberType / MethodAlias) ────────────────────────── +// the text of an identifier in any grammar's spelling (`identifier`, `type_identifier`, `simple_identifier`); "" for a null +// node or any other kind +inline constexpr std::array kIdentifierKinds = { "identifier", "type_identifier", "simple_identifier" }; +inline std::string_view identifierText( TSNode n, std::string_view src ) +{ + const std::string_view kind = ts_node_is_null( n ) ? std::string_view{} : std::string_view( ts_node_type( n ) ); + return std::ranges::find( kIdentifierKinds, kind ) != kIdentifierKinds.end() ? nodeTextOf( n, src ) : std::string_view{}; +} + +// The class a written TYPE names, final segment — "" for anything that names no single class (an array, a union, a +// string annotation, a generic's argument). Unwraps the annotation wrappers each grammar puts around the type. +inline std::string_view annotatedClass( TSNode node, std::string_view src ) +{ + for( int guard = 0; guard < 8 && !ts_node_is_null( node ); ++guard ) + { + if( const std::string_view name = identifierText( node, src ); !name.empty() ) + { + return name; + } + const char* t = ts_node_type( node ); + if( kindIs( t, "type" ) || kindIs( t, "type_annotation" ) || kindIs( t, "pointer_type" ) || kindIs( t, "parenthesized_type" ) ) + { + node = ts_node_named_child_count( node ) == 1 ? ts_node_named_child( node, 0 ) : TSNode{}; + } + else if( kindIs( t, "qualified_type" ) ) // Go `pkg.T` + { + node = fieldChild( node, NodeField::Name ); + } + else if( kindIs( t, "attribute" ) ) // Python `mod.T` + { + node = fieldChild( node, NodeField::Attribute ); + } + else if( kindIs( t, "nested_type_identifier" ) ) // TS `ns.T` + { + node = fieldChild( node, NodeField::Name ); + } + else if( kindIs( t, "generic_type" ) || kindIs( t, "generic_name" ) ) // TS `T` / Go `T[U]` / Java / C# `T`: the class + { + node = ts_node_named_child_count( node ) > 0 ? ts_node_named_child( node, 0 ) : TSNode{}; + } + else if( kindIs( t, "nullable_type" ) || kindIs( t, "optional_type" ) ) // C# / Kotlin `T?`, Swift `T?`: the class + { + node = ts_node_named_child_count( node ) > 0 ? ts_node_named_child( node, 0 ) : TSNode{}; + } + else if( kindIs( t, "user_type" ) || kindIs( t, "scoped_type_identifier" ) ) // Kotlin / Swift `a.B`, Java `a.B`: the last + { // name segment, never a type argument + TSNode last{}; + for( std::uint32_t i = 0, k = ts_node_named_child_count( node ); i < k; ++i ) + { + const TSNode c = ts_node_named_child( node, i ); + if( kindIs( ts_node_type( c ), "type_identifier" ) ) + { + last = c; + } + } + node = last; + } + else if( kindIs( t, "qualified_name" ) ) // C# `a.B` + { + node = fieldChild( node, NodeField::Name ); + } + else + { + return {}; // an array, a function type, C#'s implicit `var`, a predefined/primitive type: no single class + } + } + return {}; +} + +inline void pushEvidenceBind( BindCtx& cx, LocalBindKind kind, std::string_view var, std::string_view type, std::string_view imported, + std::uint32_t at, bool flag ) +{ + EXPECTS( kind == LocalBindKind::RecvType || kind == LocalBindKind::MemberType || kind == LocalBindKind::MethodAlias || kind == LocalBindKind::NameAlias + || kind == LocalBindKind::StaticMember, + "only the receiver-evidence kinds ride this emitter" ); + if( type.empty() || ( var.empty() && !( kind == LocalBindKind::RecvType && flag ) ) ) + { + return; // a nameless record is kept only for a method's own class (Go `func (T) m()`, JS `T.prototype.m = …`) + } + RawBind b; + b.fileId = cx.fileId; + b.startByte = at; + b.lang = cx.lang; + b.kind = kind; + b.isFromAssignment = flag; + b.var.assign( var ); + b.typeName.assign( type ); + b.importedName.assign( imported ); + cx.binds->push_back( std::move( b ) ); +} + +// the class a constructor-shaped initializer names — JS/TS `new Foo( … )`, Python `Foo( … )`, Go `Foo{ … }` / `&Foo{ … }` +inline std::string_view constructedBy( TSNode value, Lang lang, std::string_view src ) +{ + if( ts_node_is_null( value ) ) + { + return {}; + } + if( lang == Lang::Python ) + { + if( !kindIs( ts_node_type( value ), "call" ) ) + { + return {}; + } + const TSNode fn = fieldChild( value, NodeField::Function ); + return ( !ts_node_is_null( fn ) && kindIs( ts_node_type( fn ), "identifier" ) ) ? nodeTextOf( fn, src ) : std::string_view{}; + } + if( lang == Lang::Kotlin || lang == Lang::Swift ) + { + // `Store( … )` is spelled like a function call: a capitalized callee is read as the class it constructs (the house + // convention of both languages). A lower-case factory (`make()`) names no class, and a capitalized name that is no + // class in the tree proves nothing downstream (ReceiverEvidence finds no `Name::member`). + if( !kindIs( ts_node_type( value ), "call_expression" ) || ts_node_named_child_count( value ) == 0 ) + { + return {}; + } + const TSNode callee = ts_node_named_child( value, 0 ); + const std::string_view name = kindIs( ts_node_type( callee ), "simple_identifier" ) ? nodeTextOf( callee, src ) : std::string_view{}; + return ( !name.empty() && name.front() >= 'A' && name.front() <= 'Z' ) ? name : std::string_view{}; + } + return constructedClass( value, lang, src ); +} + + +// FE-B (review B4b): the names one type parameter declares, visible over [start, end): the leading identifier(s) after any +// annotation, modifier or variance (Java/C#/Kotlin/Swift/TS `T`, Go `A, B any`), or Python 3.12's `type`-wrapped name. +inline void noteTypeParameter( BindCtx& cx, TSNode param, std::uint32_t start, std::uint32_t end ) +{ + bool named = false; + for( std::uint32_t i = 0, k = ts_node_named_child_count( param ); i < k; ++i ) + { + TSNode c = ts_node_named_child( param, i ); + if( !named && kindIs( ts_node_type( c ), "type" ) && ts_node_named_child_count( c ) == 1 ) + { + c = ts_node_named_child( c, 0 ); // Python `def f[T]( … )` + } + if( const std::string_view name = identifierText( c, cx.src ); !name.empty() ) + { + cx.typeParams.push_back( { name, start, end } ); + named = true; + } + else if( named ) + { + return; // the bound / constraint after the names + } + } +} + +// a parameter LIST — the generic declaration that owns it is its parent, and the parameters are visible over all of it +// (the pre-order walk meets the list before the owner's parameters, fields and body) +inline void noteTypeParameters( BindCtx& cx, TSNode list ) +{ + const TSNode owner = ts_node_parent( list ); + if( ts_node_is_null( owner ) ) + { + return; + } + const std::uint32_t start = ts_node_start_byte( owner ); + const std::uint32_t end = ts_node_end_byte( owner ); + if( cx.lang == Lang::Python ) + { + noteTypeParameter( cx, list, start, end ); // Python's `type_parameter` IS the list: `[A, B]` → one `type` per name + return; + } + for( std::uint32_t i = 0, k = ts_node_named_child_count( list ); i < k; ++i ) + { + const TSNode p = ts_node_named_child( list, i ); + if( kindIs( ts_node_type( p ), "type_parameter" ) || kindIs( ts_node_type( p ), "type_parameter_declaration" ) ) + { + noteTypeParameter( cx, p, start, end ); + } + } +} + +// Go `func ( b Box[T] ) m()`: the receiver's type arguments are the method's type parameters +inline void noteGoReceiverTypeParameters( BindCtx& cx, TSNode method ) +{ + std::vector pending{ fieldChild( method, NodeField::Receiver ) }; + for( int guard = 0; guard < 64 && !pending.empty(); ++guard ) + { + const TSNode n = pending.back(); + pending.pop_back(); + if( ts_node_is_null( n ) ) + { + continue; + } + if( kindIs( ts_node_type( n ), "type_arguments" ) ) + { + for( std::uint32_t i = 0, k = ts_node_named_child_count( n ); i < k; ++i ) + { + TSNode a = ts_node_named_child( n, i ); + if( kindIs( ts_node_type( a ), "type_elem" ) && ts_node_named_child_count( a ) == 1 ) + { + a = ts_node_named_child( a, 0 ); + } + if( const std::string_view name = identifierText( a, cx.src ); !name.empty() ) + { + cx.typeParams.push_back( { name, ts_node_start_byte( method ), ts_node_end_byte( method ) } ); + } + } + continue; + } + for( std::uint32_t i = 0, k = ts_node_named_child_count( n ); i < k; ++i ) + { + pending.push_back( ts_node_named_child( n, i ) ); + } + } +} + +// is `name`, written at byte `at`, a type parameter of a generic that encloses it +inline bool namesTypeParameter( const BindCtx& cx, std::string_view name, std::uint32_t at ) noexcept +{ + return std::ranges::any_of( cx.typeParams, [ & ]( const TypeParamScope& p ) { return p.declares( name, at ); } ); +} + +// the class a written type names at byte `at` — "" when it names none, or names a type parameter (` … Tank t`) +inline std::string_view writtenClass( const BindCtx& cx, TSNode type, std::uint32_t at ) +{ + const std::string_view cls = annotatedClass( type, cx.src ); + return ( !cls.empty() && namesTypeParameter( cx, cls, at ) ) ? std::string_view{} : cls; +} + +// FE-B: Java's `var` is inference, not a class: the written type, unless it is `var`, else what the initializer constructs. +// A written type that is a type parameter names no class, and the initializer does not override it. +inline std::string_view declaredOrConstructed( const BindCtx& cx, TSNode type, TSNode value, std::uint32_t at ) +{ + const std::string_view written = annotatedClass( type, cx.src ); + if( !written.empty() && !( written == "var" && cx.lang == Lang::Java ) ) + { + return namesTypeParameter( cx, written, at ) ? std::string_view{} : written; + } + return constructedBy( value, cx.lang, cx.src ); +} + +// ── scopes (review B4a) ───────────────────────────────────────────────────────────────────────────────────────────── +// A local's record carries the bytes it is visible in (RawBind spanStart/spanEnd; {0,0} = anywhere in its definition), so +// receiverevidence.h visibleLocal can tell which binding of a name a call site sees. A binding whose class is unknown is +// still recorded (a TOMBSTONE): it hides the field or the outer local of its name over its span and proves nothing. + +// the nearest proper ancestor of `n` whose kind is one of `kinds`; null when none within 64 levels +inline TSNode ancestorOfKind( TSNode n, std::initializer_list kinds ) +{ + TSNode p = ts_node_is_null( n ) ? TSNode{} : ts_node_parent( n ); + for( int guard = 0; guard < 64 && !ts_node_is_null( p ); ++guard ) + { + if( std::ranges::find( kinds, std::string_view( ts_node_type( p ) ) ) != kinds.end() ) + { + return p; + } + p = ts_node_parent( p ); + } + return TSNode{}; +} + +// a name recorded at `at` and visible over `scope` ({0,0} when there is none: the whole definition) — the whole of it, or +// (`fromHere`, a block's declaration) from `at` to its end +inline BindSite siteOver( std::uint32_t at, TSNode scope, bool fromHere ) noexcept +{ + if( ts_node_is_null( scope ) ) + { + return { at, 0u, 0u }; + } + return { at, fromHere ? at : ts_node_start_byte( scope ), ts_node_end_byte( scope ) }; +} +inline BindSite siteIn( std::uint32_t at, TSNode scope ) noexcept { return siteOver( at, scope, false ); } +inline BindSite siteFrom( std::uint32_t at, TSNode scope ) noexcept { return siteOver( at, scope, true ); } + +// one local binding of `var`, naming class `cls` ("" = unknown), visible over `site`'s span. `flag`: Go's method receiver. +inline void pushLocalEvidence( BindCtx& cx, std::string_view var, std::string_view cls, BindSite site, bool flag = false ) +{ + if( var.empty() ) + { + return; + } + RawBind b; + b.fileId = cx.fileId; + b.startByte = site.startByte; + b.lang = cx.lang; + b.kind = LocalBindKind::RecvType; + b.isFromAssignment = flag; + b.spanStart = site.spanStart; + b.spanEnd = site.spanEnd; + b.var.assign( var ); + b.typeName.assign( cls ); + cx.binds->push_back( std::move( b ) ); +} +// the same for a name NODE (an identifier of any grammar's spelling; anything else records nothing) +inline void pushLocalEvidence( BindCtx& cx, TSNode name, std::string_view cls, BindSite site ) +{ + pushLocalEvidence( cx, identifierText( name, cx.src ), cls, site ); // "" records nothing +} +// every identifier `node` holds at any depth (a destructuring / tuple pattern's names), each as a tombstone over `site` +inline void pushPatternTombstones( BindCtx& cx, TSNode node, BindSite site ) +{ + std::vector pending{ node }; + for( int guard = 0; guard < 256 && !pending.empty(); ++guard ) + { + const TSNode n = pending.back(); + pending.pop_back(); + if( ts_node_is_null( n ) ) + { + continue; + } + const char* t = ts_node_type( n ); + if( kindIs( t, "identifier" ) || kindIs( t, "simple_identifier" ) || kindIs( t, "shorthand_property_identifier_pattern" ) ) + { + pushLocalEvidence( cx, nodeTextOf( n, cx.src ), {}, site ); + continue; + } + if( kindIs( t, "type_identifier" ) || kindIs( t, "user_type" ) || kindIs( t, "type_annotation" ) || kindIs( t, "predefined_type" ) ) + { + continue; + } + for( std::uint32_t i = 0, k = ts_node_named_child_count( n ); i < k; ++i ) + { + pending.push_back( ts_node_named_child( n, i ) ); + } + } +} +// a field whose class is unknown (its written type is a type parameter): a tombstone, so it hides a base class's field +inline void pushMemberTombstone( BindCtx& cx, std::string_view var, std::uint32_t at ) +{ + if( var.empty() ) + { + return; + } + RawBind b; + b.fileId = cx.fileId; + b.startByte = at; + b.lang = cx.lang; + b.kind = LocalBindKind::MemberType; + b.var.assign( var ); + cx.binds->push_back( std::move( b ) ); +} + +// FE-B (review B3): one declarator's evidence — `name` declared with the class its written `type` names, else the class its +// initializer `value` constructs. A parameter or local whose class is unknown (`var s = make()`) is still a DECLARATION: +// recorded with no class (a tombstone), so it hides a same-named field (receiverevidence.h declaresLocal) and proves nothing. +// A local is visible over `site` (review B4a); a field (MemberType) belongs to its class. +inline void pushDeclaratorEvidence( BindCtx& cx, LocalBindKind kind, TSNode type, TSNode name, TSNode value, BindSite site ) +{ + if( ts_node_is_null( name ) || !( kindIs( ts_node_type( name ), "identifier" ) || kindIs( ts_node_type( name ), "simple_identifier" ) ) ) + { + return; + } + const std::string_view cls = declaredOrConstructed( cx, type, value, site.startByte ); + if( kind == LocalBindKind::RecvType ) + { + pushLocalEvidence( cx, name, cls, site ); + } + else if( !cls.empty() ) + { + pushEvidenceBind( cx, kind, nodeTextOf( name, cx.src ), cls, {}, site.startByte, false ); + } + else + { + pushMemberTombstone( cx, nodeTextOf( name, cx.src ), site.startByte ); + } +} + +// the first (or, with `last`, the last) named child of `node` whose kind is one of `kinds`; null when none — Kotlin's grammar +// carries no field names and Swift's reuses `name` for the parameter, its label and its type +inline TSNode namedChildOfKind( TSNode node, std::initializer_list kinds, bool last = false ) +{ + TSNode hit{}; + for( std::uint32_t i = 0, k = ts_node_named_child_count( node ); i < k; ++i ) + { + const TSNode c = ts_node_named_child( node, i ); + if( std::ranges::find( kinds, std::string_view( ts_node_type( c ) ) ) != kinds.end() ) + { + hit = c; + if( !last ) + { + break; + } + } + } + return hit; +} + +// FE-B (review B3, B4): Java — a typed parameter (its method's or lambda's), each declarator of a local (from the +// declaration to its block's end) or field declaration, and every other binding form: an enhanced-for variable, an +// inferred lambda parameter, a catch parameter, a try resource, and the pattern variables of `instanceof` and `case` +// (visible in their enclosing block — `if (!(o instanceof T t)) return;` keeps `t` after the `if`, so the block is the +// superset; their class is not recorded, since an `else` branch does not see them). +inline void captureJavaDeclEvidence( BindCtx& cx, TSNode n, const char* t, std::uint32_t at ) +{ + const auto callable = [ & ] { return ancestorOfKind( n, { "method_declaration", "constructor_declaration", "compact_constructor_declaration", "lambda_expression" } ); }; + const auto block = [ & ] { return ancestorOfKind( n, { "block", "switch_block_statement_group", "switch_rule", "lambda_expression", "method_declaration" } ); }; + if( kindIs( t, "formal_parameter" ) ) + { + pushDeclaratorEvidence( cx, LocalBindKind::RecvType, fieldChild( n, NodeField::Type ), fieldChild( n, NodeField::Name ), TSNode{}, siteIn( at, callable() ) ); + return; + } + if( kindIs( t, "enhanced_for_statement" ) ) + { + const TSNode name = fieldChild( n, NodeField::Name ); + pushDeclaratorEvidence( cx, LocalBindKind::RecvType, fieldChild( n, NodeField::Type ), name, TSNode{}, + siteIn( ts_node_is_null( name ) ? at : ts_node_start_byte( name ), n ) ); + return; + } + if( kindIs( t, "lambda_expression" ) ) + { + pushLocalEvidence( cx, fieldChild( n, NodeField::Parameters ), {}, siteIn( at, n ) ); // `x -> …` (an identifier only) + return; + } + if( kindIs( t, "inferred_parameters" ) ) + { + pushPatternTombstones( cx, n, siteIn( at, ts_node_parent( n ) ) ); // `( a, b ) -> …` + return; + } + if( kindIs( t, "catch_formal_parameter" ) ) + { + pushLocalEvidence( cx, fieldChild( n, NodeField::Name ), {}, siteIn( at, ts_node_parent( n ) ) ); + return; + } + if( kindIs( t, "resource" ) ) + { + pushDeclaratorEvidence( cx, LocalBindKind::RecvType, fieldChild( n, NodeField::Type ), fieldChild( n, NodeField::Name ), fieldChild( n, NodeField::Value ), + siteIn( at, ancestorOfKind( n, { "try_with_resources_statement" } ) ) ); + return; + } + if( kindIs( t, "instanceof_expression" ) ) + { + pushLocalEvidence( cx, fieldChild( n, NodeField::Name ), {}, siteIn( at, block() ) ); // a record pattern: its components below + return; + } + if( kindIs( t, "type_pattern" ) || kindIs( t, "record_pattern_component" ) ) + { + pushLocalEvidence( cx, namedChildOfKind( n, { "identifier" }, true ), {}, siteIn( at, block() ) ); + return; + } + if( !kindIs( t, "local_variable_declaration" ) && !kindIs( t, "field_declaration" ) ) + { + return; + } + const bool field = kindIs( t, "field_declaration" ); + const LocalBindKind kind = field ? LocalBindKind::MemberType : LocalBindKind::RecvType; + const TSNode type = fieldChild( n, NodeField::Type ); + const BindSite site = field ? BindSite{ at, 0u, 0u } : siteFrom( at, ts_node_parent( n ) ); + for( std::uint32_t i = 0, k = ts_node_named_child_count( n ); i < k; ++i ) + { + const TSNode d = ts_node_named_child( n, i ); + if( kindIs( ts_node_type( d ), "variable_declarator" ) ) + { + pushDeclaratorEvidence( cx, kind, type, fieldChild( d, NodeField::Name ), fieldChild( d, NodeField::Value ), site ); + } + } +} + +// FE-B (review B3, B4): C# — a typed parameter (its method's, local function's or lambda's), each declarator of a local +// (from the declaration to its block's end) or field declaration, a property, and every other binding form: a foreach +// variable, an implicit lambda parameter, a catch variable, a query range variable, a deconstruction, and the variables a +// pattern or an `out var` declares (visible in their enclosing block, a superset, with no class recorded). +inline void captureCSharpDeclEvidence( BindCtx& cx, TSNode n, const char* t, std::uint32_t at ) +{ + const auto block = [ & ] { return ancestorOfKind( n, { "block", "switch_section", "switch_expression_arm", "lambda_expression", "arrow_expression_clause" } ); }; + if( kindIs( t, "parameter" ) ) + { + const TSNode callable = ancestorOfKind( n, { "method_declaration", "constructor_declaration", "destructor_declaration", "operator_declaration", + "conversion_operator_declaration", "indexer_declaration", "local_function_statement", + "lambda_expression", "anonymous_method_expression", "accessor_declaration" } ); + pushDeclaratorEvidence( cx, LocalBindKind::RecvType, fieldChild( n, NodeField::Type ), fieldChild( n, NodeField::Name ), TSNode{}, siteIn( at, callable ) ); + return; + } + if( kindIs( t, "implicit_parameter" ) ) + { + pushLocalEvidence( cx, nodeTextOf( n, cx.src ), {}, siteIn( at, ts_node_parent( n ) ) ); // `x => …` + return; + } + if( kindIs( t, "foreach_statement" ) ) + { + const TSNode left = fieldChild( n, NodeField::Left ); + if( !ts_node_is_null( left ) && kindIs( ts_node_type( left ), "identifier" ) ) + { + pushLocalEvidence( cx, left, writtenClass( cx, fieldChild( n, NodeField::Type ), at ), siteIn( ts_node_start_byte( left ), n ) ); + } + else + { + pushPatternTombstones( cx, left, siteIn( at, n ) ); // `foreach ( var ( a, b ) in … )` + } + return; + } + if( kindIs( t, "declaration_pattern" ) || kindIs( t, "declaration_expression" ) || kindIs( t, "recursive_pattern" ) || kindIs( t, "var_pattern" ) ) + { + const TSNode name = fieldChild( n, NodeField::Name ); + pushPatternTombstones( cx, ts_node_is_null( name ) && kindIs( t, "var_pattern" ) ? n : name, siteIn( at, block() ) ); + return; + } + if( kindIs( t, "tuple_pattern" ) ) + { + pushPatternTombstones( cx, n, siteIn( at, block() ) ); // `var ( a, b ) = p;` + return; + } + if( kindIs( t, "catch_declaration" ) ) + { + pushLocalEvidence( cx, fieldChild( n, NodeField::Name ), {}, siteIn( at, ts_node_parent( n ) ) ); + return; + } + if( kindIs( t, "from_clause" ) || kindIs( t, "join_clause" ) || kindIs( t, "let_clause" ) || kindIs( t, "query_continuation" ) || kindIs( t, "join_into_clause" ) ) + { + pushLocalEvidence( cx, fieldChild( n, NodeField::Name ), {}, siteIn( at, ancestorOfKind( n, { "query_expression" } ) ) ); + return; + } + if( kindIs( t, "property_declaration" ) ) + { + pushDeclaratorEvidence( cx, LocalBindKind::MemberType, fieldChild( n, NodeField::Type ), fieldChild( n, NodeField::Name ), fieldChild( n, NodeField::Value ), + BindSite{ at, 0u, 0u } ); + return; + } + if( !kindIs( t, "variable_declaration" ) ) + { + return; + } + // a field's declaration sits in a field_declaration, a local's in a local_declaration_statement (or a using/for), whose + // block is where it is visible + TSNode up = ts_node_parent( n ); + const bool field = !ts_node_is_null( up ) && kindIs( ts_node_type( up ), "field_declaration" ); + if( !ts_node_is_null( up ) && kindIs( ts_node_type( up ), "local_declaration_statement" ) ) + { + up = ts_node_parent( up ); + } + const LocalBindKind kind = field ? LocalBindKind::MemberType : LocalBindKind::RecvType; + const BindSite site = field ? BindSite{ at, 0u, 0u } : siteFrom( at, up ); + const TSNode type = fieldChild( n, NodeField::Type ); + for( std::uint32_t i = 0, k = ts_node_named_child_count( n ); i < k; ++i ) + { + const TSNode d = ts_node_named_child( n, i ); + if( kindIs( ts_node_type( d ), "variable_declarator" ) ) + { + // the initializer is the declarator's last named child, when it has one besides the name (no field names it) + const TSNode name = fieldChild( d, NodeField::Name ); + const TSNode value = ts_node_named_child_count( d ) > 1 ? ts_node_named_child( d, ts_node_named_child_count( d ) - 1 ) : TSNode{}; + pushDeclaratorEvidence( cx, kind, type, name, ts_node_eq( value, name ) ? TSNode{} : value, site ); + } + } +} + +// FE-B (review B3, B4): Kotlin — a typed parameter (its function's or lambda's; `val` constructor parameters are +// properties), a local or property declaration (a property in a class body, a local elsewhere, visible from the declaration +// to its block's end), and every other variable a declaration binds: a for variable, a lambda parameter, a destructuring +// entry, a `when ( val x = … )` subject, a catch parameter. +inline void captureKotlinDeclEvidence( BindCtx& cx, TSNode n, const char* t, std::uint32_t at ) +{ + if( kindIs( t, "parameter" ) || kindIs( t, "class_parameter" ) ) + { + const bool property = kindIs( t, "class_parameter" ); + if( property && ts_node_is_null( namedChildOfKind( n, { "binding_pattern_kind" } ) ) ) + { + return; // a plain constructor parameter, not a property + } + const TSNode callable = ancestorOfKind( n, { "function_declaration", "anonymous_function", "secondary_constructor", "setter", "lambda_literal" } ); + pushDeclaratorEvidence( cx, property ? LocalBindKind::MemberType : LocalBindKind::RecvType, namedChildOfKind( n, { "user_type", "nullable_type" }, true ), + namedChildOfKind( n, { "simple_identifier" }, true ), TSNode{}, property ? BindSite{ at, 0u, 0u } : siteIn( at, callable ) ); + return; + } + if( kindIs( t, "catch_block" ) ) + { + pushLocalEvidence( cx, namedChildOfKind( n, { "simple_identifier" } ), {}, siteIn( at, n ) ); + return; + } + if( kindIs( t, "variable_declaration" ) ) + { + // a declaration's own variable is read with the declaration below; here, every other place a variable is bound + TSNode up = ts_node_parent( n ); + if( !ts_node_is_null( up ) && kindIs( ts_node_type( up ), "multi_variable_declaration" ) ) + { + up = ts_node_parent( up ); + } + if( ts_node_is_null( up ) || kindIs( ts_node_type( up ), "property_declaration" ) ) + { + return; + } + TSNode scope = up; // a for loop + if( kindIs( ts_node_type( up ), "lambda_parameters" ) || kindIs( ts_node_type( up ), "when_subject" ) ) + { + scope = ts_node_parent( up ); // the lambda / the when expression + } + else if( !kindIs( ts_node_type( up ), "for_statement" ) ) + { + scope = ancestorOfKind( n, { "for_statement", "lambda_literal", "when_expression", "function_declaration" } ); + } + pushLocalEvidence( cx, namedChildOfKind( n, { "simple_identifier" } ), writtenClass( cx, namedChildOfKind( n, { "user_type", "nullable_type" } ), at ), + siteIn( at, scope ) ); + return; + } + if( !kindIs( t, "property_declaration" ) ) + { + return; + } + const TSNode up = ts_node_parent( n ); + const bool field = !ts_node_is_null( up ) && ( kindIs( ts_node_type( up ), "class_body" ) || kindIs( ts_node_type( up ), "enum_class_body" ) ); + const BindSite site = field ? BindSite{ at, 0u, 0u } : siteFrom( at, up ); + const TSNode var = namedChildOfKind( n, { "variable_declaration" } ); + if( !ts_node_is_null( var ) ) + { + pushDeclaratorEvidence( cx, field ? LocalBindKind::MemberType : LocalBindKind::RecvType, namedChildOfKind( var, { "user_type", "nullable_type" } ), + namedChildOfKind( var, { "simple_identifier" } ), namedChildOfKind( n, { "call_expression" } ), site ); + return; + } + const TSNode multi = namedChildOfKind( n, { "multi_variable_declaration" } ); // `val ( a, b ) = p` + for( std::uint32_t i = 0, k = ts_node_is_null( multi ) || field ? 0u : ts_node_named_child_count( multi ); i < k; ++i ) + { + const TSNode v = ts_node_named_child( multi, i ); + pushLocalEvidence( cx, namedChildOfKind( v, { "simple_identifier" } ), writtenClass( cx, namedChildOfKind( v, { "user_type", "nullable_type" } ), at ), site ); + } +} + +// Swift: where the names a pattern (or an if/guard/while condition) binds are visible — the statement that binds them, +// the rest of the enclosing block for `guard` and for a declaration +inline BindSite swiftBindingSite( TSNode n, std::uint32_t at ) +{ + const TSNode s = ancestorOfKind( n, { "for_statement", "if_statement", "while_statement", "repeat_while_statement", "switch_entry", "catch_block", + "lambda_literal", "guard_statement", "property_declaration", "function_declaration", "init_declaration" } ); + if( !ts_node_is_null( s ) && ( kindIs( ts_node_type( s ), "guard_statement" ) || kindIs( ts_node_type( s ), "property_declaration" ) ) ) + { + return { at, ts_node_start_byte( s ), ts_node_end_byte( ts_node_parent( s ) ) }; + } + return siteIn( at, s ); +} + +// FE-B (review B3, B4): Swift — a typed parameter (its function's), a local or property declaration (a property in a +// class body, a local elsewhere, visible from the declaration to its block's end), a closure parameter, and every name a +// pattern binds: for-in, if/guard/while `let`, `case let`, catch, a tuple declaration. +inline void captureSwiftDeclEvidence( BindCtx& cx, TSNode n, const char* t, std::uint32_t at ) +{ + if( kindIs( t, "parameter" ) ) + { + // `label p: T`: the LAST identifier is the parameter's own name + const TSNode callable = ancestorOfKind( n, { "function_declaration", "init_declaration", "subscript_declaration", "lambda_literal" } ); + pushDeclaratorEvidence( cx, LocalBindKind::RecvType, namedChildOfKind( n, { "user_type", "optional_type" }, true ), + namedChildOfKind( n, { "simple_identifier" }, true ), TSNode{}, siteIn( at, callable ) ); + return; + } + if( kindIs( t, "lambda_parameter" ) ) + { + pushLocalEvidence( cx, namedChildOfKind( n, { "simple_identifier" } ), writtenClass( cx, namedChildOfKind( n, { "user_type", "optional_type" } ), at ), + siteIn( at, ancestorOfKind( n, { "lambda_literal" } ) ) ); + return; + } + if( kindIs( t, "pattern" ) ) + { + const TSNode up = ts_node_parent( n ); + if( !ts_node_is_null( up ) && kindIs( ts_node_type( up ), "property_declaration" ) && !ts_node_is_null( namedChildOfKind( n, { "simple_identifier" } ) ) ) + { + return; // a plain declaration: read with its type below + } + for( std::uint32_t i = 0, k = ts_node_named_child_count( n ); i < k; ++i ) + { + const TSNode c = ts_node_named_child( n, i ); + if( kindIs( ts_node_type( c ), "simple_identifier" ) ) + { + pushLocalEvidence( cx, c, {}, swiftBindingSite( n, at ) ); + } + } + return; + } + if( kindIs( t, "if_statement" ) || kindIs( t, "guard_statement" ) || kindIs( t, "while_statement" ) ) + { + // `if let x = …`: the bound name is the statement's own `bound_identifier` child + const BindSite site = kindIs( t, "guard_statement" ) ? BindSite{ at, at, ts_node_end_byte( ts_node_parent( n ) ) } : siteIn( at, n ); + ChildCursor cursor( n ); + forEachChild( n, cursor.cur, [ & ]( TSNode c ) + { + const char* field = ts_tree_cursor_current_field_name( &cursor.cur ); + if( field != nullptr && kindIs( field, "bound_identifier" ) ) + { + pushLocalEvidence( cx, c, {}, site ); + } + return true; + } ); + return; + } + if( !kindIs( t, "property_declaration" ) ) + { + return; + } + const TSNode up = ts_node_parent( n ); + const bool field = !ts_node_is_null( up ) && ( kindIs( ts_node_type( up ), "class_body" ) || kindIs( ts_node_type( up ), "enum_class_body" ) ); + const TSNode pattern = fieldChild( n, NodeField::Name ); + const TSNode ann = namedChildOfKind( n, { "type_annotation" } ); + pushDeclaratorEvidence( cx, field ? LocalBindKind::MemberType : LocalBindKind::RecvType, ts_node_is_null( ann ) ? TSNode{} : namedChildOfKind( ann, { "user_type", "optional_type" } ), + ts_node_is_null( pattern ) ? TSNode{} : namedChildOfKind( pattern, { "simple_identifier" } ), fieldChild( n, NodeField::Value ), + field ? BindSite{ at, 0u, 0u } : siteFrom( at, up ) ); +} + +// FE-B (review B3): a declaration's evidence in Java, C#, Kotlin and Swift — a typed parameter, a typed or constructed +// local (RecvType), a typed or constructed field / property (MemberType); (review B4) every other binding form, and the +// type parameters a generic declares. The base resolver bound these receivers by name alone; FE-B proves a member call +// only through evidence, so the declarations are read here. +inline void captureTypedDeclEvidence( BindCtx& cx, TSNode n, const char* t ) +{ + const std::uint32_t at = ts_node_start_byte( n ); + if( kindIs( t, "type_parameters" ) || kindIs( t, "type_parameter_list" ) ) + { + noteTypeParameters( cx, n ); + return; + } + if( cx.lang == Lang::Java ) + { + captureJavaDeclEvidence( cx, n, t, at ); + } + else if( cx.lang == Lang::CSharp ) + { + captureCSharpDeclEvidence( cx, n, t, at ); + } + else if( cx.lang == Lang::Kotlin ) + { + captureKotlinDeclEvidence( cx, n, t, at ); + } + else + { + captureSwiftDeclEvidence( cx, n, t, at ); + } +} + +// JS/TS (review B4a): where a declarator's names are visible — a `let`/`const` from the declaration to the end of its block +// (or loop / switch / function), a `var` anywhere in its function ({0,0}) +inline BindSite jsDeclaratorSite( TSNode declarator ) +{ + const std::uint32_t at = ts_node_start_byte( declarator ); + const TSNode decl = ts_node_parent( declarator ); + if( ts_node_is_null( decl ) || !kindIs( ts_node_type( decl ), "lexical_declaration" ) ) + { + return { at, 0u, 0u }; + } + TSNode scope = ts_node_parent( decl ); + for( int guard = 0; guard < 64 && !ts_node_is_null( scope ); ++guard ) + { + const char* st = ts_node_type( scope ); + if( jsFunctionScope( scope ) || kindIs( st, "program" ) || kindIs( st, "statement_block" ) || kindIs( st, "for_statement" ) + || kindIs( st, "for_in_statement" ) || kindIs( st, "switch_body" ) || kindIs( st, "class_static_block" ) ) + { + return siteFrom( at, scope ); + } + scope = ts_node_parent( scope ); + } + return { at, 0u, 0u }; +} + +// each name a JS/TS binding pattern binds (never an initializer's or a type's), as a tombstone over `site` +inline void pushJsPatternTombstones( BindCtx& cx, TSNode pattern, BindSite site ) +{ + for( const std::string& name : jsPatternNames( pattern, cx.src ) ) + { + pushLocalEvidence( cx, name, {}, site ); + } +} + +// FE-B (review B4a): Python binds a name for the whole function wherever it assigns it (no block scope), so every +// rebinding form records the name with no span: a `for` target, a `with … as` / `except … as` / `case … as` name, a +// `match` capture, a walrus name, and an assignment the base Type record does not already type (an annotation is read below, a constructor call by +// the base pass). A lambda's parameters and a comprehension's targets are visible inside it only; an untyped parameter +// hides an enclosing function's binding of its name. +inline void capturePythonBindingTombstones( BindCtx& cx, TSNode n, const char* t, std::uint32_t at ) +{ + std::vector targets; + const auto tombstones = [ & ]( TSNode target, BindSite site ) + { + targets.clear(); + collectPythonNameTargets( target, targets ); + for( const TSNode& id : targets ) + { + pushLocalEvidence( cx, id, {}, site ); + } + }; + if( kindIs( t, "for_statement" ) ) + { + tombstones( fieldChild( n, NodeField::Left ), BindSite{ at, 0u, 0u } ); + } + else if( kindIs( t, "for_in_clause" ) ) + { + tombstones( fieldChild( n, NodeField::Left ), siteIn( at, ts_node_parent( n ) ) ); + } + else if( kindIs( t, "named_expression" ) ) + { + tombstones( fieldChild( n, NodeField::Name ), BindSite{ at, 0u, 0u } ); + } + else if( kindIs( t, "as_pattern" ) ) + { + // `with X as n:` / `except X as n:` (an `alias` field), `case P() as n:` (the last identifier) + TSNode alias = fieldChild( n, NodeField::Alias ); + if( ts_node_is_null( alias ) ) + { + alias = namedChildOfKind( n, { "identifier" }, true ); + } + tombstones( !ts_node_is_null( alias ) && kindIs( ts_node_type( alias ), "as_pattern_target" ) && ts_node_named_child_count( alias ) > 0 + ? ts_node_named_child( alias, 0 ) : alias, BindSite{ at, 0u, 0u } ); + } + else if( kindIs( t, "case_pattern" ) || kindIs( t, "keyword_pattern" ) || kindIs( t, "splat_pattern" ) ) + { + // a `match` capture: a bare name (`case n:`, `[n, *rest]`, `P(x=n)`); a dotted name is a value pattern, a class + // pattern's class sits under class_pattern + for( std::uint32_t i = 0, k = ts_node_named_child_count( n ); i < k; ++i ) + { + const TSNode c = ts_node_named_child( n, i ); + const char* ct = ts_node_type( c ); + if( kindIs( ct, "dotted_name" ) && ts_node_named_child_count( c ) == 1 ) + { + pushLocalEvidence( cx, ts_node_named_child( c, 0 ), {}, BindSite{ at, 0u, 0u } ); + } + else if( kindIs( t, "splat_pattern" ) && kindIs( ct, "identifier" ) ) + { + pushLocalEvidence( cx, c, {}, BindSite{ at, 0u, 0u } ); + } + } + } + else if( kindIs( t, "lambda" ) ) + { + const TSNode params = fieldChild( n, NodeField::Parameters ); + for( std::uint32_t i = 0, k = ts_node_is_null( params ) ? 0u : ts_node_named_child_count( params ); i < k; ++i ) + { + const TSNode p = ts_node_named_child( params, i ); + pushLocalEvidence( cx, kindIs( ts_node_type( p ), "identifier" ) ? p : fieldChild( p, NodeField::Name ), {}, siteIn( at, n ) ); + } + } + else if( kindIs( t, "parameters" ) ) + { + // an untyped parameter (a typed one is read with its annotation): `x`, `x=1`, `*xs`, `**kw` + for( std::uint32_t i = 0, k = ts_node_named_child_count( n ); i < k; ++i ) + { + const TSNode p = ts_node_named_child( n, i ); + const char* pt = ts_node_type( p ); + const TSNode id = kindIs( pt, "identifier" ) ? p + : kindIs( pt, "default_parameter" ) ? fieldChild( p, NodeField::Name ) + : ( kindIs( pt, "list_splat_pattern" ) || kindIs( pt, "dictionary_splat_pattern" ) ) && ts_node_named_child_count( p ) > 0 + ? ts_node_named_child( p, 0 ) : TSNode{}; + pushLocalEvidence( cx, id, {}, BindSite{ ts_node_start_byte( p ), 0u, 0u } ); + } + } + else if( kindIs( t, "assignment" ) && ts_node_is_null( fieldChild( n, NodeField::Type ) ) ) + { + const TSNode lhs = fieldChild( n, NodeField::Left ); + const TSNode rhs = fieldChild( n, NodeField::Right ); + const bool typedByBase = !ts_node_is_null( lhs ) && kindIs( ts_node_type( lhs ), "identifier" ) && !ts_node_is_null( rhs ) + && kindIs( ts_node_type( rhs ), "call" ) && !ts_node_is_null( fieldChild( rhs, NodeField::Function ) ) + && kindIs( ts_node_type( fieldChild( rhs, NodeField::Function ) ), "identifier" ); + if( !typedByBase ) + { + tombstones( lhs, BindSite{ at, 0u, 0u } ); + } + } +} + +// FE-B (review B4a): Go — where a name declared inside a function is visible: a short variable declaration in a block from +// there to the block's end; in an if / for / switch header, that whole statement +inline BindSite goShortVarSite( TSNode decl, std::uint32_t at ) +{ + const TSNode up = ts_node_parent( decl ); + if( ts_node_is_null( up ) ) + { + return { at, 0u, 0u }; + } + const char* ut = ts_node_type( up ); + if( kindIs( ut, "block" ) ) + { + return siteFrom( at, up ); + } + if( kindIs( ut, "for_clause" ) ) + { + return siteIn( at, ts_node_parent( up ) ); + } + if( kindIs( ut, "if_statement" ) || kindIs( ut, "expression_switch_statement" ) || kindIs( ut, "type_switch_statement" ) ) + { + return siteIn( at, up ); + } + return siteFrom( at, ancestorOfKind( decl, { "block" } ) ); +} + +// each identifier an expression_list holds (Go's left-hand sides), recorded naming `cls` over `site` +inline void pushGoNames( BindCtx& cx, TSNode list, std::string_view cls, BindSite site ) +{ + for( std::uint32_t i = 0, k = ts_node_is_null( list ) ? 0u : ts_node_named_child_count( list ); i < k; ++i ) + { + pushLocalEvidence( cx, ts_node_named_child( list, i ), cls, site ); // `_` is an identifier too: harmless, never a receiver + } +} + +// FE-B: Go's receiver evidence — typed parameters (the method receiver names the method's class), typed / constructed +// locals, struct field types; (review B4) every other name a function binds (a range / type-switch / receive variable, an +// untyped short declaration, a reassignment), and the type parameters a generic declares. +inline void captureGoEvidence( BindCtx& cx, TSNode n, const char* t, std::uint32_t at ) +{ + const std::string_view src = cx.src; + if( kindIs( t, "type_parameter_list" ) ) + { + noteTypeParameters( cx, n ); + } + else if( kindIs( t, "method_declaration" ) ) + { + noteGoReceiverTypeParameters( cx, n ); + } + else if( kindIs( t, "parameter_declaration" ) ) + { + const TSNode list = ts_node_parent( n ); + const TSNode method = ts_node_is_null( list ) ? TSNode{} : ts_node_parent( list ); + const bool receiver = !ts_node_is_null( method ) && kindIs( ts_node_type( method ), "method_declaration" ) + && ts_node_eq( fieldChild( method, NodeField::Receiver ), list ); + const std::string_view type = writtenClass( cx, fieldChild( n, NodeField::Type ), at ); + const BindSite site = siteIn( at, ancestorOfKind( n, { "function_declaration", "method_declaration", "func_literal" } ) ); + bool named = false; + ChildCursor cursor( n ); + forEachChild( n, cursor.cur, [ & ]( TSNode c ) + { + const char* field = ts_tree_cursor_current_field_name( &cursor.cur ); + if( field != nullptr && kindIs( field, "name" ) && kindIs( ts_node_type( c ), "identifier" ) ) + { + named = true; + pushLocalEvidence( cx, nodeTextOf( c, src ), type, site, receiver ); + } + return true; + } ); + if( !named && receiver ) + { + pushEvidenceBind( cx, LocalBindKind::RecvType, {}, type, {}, at, true ); // `func (T) m()`: the class alone + } + } + else if( kindIs( t, "var_spec" ) ) + { + std::string_view type = writtenClass( cx, fieldChild( n, NodeField::Type ), at ); + const TSNode values = fieldChild( n, NodeField::Value ); + if( type.empty() && ts_node_is_null( fieldChild( n, NodeField::Type ) ) && !ts_node_is_null( values ) && ts_node_named_child_count( values ) == 1 ) + { + type = constructedBy( ts_node_named_child( values, 0 ), cx.lang, src ); + } + const BindSite site = siteFrom( at, ancestorOfKind( n, { "block" } ) ); + ChildCursor cursor( n ); + forEachChild( n, cursor.cur, [ & ]( TSNode c ) + { + const char* field = ts_tree_cursor_current_field_name( &cursor.cur ); + if( field != nullptr && kindIs( field, "name" ) && kindIs( ts_node_type( c ), "identifier" ) ) + { + pushLocalEvidence( cx, nodeTextOf( c, src ), type, site ); + } + return true; + } ); + } + else if( kindIs( t, "short_var_declaration" ) ) + { + // `x := T{}` names T; `a, b := …`, `x := f()` name no class. (A plain assignment `x = …` keeps x's declared type: + // Go's static typing, so it records nothing.) + const TSNode left = fieldChild( n, NodeField::Left ); + const TSNode right = fieldChild( n, NodeField::Right ); + const bool one = !ts_node_is_null( left ) && !ts_node_is_null( right ) && ts_node_named_child_count( left ) == 1 && ts_node_named_child_count( right ) == 1; + pushGoNames( cx, left, one ? constructedBy( ts_node_named_child( right, 0 ), cx.lang, src ) : std::string_view{}, goShortVarSite( n, at ) ); + } + else if( ( kindIs( t, "range_clause" ) || kindIs( t, "receive_statement" ) ) && jsHasToken( n, ":=" ) ) + { + // `for k, v := range xs` (the loop) / `case v := <-ch:` (the case); with `=` they assign names declared elsewhere + pushGoNames( cx, fieldChild( n, NodeField::Left ), {}, siteIn( at, ancestorOfKind( n, { "for_statement", "communication_case" } ) ) ); + } + else if( kindIs( t, "type_switch_statement" ) ) + { + pushGoNames( cx, fieldChild( n, NodeField::Alias ), {}, siteIn( at, n ) ); // `switch v := x.(type)` + } + else if( kindIs( t, "field_declaration" ) ) + { + const std::string_view written = annotatedClass( fieldChild( n, NodeField::Type ), src ); + const bool param = !written.empty() && namesTypeParameter( cx, written, at ); + const std::string_view type = param ? std::string_view{} : written; + bool named = false; + ChildCursor cursor( n ); + forEachChild( n, cursor.cur, [ & ]( TSNode c ) + { + const char* field = ts_tree_cursor_current_field_name( &cursor.cur ); + if( field != nullptr && kindIs( field, "name" ) && kindIs( ts_node_type( c ), "field_identifier" ) ) + { + named = true; + if( param ) + { + pushMemberTombstone( cx, nodeTextOf( c, src ), at ); // `item T` in `Box[T any]` + } + else + { + pushEvidenceBind( cx, LocalBindKind::MemberType, nodeTextOf( c, src ), type, {}, at, false ); + } + } + return true; + } ); + if( !named && !param ) + { + pushEvidenceBind( cx, LocalBindKind::MemberType, type, type, {}, at, true ); // an embedded field: its methods are promoted + } + } +} + +// FE-B: JS/TS receiver evidence — constructed / typed locals and parameters, `this.x` and TS field types, `static` members, +// prototype methods; (review B4) every other name a function binds (an untyped parameter, a destructuring, a for-in/of +// variable, a catch parameter, a reassignment), each visible where the language scopes it, and TS type parameters. +inline void captureJsEvidence( BindCtx& cx, TSNode n, const char* t, std::uint32_t at ) +{ + const Lang lang = cx.lang; + const std::string_view src = cx.src; + if( kindIs( t, "type_parameters" ) ) + { + noteTypeParameters( cx, n ); + } + else if( kindIs( t, "variable_declarator" ) ) + { + const TSNode name = fieldChild( n, NodeField::Name ); + const BindSite site = jsDeclaratorSite( n ); + if( ts_node_is_null( name ) || !kindIs( ts_node_type( name ), "identifier" ) ) + { + pushJsPatternTombstones( cx, name, site ); // `const { a, b } = o`, `const [ a ] = xs` + return; + } + const TSNode value = fieldChild( n, NodeField::Value ); + if( lang == Lang::JavaScript ) + { + pushLocalEvidence( cx, name, constructedBy( value, lang, src ), site ); + return; + } + // TS records a written or constructed class as a Type record already (the base pass: the annotation's first + // type_identifier, else ctorTypeOf); here, a declaration that names none hides an outer binding of its name, and one + // whose written type is a type parameter ties that Type record (unknown) + const TSNode ann = fieldChild( n, NodeField::Type ); + TSNode written{}; + if( !ts_node_is_null( ann ) ) + { + ChildCursor annCursor( ann ); + forEachChild( ann, annCursor.cur, [ & ]( TSNode c ) + { + if( !kindIs( ts_node_type( c ), "type_identifier" ) ) + { + return true; + } + written = c; + return false; + } ); + } + if( !ts_node_is_null( written ) ) + { + if( namesTypeParameter( cx, nodeTextOf( written, src ), at ) ) + { + pushLocalEvidence( cx, name, {}, BindSite{ at, 0u, 0u } ); + } + } + else if( ctorTypeOf( value, src ).empty() ) + { + pushLocalEvidence( cx, name, {}, site ); + } + } + else if( kindIs( t, "formal_parameters" ) ) + { + const BindSite site = siteIn( at, ts_node_parent( n ) ); // the function, arrow or method the list belongs to + for( std::uint32_t i = 0, k = ts_node_named_child_count( n ); i < k; ++i ) + { + const TSNode p = ts_node_named_child( n, i ); + const TSNode pattern = lang == Lang::TypeScript ? fieldChild( p, NodeField::Pattern ) : TSNode{}; + if( !ts_node_is_null( pattern ) && kindIs( ts_node_type( pattern ), "identifier" ) ) + { + pushLocalEvidence( cx, pattern, writtenClass( cx, fieldChild( p, NodeField::Type ), at ), site ); + } + else + { + pushJsPatternTombstones( cx, p, site ); + } + } + } + else if( kindIs( t, "arrow_function" ) ) + { + pushLocalEvidence( cx, fieldChild( n, NodeField::Parameter ), {}, siteIn( at, n ) ); // `x => …` (an identifier only) + } + else if( kindIs( t, "for_in_statement" ) ) + { + const bool lexical = jsHasToken( n, "let" ) || jsHasToken( n, "const" ); + pushJsPatternTombstones( cx, fieldChild( n, NodeField::Left ), lexical ? siteIn( at, n ) : BindSite{ at, 0u, 0u } ); + } + else if( kindIs( t, "catch_clause" ) ) + { + pushJsPatternTombstones( cx, fieldChild( n, NodeField::Parameter ), siteIn( at, n ) ); + } + else if( kindIs( t, "method_definition" ) ) + { + // `static m () {}`: the member's side of the lookup, recorded inside its body so it attributes to the member + const TSNode name = fieldChild( n, NodeField::Name ); + const TSNode body = fieldChild( n, NodeField::Body ); + bool isStatic = false; + for( std::uint32_t i = 0, k = ts_node_child_count( n ); i < k && !isStatic; ++i ) + { + isStatic = kindIs( ts_node_type( ts_node_child( n, i ) ), "static" ); + } + if( isStatic && !ts_node_is_null( name ) && !ts_node_is_null( body ) ) + { + pushEvidenceBind( cx, LocalBindKind::StaticMember, nodeTextOf( name, src ), "static", {}, ts_node_start_byte( body ), false ); + } + } + else if( lang == Lang::TypeScript && kindIs( t, "public_field_definition" ) ) + { + const TSNode name = fieldChild( n, NodeField::Name ); + if( !ts_node_is_null( name ) && kindIs( ts_node_type( name ), "property_identifier" ) ) + { + const std::string_view written = annotatedClass( fieldChild( n, NodeField::Type ), src ); + if( !written.empty() && namesTypeParameter( cx, written, at ) ) + { + pushMemberTombstone( cx, nodeTextOf( name, src ), at ); // `item: T` in `class Box` + return; + } + const std::string_view type = written.empty() ? constructedBy( fieldChild( n, NodeField::Value ), lang, src ) : written; + pushEvidenceBind( cx, LocalBindKind::MemberType, nodeTextOf( name, src ), type, {}, at, false ); + } + } + else if( kindIs( t, "assignment_expression" ) ) + { + const TSNode lhs = fieldChild( n, NodeField::Left ); + if( ts_node_is_null( lhs ) ) + { + return; + } + if( kindIs( ts_node_type( lhs ), "identifier" ) ) + { + if( lang == Lang::JavaScript ) // a JS rebinding can change the class (no span); a TS one keeps the declared type + { + pushLocalEvidence( cx, lhs, constructedBy( fieldChild( n, NodeField::Right ), lang, src ), BindSite{ at, 0u, 0u } ); + } + return; + } + if( !kindIs( ts_node_type( lhs ), "member_expression" ) ) + { + return; + } + const TSNode obj = fieldChild( lhs, NodeField::Object ); + const TSNode prop = fieldChild( lhs, NodeField::Property ); + if( !ts_node_is_null( obj ) && !ts_node_is_null( prop ) && kindIs( ts_node_type( obj ), "this" ) && kindIs( ts_node_type( prop ), "property_identifier" ) ) + { + pushEvidenceBind( cx, LocalBindKind::MemberType, nodeTextOf( prop, src ), constructedBy( fieldChild( n, NodeField::Right ), lang, src ), {}, at, false ); + } + // `Foo.prototype.m = function …`: the member's class, recorded INSIDE the function so the record attributes to it + const TSNode rhs = fieldChild( n, NodeField::Right ); + if( !ts_node_is_null( obj ) && kindIs( ts_node_type( obj ), "member_expression" ) && !ts_node_is_null( rhs ) + && ( kindIs( ts_node_type( rhs ), "function_expression" ) || kindIs( ts_node_type( rhs ), "function" ) || kindIs( ts_node_type( rhs ), "arrow_function" ) ) + && nodeTextOf( fieldChild( obj, NodeField::Property ), src ) == "prototype" ) + { + const TSNode ctor = fieldChild( obj, NodeField::Object ); + if( !ts_node_is_null( ctor ) && kindIs( ts_node_type( ctor ), "identifier" ) ) + { + pushEvidenceBind( cx, LocalBindKind::RecvType, {}, nodeTextOf( ctor, src ), {}, ts_node_start_byte( rhs ), true ); + } + } + } +} + +// FE-B: one node's receiver evidence: Python, JS/TS and Go, and (review B3) Java, C#, Kotlin and Swift declarations. +inline void captureReceiverEvidence( BindCtx& cx, TSNode n, const char* t ) +{ + const Lang lang = cx.lang; + const std::string_view src = cx.src; + const std::uint32_t at = ts_node_start_byte( n ); + if( lang == Lang::Python ) + { + if( kindIs( t, "typed_parameter" ) || kindIs( t, "typed_default_parameter" ) ) + { + TSNode name = fieldChild( n, NodeField::Name ); + if( ts_node_is_null( name ) && ts_node_named_child_count( n ) > 0 ) + { + name = ts_node_named_child( n, 0 ); // typed_parameter spells its identifier without a field + } + if( !ts_node_is_null( name ) && kindIs( ts_node_type( name ), "identifier" ) ) + { + pushLocalEvidence( cx, name, writtenClass( cx, fieldChild( n, NodeField::Type ), at ), BindSite{ at, 0u, 0u } ); + } + } + else if( kindIs( t, "type_parameter" ) ) + { + noteTypeParameters( cx, n ); // `def f[T]( … )` / `class C[T]:` + } + else if( kindIs( t, "import_from_statement" ) ) + { + ChildCursor cursor( n ); + forEachChild( n, cursor.cur, [ & ]( TSNode c ) + { + if( kindIs( ts_node_type( c ), "aliased_import" ) ) + { + const TSNode name = fieldChild( c, NodeField::Name ); + const TSNode alias = fieldChild( c, NodeField::Alias ); + if( !ts_node_is_null( name ) && !ts_node_is_null( alias ) ) + { + pushEvidenceBind( cx, LocalBindKind::NameAlias, nodeTextOf( alias, src ), finalSegment( nodeTextOf( name, src ) ), {}, ts_node_start_byte( c ), false ); + } + } + return true; + } ); + } + else if( kindIs( t, "assignment" ) ) + { + const TSNode lhs = fieldChild( n, NodeField::Left ); + const TSNode rhs = fieldChild( n, NodeField::Right ); + if( ts_node_is_null( lhs ) ) + { + return; + } + const char* lt = ts_node_type( lhs ); + if( kindIs( lt, "identifier" ) && !ts_node_is_null( fieldChild( n, NodeField::Type ) ) ) + { + pushLocalEvidence( cx, lhs, writtenClass( cx, fieldChild( n, NodeField::Type ), at ), BindSite{ at, 0u, 0u } ); + } + else if( kindIs( lt, "identifier" ) && !ts_node_is_null( rhs ) && kindIs( ts_node_type( rhs ), "attribute" ) ) + { + const TSNode obj = fieldChild( rhs, NodeField::Object ); + const TSNode attr = fieldChild( rhs, NodeField::Attribute ); + if( !ts_node_is_null( obj ) && !ts_node_is_null( attr ) && kindIs( ts_node_type( obj ), "identifier" ) ) + { + pushEvidenceBind( cx, LocalBindKind::MethodAlias, nodeTextOf( lhs, src ), nodeTextOf( obj, src ), nodeTextOf( attr, src ), at, false ); + } + } + else if( kindIs( lt, "attribute" ) ) + { + const TSNode obj = fieldChild( lhs, NodeField::Object ); + const TSNode attr = fieldChild( lhs, NodeField::Attribute ); + if( !ts_node_is_null( obj ) && !ts_node_is_null( attr ) && kindIs( ts_node_type( obj ), "identifier" ) && nodeTextOf( obj, src ) == "self" ) + { + pushEvidenceBind( cx, LocalBindKind::MemberType, nodeTextOf( attr, src ), constructedBy( rhs, lang, src ), {}, at, false ); + } + } + } + capturePythonBindingTombstones( cx, n, t, at ); + return; + } + if( lang == Lang::JavaScript || lang == Lang::TypeScript ) + { + captureJsEvidence( cx, n, t, at ); + } + else if( lang == Lang::Java || lang == Lang::CSharp || lang == Lang::Kotlin || lang == Lang::Swift ) + { + captureTypedDeclEvidence( cx, n, t ); + } + else if( lang == Lang::Go ) + { + captureGoEvidence( cx, n, t, at ); + } +} + void bindsVisitNode( BindCtx& cx, TSNode n, const char* t ) { // The body below is the pass's own node step, unchanged; these aliases keep it reading against the @@ -4046,6 +5485,7 @@ void bindsVisitNode( BindCtx& cx, TSNode n, const char* t ) // DEFINITION's named parameters and a range-for's loop variable. Unconditional (the helper gates // language and node type itself); disjoint from every branch of the Rule-2 chain above. captureShadowScopeDecls( n, t, fileId, lang, src, binds ); + captureReceiverEvidence( cx, n, t ); // FE-B: typed parameters/locals, field types, method aliases (Python, JS/TS, Go, Java, C#, Kotlin, Swift) // ── L3 fn-pointer/callback capture (C/C++/ObjC) — a SEPARATE if (not part of the Rule-2 chain above): // the same `declaration` node can carry BOTH a Rule-2 var→type fact and a var→function fact diff --git a/src/ingest_cache.h b/src/ingest_cache.h index 521bceaad..ed48f68cb 100644 --- a/src/ingest_cache.h +++ b/src/ingest_cache.h @@ -85,6 +85,10 @@ struct RawRef // field of a depth-2 chained receiver (recv FieldOfThis/FieldOfVar); "" otherwise std::string composeRel; // "creates" (value/inline) or "uses" (reference/pointer) when isCompose; "" otherwise std::string memberRoot; // FE-A: the receiver chain's ROOT identifier when memberCall (`JSON` in `JSON.parse()`); see model.h + std::string memberPath; // FE-B: the member names between memberRoot and the callee, '.'-joined ("" = `root.m()`); see model.h + std::string memberCtor; // FE-B: the class a constructed receiver names (`new X().m()`, Go `X{}.m()`); see model.h + std::string memberVia; // FE-B on #373: the method a Ruby typed receiver was built by (`new`, a finder); see model.h + bool memberFactory = false; // FE-B on #373: memberCtor names a FactoryBot factory, not a class }; // P2-D Rule 2 raw local-variable type binding (pre-attribution). startByte sits inside the enclosing @@ -134,7 +138,13 @@ constexpr std::uint32_t kCacheMagic = 0x4b505443; // "CTPK" // all match) rather than silently re-absolutizing a key that was never root-relative to begin // with — a v2 cache simply misses on every lookup that survives the guard, which is exactly the // self-healing full-reparse path already used for any other corrupt/stale cache. -constexpr std::uint32_t kCacheVersion = 28; // 28: FE-A (test/falseedgecheck.sh) — RawRef gains `memberCall` (u8) and +constexpr std::uint32_t kCacheVersion = 29; // 29: FE-B (test/receiverevidencecheck.sh) — RawRef gains `memberPath` (str) and + // `memberCtor` (str) after `memberRoot` (ref record 46 -> 54 bytes lean; + // on #373 also `memberVia` (str) + `memberFactory` (u8), 54 -> 59: the Ruby + // typed receiver moved out of recvVar — same version, one FE-B format), + // and RawBind gains four APPENDED kinds (RecvType, MemberType, + // MethodAlias, NameAlias). A FORMAT change: the version guard rejects a v28 blob. + // 28: FE-A (test/falseedgecheck.sh) — RawRef gains `memberCall` (u8) and // `memberRoot` (str) after `qualifierRootsStd` (ref record 41 -> 46 // bytes lean). A FORMAT change: a v27 blob lacks the bytes, so the // version guard rejects it (self-healing full reparse). @@ -302,7 +312,35 @@ constexpr std::uint32_t kCacheVersion = 28; // 28: FE-A (test/falseedg // (Py `pkg.mod`, TS `./x`, Rust `crate::a::b`/`mod:x`) — // a target FORMAT change → old caches must be rejected. // 4: Include gained a `bool isAngle` (quote/angle) field -constexpr std::uint32_t kParserVer = 148; // bump on any grammar/.scm/extraction change +constexpr std::uint32_t kParserVer = 156; // bump on any grammar/.scm/extraction change + // 156 = 2026-10-08 (train 26b: signed lanes merged onto main's 148): cache-key hygiene. + // Two merged lanes changed extraction under lane-local numbers, both 145: + // honesty-small-068 (Go named-type kinds, `type A = B` aliases) and + // train25-cr2-followup (prototype-parameter scope, the depth-cut record, + // vrClean key slots); their notes are below. Branch builds in flight have + // used numbers up to 154 (rich file tag 155). 156 (rich file tag 157) is + // above every one of them, so no cache another build wrote is read as this + // build's. kCacheVersion 29 (FE-B); kQSnapCacheScheme 18 (cr2-followup). + // FE-B (receiver evidence, notes below: lane-local 145-154) merged in + // phase B: 156 stays above its 154 / rich tag 155. + // 154 = lane FE-B fix round 2 (review B4): every binding form records its name with + // the bytes it is visible in (RecvType spans; an unknown class is a tombstone), and a + // written type that names a type parameter names no class. 154, not 153: 152's rich + // file tag is 153; the train renumbers if it collides. + // 152 = lane FE-B fix round 1 (review B3): Java/C#/Kotlin/Swift typed parameters, locals, + // fields and constructed declarations record RecvType/MemberType. 152, not 151: 150's + // rich file tag is 151; the train renumbers if it collides. + // 150 = lane FE-B (receiver evidence) merged onto train 26a's 148: the receiver chain + // (memberPath/memberCtor), the RecvType/MemberType/MethodAlias/NameAlias/StaticMember + // bindings, appended after #373's Ruby kinds. 150, not 149: 148's RICH file tag is 149; + // the lane's own builds used 145-147 (lean) and their tags; the train renumbers if it collides. + // 146 = lane FE-B fix round: JS/TS `static` members record a StaticMember + // binding (the class side of the lookup); the lane's 145 builds lack it. + // 145 = lane FE-B (receiver evidence): member calls record memberPath/memberCtor and + // Java/Kotlin/C#/Swift member calls are memberCall; typed parameters/locals, field types + // method aliases and aliased class imports are RecvType/MemberType/MethodAlias/NameAlias + // bindings. Above train 25's 143/144 (rich); + // the train renumbers. // 148 = 2026-10-04 (train 26a: PR #373, Ruby method lookup, merged onto main's 143): // cache-key hygiene. The branch's Ruby extraction steps ran as 130–137, // 142 and 145 (its notes below); main used 130–143 for other lanes, and @@ -311,6 +349,18 @@ constexpr std::uint32_t kParserVer = 148; // bump on any grammar/.sc // kCacheVersion stays main's 28 (the branch said 27: its appended // LocalBindKind values follow ModuleAlias here, and the parser number // already keys every blob); kQSnapCacheScheme stays main's 17. + // 145 = lane honesty-small-068 (a lane-local number; the train renumbers): + // Go named types get their kind from the type_spec's form (t="type" / + // "functype"), and `type A = B` is now indexed (t="alias") — new defs and + // new kind bytes. 145, not 144: 143's RICH file tag is 144. + // 145 = lane train25-cr2-followup (lane-local; the train renumbers): three extraction changes — + // a parameter of a prototype, function-pointer typedef, Go func type or TS + // function type no longer declares its name in the enclosing scope; a file + // whose value walk stops at kVrMaxDepth gains ONE depth-cut Value record; + // the remaining written key slots (a C `[expr]` designator, a numeric or + // identifier key) go through vrClean. 145, not 144: 143's RICH file tag is + // 144 (parserVerFor below adds 1). The train renumbers if another lane + // takes 145/146. // 143 = 2026-10-04 (train 25 review fixes): two extraction changes — // a declaration named like the global object (`var self = this`, a // parameter `window`) is now a JsShadow binding, and a value-reference @@ -394,7 +444,7 @@ constexpr std::uint32_t kParserVer = 148; // bump on any grammar/.sc // 132 = 2026-10-02 (test/rubytypedrecvcheck.sh): a Ruby call // receiver the file BUILDS (`Client.new`, a finder, a FactoryBot // build, a let or local holding one) carries its type in the - // call RawRef's recvVar (model.h rubyTypedRecvToken), and each + // call RawRef's recvVar (FE-B on #373 moved it to memberCtor/memberVia), and each // FactoryBot `factory` mints a RawBind of the APPENDED kind // LocalBindKind::RubyFactory (ingest_binds.h). Same layout: // kCacheVersion unchanged (27). A Ruby cache written at 131 @@ -2216,7 +2266,7 @@ inline void writeDef( ByteW& w, const RawDef& d, bool withLex, std::size_t fileD } } } -inline void writeRef( ByteW& w, const RawRef& r ) { w.u32( r.startByte ); w.u8( std::uint8_t( r.lang ) ); w.str( r.name ); w.u8( r.isInherit ? 1 : 0 ); w.u8( r.isDocLink ? 1 : 0 ); w.str( r.qualifier ); w.u8( std::uint8_t( r.recv ) ); w.str( r.recvVar ); w.u8( r.isCompose ? 1 : 0 ); w.str( r.fieldName ); w.str( r.composeRel ); w.u8( std::uint8_t( r.role ) ); w.u32( r.line ); w.u32( r.argCount ); w.u8( r.argCountKnown ? 1 : 0 ); w.u8( r.viaArrow ? 1 : 0 ); w.u8( r.qualifierRootsStd ? 1 : 0 ); w.u8( r.memberCall ? 1 : 0 ); w.str( r.memberRoot ); } +inline void writeRef( ByteW& w, const RawRef& r ) { w.u32( r.startByte ); w.u8( std::uint8_t( r.lang ) ); w.str( r.name ); w.u8( r.isInherit ? 1 : 0 ); w.u8( r.isDocLink ? 1 : 0 ); w.str( r.qualifier ); w.u8( std::uint8_t( r.recv ) ); w.str( r.recvVar ); w.u8( r.isCompose ? 1 : 0 ); w.str( r.fieldName ); w.str( r.composeRel ); w.u8( std::uint8_t( r.role ) ); w.u32( r.line ); w.u32( r.argCount ); w.u8( r.argCountKnown ? 1 : 0 ); w.u8( r.viaArrow ? 1 : 0 ); w.u8( r.qualifierRootsStd ? 1 : 0 ); w.u8( r.memberCall ? 1 : 0 ); w.str( r.memberRoot ); w.str( r.memberPath ); w.str( r.memberCtor ); w.str( r.memberVia ); w.u8( r.memberFactory ? 1 : 0 ); } // loadCache's countFits() bounds a corrupt on-disk record COUNT against remaining bytes / // minRecordBytes BEFORE reserve() — the guard that keeps a hostile blob's 0xFFFFFFFF count from reaching @@ -2235,13 +2285,14 @@ inline void writeRef( ByteW& w, const RawRef& r ) { w.u32( r.startByte ); w.u8 // dlWeighted u32 + tokenCount u32 + tfWidth u8 = 9 bytes. A ref record is 3 u32 + 8 u8 + 5 empty // str(len u32) fields = 3*4 + 8*1 + 5*4 = 40 bytes (39 until the `viaArrow` u8, kCacheVersion 23; #150's // `qualifierRootsStd` u8 then added a 9th u8 — 40 -> 41, kCacheVersion 25; FE-A's `memberCall` u8 and `memberRoot` -// str then added a 10th u8 and a 6th str — 41 -> 46, kCacheVersion 28). +// str then added a 10th u8 and a 6th str — 41 -> 46, kCacheVersion 28; FE-B's `memberPath` and `memberCtor` strs then +// added a 7th and 8th str — 46 -> 54, kCacheVersion 29). // verifyCacheRecordMinimaTripwire() below derives these // same numbers from the REAL writer functions at runtime so the next field added to writeDef/writeRef // can't silently stale them. inline constexpr std::size_t kMinDefRecordBytesLean = 88; // 16×u32 + 16×u8 + 2×str(len u32, empty) inline constexpr std::size_t kMinDefRecordBytesRichExtra = 9; // v10 rich withLex extra: dlWeighted u32 + tokenCount u32 + tfWidth u8 -inline constexpr std::size_t kMinRefRecordBytes = 46; // 3×u32 + 10×u8 + 6×str(len u32, empty) +inline constexpr std::size_t kMinRefRecordBytes = 59; // 3×u32 + 11×u8 + 9×str(len u32, empty) inline std::size_t minDefRecordBytes( bool captureValueUses ) noexcept { @@ -2349,7 +2400,7 @@ inline RawDef readDef( ByteR& r, bool withLex, const std::vector& } return d; } -inline RawRef readRef ( ByteR& r ) { RawRef x; x.startByte = r.u32(); x.lang = r.enumU8( kLangCount ); x.name = r.str(); x.isInherit = r.u8() != 0; x.isDocLink = r.u8() != 0; x.qualifier = r.str(); x.recv = r.enumU8( kRecvKindCount ); x.recvVar = r.str(); x.isCompose = r.u8() != 0; x.fieldName = r.str(); x.composeRel = r.str(); x.role = r.enumU8( kRefRoleCount ); x.line = r.u32(); x.argCount = r.u16Of32(); x.argCountKnown = r.u8() != 0; x.viaArrow = r.u8() != 0; x.qualifierRootsStd = r.u8() != 0; x.memberCall = r.u8() != 0; x.memberRoot = r.str(); return x; } +inline RawRef readRef ( ByteR& r ) { RawRef x; x.startByte = r.u32(); x.lang = r.enumU8( kLangCount ); x.name = r.str(); x.isInherit = r.u8() != 0; x.isDocLink = r.u8() != 0; x.qualifier = r.str(); x.recv = r.enumU8( kRecvKindCount ); x.recvVar = r.str(); x.isCompose = r.u8() != 0; x.fieldName = r.str(); x.composeRel = r.str(); x.role = r.enumU8( kRefRoleCount ); x.line = r.u32(); x.argCount = r.u16Of32(); x.argCountKnown = r.u8() != 0; x.viaArrow = r.u8() != 0; x.qualifierRootsStd = r.u8() != 0; x.memberCall = r.u8() != 0; x.memberRoot = r.str(); x.memberPath = r.str(); x.memberCtor = r.str(); x.memberVia = r.str(); x.memberFactory = r.u8() != 0; return x; } inline void writeBind( ByteW& w, const RawBind& b ) { w.u32( b.startByte ); w.u8( std::uint8_t( b.lang ) ); w.u8( std::uint8_t( b.kind ) ); w.u8( b.isFromAssignment ? 1 : 0 ); w.u32( b.spanStart ); w.u32( b.spanEnd ); w.str( b.var ); w.str( b.typeName ); w.str( b.importedName ); } inline RawBind readBind( ByteR& r ) { RawBind b; b.startByte = r.u32(); b.lang = r.enumU8( kLangCount ); b.kind = r.enumU8( kLocalBindKindCount ); b.isFromAssignment = r.u8() != 0; b.spanStart = r.u32(); b.spanEnd = r.u32(); b.var = r.str(); b.typeName = r.str(); b.importedName = r.str(); return b; } inline void writeFfi( ByteW& w, const BindingAlias& a ) { w.u8( std::uint8_t( a.kind ) ); w.u8( a.lowConf ? 1 : 0 ); w.str( a.aliasName ); w.str( a.targetName ); w.str( a.targetScope ); } diff --git a/src/ingest_crawl.h b/src/ingest_crawl.h index 88420139f..d20cc2da4 100644 --- a/src/ingest_crawl.h +++ b/src/ingest_crawl.h @@ -400,7 +400,20 @@ SymKind defKind( std::string_view tail ) noexcept } if( tail == "type" ) { - return SymKind::Struct; // typedef/alias/enum bucket + return SymKind::Struct; // typedef/alias/enum bucket (every grammar but Go's; model.h NamedType note) + } + // honesty-small-068: the Go type_spec split — the grammar's `type:` field PROVES the form, so the kind says it. + if( tail == "definedtype" ) // `type N string`, `type L[T any] []T`, `type W Other` — a defined type, not a struct + { + return SymKind::NamedType; + } + if( tail == "functype" ) // `type F func(...)` + { + return SymKind::FuncType; + } + if( tail == "alias" ) // `type A = B` + { + return SymKind::Alias; } if( tail == "section" ) { diff --git a/src/ingest_model.h b/src/ingest_model.h index 13ad31101..02a5d3815 100644 --- a/src/ingest_model.h +++ b/src/ingest_model.h @@ -44,6 +44,12 @@ inline void dedupRawDefs( std::vector& rawDefs ) case SymKind::Section: return 0; // each comes from its own single capture (a #define, a heading or data key), so case SymKind::Other: return 0; // neither collides with a code kind on one name byte case SymKind::ModuleScope: return 0; // minted after this pass runs; it can never reach the dedup + // Go's type_spec fires the generic @definition.definedtype AND, for `type F func(...)`, @definition.functype on the + // same name byte (struct/interface specs fire their own pattern too): the form-specific kind must win, and a + // struct/interface must beat the generic defined type. An alias is its own node (type_alias) and never collides. + case SymKind::FuncType: return 2; + case SymKind::NamedType: return 1; + case SymKind::Alias: return 1; } return 0; }; @@ -918,6 +924,10 @@ inline void emitReferences( IngestResult& result, std::vector& rawRefs, ref.qualifierRootsStd = r.qualifierRootsStd; // #150: the FULL written qualifier chain is rooted at std ref.memberCall = r.memberCall; // FE-A: a Go/JS/TS/Rust member call (recv stays None) ref.memberRoot = std::move( r.memberRoot ); // FE-A: its receiver chain's root identifier + ref.memberPath = std::move( r.memberPath ); // FE-B: the members between that root and the callee + ref.memberCtor = std::move( r.memberCtor ); // FE-B: the class a constructed receiver names + ref.memberVia = std::move( r.memberVia ); // FE-B on #373: the method a Ruby typed receiver was built by + ref.memberFactory = r.memberFactory; // FE-B on #373: memberCtor names a FactoryBot factory ref.fieldName = std::move( r.fieldName ); // S5-E: the member variable name (e.g. "m_pool") ref.composeRel = std::move( r.composeRel ); // S5-E: "creates" or "uses" ref.startByte = r.startByte; // shadow fix round: for the block-span containment test diff --git a/src/ingest_sidecap.h b/src/ingest_sidecap.h index 6ddd733dc..6ea1140e7 100644 --- a/src/ingest_sidecap.h +++ b/src/ingest_sidecap.h @@ -1602,9 +1602,12 @@ void captureSideFacts( const LangEntry& le, std::uint32_t fileId, std::string_vi { arms.rust = &rustCtx; } + // FE-B: JavaScript and Go (and, review B3, C#, Kotlin and Swift) join for the receiver-evidence records alone + // (captureReceiverEvidence); every other branch of the pass gates its own language, so their record streams gain + // exactly those kinds. if( le.lang == Lang::Cpp || le.lang == Lang::ObjC || le.lang == Lang::Python || le.lang == Lang::TypeScript - || le.lang == Lang::Java - || le.lang == Lang::C ) + || le.lang == Lang::Java || le.lang == Lang::JavaScript || le.lang == Lang::Go + || le.lang == Lang::C || le.lang == Lang::CSharp || le.lang == Lang::Kotlin || le.lang == Lang::Swift ) { arms.bind = &bindCtx; } @@ -2258,7 +2261,7 @@ void captureTagsFacts( TSQueryCursor* cursor, const LangEntry& le, std::uint32_t // holds an error (fileHasError, one O(1) flag test per file) — see parseRecoveredBits. d.recovered = fileHasError ? parseRecoveredBits( defNode, kind, le.lang, d.scope.empty() ) : std::uint8_t( 0 ); defs.push_back( std::move( d ) ); - if( kind == SymKind::Class || kind == SymKind::Struct || kind == SymKind::Interface ) + if( kind == SymKind::Class || isStructOrNamedType( kind ) || kind == SymKind::Interface ) { captureBases( defNode, fileId, le.lang, src, refs ); // IS-A: inheritance edges (derived → base) captureFields( defNode, fileId, le.lang, src, refs ); // HAS-A: member-variable type edges (S5-E) @@ -2349,13 +2352,17 @@ void captureTagsFacts( TSQueryCursor* cursor, const LangEntry& le, std::uint32_t if( !isImportRef && le.lang != Lang::Elixir ) // an import site has no receiver and no argument list — { // the defaults (RecvKind::None, argCountKnown=false) are the truth - RecvShape rs = le.lang == Lang::Java + // FE-B: a Java method INVOCATION reads its receiver like every other language (a member call or a bare + // implicit-this call); only a method REFERENCE (`T::m`) keeps issue #74's type-candidate shape. + const bool javaMethodRef = le.lang == Lang::Java && !ts_node_is_null( roleNode ) && kindIs( ts_node_type( roleNode ), "method_reference" ); + RecvShape rs = javaMethodRef ? javaMethodReferenceReceiver( roleNode, src ) : receiverOf( nameNode, le.lang, src ); // P2-D: `this`/`self`/`x`/`base.field` shape r.recv = rs.kind; r.recvVar = std::move( rs.var ); // → one-hop narrowing in resolve.h r.fieldName = std::move( rs.field ); // depth-2 intermediate field; "" otherwise r.viaArrow = rs.viaArrow; // `p->m()`: Rule 2b's smart-pointer pointee needs it r.memberCall = rs.member; r.memberRoot = std::move( rs.root ); // FE-A: a Go/JS/TS/Rust member call and its receiver root + r.memberPath = std::move( rs.path ); r.memberCtor = std::move( rs.ctor ); // FE-B: the rest of that receiver chain if( le.lang == Lang::Ruby && r.recv == RecvKind::NamedVar ) { r.fieldName = rubyReceiverWrittenPath( nameNode, r.recvVar, src ); // parser version 137: a constant's path as written diff --git a/src/ingest_valuerefs.h b/src/ingest_valuerefs.h index 47969ca49..8670585a1 100644 --- a/src/ingest_valuerefs.h +++ b/src/ingest_valuerefs.h @@ -34,6 +34,12 @@ // Through: name=the container/parameter called through; fieldName=the written callee (through=); // composeRel=the key (".k", "[k]", "*" computed, "" bare); qualifier="p" (a parameter: argCount is its // index), "l" (a local container) or "f" (a file-scope container). +// Depth cut (role Value, at most one per file, after its rows): name="" (no identifier is empty); qualifier="d" +// (model.h kValueRefDepthCutScope); argCount=the subtrees kVrMaxDepth stopped the walk above (saturating); +// startByte/line=the first one. Not a row: valuerefindex.h counts it, and every answer that reads value +// references discloses it (value_refs_depth_capped= / value_refs_depth_at=), except --quality-delta's dead kind +// and its value-ref-excluded= count, --verify uses()/unused() (count= is a floor there) and the LSP +// references/hover handlers (named deferrals). // A local/file Through survives only when the same scope fed that container a function value (the filter at // scope exit / file end), so an ordinary `x.m()` adds nothing; a parameter Through always survives — its // values arrive from callers elsewhere. @@ -41,7 +47,8 @@ // FLOORS (stated in the legend and pinned by recallshapecheck): a member or qualified value (obj.f, self.f, // ns::f, &Cls::m) and a function as the OBJECT of a member access (f.bind, f.name) are not rows; an import // alias is a local binding; a macro body is opaque; classes are not functions; Python attribute calls never -// count as a call through a dict; nodes deeper than kVrMaxDepth are not visited. +// count as a call through a dict; nodes deeper than kVrMaxDepth are not visited — DISCLOSED, never silent: the file's +// depth-cut record (below) makes value_refs= / --dead-code say the count is a floor and name the first cut site. namespace rw { @@ -51,7 +58,7 @@ namespace using VrFam = ValueRefFamily; // model.h: the one armed-language table the resolver indexes too -inline constexpr std::uint32_t kVrMaxDepth = 512; // the value-uses pass's own depth guard (ingest_sidecap.h kSideDepthUses) +inline constexpr std::uint32_t kVrMaxDepth = 512; // the value-uses pass's own depth guard (ingest_sidecap.h kSideDepthUses); a cut is disclosed as value_refs_depth_capped= (valuerefs.h) inline constexpr std::size_t kVrTextCap = 96; // a written slot / callee longer than this is cut with "…" // A written slot or callee, made attribute-safe: whitespace runs collapse to one space (an XML attribute may not @@ -146,6 +153,23 @@ struct VrScope std::vector pending; // calls through a container declared here, awaiting `fed` }; +// The walk's DISCLOSE sink for the depth cap: the subtrees kVrMaxDepth stopped it above, and the first one's site. +struct VrDepthCut +{ + enum class DisclosureWhy : std::uint8_t + { + SubtreesBelowMaxDepth, // nodes past kVrMaxDepth were not visited: their value references are not captured + }; + std::uint32_t subtrees = 0; + std::uint32_t startByte = 0; + std::uint32_t line = 0; + bool disclosed = false; + void disclose( DisclosureWhy ) noexcept + { + disclosed = true; + } +}; + struct VrAnc { TSNode node {}; @@ -203,10 +227,17 @@ class ValueRefWalk enter( root, 0 ); for( ;; ) { - if( m_anc.size() < kVrMaxDepth && ts_tree_cursor_goto_first_child( &cur ) ) + if( m_anc.size() < kVrMaxDepth ) { - enter( ts_tree_cursor_current_node( &cur ), ts_tree_cursor_current_field_id( &cur ) ); - continue; + if( ts_tree_cursor_goto_first_child( &cur ) ) + { + enter( ts_tree_cursor_current_node( &cur ), ts_tree_cursor_current_field_id( &cur ) ); + continue; + } + } + else if( ts_node_child_count( m_anc.back().node ) > 0 ) + { + noteDepthCut( m_anc.back().node ); // the cap stops the walk ABOVE these children: one subtree not visited } bool done = false; for( ;; ) @@ -241,6 +272,10 @@ class ValueRefWalk m_out.push_back( std::move( t ) ); } } + if( m_depthCut.subtrees > 0 ) + { + emitDepthCut(); + } } private: @@ -711,6 +746,107 @@ class ValueRefWalk return s; } + // A C declarator wrapper between a parameter list and the node that owns the declarator chain. + static constexpr std::string_view kDeclaratorWrappers[] = { + "function_declarator", "abstract_function_declarator", "pointer_declarator", "abstract_pointer_declarator", + "reference_declarator", "abstract_reference_declarator", "parenthesized_declarator", + "abstract_parenthesized_declarator", "attributed_declarator", "array_declarator", "abstract_array_declarator", + }; + static bool isDeclaratorWrapper( const char* t ) noexcept + { + return std::ranges::find( kDeclaratorWrappers, std::string_view( t ) ) != std::ranges::end( kDeclaratorWrappers ); + } + + // Is node kind `t` (about to be entered; its parent is m_anc.back()) a parameter list, or a parameter of one, that + // belongs to no function this walk has opened? A parameter declares its name only inside the function that OWNS the + // list — the innermost scope, a function, whose own declarator / `parameters` holds it. A prototype (`void + // set_handler( void (*handler)( int ) );`), a function-pointer typedef or parameter (`typedef void (*fn_t)( int sig + // );`), a C++ member declaration, a Go func type or interface method (`type H func( w Writer )`) and a TS function type, + // signature or `declare function` open no scope, so their names used to land in the ENCLOSING scope — the file scope + // at top level — and hid every same-named function there (`signal( SIGINT, handler )` lost its row). Python has no + // bodiless signature. A C parameter list NOT under a function declarator (a C++ catch clause, a template parameter + // list) keeps declaring into the innermost scope, as before. + bool foreignParameters( const char* t ) const noexcept + { + if( m_anc.size() < 2 ) + { + return false; + } + const VrScope& s = m_scopes.back(); + const VrAnc& parent = m_anc.back(); + switch( m_fam ) + { + case VrFam::C: + { + if( !( kindIs( t, "parameter_declaration" ) || kindIs( t, "optional_parameter_declaration" ) ) || !kindIs( parent.kind, "parameter_list" ) ) + { + return false; + } + std::size_t j = m_anc.size() - 2; + if( !kindIs( m_anc[j].kind, "function_declarator" ) && !kindIs( m_anc[j].kind, "abstract_function_declarator" ) ) + { + return false; // a catch clause, a template parameter list: unchanged + } + while( j > 0 && isDeclaratorWrapper( m_anc[j].kind ) ) + { + --j; + } + return !( s.isFunction && ts_node_eq( m_anc[j].node, s.node ) ); + } + case VrFam::Go: + { + if( !( kindIs( t, "parameter_declaration" ) || kindIs( t, "variadic_parameter_declaration" ) ) || !kindIs( parent.kind, "parameter_list" ) ) + { + return false; + } + return !( s.isFunction && ts_node_eq( m_anc[ m_anc.size() - 2 ].node, s.node ) ); // receiver, parameters and named results + } + case VrFam::Js: + { + return kindIs( t, "formal_parameters" ) && !( s.isFunction && ts_node_eq( parent.node, s.node ) ); + } + case VrFam::Py: + case VrFam::None: break; + } + return false; + } + + // ── the depth cut ────────────────────────────────────────────────────────────────────────────────── + // A node at kVrMaxDepth whose children the walk does not visit: a value reference below it is never captured. The + // first one's site is kept as the clue the answers print (value_refs_depth_at=). + void noteDepthCut( TSNode n ) noexcept + { + if( m_depthCut.subtrees == 0 ) + { + m_depthCut.startByte = ts_node_start_byte( n ); + m_depthCut.line = ts_node_start_point( n ).row + 1; + } + if( m_depthCut.subtrees < UINT32_MAX ) + { + ++m_depthCut.subtrees; + } + } + + // The file's ONE depth-cut record (RECORD SHAPE in the header comment): the cut is disclosed through it on the + // answers that read value references (valuerefs.h value_refs_depth_capped=, --dead-code; the named exceptions are in + // the header comment), and it is cached with the file's other references, so a warm run discloses it too. + void emitDepthCut() + { + EXPECTS( m_depthCut.subtrees > 0, "a depth-cut record is written only when the cap stopped the walk" ); + DISCLOSE( m_depthCut, VrDepthCut::DisclosureWhy::SubtreesBelowMaxDepth, + "value references: subtrees below kVrMaxDepth were not visited (value_refs_depth_capped=)" ); + RawRef r; + r.fileId = m_fileId; + r.startByte = m_depthCut.startByte; + r.line = m_depthCut.line; + r.lang = m_lang; + r.role = RefRole::Value; + r.qualifier = std::string( 1, kValueRefDepthCutScope ); // name stays "": no identifier is empty + r.argCount = static_cast( std::min( m_depthCut.subtrees, 0xFFFFu ) ); + r.argCountKnown = true; + m_out.push_back( std::move( r ) ); + } + // ── the walk ─────────────────────────────────────────────────────────────────────────────────────── void enter( TSNode n, TSFieldId field ) { @@ -734,7 +870,10 @@ class ValueRefWalk m_scopes.push_back( openScope( n, a.kind, sk ) ); a.opensScope = true; } - harvest( n, a.kind, m_scopes.back().decls ); + if( !foreignParameters( a.kind ) ) + { + harvest( n, a.kind, m_scopes.back().decls ); + } m_anc.push_back( a ); const std::size_t i = m_anc.size() - 1; if( m_fam == VrFam::Py && kindIs( a.kind, "decorated_definition" ) ) @@ -959,12 +1098,12 @@ class ValueRefWalk if( kindIs( kt, "field_designator" ) ) { const std::string nm( text( ts_node_named_child( k, 0 ) ) ); - into = "." + nm; + into = "." + vrClean( nm ); key = "." + nm; return; } const std::string w( text( ts_node_named_child( k, 0 ) ) ); - into = "[" + w + "]"; + into = "[" + vrClean( w ) + "]"; // `[A +\n 1] = f`: an expression, maybe multi-line — written like every other slot key = "[" + w + "]"; return; } @@ -1003,7 +1142,7 @@ class ValueRefWalk } if( vrKeyKind( kt ) == VrKeyKind::Number ) { - into = "[" + std::string( w ) + "]"; + into = "[" + vrClean( w ) + "]"; // the written slot is capped; the match key stays whole (no two keys collide) key = "[" + std::string( w ) + "]"; return; } @@ -1013,7 +1152,7 @@ class ValueRefWalk key = "*"; return; } - into = "." + std::string( w ); // JS property_identifier / Go struct field name + into = "." + vrClean( w ); // JS property_identifier / Go struct field name, capped as above key = "." + std::string( w ); } @@ -1095,7 +1234,7 @@ class ValueRefWalk return false; } const auto [ cont, id ] = containerOf( j - 1 ); - s.into = cont + "." + std::string( text( self.node ) ); + s.into = cont + "." + vrClean( text( self.node ) ); s.container = id; s.key = "." + std::string( text( self.node ) ); s.scope = id.empty() ? 'x' : 'f'; @@ -1538,6 +1677,7 @@ class ValueRefWalk std::vector m_anc; std::vector m_fileFed; std::vector m_filePending; + VrDepthCut m_depthCut; TSFieldId m_fValue = 0, m_fKey = 0, m_fLeft = 0, m_fRight = 0, m_fFunction = 0, m_fName = 0, m_fDeclarator = 0; TSFieldId m_fConsequence = 0, m_fAlternative = 0, m_fParameters = 0, m_fParameter = 0, m_fObject = 0, m_fProperty = 0, m_fField = 0; TSFieldId m_fArgument = 0, m_fOperand = 0, m_fAttribute = 0, m_fOperator = 0, m_fPattern = 0, m_fDefinition = 0; diff --git a/src/layout.h b/src/layout.h index b3972493d..5aec3715c 100644 --- a/src/layout.h +++ b/src/layout.h @@ -1476,7 +1476,7 @@ inline AggIndex buildAggIndex( const IngestResult& ing ) AggIndex byName; for( const Symbol& s : ing.symbols ) { - if( s.kind == SymKind::Struct || s.kind == SymKind::Class || s.kind == SymKind::Interface ) + if( isStructOrNamedType( s.kind ) || s.kind == SymKind::Class || s.kind == SymKind::Interface ) { byName[ s.name ].push_back( s.id ); } diff --git a/src/legenddict.h b/src/legenddict.h index 285c5ed26..60918f24b 100644 --- a/src/legenddict.h +++ b/src/legenddict.h @@ -9,10 +9,12 @@ // core (below), one purpose line per compact schema (compactlegend.h kCompactLegendSpecs), one reading per completeness // term (kCompactCompletenessTerms), and the MCP `for` dialect's fixed clauses (graphlegend.h, lexical.h, forpage.h, // serialize.h — named constants, so these are the very bytes the headers append). An entry is served in THIS process -// once its bytes went out verbatim: the core when the client reads ripwire://legend-dict (mcp.h), every other entry the -// first time an answer carries it. The session starts in the inline posture and switches to ref only when the core was -// read; after that an answer DROPS exactly the entries this process already served and KEEPS the rest inline, in a -// trailing comment, marking them served. So every attribute a ref answer carries is defined by bytes this process +// once its bytes went out verbatim: the core when the client reads ripwire://legend-dict (mcp.h) or when the session's +// first ref answer carries it, every other entry the first time an answer carries it. The session starts in the inline +// posture and switches to ref when the core was read or — the MCP stdio default since the lean-answers lane — once its +// first answer this dictionary can reduce went out inline (openSessionWith); after that an answer DROPS exactly the +// entries this process already served and KEEPS the rest (the core included, until served) inline, in a trailing +// comment, marking them served. So every attribute a ref answer carries is defined by bytes this process // already sent, or by that answer itself — never by a dictionary the reader was only pointed at. // // WHY LAZY AND NOT ONE FETCH. The prereg registered a ~5.5 KB dictionary fetched once (the distinct compact clauses the @@ -781,7 +783,7 @@ inline RefParts assembleRef( std::string_view view, const RefShape& s, const Hea // native legend for MCP `for`. Only called once `session.refOn`. inline RefOutcome applyRefPosture( std::string& doc, LegendSession& session ) { - EXPECTS( session.refOn, "the ref posture exists only after the session was served the dictionary core" ); + EXPECTS( session.refOn, "the ref posture exists only once the session opened it (a core read, or its first inline answer)" ); session.served.resize( kEntryCount, 0 ); const std::string_view view = doc; const std::optional shape = detail::inferRefShape( view ); @@ -789,11 +791,31 @@ inline RefOutcome applyRefPosture( std::string& doc, LegendSession& session ) { return RefOutcome::NotApplicable; } - const std::optional head = detail::reduceHead( view, *shape, session ); + std::optional head = detail::reduceHead( view, *shape, session ); if( !head ) { return RefOutcome::NotApplicable; } + // lean-answers lane: a session OPENED by its first inline answer (openSessionWith, the MCP default) was never sent the + // core — the reading of itself. Its first ref answer carries every unserved core entry in the + // same trailing comment as any other unserved entry, so the honesty rule above holds without a resource read. + // The core ids are marked served ONLY when the ref answer goes out: an answer kept inline (below) never carried them. + std::string core; + std::vector coreIds; + for( std::size_t id = 0; id < kCoreCount; ++id ) + { + if( !session.isServed( id ) ) + { + coreIds.push_back( id ); + core += core.empty() ? "" : " "; + core.append( entryBody( id ) ); + core += '.'; + } + } + if( !core.empty() ) + { + head->residual.insert( head->residual.begin(), "" ); + } detail::RefParts parts = detail::assembleRef( view, *shape, *head ); const std::size_t refBytes = parts.bytes( *shape ); for( const std::size_t id : head->touched ) { session.markServed( id ); } // sent now, in either posture @@ -816,8 +838,33 @@ inline RefOutcome applyRefPosture( std::string& doc, LegendSession& session ) out += parts.trailer; out += shape->closeTag; ENSURES( out.size() <= doc.size(), "a ref answer is never longer than the inline answer it replaces" ); + for( const std::size_t id : coreIds ) { session.markServed( id ); } // carried by this ref answer's trailing comment doc.swap( out ); return RefOutcome::Ref; } +// THE MCP DEFAULT (lean-answers lane): a stdio session that never read ripwire://legend-dict is OPENED by its first answer +// this dictionary can reduce. That answer is served inline and unchanged — its whole legend, the definitions it needs — +// and every entry it carried counts as served; the session then takes the ref posture, whose first ref answer carries the +// core (applyRefPosture). An answer the dictionary cannot reduce (JSON, plain text, a legend it does not hold) leaves the +// session closed. `doc` is read, never changed. Returns whether the session opened. +inline bool openSessionWith( std::string_view doc, LegendSession& session ) +{ + EXPECTS( !session.refOn, "a session opens once; an open one takes the ref posture instead" ); + session.served.resize( kEntryCount, 0 ); + const std::optional shape = detail::inferRefShape( doc ); + if( !shape ) + { + return false; + } + const std::optional head = detail::reduceHead( doc, *shape, session ); + if( !head ) + { + return false; + } + for( const std::size_t id : head->touched ) { session.markServed( id ); } // sent now, inline + session.refOn = true; + return true; +} + } // namespace rw::legenddict diff --git a/src/lintrules.h b/src/lintrules.h index 5c7642931..b3594407d 100644 --- a/src/lintrules.h +++ b/src/lintrules.h @@ -834,8 +834,26 @@ struct LintRulesRun // ONE constant so the two tallies are capped on the same scale and `capped="1"` means the same thing in both. inline constexpr std::size_t kLintMaxPerRule = 5000; -inline LintRulesRun runLintRules( const IngestResult& ing, const std::vector& rules ) +// knob-honesty-068: the budget a run actually spends — --lint-max-per-rule=N when given, else the default above. A floored +// rule (count_capped="1") is then one call from its total: the answer's findings_next= re-runs it under a 10x budget. +inline std::size_t lintMaxPerRuleOf( int flagValue ) noexcept { + return flagValue > 0 ? std::size_t( flagValue ) : kLintMaxPerRule; +} + +// The budget a floored answer's findings_next= asks for: 10x the one that floored it — a re-run still floored names its own, +// so the chain terminates (the raw capture stream is finite). Saturating at `most`, the flag's own domain ceiling +// (cli.h kLintMaxPerRuleMost): it used to saturate at INT_MAX, above that ceiling, so a run floored at a budget over +// most/10 named a --lint-max-per-rule= value the parser refuses (CodeRabbit 5468003465 follow-through). +constexpr std::size_t lintNextMaxPerRule( std::size_t spent, std::size_t most ) noexcept +{ + constexpr std::size_t kGrowth = 10; + return spent >= most / kGrowth ? most : spent * kGrowth; +} + +inline LintRulesRun runLintRules( const IngestResult& ing, const std::vector& rules, std::size_t maxPerRule = kLintMaxPerRule ) +{ + EXPECTS( maxPerRule > 0, "runLintRules: a zero budget would floor every rule at nothing (lintMaxPerRuleOf never returns 0)" ); using namespace lintdetail; std::vector out; std::vector saturatedRuleIds; @@ -883,7 +901,7 @@ inline LintRulesRun runLintRules( const IngestResult& ing, const std::vector uncompiledQueries; // §L10: query TEXT of every spec that compiled for no grammar std::vector regexRefused; // src/regexguard.h: the rules' #match? patterns are user-authored AstRegexUndecided regexUndecided; - AstQueryGroup userRules{ &specs, kLintMaxPerRule, &uncompiledQueries }; + AstQueryGroup userRules{ &specs, maxPerRule, &uncompiledQueries }; userRules.regexRefusedOut = ®exRefused; userRules.regexUndecidedOut = ®exUndecided; const std::vector ms = std::move( astQueryGrouped( ing, { userRules } )[0] ); @@ -898,7 +916,7 @@ inline LintRulesRun runLintRules( const IngestResult& ing, const std::vector= kLintMaxPerRule ) + if( rawForRule >= maxPerRule ) { saturatedRuleIds.push_back( r.id ); } diff --git a/src/lsp.h b/src/lsp.h index 1d657799f..f5a2fcdad 100644 --- a/src/lsp.h +++ b/src/lsp.h @@ -35,6 +35,7 @@ #include "infra/os.h" // rw::os::realpath / getcwd / stat — the root-is-a-directory check at initialize, the launch-cwd base for relative index paths #include +#include #include #include #include @@ -358,28 +359,30 @@ inline int lspKindOf( rw::SymKind k ) noexcept case rw::SymKind::Macro: return 14; // Constant case rw::SymKind::Field: return 8; // Field case rw::SymKind::ModuleScope: return 2; // Module — LSP's own word for a file's top-level scope + case rw::SymKind::NamedType: return 5; // Class — LSP has no defined-type/alias kind; gopls files a non-struct named type here too + case rw::SymKind::Alias: return 5; // Class (as above) + case rw::SymKind::FuncType: return 5; // Class (as above) case rw::SymKind::Other: break; } return 13; // Variable — the neutral bucket } +// The hover word per SymKind, a declarative table in enum order (CONTRIBUTING §3's table-over-switch rule; it was a +// switch, and the three Go named-type kinds made it a near-clone of model.h's langTag switch). Sized by kSymKindCount, and +// the static_assert below rejects an empty slot: std::array value-initializes the elements an initializer list leaves out, +// so the size alone let an appended kind without a word compile to a nullptr hover word (CodeRabbit 5468003465). +inline constexpr std::array kLspKindWords = { + "function", "method", "class", "struct", "interface", "variable", "section", "macro", "field", + "symbol", // Other + "module scope", // ModuleScope + "type", "type alias", "function type" // NamedType, Alias, FuncType +}; +static_assert( std::ranges::none_of( kLspKindWords, []( const char* w ) { return w == nullptr; } ), + "every SymKind needs a hover word in kLspKindWords (an initializer list shorter than kSymKindCount leaves nullptr)" ); inline const char* lspKindWord( rw::SymKind k ) noexcept { - switch( k ) - { - case rw::SymKind::Function: return "function"; - case rw::SymKind::Method: return "method"; - case rw::SymKind::Class: return "class"; - case rw::SymKind::Struct: return "struct"; - case rw::SymKind::Interface: return "interface"; - case rw::SymKind::Var: return "variable"; - case rw::SymKind::Section: return "section"; - case rw::SymKind::Macro: return "macro"; - case rw::SymKind::Field: return "field"; - case rw::SymKind::ModuleScope: return "module scope"; - case rw::SymKind::Other: break; - } - return "symbol"; + EXPECTS( std::size_t( k ) < kLspKindWords.size(), "a symbol's kind is a SymKind enumerator (the cache validates the byte)" ); + return kLspKindWords[ std::size_t( k ) ]; } // ─── file identity: a client URI → an index fileId ────────────────────────────────────────────────── diff --git a/src/main.cpp b/src/main.cpp index 003a9b14b..ee4a09577 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,6 +1,7 @@ #include "infra/emit.h" // rw::emitTo / emitRaw / formatTo — THE emitter and its siblings #include "gitcmd.h" // rw::gitCmd — every git child starts with --no-optional-locks -c core.fsmonitor=false #include // %.*s (precision, pointer) collapses to one view +#include // std::async — the history walk that runs beside the ingest // main.cpp — ripwire entry point: parse args → ingest → graph → PageRank → minified XML, // plus --map-diff (teleport toward git-changed files) and --pack-top-n (append source). @@ -16,6 +17,7 @@ #include "serialize.h" #include "pageview.h" // §P8: the ONE --limit/--offset window + root-element shown=/capped= disclosure #include "graphlegend.h" // §H4 §3.4: the ONE counts_floor= marker + the shared graph-count legend wording +#include "pathgaps.h" // PATH-GAP: --path's gap analysis + clause, shared with MCP path_between #include "columnar.h" // RESEARCH lever 1: opt-in columnar re-serialization for the flat list verbs (--format=columnar) #include "redact.h" // RedactCounts + reportRedactions for the emitted-body secret redaction #include "filter.h" @@ -40,6 +42,7 @@ // BEFORE mcp.h so mcpverbs.h's explore verb can reach packTaskPartitionText (same rule packtask.h follows). #include "tracelocus.h" // L4: the shared --from-trace / MCP from_trace bundle assembler (fromTraceBundleText) #include "callhierarchy.h" // H14/M13: the shared --callers/--callees / MCP find_* 1-hop computation (CLI == MCP rows) +#include "countfloor.h" // count-floor: a row's callers=/implementors= floor marker and its follow-up (CLI and MCP share it) #include "editcheck.h" // L4: the shared --edit-check / MCP edit_check contract-comparison core (editCheckBundleText) #include "slice.h" // lane/paper-slice: --slice=SYM[:VAR] — the ARISE-motivated def-use slice core (sliceBundleText) #include "editpreview.h" // card A1: the PRE-APPLY contract preview (editpreview::run) — BEFORE mcp.h, which @@ -1866,6 +1869,7 @@ int runDefaultMap( const MainDispatch& d ) recentOf }; mapAnn.recentMinedHistory = recentAnyHistory; // the block rides on the FACT (serialize.h writeRecentRows) mapAnn.recentMergeBombsSkipped = recentMergeBombsSkipped; // rides (the rows' own window), filled by assignment like seed + mapAnn.viaLegendStripped = cfg.legend == "compact" && !cfg.json; // FE-B: the rewrite would strip the map's via comment mapAnn.notesDegraded = d.notesDegraded; // L3 follow-up (CodeRabbit 4053600616): onto every this run emits mapAnn.codeFirstRows = isDefaultMapScope; // the code-first row pick + its data_sections_cut= / next= (serialize.h) // C1-b (2026-09-12): --in=DIR — the scoped block and the map stub, filled by assignment like seed. The two next= strings @@ -1917,7 +1921,7 @@ int runDefaultMap( const MainDispatch& d ) { return {}; } - serialize( m, ing, rank, g.outOff, g.outTargets, k, cfg.mostImportantLast, cfg.metrics, fanInPtr, &g.ambOut, cfg.stable, mapProvPtr, cboPtr, testedPtr, lcom4Ptr, ampPtr, &g.unresolvedOut, g.bindLabel.empty() ? nullptr : &g.bindLabel, mapAutoOrder, /*outEstTokens=*/nullptr, extraPayloadTokens, mapAnn, /*statsFirstScreen=*/false, mapRootArg, &g.locPinOut, g.externalCalls, &g.declinedOut, g.gateDeclinedCalls ); + serialize( m, ing, rank, g.outOff, g.outTargets, k, cfg.mostImportantLast, cfg.metrics, fanInPtr, &g.ambOut, cfg.stable, mapProvPtr, cboPtr, testedPtr, lcom4Ptr, ampPtr, &g.unresolvedOut, g.bindLabel.empty() ? nullptr : &g.bindLabel, mapAutoOrder, /*outEstTokens=*/nullptr, extraPayloadTokens, mapAnn, /*statsFirstScreen=*/false, mapRootArg, &g.locPinOut, g.externalCalls, &g.declinedOut, g.gateDeclinedCalls, g.outNameOnly.empty() ? nullptr : &g.outNameOnly ); const rw::MemoryStreamBytes measured = probe.finish(); return measured.isWhole ? std::string( measured.bytes ) : std::string(); }; @@ -1930,7 +1934,7 @@ int runDefaultMap( const MainDispatch& d ) DISCLOSE( maxTokensFit, rw::MapAnnotations::MaxTokensFit::DisclosureWhy::ProbeUnmeasured, "runDefaultMap: open_memstream failed for the --max-tokens fit probe — the map is emitted unshaped and its ceiling unverified" ); return 0; } - serialize( m, ing, rank, g.outOff, g.outTargets, k, cfg.mostImportantLast, cfg.metrics, fanInPtr, &g.ambOut, cfg.stable, mapProvPtr, cboPtr, testedPtr, lcom4Ptr, ampPtr, &g.unresolvedOut, g.bindLabel.empty() ? nullptr : &g.bindLabel, mapAutoOrder, /*outEstTokens=*/nullptr, extraPayloadTokens, mapAnn, /*statsFirstScreen=*/false, mapRootArg, &g.locPinOut, g.externalCalls, &g.declinedOut, g.gateDeclinedCalls ); + serialize( m, ing, rank, g.outOff, g.outTargets, k, cfg.mostImportantLast, cfg.metrics, fanInPtr, &g.ambOut, cfg.stable, mapProvPtr, cboPtr, testedPtr, lcom4Ptr, ampPtr, &g.unresolvedOut, g.bindLabel.empty() ? nullptr : &g.bindLabel, mapAutoOrder, /*outEstTokens=*/nullptr, extraPayloadTokens, mapAnn, /*statsFirstScreen=*/false, mapRootArg, &g.locPinOut, g.externalCalls, &g.declinedOut, g.gateDeclinedCalls, g.outNameOnly.empty() ? nullptr : &g.outNameOnly ); const rw::MemoryStreamBytes measured = probe.finish(); if( !measured.isWhole ) { @@ -1974,7 +1978,7 @@ int runDefaultMap( const MainDispatch& d ) } serializeJson( m, ing, rank, g.outOff, g.outTargets, k, cfg.mostImportantLast, cfg.metrics, fanInPtr, &g.ambOut, cfg.stable, cboPtr, testedPtr, lcom4Ptr, ampPtr, &g.unresolvedOut, - g.bindLabel.empty() ? nullptr : &g.bindLabel, mapAutoOrder, /*outEstTokens=*/nullptr, mapProvPtr, mapAnn, mapRootArg, &g.locPinOut, g.externalCalls, &g.declinedOut, g.gateDeclinedCalls ); + g.bindLabel.empty() ? nullptr : &g.bindLabel, mapAutoOrder, /*outEstTokens=*/nullptr, mapProvPtr, mapAnn, mapRootArg, &g.locPinOut, g.externalCalls, &g.declinedOut, g.gateDeclinedCalls, g.outNameOnly.empty() ? nullptr : &g.outNameOnly ); const rw::MemoryStreamBytes measured = probe.finish(); if( !measured.isWhole ) { @@ -2318,7 +2322,7 @@ int runDefaultMap( const MainDispatch& d ) if( !expandNodes.empty() ) { bodiesSection = rw::chargeSection( [ & ]( std::FILE* f ) - { packBodies( f, ing, expandNodes, cfg.packBudgetBytes, g.outOff, g.outTargets, cfg.compress, redactPtr, + { packBodies( f, ing, expandNodes, cfg.packBudgetBytes, g.outOff, g.outTargets, g.outNameOnly, cfg.compress, redactPtr, expandRanges.empty() ? nullptr : &expandRanges, d.notesPtr, /*outEmitted=*/nullptr, /*truncateOversizedFirst=*/true, /*withFileContext=*/true, mapRootArg, &calleeOrder ); }, // V1: octocode F2 sibs=/inc= rw::kBytesPerTokenBody ); @@ -2658,7 +2662,7 @@ int runDefaultMap( const MainDispatch& d ) // not as the literal source token, for that same reason (the count is a naive grep over this file). if( !expandNodes.empty() && noteAppliesToBundle ) // !serveWholeFile is this whole branch's precondition (see the if above) { - emitSection( bodiesSection, [ & ]{ packBodies( out, ing, expandNodes, cfg.packBudgetBytes, g.outOff, g.outTargets, cfg.compress, redactPtr, + emitSection( bodiesSection, [ & ]{ packBodies( out, ing, expandNodes, cfg.packBudgetBytes, g.outOff, g.outTargets, g.outNameOnly, cfg.compress, redactPtr, expandRanges.empty() ? nullptr : &expandRanges, d.notesPtr, /*outEmitted=*/nullptr, /*truncateOversizedFirst=*/true, /*withFileContext=*/true, mapRootArg, &calleeOrder ); } ); bodiesEmittedEarly = true; @@ -2682,11 +2686,11 @@ int runDefaultMap( const MainDispatch& d ) { serializeJson( out, ing, rank, g.outOff, g.outTargets, mapTopK, cfg.mostImportantLast, cfg.metrics, fanInPtr, &g.ambOut, cfg.stable, cboPtr, testedPtr, lcom4Ptr, ampPtr, &g.unresolvedOut, - g.bindLabel.empty() ? nullptr : &g.bindLabel, mapAutoOrder, &mapEstTokens, mapProvPtr, mapAnn, mapRootArg, &g.locPinOut, g.externalCalls, &g.declinedOut, g.gateDeclinedCalls ); + g.bindLabel.empty() ? nullptr : &g.bindLabel, mapAutoOrder, &mapEstTokens, mapProvPtr, mapAnn, mapRootArg, &g.locPinOut, g.externalCalls, &g.declinedOut, g.gateDeclinedCalls, g.outNameOnly.empty() ? nullptr : &g.outNameOnly ); } else { - serialize( out, ing, rank, g.outOff, g.outTargets, mapTopK, cfg.mostImportantLast, cfg.metrics, fanInPtr, &g.ambOut, cfg.stable, mapProvPtr, cboPtr, testedPtr, lcom4Ptr, ampPtr, &g.unresolvedOut, g.bindLabel.empty() ? nullptr : &g.bindLabel, mapAutoOrder, &mapEstTokens, payloadTokens, mapAnn, /*statsFirstScreen=*/false, mapRootArg, &g.locPinOut, g.externalCalls, &g.declinedOut, g.gateDeclinedCalls ); + serialize( out, ing, rank, g.outOff, g.outTargets, mapTopK, cfg.mostImportantLast, cfg.metrics, fanInPtr, &g.ambOut, cfg.stable, mapProvPtr, cboPtr, testedPtr, lcom4Ptr, ampPtr, &g.unresolvedOut, g.bindLabel.empty() ? nullptr : &g.bindLabel, mapAutoOrder, &mapEstTokens, payloadTokens, mapAnn, /*statsFirstScreen=*/false, mapRootArg, &g.locPinOut, g.externalCalls, &g.declinedOut, g.gateDeclinedCalls, g.outNameOnly.empty() ? nullptr : &g.outNameOnly ); } } else @@ -2732,7 +2736,7 @@ int runDefaultMap( const MainDispatch& d ) } if( !expandNodes.empty() && !serveWholeFile && !bodiesEmittedEarly ) // #289: already served ahead of the map below { - emitSection( bodiesSection, [ & ]{ packBodies( out, ing, expandNodes, cfg.packBudgetBytes, g.outOff, g.outTargets, cfg.compress, redactPtr, + emitSection( bodiesSection, [ & ]{ packBodies( out, ing, expandNodes, cfg.packBudgetBytes, g.outOff, g.outTargets, g.outNameOnly, cfg.compress, redactPtr, expandRanges.empty() ? nullptr : &expandRanges, d.notesPtr, /*outEmitted=*/nullptr, /*truncateOversizedFirst=*/true, /*withFileContext=*/true, mapRootArg, &calleeOrder ); } ); // L3: --expand bodies surface notes; V1: sibs=/inc= } @@ -3696,6 +3700,26 @@ static std::string_view scipIndexUnreadableReason( const std::string& scipPath ) return {}; } +// --for / --metrics / --exemplar mine 18 months of history after the graph build (the amp=/churn= block in +// dispatchMain). That walk is `git` subprocesses plus a blob read and touches nothing the ingest produces, so it +// starts BEFORE the ingest, one thread per root, and runs beside it; the amp=/churn= block only resolves each walk's +// paths against `ing`. Empty when no such verb runs — and for --help-task, the one verb that answers between the +// two, because a run that stops before the block still waits for its walks (a std::async future joins). +static std::vector> startHistoryWalks( const rw::Config& cfg, bool multiRoot, + const std::vector& ws, const std::string& root ) +{ + std::vector> walks; + if( ( !cfg.metrics && cfg.forTask.empty() && cfg.exemplar.empty() ) || !cfg.helpTask.empty() ) + { + return walks; + } + for( std::size_t r = 0; r < ( multiRoot ? ws.size() : 1u ); ++r ) + { + walks.push_back( std::async( std::launch::async, [ rootArg = multiRoot ? ws[r].arg : root ]() { return rw::quality::gitHistoryWalk( rootArg ); } ) ); + } + return walks; +} + static int dispatchMain( const rw::Config& cfg, char** argv ); // The key for a SHARED root (`r` = the map family, `ctx` = the bundle family) is read off the ANSWER, never off a flag order. @@ -4329,7 +4353,8 @@ static int dispatchMain( const rw::Config& cfg, char** argv ) mcpRoots.emplace_back( r ); } return runMcp( { .topK = cfg.topK, .stable = cfg.stable, .noRedact = cfg.noRedact, .root = std::string( cfg.rootPath ), - .roots = mcpRoots, .toolMask = tools.mask, .toolSpec = std::string( cfg.mcpTools ) } ); // P2-C: --mcp turns --stable on by default (set in parseArgs); A3-F3: the server redacts by default like the CLI + .roots = mcpRoots, .toolMask = tools.mask, .toolSpec = std::string( cfg.mcpTools ), + .legendInline = cfg.mcpLegend == "inline" } ); // P2-C: --mcp turns --stable on by default (set in parseArgs); A3-F3: the server redacts by default like the CLI } // ── multi-root workspace refusals: each cut verb refuses with ONE clear stderr @@ -5034,6 +5059,7 @@ static int dispatchMain( const rw::Config& cfg, char** argv ) // confident, wrong zero; --quality-panel builds two of its six families out of exactly those two lenses. const bool needsValueUses = rw::needsValueUses( cfg ); // cli.h — ONE definition, and --doctor's // rich_verbs= roster is derived from it + std::vector> historyWalks = startHistoryWalks( cfg, multiRoot, ws, root ); IngestResult ing; if( multiRoot ) { @@ -5224,6 +5250,8 @@ static int dispatchMain( const rw::Config& cfg, char** argv ) // churn work, sets byte-identical to the old gitCommitFileSets("18 months ago", 30). // Multi-root (§5): one popen PER root, each resolved only against its own files; the per-commit // sets concatenate (commits are disjoint across repos) and churn accumulates per file. + ASSUME( historyWalks.size() == ( multiRoot ? ws.size() : 1u ), + "this block runs under startHistoryWalks' condition (--help-task has already answered), so every root's walk was started" ); std::vector> commits; if( multiRoot ) { @@ -5233,7 +5261,7 @@ static int dispatchMain( const rw::Config& cfg, char** argv ) // Y2: the memoized form — skips the 431 ms `git log --name-only` walk on a // warm (repo, HEAD sha, window, boundary-sha) hit; see quality.h's qchurn family. std::vector> part = - quality::gitCoChangeAndChurnCached( ws[r].arg, ing, "18 months ago", 30, + quality::resolveHistoryWalk( historyWalks[ r ].get(), ing, cfg.forTask.empty() ? 0u : 12u, cfg.forTask.empty() ? nullptr : &rootChurn, r ); for( std::vector& c : part ) @@ -5256,7 +5284,7 @@ static int dispatchMain( const rw::Config& cfg, char** argv ) else { // Y2: memoized — see the multi-root branch above. - commits = quality::gitCoChangeAndChurnCached( root, ing, "18 months ago", 30, + commits = quality::resolveHistoryWalk( historyWalks[ 0 ].get(), ing, cfg.forTask.empty() ? 0u : 12u, cfg.forTask.empty() ? nullptr : &forChurn ); } diff --git a/src/mcp.h b/src/mcp.h index aa31220ee..bd1fac0c8 100644 --- a/src/mcp.h +++ b/src/mcp.h @@ -531,6 +531,9 @@ struct McpDispatchPolicy // time); the HTTP transport passes none, so every answer there keeps its legend inline and the resource read that // switches a stdio session to legend="ref" only serves text. See legenddict.h. legenddict::LegendSession* legendSession = nullptr; + // lean-answers lane: whether that session OPENS on its first reducible answer (the stdio default) or only on a read of + // ripwire://legend-dict (--mcp-legend=inline, the pre-lane posture). Read only where legendSession is set. + bool legendOpensOnFirstAnswer = false; // --mcp-tools: bit i = kMcpVerbTable[ i ] is listed and callable. The whole catalog unless the flag narrowed it; // toolSpec is the flag's value as typed, rendered only under a subset (the refusal and the instructions note). McpToolMask toolMask = kMcpAllToolsMask; @@ -1034,9 +1037,16 @@ inline std::string mcpLegendPointer( const McpDispatchPolicy& policy ) { return {}; } - return " Legends: each answer defines its own attributes until this session reads the resource ripwire://legend-dict " - "once (dictv=" + legenddict::dictionaryVersion() + "); after that answers list rows first, carry a definition " - "only the first time the session meets it, and end with . legend:\"compact\" on a call " + if( !policy.legendOpensOnFirstAnswer ) // --mcp-legend=inline: the pre-lane pointer, byte for byte + { + return " Legends: each answer defines its own attributes until this session reads the resource ripwire://legend-dict " + "once (dictv=" + legenddict::dictionaryVersion() + "); after that answers list rows first, carry a definition " + "only the first time the session meets it, and end with . legend:\"compact\" on a call " + "that takes it keeps that answer's legend inline."; + } + return " Legends: the session's first answer defines its own attributes inline (dictv=" + legenddict::dictionaryVersion() + + "); later answers list rows first, carry a definition only the first time the session meets it, and end with " + "; ripwire://legend-dict/full holds every definition. legend:\"compact\" on a call " "that takes it keeps that answer's legend inline."; } @@ -1334,7 +1344,7 @@ inline McpDispatchResult dispatchMcpLine( const std::string& line, int topK, boo // paraphrased — this stanza used to describe an ordering the selector does not use. "{\"name\":\"exemplar\",\"description\":\"BEFORE writing a function / method / class / struct / interface / variable, get the repo's single best-in-class instance of that kind to imitate — signature AND full body. " + std::string( kExemplarSelectionRule ) + ". Beats grep/find_symbol: those find A definition, this ranks every definition and returns the one worth copying. kind = fn|method|class|struct|iface|var, OR a task string (its top match's kind is used).\"," + mcprefuse::toolMetadataFor( "exemplar", pathIsRequired ) + "}," - "{\"name\":\"quality_delta\",\"description\":\"Your PR self-check, run every time you think a change is DONE — pairs with the CLI-only --test-gate (names the tests to run + the untested blast radius; not MCP-exposed) to form the two-step pre-PR gate. Reports ONLY what your working tree made WORSE vs baseline, across 11 measured failure modes (complexity, verbosity, nesting, params, new duplication, new dead code, new public API surface, error-masking, short-horizon churn, new clone of a reused helper, an added stub or TODO). Read-only; auto-compares vs git HEAD with no setup, or a pinned .ripwire_quality_baseline sidecar if present and not stale (baseline says which). sev=minor does not gate; acked findings stay suppressed until one worsens. at= is the commit compared at (+dirty = the working tree differed).\"," + "{\"name\":\"quality_delta\",\"description\":\"Your PR self-check, run every time you think a change is DONE — pairs with the CLI-only --test-gate (names the tests to run + the untested blast radius; not MCP-exposed) to form the two-step pre-PR gate. Reports ONLY what your working tree made WORSE vs baseline, across 12 kinds (complexity, verbosity, nesting, params, new duplication, new dead code, new public API surface, error-masking, short-horizon churn, new clone of a reused helper, an added stub or TODO, a defect shape). new-symbol rows never gate, except defect-shape format-arity. Read-only; auto-compares vs git HEAD with no setup, or a pinned .ripwire_quality_baseline sidecar if present and not stale (baseline says which). sev=minor does not gate; acked findings stay suppressed until one worsens. at= is the commit compared at (+dirty = the working tree differed).\"," + mcprefuse::toolMetadataFor( "quality_delta", pathIsRequired ) + "}," "{\"name\":\"quality_baseline\",\"description\":\"PIN the quality floor: writes .ripwire_quality_baseline stamped with the current git HEAD sha, snapshotting complexity / duplication / dead-code / API surface. Call once at the start of non-trivial work, then quality_delta compares against this pinned floor instead of HEAD. Side-effecting (writes a file) — skip it for a simple 'before I push' check, quality_delta already compares vs HEAD with zero setup.\"," + mcprefuse::toolMetadataFor( "quality_baseline", pathIsRequired ) + "}," @@ -1484,6 +1494,10 @@ inline McpDispatchResult dispatchMcpLine( const std::string& line, int topK, boo const std::string text = strArg( "text" ); // insert_before/after const std::string handle = strArg( "handle" ); // T4 fetch_body const std::string kind = strArg( "kind" ); // exemplar kind token; whereis/stray_content/flags/doc_drift name filter + // whereis: defs|refs|all. Read HERE with the other strings, before the shared shape gate below: read inside the + // whereis arm, a non-string `listing:5` decoded to "" after that gate had run, its shape refusal was dropped, and the + // closed-set refusal then told the caller it sent '' (CodeRabbit 5468003465; test/crossrefcheck.sh LEAN (L10b)). + const std::string listingArg = strArg( "listing" ); const std::string from = strArg( "from" ); // path verb: source symbol const std::string to = strArg( "to" ); // path verb: destination symbol const std::string trace = strArg( "trace" ); // L4 from_trace: the raw trace TEXT @@ -1533,9 +1547,14 @@ inline McpDispatchResult dispatchMcpLine( const std::string& line, int topK, boo // and only on the verbs whose legend the dictionary holds: the ones that declare `legend` (compacted first, // then reduced) and `for` (its native dialect). `legend:"ref"` before the read is the compact posture: an // answer is never ref before the dictionary was served. + // lean-answers lane: the session no longer waits for that read. Its FIRST such answer is served inline, whole, + // and OPENS the session (legenddict::openSessionWith); every later one takes the ref posture, the first of them + // carrying the core. legend:"compact"/"full" still keep one answer inline; an HTTP request holds no session. legenddict::LegendSession* const legendSession = policy.legendSession; - const bool legendRefPosture = legendSession != nullptr && legendSession->refOn - && ( legendDeclaredHere ? ( legendArg.empty() || legendArg == "ref" ) : name == "for" ); + const bool legendSessionPosture = legendSession != nullptr + && ( legendDeclaredHere ? ( legendArg.empty() || legendArg == "ref" ) : name == "for" ); + const bool legendRefPosture = legendSessionPosture && legendSession->refOn; + const bool legendOpensSession = legendSessionPosture && !legendSession->refOn && policy.legendOpensOnFirstAnswer; // ── W3FIX H4/M5: every NUMERIC argument through the ONE guarded reader ───────────────────────── // @@ -1657,6 +1676,10 @@ inline McpDispatchResult dispatchMcpLine( const std::string& line, int topK, boo } legenddict::applyRefPosture( compacted, *legendSession ); } + else if( legendOpensSession ) + { + legenddict::openSessionWith( *body, *legendSession ); // served as-is; opens the session when reducible + } return "{\"jsonrpc\":\"2.0\",\"id\":" + id + ",\"result\":{\"content\":[{\"type\":\"text\",\"text\":\"" + mcpdetail::jsonEscape( *body ) + "\"}],\"_index\":\"" + mcpdetail::jsonEscape( stamp ) + "\"" + mcpReingestField( passesAtEntry ) + mcpFreshFields( passesAtEntry ) @@ -2294,10 +2317,13 @@ inline McpDispatchResult dispatchMcpLine( const std::string& line, int topK, boo // — the shared triple (flag + problem + the per-fault clause selectorrefuse.h speaks for // the CLI), not a hits="0" answer about the literal string. `seedFault` distinguishes it // from the non-git degrade, which is the other way whereisText returns "". - resp = pagedResult( [ & ]( McpPageArgs pg ) + crossref::WhereisListing listing = crossref::WhereisListing::ShorterOfDefsAll; + const std::string listingRefusal = whereisListingFromArg( listingArg, + mcpdetail::findRawValue( args, "listing" ).isPresent, listing ); + resp = !listingRefusal.empty() ? errResultMsg( -32602, listingRefusal ) : pagedResult( [ & ]( McpPageArgs pg ) { bool seedFault = false; - const std::string t = whereisText( path, symbol, kind, crossref::kWhereisHits, pg, &seedFault ); + const std::string t = whereisText( path, symbol, kind, crossref::kWhereisHits, pg, &seedFault, listing ); if( seedFault ) { return errResultMsg( -32602, mcprefuse::notFound( getIndex( path ).ing, "symbol", symbol ) ); @@ -2845,6 +2871,7 @@ struct McpStdioConfig std::vector roots; McpToolMask toolMask = kMcpAllToolsMask; std::string toolSpec; + bool legendInline = false; // --mcp-legend=inline: the session opens only on a read of the core }; // The stdio server's dispatch policy. Same root plumbing as runMcpHttp(), building McpDispatchPolicy::defaultRoot @@ -2875,6 +2902,7 @@ inline McpDispatchPolicy mcpStdioPolicy( const McpStdioConfig& config ) policy.defaultRoot = defaultRoot; // "" unless a startup root was given — see the comment above policy.toolMask = config.toolMask; // --mcp-tools policy.toolSpec = config.toolSpec; + policy.legendOpensOnFirstAnswer = !config.legendInline; // lean-answers lane: the default session posture // R2a (the 2026-08-12 usage mine): with NO startup root, resolve the launch cwd ONCE as the softest // default — see McpDispatchPolicy::assumedRoot for the full contract and mcpResolveAssumedRoot for diff --git a/src/mcprefusal.h b/src/mcprefusal.h index f69fe9fcf..075069dbd 100644 --- a/src/mcprefusal.h +++ b/src/mcprefusal.h @@ -337,6 +337,8 @@ inline constexpr McpValueSpec kMcpValueFields[] = { // @FILE:LINE seed line pre-pick), flow is a CLOSED direction set so the sentence names it. { "var", "a STRING variable name inside the resolved definition (omit it to list the sliceable locals)", "var=\"out\"" }, { "flow", "a STRING flow direction: back, fwd or both (omit it for the flat per-line rows)", "flow=\"back\"" }, + // lean-answers lane: whereis's CLOSED listing set, the CLI --whereis-listing= values verbatim. + { "listing", "a STRING: defs (refs counted), refs or all; default: more defs listed, else the shorter page", "listing=\"all\"" }, // lane/t10-mcp-coverage: rank_by's own CLOSED value set, the CLI --rank-by= spelling verbatim (cli.h's // parse arm). churn/churn-decay are valid CLI values that this row still names — the closed-set check // must accept them so the refusal can then say WHY they are unsupported HERE (a named gap, not an @@ -1063,7 +1065,7 @@ inline constexpr McpVerbFields kMcpVerbFields[] = { // unflagged-row window in editcheck.h), the same rule the `impact`/`uses` rows above state. They // page the CONTEXT rows only; the flagged callers this verb exists to name are never windowed. { "edit_check", "path paths symbol new_body limit offset legend" }, - { "whereis", "path symbol kind limit offset legend" }, + { "whereis", "path symbol kind limit offset listing legend" }, { "stray_content", "path kind limit offset legend" }, { "flags", "path kind symbol limit offset legend" }, { "doc_drift", "path kind limit offset legend" }, diff --git a/src/mcpverbs.h b/src/mcpverbs.h index 7cc680277..dfdfa611b 100644 --- a/src/mcpverbs.h +++ b/src/mcpverbs.h @@ -23,6 +23,7 @@ #include "packtask.h" // L4: the shared --pack-task / MCP explore+pack_task bundle assembler (packTaskBundleText) #include "partition.h" // the explore verb's `partition` argument (packTaskPartitionText) #include "tracelocus.h" // L4: the shared --from-trace / MCP from_trace bundle assembler (fromTraceBundleText) +#include "countfloor.h" // count-floor: a row's callers=/implementors= floor marker and its follow-up (CLI and MCP share it) #include "editcheck.h" // L4: the shared --edit-check / MCP edit_check contract-comparison core (editCheckBundleText) #include "callhierarchy.h" // H14/M13: the shared 1-hop call-hierarchy computation — find_symbol / find_referencing_symbols #include "graphlegend.h" // §H4 §3.4: the ONE counts_floor= marker + shared graph-count legend wording (CLI ≡ MCP) @@ -37,6 +38,7 @@ #include "slice.h" // lane/tc-sliceat: the shared --slice / MCP slice def-use core (sliceBundleText — ONE emitter, two surfaces) #include "fielduses.h" // the member-variable round: the ONE --uses=Owner.field renderer (renderFieldUses — CLI ≡ MCP) #include "testmap.h" // lane/t10-mcp-coverage: writeAffectedReport — the shared --affected / `affected` verb renderer +#include "pathgaps.h" // PATH-GAP: the shared gap analysis + clause of --path / path_between #include // §B6 M3: the shared root-path existence/directory check (mcpRootRefusal below) #include // mcpAnswerText / usesText: nullopt is an answer buffer that failed, never an empty answer @@ -448,7 +450,8 @@ inline std::string analyzeToString( const std::string& root, int topK, bool stab // this one must too — "the clause landed at 3 of its 5 echo sites" is the // §B4 family, and mcpclidiffcheck is the gate that keeps the two surfaces one. /*ann=*/rw::MapAnnotations{ .prDisclosure = ix.prDisclosure, .codeFirstRows = ix.isCleanWorkingSet }, - /*statsFirstScreen=*/true, anRootArg, &ix.g.locPinOut, ix.g.externalCalls, &ix.g.declinedOut, ix.g.gateDeclinedCalls ); } ); + /*statsFirstScreen=*/true, anRootArg, &ix.g.locPinOut, ix.g.externalCalls, &ix.g.declinedOut, ix.g.gateDeclinedCalls, + ix.g.outNameOnly.empty() ? nullptr : &ix.g.outNameOnly ); } ); } // `rank_by` verb (lane/t10-mcp-coverage): the MCP twin of --rank-by=pagerank|authority|hub|rrf — the SAME @@ -515,7 +518,8 @@ inline std::string rankByText( const std::string& root, std::string_view mode, i /*autoOrder=*/false, /*outEstTokens=*/nullptr, /*extraPayloadTokens=*/0, /*ann=*/rw::MapAnnotations{ .rankByLabel = rankByLabel, .prDisclosure = disclosure, .codeFirstRows = rankByLabel == nullptr }, // pagerank: the map scope's code-first pick - /*statsFirstScreen=*/true, rbRootArg, &ix.g.locPinOut, ix.g.externalCalls, &ix.g.declinedOut, ix.g.gateDeclinedCalls ); } ); + /*statsFirstScreen=*/true, rbRootArg, &ix.g.locPinOut, ix.g.externalCalls, &ix.g.declinedOut, ix.g.gateDeclinedCalls, + ix.g.outNameOnly.empty() ? nullptr : &ix.g.outNameOnly ); } ); } // ─── the cross-branch + dark-content MCP twins (`whereis`, `stray_content`, `flags`) ─── @@ -555,7 +559,8 @@ inline std::string rankByText( const std::string& root, std::string_view mode, i // returns "" with `seedFault` set, and the dispatcher speaks the shared refusal triple over -32602 rather // than answering a question the caller did not ask. inline std::string whereisText( const std::string& root, const std::string& symbol, const std::string& filter, - std::size_t maxHits, McpPageArgs page = {}, bool* seedFault = nullptr ) + std::size_t maxHits, McpPageArgs page = {}, bool* seedFault = nullptr, + crossref::WhereisListing listing = crossref::WhereisListing::ShorterOfDefsAll ) { std::string sel = symbol; std::string seedSpec; @@ -590,7 +595,7 @@ inline std::string whereisText( const std::string& root, const std::string& symb { res.nearMiss = didYouMean( getIndex( root ).ing, sel ); } - return captureXml( [ & ]( std::FILE* f ) { crossref::writeWhereisPage( f, res, maxHits, page.limit, page.offset ); } ); + return captureXml( [ & ]( std::FILE* f ) { crossref::writeWhereisPage( f, res, maxHits, page.limit, page.offset, listing ); } ); } // `stray_content` verb: per ref, the content its own divergent work authored that the live line lacks. @@ -716,8 +721,10 @@ inline std::string symbolQueryJson( const std::string& root, const std::string& // root it twins), and reused per row so `tested` is the same predicate the CLI prints. const HopTestedPartition chTested = computeHopTestedPartition( ing, g, referencingOnly ? calledBy : calls ); - const auto symObj = [ & ]( NodeId id ) -> std::string + bool anyVia = false; // FE-B: an entry carried "via":"name" — the answer then carries via_note, its reading + const auto symObj = [ & ]( NodeId id, bool via ) -> std::string { + anyVia = anyVia || via; const Symbol& s = ing.symbols[id]; // T4: attach the stable content-handle so the agent can `fetch_body{handle}` instead of us re-sending // the body. Names/signatures by default (this verb); bodies by handle on request. @@ -730,15 +737,16 @@ inline std::string symbolQueryJson( const std::string& root, const std::string& + "\",\"line\":" + std::to_string( s.line ) + ",\"handle\":\"" + mcpdetail::jsonEscape( handleFor( ix, id ) ) + "\"" + ( rw::isTestedByReach( ing, chTested.testReach, id ) ? ",\"tested\":true" : "" ) + + ( via ? ",\"via\":\"name\"" : "" ) // FE-B: the CLI row's via="name", the same edge bit (graph.h rowNameOnly) + "}"; }; - const auto rowArray = [ & ]( const std::vector& ids, const PageWindow& w ) -> std::string + const auto rowArray = [ & ]( const std::vector& ids, const PageWindow& w, bool callersSide ) -> std::string { std::string a = "["; for( std::size_t i = w.begin; i < w.end; ++i ) { if( i != w.begin ) { a += ","; } - a += symObj( ids[i] ); + a += symObj( ids[i], rowNameOnly( g, chRows.matches, ids[i], callersSide ) ); } a += "]"; return a; @@ -758,7 +766,7 @@ inline std::string symbolQueryJson( const std::string& root, const std::string& { out += "\"root\":\"" + mcpdetail::jsonEscape( root ) + "\","; } - out += "\"symbol\":" + symObj( chRows.matches.front() ); + out += "\"symbol\":" + symObj( chRows.matches.front(), false ); // §P10.6 / A6, in the CLI's own key names: defs= = definitions the name resolved to (the rows below are // the UNION of all of their neighbours), count= = the un-windowed row total, hop_tested/hop_untested = // the partition over that full set. @@ -798,10 +806,14 @@ inline std::string symbolQueryJson( const std::string& root, const std::string& { out += calledByCutJson( name, calledBy.size(), pwSecond, page.limit ); // cut-fix C: the second array's own cut } - out += ",\"calledBy\":" + rowArray( calledBy, referencingOnly ? pwPrimary : pwSecond ); + out += ",\"calledBy\":" + rowArray( calledBy, referencingOnly ? pwPrimary : pwSecond, true ); if( !referencingOnly ) { - out += ",\"calls\":" + rowArray( calls, pwPrimary ); + out += ",\"calls\":" + rowArray( calls, pwPrimary, false ); + } + if( anyVia ) + { + out += rw::viaNameNoteJson(); // FE-B: the reading of "via":"name", only in an answer that carries it } // Reference-as-value round: the CLI's window, the same rows (callhierarchy.h computed them once): valueRefs = // where the symbol is USED AS A VALUE (the --callers side), valueCallees = what it stores/passes and may call @@ -812,7 +824,11 @@ inline std::string symbolQueryJson( const std::string& root, const std::string& const ValueRefRows& callerSide = referencingOnly ? chRows.valueRefs : chCallers.valueRefs; if( referencingOnly ) { - out += valueRefsCountKeyJson( callerSide.rows.size() ); + out += ( valueRefsCountKeyJson( callerSide.rows.size() ) + valueRefsDepthKeyJson( ing, callerSide.depthCut, vr ) ); // + the depth cut that makes it a floor, as on the CLI + } + else + { + out += valueRefsDepthKeyJson( ing, callerSide.depthCut, vr ); // the --callers/--callees roots' depth cut (one defs set) } out += valueRefsJson( ing, callerSide, true, vr, "valueRefs", "--uses=" + name ); if( !referencingOnly ) @@ -864,16 +880,28 @@ inline std::string grepEnclosingJson( const McpIndex& ix, std::span row's callers_floor=/floor_next=, by the same function over the same rows (countfloor.h). + std::vector> rowIds; + rowIds.reserve( encRows.size() ); for( const GrepEncRow& row : encRows ) { - if( !first ) + rowIds.push_back( row.ids ); + } + const std::vector floors = callerFloors( ing, g, rowIds, &valueRefIndexOf( ix ) ); + ENSURES( floors.size() == encRows.size() ); + std::string out = ",\"enclosing\":["; + for( std::size_t r = 0; r < encRows.size(); ++r ) + { + const GrepEncRow& row = encRows[r]; + if( r > 0 ) { out += ","; } - first = false; out += "{\"n\":\"" + mcpdetail::jsonEscape( row.chain ) + "\",\"callers\":" + std::to_string( row.callerCount ); + if( floors[r].isFloor ) + { + out += ",\"callers_floor\":1,\"floor_next\":\"" + mcpdetail::jsonEscape( floors[r].next ) + "\""; + } if( row.defCount > 1 ) { out += ",\"defs\":" + std::to_string( row.defCount ); @@ -1053,6 +1081,18 @@ inline std::string grepAuxJson( const std::vector& hits, const PageW // was false: mcprefusal.h already registers the field, and the batch surface refuses loudly. // · `in` reaches the batch arm at all. It previously took the defaulted GrepIn::Code with no hatch. // Absent reads as the default, as an OPTIONAL field must; only a PRESENT unknown spelling refuses. +// `listing` on whereis — the CLI --whereis-listing= twin, the same closed set (defs|refs|all; absent = the page listing more definitions, else the shorter of defs and all), refused on +// any other value through the shared sentence so a typo never reads as the default listing. +inline std::string whereisListingFromArg( std::string_view typed, bool isPresent, crossref::WhereisListing& out ) +{ + out = crossref::whereisListingOf( typed ); + if( !isPresent || typed == "defs" || typed == "refs" || typed == "all" ) + { + return {}; + } + return mcprefuse::badValueRefusal( "listing", typed ); +} + inline std::string grepInModeFromArg( std::string_view typed, GrepIn& out ) { out = GrepIn::Code; @@ -1781,6 +1821,22 @@ inline void spliceForSectionStubLegend( std::string& doc, bool stubbed ) "spliceForSectionStubLegend: a section stubbed but the legend clause was not spliced into the returned document" ); } +// count-floor: the MCP twin of the CLI's lego COUNT clause (verbs_for.h, appended to sectionsStubNote) — inserted at the +// same header boundary, AFTER spliceForSectionStubLegend so the two clauses land in the CLI's order, and before the price. +inline void spliceForLegoCountLegend( std::string& doc, bool carries ) +{ + if( !carries ) + { + return; + } + const std::size_t legendAt = doc.find( " -->" ); + ASSUME( legendAt != std::string::npos, "spliceForLegoCountLegend: a lego count attribute rode but the header's closing \"-->\" was not found" ); + if( legendAt != std::string::npos ) + { + doc.insert( legendAt, rw::kForLegoCountLegend ); + } +} + inline void priceForTaskRoot( std::string& doc, std::size_t budgetTokens ) { if( doc.empty() ) @@ -1802,6 +1858,293 @@ inline void priceForTaskRoot( std::string& doc, std::size_t budgetTokens ) rw::spliceRootAttrs( doc, rootAttrs ); } +// knob-honesty-068 (orchestrator ruling C3, 2026-10-07): MCP `for`'s ASSEMBLY — everything from the sigs render to the +// finished, UNPRICED document (est_tokens= / over_ceiling= are spliced once, on the chosen one, by forTaskText) — as one pass +// forTaskText runs up to twice over a single ranking (unpaid; paid only when the unpaid answer lands over — ruling C3). The pass +// takes its own copies of what it edits (the header, the lego/compose strings, the lego scope and its pre-cap count), so a +// second pass starts from the same state as the first. A free function, not a lambda: forTaskText is already the largest +// function in this file, and the assembly is a step of its own. +struct McpForAssembly +{ + std::optional doc; // nullopt: the answer buffer lost bytes (internal error) + rw::SigsCutReport cut; +}; +struct McpForAssemblyInputs // forTaskText's locals the assembly reads, by reference; all outlive the call +{ + const IngestResult& ing; + const std::vector& lensRank; + int forTopN; + const std::vector& fanIn; + const std::vector& impure; + RedactCounts* redact; + std::size_t sigsBudget; + std::size_t fixedBytes; + std::size_t budgetTokens; + const notes::NoteIndex& noteIndex; + const notes::NoteIndex* notesPtr; + std::string_view flRootArg; + const std::string& task; + const std::string& mcpTopRowNext; + const std::vector& mcpForHdrRows; + const std::string& routeStr; + std::size_t composePreCapCount; + const std::string& sections; + rw::SigLensRules lensRules; // train 26b: the --for lens rules (LB-A floor, e=, docs reorder), + // decided once in forTaskText (the noRoute/task/budget verdicts live there) + const std::vector>& implementors; // train 26b: ix.g.implementors, for the count-floor lego re-render +}; +inline McpForAssembly assembleMcpFor( const McpForAssemblyInputs& in, bool payFromRows, std::string headerStr, std::string legoStr, + std::string composeStr, std::vector> legoScoped, std::size_t legoPreCapCount ) +{ + const IngestResult& ing = in.ing; + const std::vector& lensRank = in.lensRank; + const int forTopN = in.forTopN; + const std::vector& fanIn = in.fanIn; + const std::vector& impure = in.impure; + RedactCounts* const redact = in.redact; + const std::size_t sigsBudget = in.sigsBudget; + const std::size_t fixedBytes = in.fixedBytes; + const std::size_t budgetTokens = in.budgetTokens; + const notes::NoteIndex& noteIndex = in.noteIndex; + const notes::NoteIndex* const notesPtr = in.notesPtr; + const std::string_view flRootArg = in.flRootArg; + const std::string& task = in.task; + const std::string& mcpTopRowNext = in.mcpTopRowNext; + const std::vector& mcpForHdrRows = in.mcpForHdrRows; + const std::string& routeStr = in.routeStr; + const std::size_t composePreCapCount = in.composePreCapCount; + const std::string& sections = in.sections; + const auto& implementors = in.implementors; + const auto renderToString = [ ]( auto&& emitFn ) -> std::string { return captureXml( emitFn ); }; // forTaskText's own seam + rw::MemoryStream stream; + std::FILE* const mem = stream.open(); + if( !mem ) + { + return McpForAssembly{}; // the answer buffer could not be opened: an internal error, never "not found" + } + const rw::SigsCutContinuation mcpSigsNext{ task, 0, std::string_view(), fixedBytes, + /*charged=*/budgetTokens > 0, /*json=*/false, /*pasteHandle=*/false, + /*ledgerGapBytes=*/0u, payFromRows }; + // A2 (survey card, 2026-09-03): sigs render into their OWN buffer (rather than streaming straight into + // `mem` the way this call used to) so droppedPositive is known BEFORE headerStr is written — headerStr, + // once flushed to `mem`, cannot be edited retroactively (the same reason the CLI twin's degrade path + // never gets the attribute). Byte-for-byte the same content this call always produced. + std::size_t mcpDroppedPositive = 0; + bool mcpSigsCapped = false; // did the H1 ladder trim ? — decides the budget_bytes= disclosure below + rw::SigsCutReport mcpSigsCut; // cut-fix lane A: the tag's cut readings, spliced below as the CLI twin does + mcpSigsCut.continuationRequest = &mcpSigsNext; // knob-honesty-068: IN — the capped block's machine continuation + std::vector mcpShownIds; // lane 2: the sigs rows actually emitted — the tail excludes these files, not the whole surface + std::string sigsStr = renderToString( [ & ]( std::FILE* m2 ) + { + packSignatures( m2, ing, lensRank, forTopN, 0 /* no byte budget in MCP (0 = unlimited) */, true, &fanIn, &impure, redact, + nullptr, nullptr, nullptr, nullptr, // Q3 lens vectors — the MCP verb has no git/clone pass (as before) + /*rankAdaptivePayload=*/true, // B0.3: same rank-adaptive payload rule as the CLI --for lens + sigsBudget, // H1: global payload budget (trim ladder; payload="capped" marker) + notesPtr, // L3: field-notes surfacing (inert when null) + flRootArg, // R-E: root-relative p=, same argument the CLI twin passes + in.lensRules, // LB-A, e=, docs reorder (decided in forTaskText) + &mcpDroppedPositive, // A2: exact count, see droppedPositiveCount (serialize.h) + &mcpShownIds, // lane 2: see verbs_for.h shownSigIds + &mcpSigsCapped, // the ladder's own verdict — see the budget_bytes= splice below + mcpTopRowNext, // L-W: the widening page on a thin answer, else the body + &mcpSigsCut, // cut-fix lane A: docs_dropped= / shrunk readings; + // knob-honesty-068: carries mcpSigsNext IN + SigRowSpelling{ .elideZeroMetrics = true } ); // lean-answers lane: the CLI twin's row spelling + } ); + // A2: same insert-before-"-->" splice as the CLI twin (verbs_for.h) — absent entirely on the (overwhelming) + // no-drop path, so headerStr's bytes are unchanged there (byte-identical to the pre-A2 output). Bare + // spelling (no bracket note), same economy and same reasoning as the CLI twin — byte-consistent between + // the two dialects, the way every other --for header fragment in this function already is. + if( mcpDroppedPositive > 0 ) + { + const std::size_t closeAt = headerStr.rfind( " -->" ); + if( closeAt != std::string::npos ) + { + char nb[ 40 ]; + rw::formatTo( nb, sizeof( nb ), " dropped_positive=\"{}\"", mcpDroppedPositive ); + headerStr.insert( closeAt, nb ); // else: unexpected shape, header left as-is + } + } + // budget_bytes= — the CLI twin's disclosure (verbs_for.h, where the full argument lives), on this + // surface for the §P8 reason every other fragment in this function is: one element name, one attribute + // order, both surfaces. This dialect is budgeted by DEFAULT exactly as the CLI is (forBudgetBytes above + // is kForPayloadBudgetBytes when the caller passed no budget_tokens), and it disclosed the cut + // () without naming what did the cutting. Same two conditions as the CLI: the ladder + // actually fired, and the caller named no ceiling of their own — so exactly one ceiling rides a trimmed + // bundle. Same clause text, so the two dialects say the same sentence. Spliced AFTER the sigs render, + // like dropped_positive= above, so sigsBudget and therefore the served row set are untouched; est_tokens + // is spliced onto the finished document further down and so prices these bytes. + if( mcpSigsCapped && budgetTokens == 0 ) + { + const std::size_t rootCloseAt = headerStr.find( ">" ); + if( closeAt != std::string::npos ) + { + headerStr.insert( closeAt, rw::kForBudgetBytesNote ); + } + } + // cut-fix lane A: the tag's cut readings (docs_dropped=, shrunk-not-dropped, docs_after_code=, the continuation's) — + // the CLI twin's clauses, same text, same splice point, present only when the tag carries the case (serialize.h sigsCutReportLegend). + if( const std::string cutNotes = rw::sigsCutReportLegend( mcpSigsCut ); + !cutNotes.empty() ) + { + const std::size_t closeAt = headerStr.rfind( " -->" ); + if( closeAt != std::string::npos ) + { + headerStr.insert( closeAt, cutNotes ); + } + } + // L3 follow-up (CodeRabbit 4053600616): same splice shape as dropped_positive=/budget_bytes= above — absent + // entirely on a clean read. `noteIndex.degraded` is read here (not through notesPtr, which is nulled on an + // EMPTY index even when the read itself was not clean — see notesPtr's own comment above). + if( noteIndex.degraded ) + { + const std::size_t rootCloseAt = headerStr.find( ">" ); + if( closeAt != std::string::npos ) + { + headerStr.insert( closeAt, " [" + std::string( rw::notes::kNotesDegradedReading ) + "]" ); + } + } + std::fwrite( headerStr.data(), 1, headerStr.size(), mem ); + // R2-AF (round 2, S4): first rows inside the root, right after the legend — the CLI twin's exact rule + // (verbs_for.h renderForHdrRowsXml), duplicated here rather than shared because main.cpp includes this + // file (mcp.h, line 48) before verbs_for.h (line 525) — the render is ~10 lines over the SAME resolver + // (rw::forNamedHeaderRows, mention.h), which is the part a divergence would actually cost. + if( !mcpForHdrRows.empty() ) + { + const std::string mcpHdrRootPrefix = flRootArg.empty() ? std::string() : rw::sarif::rootPrefixOf( flRootArg ); + const auto mcpHdrRel = [ & ]( std::uint32_t f ) -> std::string + { + return flRootArg.empty() ? std::string( ing.files[f] ) : std::string( rw::sarif::rootRelativeUri( ing.files[f], mcpHdrRootPrefix ) ); + }; + std::vector mcpHdrEsc; + std::string mcpHdrXml; + for( const rw::ForNamedHeaderRow& row : mcpForHdrRows ) + { + // same local invariant as the CLI twin's renderForHdrRowsXml (verbs_for.h): the resolver + // (rw::forNamedHeaderRows, mention.h) is the only producer of these ids. + ASSUME( row.partnerFile < ing.files.size() && row.namedFile < ing.files.size() ); + mcpHdrXml += ""; + } + std::fwrite( mcpHdrXml.data(), 1, mcpHdrXml.size(), mem ); + } + // §P3 × §P4 (parity with the CLI --for): narrow the lego block to the files the budget-trimmed sigs + // actually kept and re-render (a byte-subset of what the budget already charged for) — reads sigsStr + // directly now rather than re-slicing it back out of the flushed memstream buffer. + // count-floor (CLI/MCP parity): the root prefix the CLI twin passes. sigsStr's rows are root-relative (flRootArg) and + // without the prefix this scan compared them against ABSOLUTE ing.files spellings, matched nothing and narrowed the + // whole block away (serialize.h narrowLegoToRenderedSigs' own R-E note): MCP `for` served no at all where the + // CLI served six rows. + const std::string mcpLegoRootPrefix = flRootArg.empty() ? std::string() : rw::sarif::rootPrefixOf( flRootArg ); + if( !legoStr.empty() && narrowLegoToRenderedSigs( ing, legoScoped, sigsStr, mcpLegoRootPrefix ) ) + { + legoStr = renderToString( [ & ]( std::FILE* m2 ) { packLego( m2, ing, legoScoped, lensRank, 12, redact, &impure, kNoNode, /*withPaths=*/true, flRootArg, {}, &legoPreCapCount, &implementors ); } ); // R-R: the re-render dropped the root its first render (above) passed + } + // R2-L2' (round-2, priced re-registration of L2/B1): the CLI twin's exact stub substitution + // (verbs_for.h) — same rule, same restoring spelling (kept as the CLI flag form: this dialect's next= + // is already CLI-flag-shaped everywhere else, e.g. the r=1 row's --expand=FILE:NAME, so the two + // surfaces do not need two spellings), and the SAME shared size gate (rw::priceSectionStub, + // serialize.h). This surface has no buffered/degrade-path split (renderToString always renders whole), + // so hasRenderedBytes is always true here and the size gate always has a true length to price. + // + // R2-L2p (independent review, 2026-09-19): mcpLegoWillStub/mcpComposeWillStub decide the SAME collapse + // the CLI twin discloses via kForSectionStubLegend (verbs_for.h finishForLensHeaderPriced), but this + // block used to just swap legoStr/composeStr for their stubs and never carried that clause anywhere — + // an MCP caller got a stubbed section with no legend explaining the cut. mcpSectionsWillStub survives + // this block's scope so the splice below (after `out` is assembled, mirroring priceForTaskRoot's own + // late-insertion technique for kOverCeilingLegend) knows whether to add it. + bool mcpSectionsWillStub = false; + { + const bool mcpWantLego = sectionsWant( sections, "lego" ); + const bool mcpWantCompose = sectionsWant( sections, "compose" ); + const bool mcpLegoCandidate = !legoStr.empty() && !mcpWantLego; + const bool mcpComposeCandidate = !composeStr.empty() && !mcpWantCompose; + if( mcpLegoCandidate || mcpComposeCandidate ) + { + std::string sectionsNextMcp = nextFlag( "--for=", task ); + sectionsNextMcp += ' '; + sectionsNextMcp += nextFlag( "--sections=", "lego,compose" ); + const SectionStubPricing legoPricing = mcpLegoCandidate + ? priceSectionStub( "lego", legoPreCapCount, sectionsNextMcp, /*hasRenderedBytes=*/true, legoStr.size() ) + : SectionStubPricing{}; + const SectionStubPricing composePricing = mcpComposeCandidate + ? priceSectionStub( "compose", composePreCapCount, sectionsNextMcp, /*hasRenderedBytes=*/true, composeStr.size() ) + : SectionStubPricing{}; + const bool mcpLegoWillStub = mcpLegoCandidate && legoPricing.collapse; + const bool mcpComposeWillStub = mcpComposeCandidate && composePricing.collapse; + // postcondition of the rule itself, checked BEFORE the swap below discards the original size: + // whichever section actually collapses is, by construction, smaller than what it replaced. + ENSURES( !mcpLegoWillStub || legoPricing.stubXml.size() < legoStr.size(), + "R2-L2' MCP twin: a lego stub collapsed without being smaller than the section it replaced" ); + ENSURES( !mcpComposeWillStub || composePricing.stubXml.size() < composeStr.size(), + "R2-L2' MCP twin: a compose stub collapsed without being smaller than the section it replaced" ); + if( mcpLegoWillStub ) + { + legoStr = legoPricing.stubXml; + } + if( mcpComposeWillStub ) + { + composeStr = composePricing.stubXml; + } + // R2-L2p: same truth table as the CLI's `if( legoWillStub || composeWillStub ) sectionsStubNote = …` + // (verbs_for.h ~2858): the legend rides iff at least one section actually collapsed. + mcpSectionsWillStub = mcpLegoWillStub || mcpComposeWillStub; + } + } + const bool mcpLegoCountAttrs = legoStr.find( rw::kLegoCountAttrPrefix ) != std::string::npos; // false on a stub (its own attributes only) + std::fwrite( sigsStr.data(), 1, sigsStr.size(), mem ); + std::fwrite( legoStr.data(), 1, legoStr.size(), mem ); + std::fwrite( composeStr.data(), 1, composeStr.size(), mem ); + std::fwrite( routeStr.data(), 1, routeStr.size(), mem ); // B6.3 + // DEEP-TAIL d2, MCP twin: the same shared walk + renderer the CLI --for uses (serialize.h), from the + // same resolved surface — the fixed default budget regime, so the tail rides on top (default row cap) + // and the ranked head above stays byte-identical to a tail-less bundle. + { + std::vector tailEsc; + const std::string tailStr = renderFileTailXml( computeFileTail( ing, lensRank, mcpShownIds, flRootArg ), + kForFileTailShownCap, tailEsc ); + std::fwrite( tailStr.data(), 1, tailStr.size(), mem ); + } + rw::emitRaw( mem, "" ); + std::optional answer = mcpAnswerText( stream ); + if( !answer ) + { + return McpForAssembly{}; // the buffer lost bytes: the same internal error as the failed open above + } + std::string out = std::move( *answer ); + // R2-L2p: the legend clause disclosing a stubbed lego/compose section (kForSectionStubLegend) — the + // whole contract lives in spliceForSectionStubLegend above; `stubbed` false (the overwhelming common + // path) returns immediately and costs this function nothing but the call. Done BEFORE priceForTaskRoot + // so the clause's bytes are part of the est_tokens price exactly as the CLI's own version is. + spliceForSectionStubLegend( out, mcpSectionsWillStub ); + spliceForLegoCountLegend( out, mcpLegoCountAttrs ); // count-floor: present-only, the CLI twin's clause + // F5 (terminality round A 2026-09-05): PRICE the bundle instead of declaring it unpriced. The document is + // complete here, so this is the same measurement the CLI twin makes over its own (deliberately different) + // bytes: pricedRootAttr's ≤4-pass fixpoint at kBytesPerTokenDefault, spliced onto the root by the + // shared splicer — no second estimator, and no number that could disagree with the CLI's for the same + // reason the CLI's could disagree with itself. Charged AFTER the sigs budget on purpose: a disclosure's + // contract is disclosure only, and charging its ~18 bytes against the ranked head would drop a row to pay + // for the attribute that describes the head — the same exemption mcpConfidenceExemptBytes already makes. + // r2-LO: a completeness attribute this bundle carries and its own legend never spells (at=, ccx=, next=) gets its compact + // reading — BEFORE pricing, so est_tokens= prices the bytes served, and after the sigs budget, so the rows are unchanged. + closeRosterGaps( out ); + mcpSigsCut.continuationRequest = nullptr; // it pointed at this pass's mcpSigsNext, which ends here + return McpForAssembly{ std::move( out ), mcpSigsCut }; +} + inline std::optional forTaskText( const std::string& root, const std::string& task, RedactCounts* redact = nullptr, std::size_t budgetTokens = 0, bool noRoute = false, McpPageArgs page = {}, // L-W: limit/offset select the FILE PAGE (forpage.h), the CLI --for --limit twin @@ -2019,13 +2362,6 @@ inline std::optional forTaskText( const std::string& root, const st } } - rw::MemoryStream stream; - std::FILE* const mem = stream.open(); - if( !mem ) - { - return std::nullopt; // the answer buffer could not be opened: an internal error, never "not found" - } - // G4: task and rc.reason are agent-controlled and land verbatim in an XML comment below — a "-->" run // would close the comment early and inject XML, and any "--" run alone breaks strict xmllint parsing. // W3FIX M3: the dash collapse was the ONLY scrub here, so an agent-supplied C0 byte, invalid UTF-8 @@ -2115,6 +2451,13 @@ inline std::optional forTaskText( const std::string& root, const st { mcpForScPresent = lensRank[i] > 0 && rw::hasScopeAttr( ing.symbols[i] ); } + // e= (serialize.h defEndLine): may a served row carry it? The sc= presence rule's shape (ranked set, over-approximating). + bool mcpForEndLinePresent = false; + for( std::size_t i = 0; i < ing.symbols.size() && !mcpForEndLinePresent; ++i ) + { + mcpForEndLinePresent = rw::endLinesFitCeiling( budgetTokens, false ) && lensRank[i] > 0 && rw::mayCarryEndLine( ing.symbols[i] ); + } + const std::string_view mcpEndLineLegend = mcpForEndLinePresent ? rw::kForEndLineLegend : std::string_view(); // ONE decision, read twice below: appended into the header here, subtracted from the sigs charge there. const rw::ForIdRouteLegendParts mcpIdRouteParts = rw::forIdRouteLegendParts( /*legendOn=*/true, mcpForScPresent, mcpForRouteAttrOn ); // R2-AF (round 2, S4): the SAME resolver the CLI twin calls (rw::forNamedHeaderRows, mention.h) — one @@ -2135,6 +2478,7 @@ inline std::optional forTaskText( const std::string& root, const st + std::string( rw::kMcpForLensColumnsLegend ) + std::string( rw::kForFileTailLegend ) // deep-tail: r= + definitions, the CLI twin's exact clause (sigs-charge-exempt below) + ( mcpForHdrRows.empty() ? std::string() : std::string( rw::kForHdrLegend ) ) // R2-AF: present-only + + std::string( mcpEndLineLegend ) // e=: present-only, the CLI twin's full clause (sig-charge exempt below) + " -->" // r2-LO: at= rides this root (mcpForAtAttrStr) and was defined nowhere in the document — the CLI twin passes // its at= condition here and this twin did not. Its 24 B are exempt from the sigs charge below, so the served @@ -2153,7 +2497,7 @@ inline std::optional forTaskText( const std::string& root, const st // L2 (round-1 lever B1): each renderer's OWN pre-cap row count, captured by the SAME call that renders // the real body — see verbs_for.h's identical CLI-twin comment for the full rationale. std::size_t legoPreCapCount = 0, composePreCapCount = 0; - std::string legoStr = renderToString( [ & ]( std::FILE* m2 ) { packLego( m2, ing, legoScoped, lensRank, 12, redact, &impure, kNoNode, /*withPaths=*/true, flRootArg, {}, &legoPreCapCount ); } ); + std::string legoStr = renderToString( [ & ]( std::FILE* m2 ) { packLego( m2, ing, legoScoped, lensRank, 12, redact, &impure, kNoNode, /*withPaths=*/true, flRootArg, {}, &legoPreCapCount, &ix.g.implementors ); } ); std::string composeStr, routeStr; if( !ix.g.composeEdges.empty() ) { @@ -2182,221 +2526,71 @@ inline std::optional forTaskText( const std::string& root, const st // (the CLI twin's own idRouteParts ledger, verbs_for.h, for the identical reason). const std::size_t mcpIdRouteExemptBytes = mcpIdRouteParts.bytes(); const std::size_t mcpAtLegendExemptBytes = !flRootArg.empty() && !mcpForAtAttrStr.empty() ? rw::kForAtStampProse.size() : 0; // r2-LO, above + // e=: a disclosure on the same contract (the CLI twin exempts the same clause) + const std::size_t mcpEndLineExemptBytes = mcpEndLineLegend.size(); const std::size_t fixedBytes = headerStr.size() - rw::kForFileTailLegend.size() - mcpConfidenceExemptBytes - mcpIdRouteExemptBytes - mcpAtLegendExemptBytes + - mcpEndLineExemptBytes + - rw::forZeroNoteBytes( headerStr ) // lean-answers: the zero reading never costs a row + legoStr.size() + composeStr.size() + routeStr.size() + 6; // + "" const std::size_t sigsBudget = forBudgetBytes > fixedBytes ? forBudgetBytes - fixedBytes : 1; // ≥1: 0 = "no budget" + // knob-honesty-068: the CLI twin's continuation (serialize.h SigsCutContinuation). This surface is signatures-only, + // so its sig side IS the ceiling: charged under a caller's budget_tokens, exempt at the default. NO CLI argv here — an + // MCP client continues by re-calling the tool: the machine form next_budget_tokens=T (re-call `for` with budget_tokens=T) + // plus next_offset=, the resume index. Built per assembly pass below (payFromRows: orchestrator ruling C3). // L3: field-notes surfacing — parity with the CLI --for lens. loadNoteIndex reads root/.ripwire_notes (a // small file); nullptr when EMPTY so the bundle stays byte-identical when there is nothing to surface. const notes::NoteIndex noteIndex = notes::loadNoteIndex( root ); const notes::NoteIndex* const notesPtr = noteIndex.empty() ? nullptr : ¬eIndex; - // A2 (survey card, 2026-09-03): sigs render into their OWN buffer (rather than streaming straight into - // `mem` the way this call used to) so droppedPositive is known BEFORE headerStr is written — headerStr, - // once flushed to `mem`, cannot be edited retroactively (the same reason the CLI twin's degrade path - // never gets the attribute). Byte-for-byte the same content this call always produced. - std::size_t mcpDroppedPositive = 0; - bool mcpSigsCapped = false; // did the H1 ladder trim ? — decides the budget_bytes= disclosure below - rw::SigsCutReport mcpSigsCut; // cut-fix lane A: the tag's cut readings, spliced below as the CLI twin does - std::vector mcpShownIds; // lane 2: the sigs rows actually emitted — the tail excludes these files, not the whole surface - std::string sigsStr = renderToString( [ & ]( std::FILE* m2 ) - { - packSignatures( m2, ing, lensRank, forTopN, 0 /* no byte budget in MCP (0 = unlimited) */, true, &fanIn, &impure, redact, - nullptr, nullptr, nullptr, nullptr, // Q3 lens vectors — the MCP verb has no git/clone pass (as before) - /*rankAdaptivePayload=*/true, // B0.3: same rank-adaptive payload rule as the CLI --for lens - sigsBudget, // H1: global payload budget (trim ladder; payload="capped" marker) - notesPtr, // L3: field-notes surfacing (inert when null) - flRootArg, // R-E: root-relative p=, same argument the CLI twin passes - /*hasRelevanceFloor=*/true, // LB-A: shrink past the zero-score tail, never pad - &mcpDroppedPositive, // A2: exact count, see droppedPositiveCount (serialize.h) - &mcpShownIds, // lane 2: see verbs_for.h shownSigIds - &mcpSigsCapped, // the ladder's own verdict — see the budget_bytes= splice below - mcpTopRowNext, // L-W: the widening page on a thin answer, else the body - &mcpSigsCut ); // cut-fix lane A: docs_dropped= / shrunk readings - } ); - // A2: same insert-before-"-->" splice as the CLI twin (verbs_for.h) — absent entirely on the (overwhelming) - // no-drop path, so headerStr's bytes are unchanged there (byte-identical to the pre-A2 output). Bare - // spelling (no bracket note), same economy and same reasoning as the CLI twin — byte-consistent between - // the two dialects, the way every other --for header fragment in this function already is. - if( mcpDroppedPositive > 0 ) - { - const std::size_t closeAt = headerStr.rfind( " -->" ); - if( closeAt != std::string::npos ) - { - char nb[ 40 ]; - rw::formatTo( nb, sizeof( nb ), " dropped_positive=\"{}\"", mcpDroppedPositive ); - headerStr.insert( closeAt, nb ); // else: unexpected shape, header left as-is - } - } - // budget_bytes= — the CLI twin's disclosure (verbs_for.h, where the full argument lives), on this - // surface for the §P8 reason every other fragment in this function is: one element name, one attribute - // order, both surfaces. This dialect is budgeted by DEFAULT exactly as the CLI is (forBudgetBytes above - // is kForPayloadBudgetBytes when the caller passed no budget_tokens), and it disclosed the cut - // () without naming what did the cutting. Same two conditions as the CLI: the ladder - // actually fired, and the caller named no ceiling of their own — so exactly one ceiling rides a trimmed - // bundle. Same clause text, so the two dialects say the same sentence. Spliced AFTER the sigs render, - // like dropped_positive= above, so sigsBudget and therefore the served row set are untouched; est_tokens - // is spliced onto the finished document further down and so prices these bytes. - if( mcpSigsCapped && budgetTokens == 0 ) - { - const std::size_t rootCloseAt = headerStr.find( ">" ); - if( closeAt != std::string::npos ) - { - headerStr.insert( closeAt, rw::kForBudgetBytesNote ); - } - } - // cut-fix lane A: the tag's cut readings (docs_dropped=, shrunk-not-dropped) — the CLI twin's clauses, same - // text, same splice point, present only when the tag carries the case (serialize.h sigsCutLegendNotes). - if( const std::string cutNotes = rw::sigsCutLegendNotes( mcpSigsCut.isCapped, mcpSigsCut.shown, mcpSigsCut.total, mcpSigsCut.docsDropped ); - !cutNotes.empty() ) + // knob-honesty-068 (orchestrator ruling C3): the assembly is one pass (assembleMcpFor above), run once or twice below. + const McpForAssemblyInputs assemblyIn{ ing, lensRank, forTopN, fanIn, impure, redact, sigsBudget, fixedBytes, budgetTokens, + noteIndex, notesPtr, flRootArg, task, mcpTopRowNext, mcpForHdrRows, routeStr, + composePreCapCount, sections, + rw::forLensRules( !noRoute && !taskAsksAboutDocs( task ) && !std::getenv( "RIPWIRE_NO_DOCS_AFTER_CODE" ) + && rw::docsAfterCodeFitsCeiling( budgetTokens, /*bodyCeiling=*/false ), + rw::endLinesFitCeiling( budgetTokens, /*bodyCeiling=*/false ) ), // LB-A, e=, docs reorder + ix.g.implementors }; + // C3 (completed 2026-10-08) — drop a row to pay for the handle ONLY where dropping it is what makes the answer fit (the CLI + // twin's rule, runForLens). The UNPAID pass runs first: every row the cut leaves + the handle; when it fits (or nothing is + // capped under a budget) it is served and nothing is paid. Only when it lands past budget_tokens does the PAID pass run, and + // it is served only when IT fits — paying is what made it fit. Otherwise (this surface overshoots on its own: the header + // bytes its ledger exempts) the unpaid answer is served: the cut's rows, the handle riding unpaid, over_ceiling="1" + // (priceForTaskRoot). Both passes share the one ranking above; only the assembly re-runs. + const auto estOf = []( std::size_t docBytes ) + { + std::size_t est = 0; + (void)rw::pricedRootAttr( docBytes, rw::kBytesPerTokenDefault, 0, &est ); // priceForTaskRoot's own reading + return est; + }; + const std::optional tallyAtEntry = redact != nullptr ? std::optional( *redact ) : std::nullopt; + McpForAssembly chosen = assembleMcpFor( assemblyIn, /*payFromRows=*/false, headerStr, legoStr, composeStr, legoScoped, legoPreCapCount ); + if( !chosen.doc ) { - const std::size_t closeAt = headerStr.rfind( " -->" ); - if( closeAt != std::string::npos ) - { - headerStr.insert( closeAt, cutNotes ); - } + return std::nullopt; } - // L3 follow-up (CodeRabbit 4053600616): same splice shape as dropped_positive=/budget_bytes= above — absent - // entirely on a clean read. `noteIndex.degraded` is read here (not through notesPtr, which is nulled on an - // EMPTY index even when the read itself was not clean — see notesPtr's own comment above). - if( noteIndex.degraded ) + if( budgetTokens > 0 && chosen.cut.hasContinuation && estOf( chosen.doc->size() ) > budgetTokens ) { - const std::size_t rootCloseAt = headerStr.find( ">" ); - if( closeAt != std::string::npos ) + McpForAssembly paid = assembleMcpFor( assemblyIn, /*payFromRows=*/true, headerStr, legoStr, composeStr, legoScoped, legoPreCapCount ); + if( !paid.doc ) { - headerStr.insert( closeAt, " [" + std::string( rw::notes::kNotesDegradedReading ) + "]" ); + return std::nullopt; } - } - std::fwrite( headerStr.data(), 1, headerStr.size(), mem ); - // R2-AF (round 2, S4): first rows inside the root, right after the legend — the CLI twin's exact rule - // (verbs_for.h renderForHdrRowsXml), duplicated here rather than shared because main.cpp includes this - // file (mcp.h, line 48) before verbs_for.h (line 525) — the render is ~10 lines over the SAME resolver - // (rw::forNamedHeaderRows, mention.h), which is the part a divergence would actually cost. - if( !mcpForHdrRows.empty() ) - { - const std::string mcpHdrRootPrefix = flRootArg.empty() ? std::string() : rw::sarif::rootPrefixOf( flRootArg ); - const auto mcpHdrRel = [ & ]( std::uint32_t f ) -> std::string - { - return flRootArg.empty() ? std::string( ing.files[f] ) : std::string( rw::sarif::rootRelativeUri( ing.files[f], mcpHdrRootPrefix ) ); - }; - std::vector mcpHdrEsc; - std::string mcpHdrXml; - for( const rw::ForNamedHeaderRow& row : mcpForHdrRows ) + if( estOf( paid.doc->size() ) <= budgetTokens ) { - // same local invariant as the CLI twin's renderForHdrRowsXml (verbs_for.h): the resolver - // (rw::forNamedHeaderRows, mention.h) is the only producer of these ids. - ASSUME( row.partnerFile < ing.files.size() && row.namedFile < ing.files.size() ); - mcpHdrXml += ""; + chosen = std::move( paid ); } - std::fwrite( mcpHdrXml.data(), 1, mcpHdrXml.size(), mem ); - } - // §P3 × §P4 (parity with the CLI --for): narrow the lego block to the files the budget-trimmed sigs - // actually kept and re-render (a byte-subset of what the budget already charged for) — reads sigsStr - // directly now rather than re-slicing it back out of the flushed memstream buffer. - if( !legoStr.empty() && narrowLegoToRenderedSigs( ing, legoScoped, sigsStr ) ) - { - legoStr = renderToString( [ & ]( std::FILE* m2 ) { packLego( m2, ing, legoScoped, lensRank, 12, redact, &impure, kNoNode, /*withPaths=*/true, flRootArg, {}, &legoPreCapCount ); } ); // R-R: the re-render dropped the root its first render (above) passed - } - // R2-L2' (round-2, priced re-registration of L2/B1): the CLI twin's exact stub substitution - // (verbs_for.h) — same rule, same restoring spelling (kept as the CLI flag form: this dialect's next= - // is already CLI-flag-shaped everywhere else, e.g. the r=1 row's --expand=FILE:NAME, so the two - // surfaces do not need two spellings), and the SAME shared size gate (rw::priceSectionStub, - // serialize.h). This surface has no buffered/degrade-path split (renderToString always renders whole), - // so hasRenderedBytes is always true here and the size gate always has a true length to price. - // - // R2-L2p (independent review, 2026-09-19): mcpLegoWillStub/mcpComposeWillStub decide the SAME collapse - // the CLI twin discloses via kForSectionStubLegend (verbs_for.h finishForLensHeaderPriced), but this - // block used to just swap legoStr/composeStr for their stubs and never carried that clause anywhere — - // an MCP caller got a stubbed section with no legend explaining the cut. mcpSectionsWillStub survives - // this block's scope so the splice below (after `out` is assembled, mirroring priceForTaskRoot's own - // late-insertion technique for kOverCeilingLegend) knows whether to add it. - bool mcpSectionsWillStub = false; - { - const bool mcpWantLego = sectionsWant( sections, "lego" ); - const bool mcpWantCompose = sectionsWant( sections, "compose" ); - const bool mcpLegoCandidate = !legoStr.empty() && !mcpWantLego; - const bool mcpComposeCandidate = !composeStr.empty() && !mcpWantCompose; - if( mcpLegoCandidate || mcpComposeCandidate ) + else if( tallyUnpaid ) { - std::string sectionsNextMcp = nextFlag( "--for=", task ); - sectionsNextMcp += ' '; - sectionsNextMcp += nextFlag( "--sections=", "lego,compose" ); - const SectionStubPricing legoPricing = mcpLegoCandidate - ? priceSectionStub( "lego", legoPreCapCount, sectionsNextMcp, /*hasRenderedBytes=*/true, legoStr.size() ) - : SectionStubPricing{}; - const SectionStubPricing composePricing = mcpComposeCandidate - ? priceSectionStub( "compose", composePreCapCount, sectionsNextMcp, /*hasRenderedBytes=*/true, composeStr.size() ) - : SectionStubPricing{}; - const bool mcpLegoWillStub = mcpLegoCandidate && legoPricing.collapse; - const bool mcpComposeWillStub = mcpComposeCandidate && composePricing.collapse; - // postcondition of the rule itself, checked BEFORE the swap below discards the original size: - // whichever section actually collapses is, by construction, smaller than what it replaced. - ENSURES( !mcpLegoWillStub || legoPricing.stubXml.size() < legoStr.size(), - "R2-L2' MCP twin: a lego stub collapsed without being smaller than the section it replaced" ); - ENSURES( !mcpComposeWillStub || composePricing.stubXml.size() < composeStr.size(), - "R2-L2' MCP twin: a compose stub collapsed without being smaller than the section it replaced" ); - if( mcpLegoWillStub ) - { - legoStr = legoPricing.stubXml; - } - if( mcpComposeWillStub ) - { - composeStr = composePricing.stubXml; - } - // R2-L2p: same truth table as the CLI's `if( legoWillStub || composeWillStub ) sectionsStubNote = …` - // (verbs_for.h ~2858): the legend rides iff at least one section actually collapsed. - mcpSectionsWillStub = mcpLegoWillStub || mcpComposeWillStub; + *redact = *tallyUnpaid; // the unpaid answer is served: its tally } } - std::fwrite( sigsStr.data(), 1, sigsStr.size(), mem ); - std::fwrite( legoStr.data(), 1, legoStr.size(), mem ); - std::fwrite( composeStr.data(), 1, composeStr.size(), mem ); - std::fwrite( routeStr.data(), 1, routeStr.size(), mem ); // B6.3 - // DEEP-TAIL d2, MCP twin: the same shared walk + renderer the CLI --for uses (serialize.h), from the - // same resolved surface — the fixed default budget regime, so the tail rides on top (default row cap) - // and the ranked head above stays byte-identical to a tail-less bundle. - { - std::vector tailEsc; - const std::string tailStr = renderFileTailXml( computeFileTail( ing, lensRank, mcpShownIds, flRootArg ), - kForFileTailShownCap, tailEsc ); - std::fwrite( tailStr.data(), 1, tailStr.size(), mem ); - } - rw::emitRaw( mem, "" ); - std::optional answer = mcpAnswerText( stream ); - if( !answer ) - { - return std::nullopt; // the buffer lost bytes: the same internal error as the failed open above - } - std::string out = std::move( *answer ); - // R2-L2p: the legend clause disclosing a stubbed lego/compose section (kForSectionStubLegend) — the - // whole contract lives in spliceForSectionStubLegend above; `stubbed` false (the overwhelming common - // path) returns immediately and costs this function nothing but the call. Done BEFORE priceForTaskRoot - // so the clause's bytes are part of the est_tokens price exactly as the CLI's own version is. - spliceForSectionStubLegend( out, mcpSectionsWillStub ); - // F5 (terminality round A 2026-09-05): PRICE the bundle instead of declaring it unpriced. The document is - // complete here, so this is the same measurement the CLI twin makes over its own (deliberately different) - // bytes: pricedRootAttr's ≤4-pass fixpoint at kBytesPerTokenDefault, spliced onto the root by the - // shared splicer — no second estimator, and no number that could disagree with the CLI's for the same - // reason the CLI's could disagree with itself. Charged AFTER the sigs budget on purpose: a disclosure's - // contract is disclosure only, and charging its ~18 bytes against the ranked head would drop a row to pay - // for the attribute that describes the head — the same exemption mcpConfidenceExemptBytes already makes. - // r2-LO: a completeness attribute this bundle carries and its own legend never spells (at=, ccx=, next=) gets its compact - // reading — BEFORE pricing, so est_tokens= prices the bytes served, and after the sigs budget, so the rows are unchanged. - closeRosterGaps( out ); + std::string out = std::move( *chosen.doc ); priceForTaskRoot( out, budgetTokens ); // R1: est_tokens=, and over_ceiling="1" when it exceeds budgetTokens return out; } @@ -2432,8 +2626,9 @@ inline std::string legoText( const std::string& root, const std::string& type, R // H5: the same legend the CLI --lego prints, and (issue #66) the same adjacent clause defining the // graph_unindexed= the root below carries — CLI and MCP are one wording by construction. H1: the unproven_defs= // clause rides as its own comment beside the closed literal, exactly as on the CLI. - rw::emitTo( mem, "{}{}{}", kLegoLegend, graphUnindexedLegendComment( rw::graphGaugeClauses( ix.g ) ).c_str(), - unprovenDefsVerbComment( UnprovenDefsVerb::Lego, unprovenDefs > 0, "", kImpactLegendOpen, kPageRaiseCapClause, reach.empty() ? "" : kImpactDepthLegend, // 0.6.5: exactly when d=/by_depth= ride, as on the CLI kImpactImportTierLegend, - impactTsImportLegend( imports.importsUnresolved, imports.tsconfigUnread ).c_str(), // #220: exactly when the root carries them, as on the CLI + ( impactTsImportLegend( imports.importsUnresolved, imports.tsconfigUnread ) // #220: exactly when the root carries them, as on the CLI + + viaNameLegend( imHasVia, true ) ).c_str(), // FE-B: exactly when a shown row is via="name", as on the CLI kTestedRowLegend, kImpactTestedPartitionLegend, // A6 kTestedLensBlindSpotLegend, // F-02: rides with the partition, byte-identical to the CLI twin unprovenDefsVerbLegend( UnprovenDefsVerb::Impact, unprovenDefs > 0 ).c_str(), // H1: exactly when the root carries unproven_defs=, as on the CLI declinedCallsLegendWithGate( declinedCalls > 0, g.gateDeclinedCalls > 0 ), // exactly when the root carries declined_calls=, as on the CLI declinedIfaceLegend( declinedIface > 0 ), // likewise declined_iface=, as on the CLI - valueRefsReachLegend( !imValueRefs.rows.empty() ), // exactly when the root carries value_refs=, as on the CLI + ( std::string( valueRefsReachLegend( !imValueRefs.rows.empty() ) ) // exactly when the root carries value_refs=, as on the CLI + + valueRefsDepthLegendFor( imValueRefs ) ).c_str(), // ... and the depth disclosure, as on the CLI graphCountDisclosure( rw::graphGaugeClauses( g ) ).c_str(), renderDisclosure( prD, DiscloseAs::LegendClause ).c_str() ); // r27-emitters §P2.1: the listing is capped at 40 by rank. Without shown=/capped= a 40-row answer to // "is it safe to change X?" reads as the WHOLE blast radius when it can be 3% of it. Same attributes, // same meaning as the CLI --impact — the two surfaces must not diverge on an honesty marker. // §B6 M4: the window and the disclosure now come from the shared pageview.h trio, exactly as they do on // the CLI arm, instead of a hand-rolled 40-row slice with a hand-rolled shown=/capped= pair. - const PageWindow ipw = pageWindow( show.size(), effectiveRowCap( page.limit, 40 ), page.offset ); const std::size_t shownRows = ipw.end - ipw.begin; char ipab[ kPageDisclosureCap ]; // R-E fix (2026-08-19): root-relative p= + root=, exactly as the CLI --impact now emits them. The first @@ -2763,7 +2962,7 @@ inline std::optional impactText( const std::string& root, const std ex( symbol ).c_str(), seeds.size(), reach.size(), unprovenDefsAttrXml( unprovenDefs ).c_str(), // H1: where the CLI root carries it byDepthAttrXml( byDepth ), // 0.6.5: the CLI root's by_depth= imports.xmlAttrs.c_str(), radiusTested, radiusUntested, ( declinedCallsAttrXml( declinedCalls ) + declinedIfaceAttrXml( declinedIface ) ).c_str(), - valueRefsCountAttrXml( imValueRefs.rows.size() ), imRootAttr.c_str(), + ( valueRefsCountAttrXml( imValueRefs.rows.size() ) + valueRefsDepthAttrXml( ing, imValueRefs.depthCut, VrRender{ imSingleRoot, imRootPrefix } ) ), imRootAttr.c_str(), pageDisclosure( ipab, sizeof( ipab ), shownRows, show.size(), ipw.end, page.limit, page.offset, true ), graphCountFloorAttrXml( g ).c_str(), renderDisclosure( prD, DiscloseAs::XmlAttrs ).c_str(), // M15: gauge + marker nextAttrXml( nextFlag( "--safe-delete=", symbol ) ).c_str() ); // P3 (L7): the CLI twin's next=, same root attribute set (mcpclidiffcheck) @@ -2771,8 +2970,9 @@ inline std::optional impactText( const std::string& root, const std { const Symbol& s = ing.symbols[ show[i] ]; const std::string_view rp = imSingleRoot ? sarif::rootRelativeUri( ing.files[ s.fileId ], imRootPrefix ) : std::string_view( ing.files[ s.fileId ] ); // A6: tested="1" only (never a literal 0) — see kTestedRowLegend. 0.6.5: d= as on the CLI row. - rw::emitTo( mem, "", symTag( s.kind ), ex( s.name ).c_str(), ex( rp ).c_str(), s.line, - depthRunAttrXml( show, depth, i, ipw.begin ), isTestedByReach( ing, impTestReach, show[i] ) ? " tested=\"1\"" : "" ); } + rw::emitTo( mem, "", symTag( s.kind ), ex( s.name ).c_str(), ex( rp ).c_str(), s.line, + depthRunAttrXml( show, depth, i, ipw.begin ), isTestedByReach( ing, impTestReach, show[i] ) ? " tested=\"1\"" : "", + viaNameAttr( impactRowNameOnly( proven, show[i] ) ) ); } // FE-B (D3) // the import tier's rows, after the symbol rows and under their own tag — a different unit, so a // different element (see the CLI arm and kImpactImportTierLegend for why they are never one number). emitImportRowsXml( mem, ing, std::span( imports.files ).first( imports.shown ), imRootPrefix, @@ -3012,6 +3212,7 @@ inline std::optional usesText( const std::string& root, const std:: std::vector elixirDefs = resolveAllByNameQualified( ing, symbol ); std::erase_if( elixirDefs, [ & ]( NodeId node ) { return ing.symbols[ node ].lang != Lang::Elixir; } ); const UsesValueFilter valueFilter( ing, sym, defs, &valueRefIndexOf( ix ) ); // the CLI --uses' value-site filter, shared (valuerefs.h) + const ValueRefIndex::DepthCuts usDepthCut = valueRefIndexOf( ix ).depthCutsFor( defs ); // the CLI --uses' depth cut, off the same index for( std::uint32_t refIndex = 0; refIndex < ing.references.size(); ++refIndex ) { const Reference& r = ing.references[refIndex]; @@ -3080,7 +3281,8 @@ inline std::optional usesText( const std::string& root, const std:: "external=\"1\" means SYM has no definition in the indexed tree under ANY spelling (stdlib/third-party); " "qualified file:name and \"::\" spellings whose bare name IS defined refuse instead (the CLI uses verb narrows them). " "{}{}{}-->{}", kUsesLegendOpen, - usesValueRoleLegend( std::any_of( sites.begin(), sites.end(), []( const UseSite& u ) { return u.role == RefRole::Value; } ) ), + ( std::string( usesValueRoleLegend( std::any_of( sites.begin(), sites.end(), []( const UseSite& u ) { return u.role == RefRole::Value; } ) ) ) + + valueRefsDepthLegend( usDepthCut.files > 0 ) ).c_str(), // exactly when the root carries the depth disclosure, as the CLI capLegendClause( computePageDisclosure( upageRows, sites.size(), upw.end, page.limit, page.offset, usDiscloseCap ).active ), graphCountDisclosure( rw::graphGaugeClauses( ix.g ) ).c_str(), @@ -3092,8 +3294,9 @@ inline std::optional usesText( const std::string& root, const std:: const bool usSingleRoot = ing.realPaths.empty(); const std::string usRootPrefix = usSingleRoot ? sarif::rootPrefixOf( root ) : std::string(); const std::string usRootAttr = usSingleRoot ? ( " root=\"" + ex( root ) + "\"" ) : std::string(); - rw::emitTo( mem, "", - ex( symbol ).c_str(), defs.size(), external ? 1 : 0, sites.size(), usRootAttr.c_str(), upage, graphCountFloorAttrXml( ix.g ).c_str() ); // of= echoes the selector as TYPED (an @-seed stays an @-seed) + rw::emitTo( mem, "", + ex( symbol ).c_str(), defs.size(), external ? 1 : 0, sites.size(), usRootAttr.c_str(), upage, graphCountFloorAttrXml( ix.g ).c_str(), + valueRefsDepthAttrXml( ing, usDepthCut, VrRender{ usSingleRoot, usRootPrefix } ).c_str() ); // of= echoes the selector as TYPED (an @-seed stays an @-seed) for( std::size_t siteIndex = upw.begin; siteIndex < upw.end; ++siteIndex ) { const UseSite& u = sites[ siteIndex ]; @@ -3196,27 +3399,31 @@ inline std::optional pathText( const std::string& root, const std:: // verb has no legend of its own either, and the two dialects must not differ on what they explain. // H5: the same brief floor legend + marker the CLI --path prints (verbs_navigate.h) — one wording, two transports. // Reference-as-value round: the CLI --path's to_value_refs=, by the same call. - const std::size_t ptToValueRefs = toValueRefsCount( ing, pth.empty(), dstDefs, &valueRefIndexOf( ix ) ); + const ToValueRefs ptToValueRefs = toValueRefs( ing, pth.empty(), dstDefs, &valueRefIndexOf( ix ) ); + // PATH-GAP: the CLI --path's gap clause, by the same analysis and emitter (src/pathgaps.h). + const PathSearchGaps ptGaps = pth.empty() ? pathSearchGaps( ing, g, srcDefs, valueRefIndexOf( ix ) ) : PathSearchGaps{}; + const bool ptHasVia = pathHasNameOnlyHop( g, pth ); // FE-B: exactly when a hop of this path is via="name", as on the CLI rw::emitTo( mem, "{}", unprovenDefsVerbLegend( UnprovenDefsVerb::Path, unprovenDefs > 0 ).c_str(), - toValueRefsLegend( ptToValueRefs > 0 ), + "graph holds none. {}{}{}{}-->{}", ( unprovenDefsVerbLegend( UnprovenDefsVerb::Path, unprovenDefs > 0 ) + viaNameLegend( ptHasVia ) ).c_str(), + ( std::string( toValueRefsLegend( ptToValueRefs.count > 0 ) ) + valueRefsDepthLegend( ptToValueRefs.depthCut.files > 0 ) ).c_str(), + pathGapsLegend( ptGaps.any() ), graphCountFloorBrief( rw::graphGaugeClauses( g ) ).c_str(), rootRelPathsLegend( ptSingleRoot ) ); rw::emitTo( mem, "" ); - for( NodeId n : pth ) - { const Symbol& s = ing.symbols[n]; + rw::emitTo( mem, "{}{}", countAttrXmlOrEmpty( "to_value_refs", ptToValueRefs.count ), // absent at zero, as on the CLI + valueRefsDepthAttrXml( ing, ptToValueRefs.depthCut, VrRender{ ptSingleRoot, ptRootPrefix } ).c_str() ); // + the depth cut, as the CLI + const PathGapsXml ptUnreached = pth.empty() + ? pathUnreachedXml( ing, ptGaps, ptSingleRoot, ptRootPrefix, { "the connect verb on " + from + "," + to, "uses/impact", "" } ) + : PathGapsXml{}; + rw::emitTo( mem, "{}>{}", ptUnreached.rootAttrs, ptUnreached.rows ); + for( std::size_t i = 0; i < pth.size(); ++i ) + { const Symbol& s = ing.symbols[ pth[i] ]; const std::string_view rp = ptSingleRoot ? sarif::rootRelativeUri( ing.files[ s.fileId ], ptRootPrefix ) : std::string_view( ing.files[ s.fileId ] ); - rw::emitTo( mem, "", symTag( s.kind ), ex( s.name ).c_str(), ex( rp ).c_str(), s.line ); } + rw::emitTo( mem, "", symTag( s.kind ), ex( s.name ).c_str(), ex( rp ).c_str(), s.line, + viaNameAttr( i > 0 && edgeNameOnly( g, pth[ i - 1 ], pth[ i ] ) ) ); } // FE-B: as on the CLI rw::emitRaw( mem, "" ); return mcpAnswerText( stream ); // nullopt = the buffer lost bytes (dispatch answers -32603), "" stays not-found } @@ -3475,9 +3682,15 @@ inline void packConnect( std::FILE* out, const IngestResult& ing, const Graph& g const std::string connectUnprovenLegend = unprovenDefsVerbComment( UnprovenDefsVerb::Connect, unprovenDefs > 0, "" : std::string(); const std::size_t connectExtraBytes = connectRootAttr.size() + std::strlen( rootRelPathsLegend( !rootArg.empty() ) ) + connectUnindexedLegend.size() + connectUnprovenAttr.size() + connectUnprovenLegend.size() - + connectAmbiguousAttr.size(); + + connectAmbiguousAttr.size() + connectViaLegend.size(); // §2.4a: the derived hub threshold every Steiner row's connects= is read against, computed ONCE (it is a // property of the graph, not of a row) and named on the root so the label is never a bare assertion. @@ -3625,7 +3838,8 @@ inline void packConnect( std::FILE* out, const IngestResult& ing, const Graph& g for( const ConnectEdge& e : edges ) { payload.append( "" ); + .append( "\" t=\"" ).append( ex( ing.symbols[ e.to ].name ) ).append( "\"" ) + .append( viaNameAttr( edgeNameOnly( g, e.from, e.to ) ) ).append( "/>" ); // FE-B: a name-only edge says so } payload.append( "" ); } @@ -3688,8 +3902,8 @@ inline void packConnect( std::FILE* out, const IngestResult& ing, const Graph& g { estTokens = connectEstTokens( payload.size(), connectExtraBytes + std::strlen( connectOverAttr ) ); } - rw::emitTo( out, "{}{}{}{}", rw::cstr( kConnectHeader ), connectUnindexedLegend.c_str(), connectUnprovenLegend.c_str(), - rootRelPathsLegend( !rootArg.empty() ) ); + rw::emitTo( out, "{}{}{}{}{}", rw::cstr( kConnectHeader ), connectUnindexedLegend.c_str(), connectUnprovenLegend.c_str(), + connectViaLegend, rootRelPathsLegend( !rootArg.empty() ) ); rw::emitTo( out, "", res.terminals.size(), nodeTotal, edgeTotal, res.radius, connectedGroups, connectUnprovenAttr.c_str(), // H1: beside the counts it qualifies; absent at zero @@ -3747,7 +3961,7 @@ inline std::string connectText( const std::string& root, const std::vector qualityDeltaJson( const std::string& { ++newSymbolCount; } - else if( !r.isMinor ) + if( rw::quality::rowGates( r ) ) { ++gatingCount; } @@ -4041,9 +4255,9 @@ inline std::pair qualityDeltaJson( const std::string& { out += ",\"p\":\"" + mcpdetail::jsonEscape( r.path ) + ":" + std::to_string( r.line ) + "\""; // P2.5 locator } - if( !r.isNewSymbol && !r.isMinor ) + if( rw::quality::rowGates( r ) ) { - out += ",\"gating\":true"; // the exit predicate, per row + out += ",\"gating\":true"; // the exit predicate, per row (rowGates) } if( r.isMinor ) { diff --git a/src/model.h b/src/model.h index f3228c46f..8a94f8948 100644 --- a/src/model.h +++ b/src/model.h @@ -43,7 +43,7 @@ template using HashMap = ankerl::unordered_dense::map; using NodeId = std::uint32_t; inline constexpr NodeId kNoNode = 0xFFFFFFFFu; -// symbol kind → the terse XML attribute (t="fn|method|cls|struct|iface|var|sec|macro|modscope"). +// symbol kind → the terse XML attribute (t="fn|method|cls|struct|iface|var|sec|macro|modscope|type|alias|functype"). // Macro (the macro-edges round) is APPENDED before Other so no existing kind renumbers: a preprocessor // `#define` definition (@definition.macro — C/C++ preproc_def/preproc_function_def, Rust macro_definition). // Previously the C/Rust captures mapped to Function, which read as a lie on every t= surface; the kind now @@ -65,12 +65,23 @@ inline constexpr NodeId kNoNode = 0xFFFFFFFFu; // mintModuleScopeOwners() appends one per file that has a file-scope call, AFTER the parse cache is released, // so it is the owner a top-level statement or an anonymous callback body attributes to. It is a CALLER, never a // callee: nothing in any language can name ``, so it takes no in-edge and cannot become a rank hub. -enum class SymKind : std::uint8_t { Function, Method, Class, Struct, Interface, Var, Section, Macro, Field, Other, ModuleScope }; +// NamedType / Alias / FuncType (honesty-small-068) are APPENDED after ModuleScope so no existing kind renumbers. They +// split the old `@definition.type` → Struct bucket where a grammar's syntax PROVES the type's form, which today is Go +// alone: `type N string` / `type L[T any] []T` (a defined type over a non-struct, non-interface, non-func form, or over +// another named type) is t="type", `type F func(...)` is t="functype", `type A = B` is t="alias" (a floor: a Go 1.24 +// generic alias `type A[T any] = B` reads t="type", because the grammar gives it no type_alias node). A Go struct or +// interface keeps t="struct"/t="iface". Every other language's @definition.type capture still maps to Struct (C/C++ +// typedef and enum, TS/Swift/Dart aliases, Java/C#/PHP/GDScript enums, Elixir @type) — a disclosed floor, not a claim +// that those are structs. The three kinds BEHAVE as Struct did everywhere a predicate reads the kind +// (isStructOrNamedType below), so only the label changes: a label-normalised base-vs-head sample over Go fixtures and +// 26 argv found no other byte moved (2026-10-04, this lane). Dropping the helper moved nothing on +// those probes either, so it is a guard for predicates no Go fixture reaches today, not a proven behaviour. +enum class SymKind : std::uint8_t { Function, Method, Class, Struct, Interface, Var, Section, Macro, Field, Other, ModuleScope, NamedType, Alias, FuncType }; // The number of SymKind enumerators, and the bound a cached def's kind byte is VALIDATED against on the way // back in (ingest_cache.h ByteR::enumU8). The static_assert is not a restatement: enumCountIsExact asks the // compiler whether the last enumerator is the last NAMED value, so appending a kind without moving this is a // build error rather than a validator that silently refuses the new kind's every cached record. -inline constexpr std::size_t kSymKindCount = static_cast( SymKind::ModuleScope ) + 1; +inline constexpr std::size_t kSymKindCount = static_cast( SymKind::FuncType ) + 1; static_assert( enumCountIsExact(), "kSymKindCount must name the LAST SymKind enumerator — move it with the append" ); inline const char* symTag( SymKind k ) noexcept @@ -88,10 +99,25 @@ inline const char* symTag( SymKind k ) noexcept case SymKind::Field: return "field"; // member variable (id=path::Owner::field; use-sites via --uses=Owner.field) case SymKind::Other: return "other"; case SymKind::ModuleScope: return "modscope"; // the file's module scope: n="", no body to expand + case SymKind::NamedType: return "type"; // Go `type N string`: a defined type whose WRITTEN form is not a struct/iface/func literal + case SymKind::Alias: return "alias"; // Go `type A = B`: another name for B + case SymKind::FuncType: return "functype"; // Go `type F func(...)`: a named function type } return "other"; // a byte past the enum; a NEW SymKind is a -Werror=switch error above, never a silent "other" } +// The kinds the `@definition.type` bucket mapped to Struct before honesty-small-068 split Go's: every predicate that +// admitted Struct as "a named type" (callable as a conversion, a method owner, a type-like row) admits these too, so +// the split changes the t= LABEL and nothing else. Read this, not `== SymKind::Struct`, wherever the question is +// "is this a struct-or-named-type definition". +// The three split kinds are the LAST three enumerators, so they are one range; the asserts pin that. +static_assert( SymKind::Alias == SymKind( unsigned( SymKind::NamedType ) + 1 ) && SymKind::FuncType == SymKind( unsigned( SymKind::NamedType ) + 2 ) + && kSymKindCount == unsigned( SymKind::FuncType ) + 1, "NamedType..FuncType are the last, contiguous kinds" ); +inline bool isStructOrNamedType( SymKind k ) noexcept +{ + return k == SymKind::Struct || k >= SymKind::NamedType; +} + // #324: a ModuleScope owner is SYNTHETIC (comment above the enum) — a CALLER minted after the tags.scm pass, // never something a test (or anything else) can name or invoke, so it can never be discharged as a test // obligation. It is legitimate wherever a CALLER is shown (--callers/--impact/--for already carry the @@ -367,6 +393,9 @@ inline bool isJsTsBuiltinMember( std::string_view ctor, std::string_view name ) // field reuse (graph.h valueRefIndex reads exactly these): fieldName = the slot as written (into=), // recvVar = the simple container identifier, composeRel = the normalised key, qualifier = scope char + // file-shadow flag, argCount = the argument index. +// ONE Value record per file may instead be the DEPTH CUT (qualifier = kValueRefDepthCutScope, name = "", +// which no identifier is): the walk stopped at its depth cap there and argCount subtrees were not visited +// (saturating), line = the first one. It is no row; valuerefindex.h counts it for the answers' depth disclosure. // Through — a call THROUGH a value: a called parameter, `tbl[k](…)` / `tbl.k(…)` on a container that received a // function value. name = the container, fieldName = the written callee, composeRel = the key, // qualifier = p|l|f, argCount = the parameter index. Joined to Value rows only (called_by=/through=, a @@ -406,6 +435,9 @@ inline ValueRefFamily valueRefFamily( Lang l ) noexcept { return enumTableAt( kValueRefFamilyOfLang, l, ValueRefFamily::None ); } +// The scope char (qualifier[0]) of a file's depth-cut Value record (see RefRole::Value above): written by +// src/ingest_valuerefs.h, read by src/valuerefindex.h — distinct from every row scope (f l a p x). +inline constexpr char kValueRefDepthCutScope = 'd'; // Essential-complexity ev_why= reason vocabulary (the essential-complexity design note, §5.1). PUBLIC the // moment it ships (test/attrvocabcheck.sh's standing posture; test/essentialcxcheck.sh pins the spellings): @@ -648,7 +680,7 @@ struct FnLocalScope // an unrelated Join#aliases. Gate: test/rubybarecallcheck.sh ("Ruby has no declarations"). inline bool isDefinitionNotDeclaration( const Symbol& s ) noexcept { - const bool kotlinType = s.lang == Lang::Kotlin && ( s.kind == SymKind::Class || s.kind == SymKind::Struct || s.kind == SymKind::Interface ); + const bool kotlinType = s.lang == Lang::Kotlin && ( s.kind == SymKind::Class || isStructOrNamedType( s.kind ) || s.kind == SymKind::Interface ); return s.endByte > s.sigEndByte || kotlinType || s.lang == Lang::Ruby; } @@ -744,7 +776,7 @@ struct Reference std::string calleeName; // referenced name (final identifier segment) std::string qualifier; // explicit scope at the call site (`A` in `A::b()`); "" if bare/method — for canonical resolve std::string recvVar; // receiver variable identifier when recv==NamedVar/FieldOfVar (`x` in `x->m()`); "" otherwise — for Rule 2 - // A Ruby receiver the code BUILDS carries its type here instead (rubyTypedRecvOf below). + // A Ruby receiver the code BUILDS leaves it empty: its type is memberCtor/memberVia (rubyTypedRecvOf). std::string fieldName; // member variable name when isCompose (e.g. "m_pool"); ALSO the INTERMEDIATE // field of a depth-2 chained receiver when recv is FieldOfThis/FieldOfVar // (`this->m_pool.run()` → "m_pool") — the two are mutually exclusive @@ -764,17 +796,29 @@ struct Reference // `new X()`). Read only by graph.h's FalseEdgeRules. false/"" for every other language and every non-call ref. bool memberCall = false; std::string memberRoot; + // FE-B (test/receiverevidencecheck.sh): the rest of a member call's receiver chain, for the receiver-evidence rule + // (graph.h ReceiverEvidence). memberPath is the member names BETWEEN memberRoot and the callee, '.'-joined — + // "bucket" for `this.bucket.listSchemas()`, "req.raw.headers" for `ctx.req.raw.headers.get()`, "" for `root.m()`. + // memberCtor is the class a CONSTRUCTED receiver names, final segment — "Reply" for `new Reply( r ).send()`, Go + // "Merger" for `(&Merger{}).Len()` — with memberRoot "" (a construction has no root identifier). Both "" for every + // other shape and every language that records no member-call shape. + std::string memberPath; + std::string memberCtor; + // FE-B on #373: a Ruby receiver the code BUILDS (`User.new`, a finder, a FactoryBot build, a let or local holding one) + // carries its type here, out of band: memberCtor = the class's constant as written (or the factory name when + // memberFactory), memberVia = the method that built it. recvVar is then empty. Read through rubyTypedRecvOf. + std::string memberVia; + bool memberFactory = false; }; // A Ruby call whose RECEIVER the code builds (parser version 132, test/rubytypedrecvcheck.sh): `c = Client.new` then `c.get`, // `let( :user ) { create( :user ) }` then `user.activate!`, `User.find_by( … ).activate!`. ingest_binds.h reads the shape -// by Ruby's own local rule and writes the type into Reference::recvVar as "." — the class's constant as written -// and the method that built it (`User.find_by`, `OpenSSL::Cipher.new`) — or ":." for a FactoryBot build (`:user.create`), whose class -// only the tree's factory definitions know (graph.h rubyFactoryClasses). The receiver KIND is left as it was (NamedVar, -// FieldOfVar), so every rule that does not read the type sees the call exactly as before; and a `.` is in no Ruby -// identifier or constant segment, so no receiver the tool recorded before can read as a type. -inline constexpr char kRubyTypedRecvSep = '.'; - +// by Ruby's own local rule and writes the type into the reference's OWN fields (FE-B on #373: it used to ride recvVar as an +// in-band "." token): Reference::memberCtor = the class's constant as written (`User`, `OpenSSL::Cipher`), or +// the factory name with memberFactory set for a FactoryBot build, whose class only the tree's factory definitions know +// (graph.h rubyFactoryClasses); memberVia = the method that built it (`find_by`, `new`, `create`). recvVar is left empty, +// and the receiver KIND as it was (NamedVar, FieldOfVar), so every rule that does not read the type sees the call exactly +// as before. struct RubyTypedRecv { std::string_view type; // the class's constant as written (`User`, `Admin::User`), or the factory name @@ -782,22 +826,15 @@ struct RubyTypedRecv bool factory = false; // `type` names a FactoryBot factory, not a class }; -inline std::string rubyTypedRecvToken( std::string_view type, std::string_view via, bool factory ) -{ - return std::string( factory ? ":" : "" ).append( type ).append( 1, kRubyTypedRecvSep ).append( via ); -} // the type a Ruby reference's receiver was built as, or nullopt for every other reference inline std::optional rubyTypedRecvOf( const Reference& r ) noexcept { - const std::size_t sep = r.lang == Lang::Ruby ? r.recvVar.find( kRubyTypedRecvSep ) : std::string::npos; - if( sep == std::string::npos ) + if( r.lang != Lang::Ruby || r.memberCtor.empty() || r.memberVia.empty() ) { return std::nullopt; } - const std::string_view token = r.recvVar; - const bool factory = token.starts_with( ':' ); - return RubyTypedRecv { token.substr( factory ? 1 : 0, sep - ( factory ? 1 : 0 ) ), token.substr( sep + 1 ), factory }; + return RubyTypedRecv { r.memberCtor, r.memberVia, r.memberFactory }; } // RSpec's targets (test/rubyrspectargetcheck.sh, parser version 133): inside an example group, the receiver-less builder @@ -1027,9 +1064,35 @@ enum class LocalBindKind : std::uint8_t // has no constant: var="self", at the directive's own start byte (ingest_binds.h // captureRubyClassMixins). Read by graph.h's class-object lookup ONLY (rubyFqnAncestry). APPENDED, // as above. + // FE-B (test/receiverevidencecheck.sh): the receiver-evidence facts graph.h ReceiverEvidence reads, and NOTHING else. + // Every other binding consumer filters by kind or skips these five by isReceiverEvidenceKind below — a field name + // recorded here is not a local of the method that assigns it, and a type written on a parameter is not a class the + // file "names" for the builtin-method gate. APPENDED (cache u8). + RecvType, // a parameter or local whose class the source states: var = the name, typeName = the class's final + // segment, importedName = the written type whole. A TS/Python/Go parameter's annotation, a JS + // `const x = new Foo()`, a Go `var x T` / `x := T{…}` / `x := &T{…}`, and a Go METHOD RECEIVER — the + // receiver's record sets isFromAssignment (the method's own class, which Go spells nowhere else), and + // so does a JS `Foo.prototype.m = function …` (var "", typeName Foo): that member's class. + MemberType, // a field whose class the source states: var = the field, typeName = the class's final segment. + // fromSymbol is the def that states it — a Python method's `self.x = Foo()`, a JS/TS method's + // `this.x = new Foo()`, a Go struct's `x T` — and the owning class is that def's class. A Go + // EMBEDDED field (`struct { u.Chars }`) sets isFromAssignment: its methods are promoted. + MethodAlias, // a local bound to an object's method: var = the local, typeName = the object's variable, + // importedName = the method (Python `feed = parser.feed`). + NameAlias, // a file's local spelling of an imported class: var = the local name, typeName = the name it imports + // (Python `from m import Stylesheet as Sheet`; JS/TS read their JsImport records instead). + StaticMember, // a JS/TS class member declared `static`: var = the member's name, typeName "static", recorded INSIDE its + // body so it attributes to the member. A call on the CLASS reaches only these, a call on an INSTANCE + // only the others (the two sides of the lookup; #373's Ruby class-object rule, here for JS/TS). }; // The number of LocalBindKind enumerators — the bound readBind validates a cached kind byte against (see kSymKindCount). -inline constexpr std::size_t kLocalBindKindCount = static_cast( LocalBindKind::RubyClassMixin ) + 1; +inline constexpr std::size_t kLocalBindKindCount = static_cast( LocalBindKind::StaticMember ) + 1; +// FE-B: the five kinds only graph.h ReceiverEvidence reads (see RecvType above). +inline bool isReceiverEvidenceKind( LocalBindKind k ) noexcept +{ + return k == LocalBindKind::RecvType || k == LocalBindKind::MemberType || k == LocalBindKind::MethodAlias || k == LocalBindKind::NameAlias + || k == LocalBindKind::StaticMember; +} // The importedName of a Ruby class-object binding that acts on each class INCLUDING the module, not on the module // (LocalBindKind::RubySingletonDef, RubyClassMixin; parser version 145): written in a concern's `included do`, or a def // in its `class_methods do`. diff --git a/src/nextverb.h b/src/nextverb.h index ff193bf86..d52d7635e 100644 --- a/src/nextverb.h +++ b/src/nextverb.h @@ -6,7 +6,7 @@ // --test-gate/--safe-delete/--situ handed the agent nothing, so "contract-change → find the sites → open the // file" was three calls with the middle one guessed. The owner's frame for this lane: an answer should TERMINATE // the search in one compact shot — so every root in P3's enumeration carries exactly one next= holding a -// pasteable invocation of ≤ kNextAttrMaxBytes: --edit-check contract-change → --uses=SYM; --impact → +// pasteable invocation, typically ≤ kNextAttrMaxBytes and never dropped past it: --edit-check contract-change → --uses=SYM; --impact → // --safe-delete=SYM; a gating --quality-delta row → --expand=FILE:NAME; --test-gate → its first run= command; // --situ → --test-gate; --from-trace/--run-trace → --slice=@FILE:LINE (the innermost in-corpus frame); // --callers → --uses=SELECTOR (bare NAME for a narrowed selector with declined calls; otherwise the spelling is mirrored); @@ -24,6 +24,12 @@ namespace rw { +// NOT A RUNTIME CEILING (knob-honesty-068). No producer drops or truncates a next= for its length: nextAttrXml has no +// length branch (138b383c, 2026-09-25 ruling: a dropped next= leaves a cut with no way back, and an untruncated one never +// pastes wrong), and the last pre-filter — graph.h's importers_next= — went with this lane. What remains is the TYPICAL +// size the short-fixture gates hold their own next= to (nextverbcheck.sh, forsectioncollapsecheck.sh (5a)): a compactness +// target for the common case, never a reason to omit a continuation. A producer that wants a shorter next= must spell +// a shorter equivalent call, not drop it. inline constexpr std::size_t kNextAttrMaxBytes = 120; // the ONE definition every legend that meets next= splices (legendcoveragecheck: an attribute is defined where @@ -73,11 +79,14 @@ inline std::string nextAttrXml( std::string_view invocation, std::string_view at return a; } -// the JSON twin: `,"next":"…"` with the JSON escapes a flag value can need -inline std::string nextFieldJson( std::string_view invocation ) +// the JSON twin: `,"next":"…"` with the JSON escapes a flag value can need; `key` names a SECONDARY listing's own +// follow-up, as nextAttrXml's `attr` does (knob-honesty-068: --for --json's "sigs_next") +inline std::string nextFieldJson( std::string_view invocation, std::string_view key = "next" ) { if( invocation.empty() ) { return {}; } - std::string a = ",\"next\":\""; + std::string a = ",\""; + a += key; + a += "\":\""; for( const char c : invocation ) { switch( c ) diff --git a/src/nonlocalstate.h b/src/nonlocalstate.h index 62fb3c145..216139dc4 100644 --- a/src/nonlocalstate.h +++ b/src/nonlocalstate.h @@ -572,7 +572,7 @@ inline std::vector collectAccesses( const IngestResult& ing, const s HashMap locallyBound; // "fromSymbol\x1fvar" -> 1 for( const Binding& b : ing.bindings ) { - if( b.fromSymbol != kNoNode ) + if( b.fromSymbol != kNoNode && !isReceiverEvidenceKind( b.kind ) ) // FE-B: a field's type fact binds no local { locallyBound[ std::to_string( b.fromSymbol ) + "\x1f" + b.var ] = 1; } diff --git a/src/packtask.h b/src/packtask.h index 62c7d49a4..edf6e1757 100644 --- a/src/packtask.h +++ b/src/packtask.h @@ -1039,7 +1039,7 @@ inline RankingSection renderRankingWithFar( const IngestResult& ing, const Ranki /*rankAdaptivePayload=*/true, /*payloadBudgetBytes=*/ri.sigsBudget, /*noteIndex=*/nullptr, // notes are a DEDICATED section (4), never inline here (avoids double-emit) ri.in->rootArg, - /*hasRelevanceFloor=*/false, // R2: eligibleIds is ALREADY the curated set (d0∪d1 depth mask), + rw::SigLensRules::None, // no LB-A floor, no e=. R2: eligibleIds is ALREADY the curated set (d0∪d1 depth mask), // not a floor-narrowed topN — droppedPositiveCount re-checks // rank>0 per symbol regardless, so this is unaffected either way &out.droppedPositive, // A2: exact count, see droppedPositiveCount (serialize.h) @@ -1189,7 +1189,7 @@ inline std::size_t probeBodyCost( const IngestResult& ing, const Graph& g, NodeI // would price a listing the bundle will never emit, and every fit decision downstream inherits that. const PackTaskRendered one = packTaskRender( [ & ]( std::FILE* m ) { - packBodies( m, ing, { id }, SIZE_MAX, g.outOff, g.outTargets, in.compress, in.redact, nullptr, nullptr, &dummy, + packBodies( m, ing, { id }, SIZE_MAX, g.outOff, g.outTargets, g.outNameOnly, in.compress, in.redact, nullptr, nullptr, &dummy, /*truncateOversizedFirst=*/true, /*withFileContext=*/false, in.rootArg, in.calleeRank ); }, in.renderFaults ); if( !one.ok ) @@ -1337,7 +1337,7 @@ inline std::vector selectMonotoneBodySubset( const IngestResult& ing, co EmittedBodies dummy; wrapperLen = packTaskRender( [ & ]( std::FILE* m ) { - packBodies( m, ing, {}, SIZE_MAX, g.outOff, g.outTargets, in.compress, in.redact, nullptr, nullptr, &dummy ); + packBodies( m, ing, {}, SIZE_MAX, g.outOff, g.outTargets, g.outNameOnly, in.compress, in.redact, nullptr, nullptr, &dummy ); }, in.renderFaults ).text.size(); for( std::size_t i = 0; i < n; ++i ) { @@ -1729,7 +1729,7 @@ inline std::string packTaskBundleText( const IngestResult& ing, const Graph& g, const std::vector renderIds = selectMonotoneBodySubset( ing, g, bodyIds, bodiesBudget, in ); const PackTaskRendered bodies = packTaskRender( [ & ]( std::FILE* m ) { - packBodies( m, ing, renderIds, bodiesBudget, g.outOff, g.outTargets, in.compress, in.redact, + packBodies( m, ing, renderIds, bodiesBudget, g.outOff, g.outTargets, g.outNameOnly, in.compress, in.redact, /*ranges=*/nullptr, /*noteIndex=*/nullptr, &emittedBodies, /*truncateOversizedFirst=*/true, /*withFileContext=*/false, in.rootArg, in.calleeRank ); }, in.renderFaults ); diff --git a/src/pathgaps.h b/src/pathgaps.h new file mode 100644 index 000000000..9b66f209c --- /dev/null +++ b/src/pathgaps.h @@ -0,0 +1,346 @@ +#pragma once +// pathgaps.h — what a reachable="0" path answer's search MET that the call graph holds no edge for (PATH-GAP honesty). +// +// THE DEFECT. --path=A,B (and the MCP path_between twin) answered `reachable="0" hint="no directed call path"` whenever +// the BFS over resolved call edges did not reach B, although that search had walked past calls the graph keeps NO edge +// for: a call the resolver DECLINED (two or more same-language candidates, none local, nothing chose one), a call left +// UNRESOLVED (an in-repo name whose every definition was filtered out), a function handed off as a VALUE (stored or +// passed, then invoked by whoever holds it) and a call made THROUGH a parameter or slot. Any of those can be the hop +// that joins A to B, so "no path" there was a wholeness claim the search could not make. +// A fifth population (train 26b, honesty-small condition 3): a call bound by NAME ALONE (FE-B via="name") has edges only to +// the candidates in reach of the caller, so a same-language, same-kind definition of that name elsewhere (another +// directory) is never searched — the fx/lpy case: run(obj) calls obj.process(), the edge goes to the same-directory +// A.process, and other/B.process, which calls the target, was invisible behind a plain "no directed call path". +// +// THE CONTRACT. pathSearchGaps walks the same directed cone the BFS walked (every symbol reachable from the from= +// definitions over resolved call edges) and counts those five populations inside it. When the count is non-zero the +// answer never says "no directed call path": it says the search was incomplete, gives the counts (searched=, gaps=), +// and names the cone symbols that carry them ( rows, nearest to from= first, capped at kPathGapRows and +// disclosed) with a next= that reads their bodies. When the count is zero the old wording stands, byte for byte. +// +// What it does NOT mean: a row is not a hop and not evidence that a path exists — it is where the search could +// not see. AMBIGUOUS calls are deliberately NOT a gap: the resolver gives every candidate of a k-way split its own edge +// (graph.h buildGraph's splitPick), and the BFS follows all of them, so ambiguity never hides a path (it can only make +// a FOUND path a guess, which prov="split" / amb= already disclose). Calls to names with no in-repo definition at all +// (Undefined) are not counted either: the graph keeps no per-caller tally of them, and nearly all are library calls. +// Both are floors the legend sentence states. A via="name" call whose every same-language, same-kind definition of the +// name is in the searched cone (its own edge or another cone symbol's) is not a gap either: nothing it could bind to was unsearched. +// +// ONE analysis and ONE emitter for both transports (verbs_navigate.h runPath, mcpverbs.h pathText): the two used to +// carry their own copies of the hint and must not drift on this clause. + +#include "graph.h" // Graph — out-edges (the cone), declinedOut / unresolvedOut (the per-caller gap counts) +#include "model.h" +#include "nextverb.h" // nextAttrXml — the one pasteable follow-up +#include "sarif.h" // rootRelativeUri — p= relative to root=, as every other row +#include "serialize.h" // escapeXml +#include "valuerefindex.h" // ValueRefIndex::targetsOf — what a value reference may hand off +#include "infra/sortutil.h" // svLess: the explicit byte order every string_view sort here takes (portablebuildcheck #6) + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace rw +{ + +inline constexpr std::size_t kPathGapRows = 3; // rows printed; gap_syms= counts them all, gap_syms_capped="1" says so + +// The five gap populations, in the order gaps= lists them. A declarative table (CONTRIBUTING §3): the spelling lives once. +enum class PathGapKind : std::uint8_t { Declined, Unresolved, Value, Through, Name }; +inline constexpr std::size_t kPathGapKindCount = 5; +inline constexpr std::array kPathGapKindNames = { "declined", "unresolved", "value", "through", "name" }; +using PathGapCounts = std::array; + +struct PathGapSym +{ + NodeId id = kNoNode; + std::uint32_t depth = 0; // hops from the nearest from= definition + std::size_t calls = 0; // the sum of counts — the row order's second key + PathGapCounts counts{}; +}; + +struct PathSearchGaps +{ + std::size_t searched = 0; // symbols the directed search reached, the from= definitions included + std::size_t calls = 0; // every gap in the cone: the sum of totals + PathGapCounts totals{}; + std::vector syms; // cone symbols carrying >= 1 gap: nearest first, then more gaps, then id + + bool any() const noexcept { return calls > 0; } +}; + +// NAME gaps: per cone symbol, the callee names it reaches only by name (an edge whose Graph::outNameOnly bit is set) for +// which the tree holds a same-language, same-kind DEFINITION outside the cone — a namesake the search never walked. A +// namesake inside the cone was searched (another cone symbol reaches it), and every edge target of a cone symbol is in +// the cone, so "outside the cone" also means the call has no edge to it. Counted once per such name per caller. Only the +// names behind the cone's name-only edges are looked up. +inline void countUnlistedNamesakes( const IngestResult& ing, const Graph& g, std::span cone, std::vector& per ) +{ + EXPECTS( g.outNameOnly.size() == g.outTargets.size(), "buildGraph allocates the hedge bits one per flattened edge" ); + struct Key + { + Lang lang; + SymKind kind; + std::string_view name; + }; + // explicit byte order on the name (never string_view's operator< / <=>: libstdc++'s compare wraps n1 - n2 under + // -fsanitize=integer, portablebuildcheck #6/#6b); == needs no order + const auto keyLess = []( const Key& a, const Key& b ) + { + if( a.lang != b.lang ) + { + return a.lang < b.lang; + } + if( a.kind != b.kind ) + { + return a.kind < b.kind; + } + return sortutil::svLess( a.name, b.name ); + }; + const auto keyEq = []( const Key& a, const Key& b ) { return a.lang == b.lang && a.kind == b.kind && a.name == b.name; }; + const auto keyOf = [ & ]( NodeId t ) { const Symbol& s = ing.symbols[t]; return Key{ s.lang, s.kind, s.name }; }; + const auto hasHedge = [ & ]( NodeId u ) { return std::any_of( g.outNameOnly.begin() + g.outOff[u], g.outNameOnly.begin() + g.outOff[u + 1], []( std::uint8_t b ) { return b != 0; } ); }; + const auto isSym = [ & ]( std::uint32_t k ) { return g.outTargets[k] < ing.symbols.size(); }; + std::vector names; + for( const NodeId u : cone ) + { + for( std::uint32_t k = g.outOff[u]; k < g.outOff[u + 1]; ++k ) + { + if( g.outNameOnly[k] != 0 && isSym( k ) ) + { + names.push_back( ing.symbols[ g.outTargets[k] ].name ); + } + } + } + if( names.empty() ) + { + return; + } + std::ranges::sort( names, sortutil::svLess ); + names.erase( std::ranges::unique( names ).begin(), names.end() ); + std::vector searched( ing.symbols.size(), 0 ); // the cone, by symbol id + for( const NodeId u : cone ) + { + if( u < searched.size() ) + { + searched[u] = 1; + } + } + std::vector unsearched; // the tree's definitions of exactly those names that the search never reached + for( std::size_t i = 0; i < ing.symbols.size(); ++i ) + { + const Symbol& s = ing.symbols[i]; + if( searched[i] == 0 && isDefinitionNotDeclaration( s ) && std::ranges::binary_search( names, std::string_view( s.name ), sortutil::svLess ) ) + { + unsearched.push_back( Key{ s.lang, s.kind, s.name } ); + } + } + if( unsearched.empty() ) + { + return; + } + std::ranges::sort( unsearched, keyLess ); + for( const NodeId u : cone ) + { + if( !hasHedge( u ) ) + { + continue; + } + std::vector hedged; // the targets u reached by name alone + for( std::uint32_t k = g.outOff[u]; k < g.outOff[u + 1]; ++k ) + { + if( isSym( k ) && g.outNameOnly[k] != 0 ) + { + hedged.push_back( keyOf( g.outTargets[k] ) ); + } + } + std::ranges::sort( hedged, keyLess ); + hedged.erase( std::ranges::unique( hedged, keyEq ).begin(), hedged.end() ); + for( const Key& key : hedged ) + { + per[u][ std::size_t( PathGapKind::Name ) ] += std::ranges::binary_search( unsearched, key, keyLess ) ? 1u : 0u; + } + } +} + +// The directed cone of `srcs` (the same out-edges shortestPathAny walks, every one of them — no early stop at a target, +// because this runs only when there was none) and the gap populations inside it. Deterministic: BFS in id-ascending +// out-edge order, a reference pass in index order, and a total order on the rows. +inline PathSearchGaps pathSearchGaps( const IngestResult& ing, const Graph& g, std::span srcs, const ValueRefIndex& values ) +{ + constexpr std::uint32_t kUnseen = std::numeric_limits::max(); + const std::size_t N = g.wOutDeg.size(); + EXPECTS( g.outOff.size() == N + 1, "the out-edge CSR has one offset per node plus the end" ); + std::vector depth( N, kUnseen ); + std::vector queue; + queue.reserve( 64 ); + for( const NodeId s : srcs ) + { + if( s < N && depth[s] == kUnseen ) + { + depth[s] = 0; + queue.push_back( s ); + } + } + for( std::size_t head = 0; head < queue.size(); ++head ) + { + const NodeId u = queue[head]; + for( std::uint32_t k = g.outOff[u]; k < g.outOff[u + 1]; ++k ) + { + const NodeId v = g.outTargets[k]; + if( v < N && depth[v] == kUnseen ) + { + depth[v] = depth[u] + 1; + queue.push_back( v ); + } + } + } + + PathSearchGaps out; + out.searched = queue.size(); + std::vector per( N ); // only cone entries are ever written + const auto inCone = [ & ]( NodeId n ) noexcept { return n < N && depth[n] != kUnseen; }; + for( const NodeId u : queue ) + { + per[u][ std::size_t( PathGapKind::Declined ) ] = u < g.declinedOut.size() ? g.declinedOut[u] : 0; + per[u][ std::size_t( PathGapKind::Unresolved ) ] = u < g.unresolvedOut.size() ? g.unresolvedOut[u] : 0; + } + for( std::uint32_t i = 0; i < ing.references.size(); ++i ) + { + const Reference& r = ing.references[i]; + if( !inCone( r.fromSymbol ) ) + { + continue; + } + if( r.role == RefRole::Through ) + { + ++per[ r.fromSymbol ][ std::size_t( PathGapKind::Through ) ]; + } + else if( r.role == RefRole::Value ) + { + // A function handed off as a value is a gap only when the search did not reach it by a call anyway. + const std::vector targets = values.targetsOf( i ); + if( std::ranges::any_of( targets, [ & ]( NodeId t ) { return t < N && !inCone( t ); } ) ) + { + ++per[ r.fromSymbol ][ std::size_t( PathGapKind::Value ) ]; + } + } + } + if( !g.outNameOnly.empty() ) // FE-B's hedge bits (absent on a graph built without them: no name population) + { + countUnlistedNamesakes( ing, g, queue, per ); + } + for( const NodeId u : queue ) + { + std::size_t here = 0; + for( std::size_t k = 0; k < kPathGapKindCount; ++k ) + { + here += per[u][k]; + out.totals[k] += per[u][k]; + } + if( here > 0 ) + { + out.calls += here; + out.syms.push_back( PathGapSym{ u, depth[u], here, per[u] } ); + } + } + std::ranges::sort( out.syms, []( const PathGapSym& a, const PathGapSym& b ) + { + if( a.depth != b.depth ) + { + return a.depth < b.depth; + } + if( a.calls != b.calls ) + { + return a.calls > b.calls; + } + return a.id < b.id; + } ); + ENSURES( out.any() == !out.syms.empty(), "a non-zero total is carried by at least one cone symbol, and only then" ); + return out; +} + +// gaps="declined:D,unresolved:U,value:V,through:T,name:M" — zero entries left out, table order. +inline std::string pathGapCountsValue( const PathGapCounts& c ) +{ + std::string v; + for( std::size_t k = 0; k < kPathGapKindCount; ++k ) + { + if( c[k] > 0 ) + { + v += ( v.empty() ? "" : "," ) + std::string( kPathGapKindNames[k] ) + ":" + std::to_string( c[k] ); + } + } + return v; +} + +// The legend sentence, carried only by an answer that prints the clause (absent otherwise: byte-identical). +inline constexpr std::string_view kPathGapsLegend = + "searched=N gaps= gap_syms=: reachable=0 but the search is INCOMPLETE — the N symbols reachable from from= over resolved call edges hold calls the graph " + "keeps no edge for (gaps= counts them: declined = several candidates and nothing chose one, unresolved = an in-repo name every definition of which was " + "filtered out, value = a function stored or passed as a value and not otherwise reached, through = a call through a parameter or slot, name = a call " + "bound by name alone (via=name) whose name the tree also defines, same language and kind, where the call has no edge: that namesake was never " + "searched), so this is NOT proof that no path exists. rows are the symbols that carry them, nearest to from= first, " + "gap_syms_capped=1 = more than shown; a row is where the search could not see, never a hop. Not counted: ambiguous calls (every candidate has an " + "edge the search followed) and calls to names defined nowhere in the tree. next= reads the rows' bodies. "; +inline std::string_view pathGapsLegend( bool on ) noexcept +{ + return on ? kPathGapsLegend : std::string_view(); +} + +struct PathGapsXml +{ + std::string rootAttrs; // searched= gaps= gap_syms= [gap_syms_capped=] hint= next=, or the plain no-path hint alone + std::string rows; // the rows, the path element's only children when reachable="0" ("" with the plain hint) +}; + +// The transport's own spellings of the follow-ups, so one function writes both dialects: `connect` is the undirected verb +// ("--connect=A,B" on the CLI, "the connect verb on A,B" on MCP), `usesImpact` the non-call one ("--uses/--impact" / +// "uses/impact"), `tail` the CLI's several-defs clause (plain text, "" on MCP) — exactly as the no-gap hint had them. +struct PathHintSpelling +{ + std::string connect; + std::string_view usesImpact; + std::string_view tail; +}; + +// The reachable="0" clause: the gap clause when the search met a gap, else the plain hint, byte-identical to before. +// `rootPrefix` empty ⇒ p= stays as indexed (multi-root), exactly as the hop rows spell it. +inline PathGapsXml pathUnreachedXml( const IngestResult& ing, const PathSearchGaps& gaps, bool singleRoot, std::string_view rootPrefix, + const PathHintSpelling& say ) +{ + std::vector esc; + const auto ex = [ & ]( std::string_view s ) -> std::string { return std::string( escapeXml( s, esc ) ); }; + PathGapsXml out; + if( !gaps.any() ) + { + out.rootAttrs = " hint=\"no directed call path — try " + ex( say.connect ) + " (undirected: finds a shared caller), or " + + std::string( say.usesImpact ) + " for non-call references" + std::string( say.tail ) + "\""; + return out; + } + const std::size_t shown = std::min( gaps.syms.size(), kPathGapRows ); + std::string next = "--expand="; + for( std::size_t i = 0; i < shown; ++i ) + { + const Symbol& s = ing.symbols[ gaps.syms[i].id ]; + const std::string_view rp = singleRoot ? sarif::rootRelativeUri( ing.files[ s.fileId ], rootPrefix ) : std::string_view( ing.files[ s.fileId ] ); + out.rows += ""; + // file:name, the selector spelling --expand resolves to this one definition (the same p= the row prints) + next += ( i == 0 ? "" : "," ) + std::string( rp ) + ":" + s.name; + } + out.rootAttrs = " searched=\"" + std::to_string( gaps.searched ) + "\" gaps=\"" + pathGapCountsValue( gaps.totals ) + "\" gap_syms=\"" + + std::to_string( gaps.syms.size() ) + "\"" + ( gaps.syms.size() > shown ? " gap_syms_capped=\"1\"" : "" ) + + " hint=\"no path through resolved call edges, but the search is incomplete: it met calls or hand-offs it could not follow in full (gaps=) — read the gap rows, or try " + + ex( say.connect ) + " (undirected: finds a shared caller)" + std::string( say.tail ) + "\"" + nextAttrXml( next ); + return out; +} + +} // namespace rw diff --git a/src/quality.h b/src/quality.h index 3de7c2fae..3b8b98450 100644 --- a/src/quality.h +++ b/src/quality.h @@ -26,6 +26,7 @@ #include "clones.h" #include "cloneidiom.h" // idiom-class demotion — the closed 3-idiom shape classifier that turns an idiom-COLLISION clone group into a minor row instead of a gating one #include "lintrules.h" // findQualityConstructs — the built-in error-masking rule table (GitClear +47% kind) + the placeholder shapes +#include "defectshape.h" // the defect-shape kind's four scanners (format-arity, utf8-cut, dedup-first, vacuous-assert) #include "arch.h" // fnv1a64 #include "pathguard.h" // CWE-59/367: rw::pathguard::openNoFollowTruncate — writeBaseline truncates, so its open must refuse a link atomically #include "gitmine.h" // shSingleQuote + gitFileCommitCountsInDayWindow — short-horizon-churn window mining @@ -249,6 +250,21 @@ inline std::string baselineCanonId( const IngestResult& ing, NodeId i, std::stri return canonicalIdRelTo( ing, ing.symbols[i], root ); } +// The defect-shape kind's per-site record. `anchor` is the definition the site sits in (its qualityKey) or, for +// top-level code, defectFileAnchor( path ). `site` is fnv1a64 of the facet name and the site's NORMALIZED text, +// with the facet in its low two bits; two values are reserved: 0 is the FILE-PRESENCE record (the file was +// scanned at all — the origin oracle of a file anchor), 1 is "the file did not read" (its sites are unknown, so +// no current site in that file may be judged new against it). +struct DefectSiteKey +{ + std::uint64_t anchor = 0; + std::uint64_t site = 0; + friend bool operator==( const DefectSiteKey&, const DefectSiteKey& ) = default; + friend auto operator<=>( const DefectSiteKey&, const DefectSiteKey& ) = default; +}; +inline constexpr std::uint64_t kDefectFilePresent = 0; +inline constexpr std::uint64_t kDefectFileUnreadable = 1; + // A deterministic snapshot of the structural-quality state. Each per-symbol metric map is keyed by // hash(baselineCanonId) and stores the MAX over the overload set sharing that id (see computeSnapshot) — so a // low-metric overload written last can never manufacture a phantom regression on the next delta (the trap that @@ -267,6 +283,7 @@ struct Snapshot std::vector cloneGroups; // sorted hash(sorted member canonIds) std::vector dead; // sorted hash(canonId) of dead-candidate symbols std::vector publicApi; // Q1 contract drift — sorted hash(canonId) of PUBLIC/exported symbols (see isPublicApi) + std::vector defectSites; // the defect-shape kind — sorted (anchor, site) records, one per offending site (a multiset), plus each scanned file's presence record }; // Q1 api-surface — the PUBLIC/exported contract surface. DEFINITION (deterministic, documented): @@ -692,7 +709,7 @@ inline std::vector topLevelCalleeNameHashes( const IngestResult& // which is the direction a deletion candidate must err in. inline bool languageInvokedSymbol( const Symbol& s ) noexcept { - if( s.kind == SymKind::Class || s.kind == SymKind::Struct || s.kind == SymKind::Interface ) + if( s.kind == SymKind::Class || isStructOrNamedType( s.kind ) || s.kind == SymKind::Interface ) { return true; // a type is declared, never called } @@ -984,6 +1001,90 @@ inline std::uint64_t qualityKey( const IngestResult& ing, NodeId i, std::string_ return pathQualifiedKey( relForHash( ing.files[ s.fileId ], root ), s ); // the Symbol overload: the one keying rule per language } +// ─── the defect-shape kind (the twelfth) ───────────────────────────────────────────────────────────────── +// The anchor of top-level code: a key no definition can carry (no symbol has an empty name). +inline std::uint64_t defectFileAnchor( std::string_view relPath ) +{ + return pathQualifiedKey( relPath, "", "" ); +} + +inline defectshape::Facet defectFacetOf( std::uint64_t site ) noexcept +{ + return static_cast( site & 3u ); +} + +// A site's identity: the facet and its normalized text. Never one of the two reserved file records (0, 1). +inline std::uint64_t defectSiteValue( defectshape::Facet f, std::string_view text ) +{ + std::string id( defectshape::facetName( f ) ); + id.push_back( '\0' ); + id.append( text ); + std::uint64_t h = fnv1a64( id ) & ~std::uint64_t( 3 ); + if( h == 0 ) + { + h = 4; + } + const std::uint64_t v = h | static_cast( f ); + const bool keepsFacet = ( v & 3u ) == static_cast( f ); + ENSURES( v != kDefectFilePresent && v != kDefectFileUnreadable && keepsFacet, "a site value is never a reserved file record and carries its facet" ); + return v; +} + +// A defect-shape ROW's identity — the ack key: the anchor, the facet AND the identities of the anchor's sites of +// that facet (each a defectSiteValue: facet + normalized offending text), so an ack covers exactly the defects it +// named (checklist 16). A format-arity ack and a utf8-cut ack on one definition are two acks; and an ack of +// defect A does not cover a different defect B swapped into the same definition, nor a second site added beside +// A — any change to the anchor's site set of that facet re-reports. `siteVals` is that set, ascending, copies +// included (a multiset). fnv1a64 over the bytes: no wrapping multiply of our own (-fsanitize=integer). +// Ledgers: the line format is unchanged; an ack written by an earlier build of this lane (key = anchor + facet) +// matches no row, so its row re-reports and the ack is listed stale (finding-gone). No released build wrote one. +inline std::uint64_t defectRowKey( std::uint64_t anchor, defectshape::Facet f, const std::vector& siteVals ) +{ + std::string id( defectshape::facetName( f ) ); + id.push_back( '\0' ); + const auto put = [ &id ]( std::uint64_t v ) + { + for( int b = 0; b < 8; ++b ) + { + id.push_back( static_cast( ( v >> ( 8 * b ) ) & 0xFFu ) ); + } + }; + put( anchor ); + for( std::uint64_t v : siteVals ) + { + put( v ); + } + return fnv1a64( id ); +} + +// The anchor's real sites of facet `f` in a key-sorted record list (records of one anchor are contiguous and +// their site values ascending), for defectRowKey. +template +inline std::vector defectSiteValsOf( const Range& sorted, std::uint64_t anchor, defectshape::Facet f, KeyOf keyOf ) +{ + std::vector vals; + for( const auto& rec : sorted ) + { + const DefectSiteKey& k = keyOf( rec ); + if( k.anchor == anchor && k.site != kDefectFilePresent && k.site != kDefectFileUnreadable && defectFacetOf( k.site ) == f ) + { + vals.push_back( k.site ); + } + } + return vals; +} + +// One current-side site with what the delta needs to print it: the anchoring definition (kNoNode for a file +// anchor), its file and 1-based line. +struct DefectSiteAt +{ + DefectSiteKey key; + NodeId owner = kNoNode; + std::uint32_t fileId = 0; + std::uint32_t line = 0; +}; + + // §D#4 short-horizon-churn — a per-identity hash of the symbols' RAW body bytes, for CHANGE detection that // metrics miss. `hot(){ return 2; }` → `hot(){ return 3; }` moves NO metric (same ccx/loc/nest/params), and // clones.h normalization erases the literal (`$N`), so neither can tell the body changed — only a raw-byte @@ -2179,10 +2280,16 @@ inline std::string cacheRootKeyHex( const std::string& root ) // `static_assert( quality::kIngestParserVerMirror == kParserVer && … )`, so a missed mirror now fails the build. It does // not include this header; it relies on ingest.cpp including quality.h (line 13) before ingest_cache.h, and a reorder // that broke that fails the build on the undeclared name rather than passing. -constexpr std::uint32_t kIngestCacheVersionMirror = 28; // MUST equal ingest.cpp's kCacheVersion (gated); 28 = FE-A ref memberCall/memberRoot, 27 = corrected fnScope values, 26 = function-local def scope span (25 = #157 + #150) -constexpr std::uint32_t kIngestParserVerMirror = 148; // MUST equal ingest.cpp's kParserVer (gated) +constexpr std::uint32_t kIngestCacheVersionMirror = 29; // MUST equal ingest.cpp's kCacheVersion (gated); 29 = FE-B ref memberPath/memberCtor + 4 bind kinds, 28 = FE-A ref memberCall/memberRoot, 27 = corrected fnScope values, 26 = function-local def scope span (25 = #157 + #150) +constexpr std::uint32_t kIngestParserVerMirror = 156; // MUST equal ingest.cpp's kParserVer (gated) + // 156 = 2026-10-08 (train 26b: above every lane build, up to 154 / rich tag 155; see the + // kParserVer note) // 148 = 2026-10-04 (train 26a: PR #373 merged; above the branch's 145 and the 145–147 // lanes in flight, see kParserVer note; kIngestCacheVersionMirror stays main's 28) + // 145 = lane honesty-small-068 (lane-local; the train renumbered it) + // 145 = lane train25-cr2-followup (train 25 re-review fixes; lane-local, the train renumbered it) + // 150..154 = lane FE-B on train 26a (lane-local; the train renumbered it; 146/145 = earlier + // lane FE-B builds; kIngestCacheVersionMirror 29 is FE-B's) // 141 = 2026-10-04 (train 25: above FE-A's 134/135 and refval-edges' 140, see kParserVer // note; kIngestCacheVersionMirror 28 from FE-A, kQSnapCacheScheme 17 from refval-edges) // 140 = lane refval-edges (reference-as-value rows; see kParserVer note) @@ -3309,6 +3416,10 @@ inline void evictOldHeadSnapCaches( const std::string& dir, const std::string& r // constexpr array, not a function, so this manifest mechanism // cannot hash IT — a future edit that only changes the built-in // NAME LIST (not these functions' text) will not trip this gate. +// defectSitesOf (quality.h) — the defect-shape site identity (anchor + facet + normalized text) +// the baseline stores. NOTE (disclosed limitation): the scanners it +// calls live in defectshape.h, outside this manifest, so a change +// to what a scanner reports must bump kQSnapCacheScheme by hand. // v4 (r27 P0.2) — the blob header gained the EXTRACTION IDENTITY (kIngestCacheVersionMirror + // kIngestParserVerMirror; see the long note at their declaration). Everything a Snapshot contains is a // function of tree-sitter extraction, so a parserVer bump must retire the blob — it did not, and 28c7d32's @@ -3393,7 +3504,18 @@ inline void evictOldHeadSnapCaches( const std::string& dir, const std::string& r // argument holds as a VALUE (valuerefindex.h ValueRefIndex::isValueReferenced, checked after every other exemption, // counted as value-ref-excluded= like --dead-code): the dead SET moved, as in v9/v12/v15. kParserVer moved with // the extraction (the Value/Through rows) and its mirror moved with it. Bumped 16 -> 17. -constexpr std::uint32_t kQSnapCacheScheme = 17; +// v18 (2026-10-04, train 25 re-review fixes) — isValueReferenced ignores a value reference made inside the function +// itself (`timer_set( tick )` in tick), as the CSR drops a recursive self-call: the dead SET moved, as in v9/v12/v15/v17. +// kParserVer moved too (145: prototype parameters, the depth-cut record). Bumped 17 -> 18. +// v19 (lane/cr-qd-kinds-068, the defect-shape kind; the lane's own number was 18, which the cr2 follow-up above also took for +// a different scheme — train 26b renumbers it 19, both notes kept) — the blob gained the defect-shape (anchor, site) records +// after publicApi: a BLOB SHAPE change. A v17 blob has none, so served here it would read every format-arity +// site in the tree as newly added. Bumped by the v4/v5 rule (the producer identity already keeps this build's +// blobs apart; the bump keeps the history above complete). Fix round 1 of the same lane, still the lane's scheme 18 (18 never +// shipped): defectSitesOf passes the tree's macro names to the C++ scanner, and the scanners skip more calls (an +// argument that may be a macro, a directive inside the call, #if 0, a brace-spelling escape) and guard more +// vacuous-assert absences — fewer records, the same blob shape; the producer identity keeps the builds apart. +constexpr std::uint32_t kQSnapCacheScheme = 19; constexpr char kQSnapMagic[4] = { 'Q', 'S', 'N', 'P' }; // The qsnap EXCLUDES-config key folds the qsnap SCHEME (independent of the ingest cache's kHeadSnapCacheScheme) @@ -3488,8 +3610,9 @@ inline bool qsnapCountFits( const char* p, const char* end, std::uint32_t count, } // Serialize a Snapshot to a self-validating blob: [magic][scheme][cacheVer][parserVer][producer][fnv(headSha)] then each -// of the 9 fields as a uint32 count followed by its flat records (btree maps in sorted key order, vectors -// as-is), then an fnv1a64 checksum over all preceding bytes. Byte-stable for a fixed Snapshot. +// field as a uint32 count followed by its flat records (btree maps in sorted key order, vectors as-is; the +// defect-shape records last, as anchor+site pairs), then an fnv1a64 checksum over all preceding bytes. +// Byte-stable for a fixed Snapshot. // P0.2 (r27): cacheVer/parserVer are the EXTRACTION IDENTITY every field below is a function of — see the note // at kIngestCacheVersionMirror. They are in the filename key too; carrying them here as well means a blob // reached by any other route (hand-copied, collided) is REJECTED rather than believed. @@ -3523,6 +3646,12 @@ inline std::string serializeSnapshot( const Snapshot& s, const std::string& head putVec( s.cloneGroups ); putVec( s.dead ); putVec( s.publicApi ); + qsnapPut( buf, std::uint32_t( s.defectSites.size() ) ); // v18: the defect-shape (anchor, site) records, in sorted order + for( const DefectSiteKey& d : s.defectSites ) + { + qsnapPut( buf, d.anchor ); + qsnapPut( buf, d.site ); + } const std::uint64_t sum = fnv1a64( std::string_view( buf.data(), buf.size() ) ); qsnapPut( buf, sum ); @@ -3646,7 +3775,28 @@ inline bool deserializeSnapshot( const std::string& blob, const std::string& hea return true; }; - if( !getValMap( s.ccxBySym ) || !getValMap( s.locBySym ) || !getValMap( s.nestBySym ) || !getValMap( s.paramsBySym ) || !getValMap( s.defsBySym ) || !getValMap( s.maskBySym ) || !getValMap( s.maskReportBySym ) || !getValMap( s.placeholderBySym ) || !getHashMap( s.bodyHashBySym ) || !getVec( s.cloneGroups ) || !getVec( s.dead ) || !getVec( s.publicApi ) ) + const auto getDefectSites = [ & ]( std::vector& v ) -> bool + { + std::uint32_t n = 0; + if( !qsnapGet( p, end, n ) || !qsnapCountFits( p, end, n, 2 * sizeof( std::uint64_t ) ) ) + { + return false; // a corrupt blob: the caller's "cache corrupt — recomputing" path + } + v.reserve( n ); + for( std::uint32_t i = 0; i < n; ++i ) + { + DefectSiteKey d; + if( !qsnapGet( p, end, d.anchor ) || !qsnapGet( p, end, d.site ) ) + { + return false; + } + v.push_back( d ); + } + return true; + }; + + if( !getValMap( s.ccxBySym ) || !getValMap( s.locBySym ) || !getValMap( s.nestBySym ) || !getValMap( s.paramsBySym ) || !getValMap( s.defsBySym ) || !getValMap( s.maskBySym ) || !getValMap( s.maskReportBySym ) || !getValMap( s.placeholderBySym ) || !getHashMap( s.bodyHashBySym ) || !getVec( s.cloneGroups ) || !getVec( s.dead ) || !getVec( s.publicApi ) + || !getDefectSites( s.defectSites ) ) { return false; } @@ -3654,6 +3804,7 @@ inline bool deserializeSnapshot( const std::string& blob, const std::string& hea std::sort( s.cloneGroups.begin(), s.cloneGroups.end() ); // computeDelta binary_searches these — enforce order std::sort( s.dead.begin(), s.dead.end() ); std::sort( s.publicApi.begin(), s.publicApi.end() ); + std::sort( s.defectSites.begin(), s.defectSites.end() ); // computeDelta merges these in key order out = std::move( s ); return true; } @@ -4421,29 +4572,25 @@ inline bool deserializeRawCommitStream( const std::string& blob, const std::stri return true; } -// The memoized drop-in for gitCoChangeAndChurn: same signature, same return contract, but the expensive -// `git log --name-only` walk is skipped on a warm hit (blob self-validates against `keyMat`; any mismatch — -// wrong headSha, wrong coSince, drifted boundary, or a corrupt/foreign blob — is a clean miss that falls -// through to a full recompute, never a wrong answer). No git repo / no resolvable HEAD degrades straight to -// the uncached walk (which itself degrades to an empty stream — gitLogNameOnlyRaw's own contract). -inline std::vector> gitCoChangeAndChurnCached( - const std::string& root, const IngestResult& ing, const char* coSince, std::size_t maxFiles, - unsigned churnMonths = 0, std::vector* outChurn = nullptr, - std::uint32_t onlyRoot = UINT32_MAX ) +// The memoized raw stream, WITHOUT any ingest: the git probes, the qchurn blob and, on a miss, the walk — everything +// the amp=/churn= computation needs before it resolves paths against an `ing`. It reads nothing but the repo's committed +// history and the blob, so a caller that wants the walk's wall time hidden behind its own ingest starts it first +// (main.cpp does, for --for/--metrics/--exemplar) and resolves the result once `ing` exists. A cache hit skips the walk +// (blob self-validates against `keyMat`; any mismatch — wrong headSha, wrong coSince, drifted boundary, or a +// corrupt/foreign blob — is a clean miss that falls through to a full recompute, never a wrong answer). No git repo +// degrades to an empty stream; no resolvable HEAD to the uncached walk (which itself degrades to an empty stream — +// gitLogNameOnlyRaw's contract). Thread-safe: every memo it reaches (the HEAD anchor, the toplevel) takes its own +// mutex, and the blob write is an atomic rename. +inline RawCommitStream gitRawCommitStreamCached( const std::string& root, const char* coSince ) { if( !hasEnclosingGitRepo( root ) ) { - return resolveCommitStream( RawCommitStream{}, ing, maxFiles, /*churnCutoff=*/0, outChurn, onlyRoot ); + return RawCommitStream{}; } - // F1: the churn sub-window's cutoff, resolved ONCE from HEAD's own committer epoch — the same anchor the - // co-change window (inside gitLogNameOnlyRaw) uses, so the two horizons this one walk yields agree. - // AFTER the no-repo check, so a non-git root never reaches the anchor read at all. - const std::int64_t churnCutoff = rw::defaultWindowCutoffEpoch( root, churnMonths ); - const std::string headSha = gitHeadSha( root ); if( headSha.empty() ) { - return resolveCommitStream( gitLogNameOnlyRaw( root, coSince ), ing, maxFiles, churnCutoff, outChurn, onlyRoot ); + return gitLogNameOnlyRaw( root, coSince ); } const std::string repoHex = cacheRootKeyHex( root ); @@ -4458,13 +4605,43 @@ inline std::vector> gitCoChangeAndChurnCached( const std::optional blob = readQSnapBlob( cachePath ); if( blob && deserializeRawCommitStream( *blob, keyMat, raw ) ) { - return resolveCommitStream( raw, ing, maxFiles, churnCutoff, outChurn, onlyRoot ); // warm hit — no walk + return raw; // warm hit — no walk } raw = gitLogNameOnlyRaw( root, coSince ); // cold — the 431 ms walk atomicWriteFile( cachePath, serializeRawCommitStream( raw, keyMat ) ); // best-effort; a failed // write just recomputes next time - return resolveCommitStream( raw, ing, maxFiles, churnCutoff, outChurn, onlyRoot ); + return raw; +} + +// One root's amp=/churn= history, fetched without an ingest: the root it was walked for and its raw 18-month stream. +// main.cpp starts one per root beside the ingest and resolves each against `ing` afterwards. +struct HistoryWalk +{ + std::string root; + RawCommitStream raw; +}; + +inline HistoryWalk gitHistoryWalk( std::string root ) +{ + RawCommitStream raw = gitRawCommitStreamCached( root, "18 months ago" ); + return HistoryWalk{ std::move( root ), std::move( raw ) }; +} + +// The resolve half, the memoized drop-in for gitCoChangeAndChurn on the 18-month window main.cpp's amp=/churn= block +// reads (commits with more than 30 files dropped from the sets): `walk` resolved against the CALLER's current `ing`. +// Pure in-memory work except the HEAD anchor's memo (one `git log -1` the first time a root asks); a root with no +// enclosing repo never reaches that anchor at all. +inline std::vector> resolveHistoryWalk( + const HistoryWalk& walk, const IngestResult& ing, unsigned churnMonths = 0, + std::vector* outChurn = nullptr, std::uint32_t onlyRoot = UINT32_MAX ) +{ + constexpr std::size_t kMaxFilesPerCommit = 30; + // F1: the churn sub-window's cutoff, resolved ONCE from HEAD's own committer epoch — the same anchor the + // co-change window (inside gitLogNameOnlyRaw) uses, so the two horizons this one walk yields agree. + // AFTER the no-repo check, so a non-git root never reaches the anchor read at all. + const std::int64_t churnCutoff = hasEnclosingGitRepo( walk.root ) ? rw::defaultWindowCutoffEpoch( walk.root, churnMonths ) : 0; + return resolveCommitStream( walk.raw, ing, kMaxFilesPerCommit, churnCutoff, outChurn, onlyRoot ); } // `root` = the ingest root exactly as invoked (cfg.rootPath). It is folded into every baseline key via @@ -4553,6 +4730,87 @@ inline std::vector baselineCloneGroupHashes( const IngestResult& return out; } +// Every defect-shape site in the tree, anchored on the innermost enclosing definition (byte-span containment, +// the same rule error-masking counts by) or on the file. Languages: C++ (format-arity, utf8-cut, +// dedup-first), Python (format-arity), Bash under a test-script path (vacuous-assert). Each scanned file also +// leaves its presence record; a file that does not read leaves the unreadable record instead and contributes +// no site (a partial read is evidence of nothing, forEachSymbolBody's rule) — the delta then judges no current +// site in that file against it. Sorted by key; deterministic (files in id order, scanners pure). +inline std::vector defectSitesOf( const IngestResult& ing, std::string_view root, const std::vector* fileInBaseline ) +{ + std::vector out; + const SymbolsByFile byFile = symbolsByFileInIdOrder( ing, []( const Symbol& s ) { return s.endByte > s.sigStartByte && s.kind != SymKind::ModuleScope; } ); + // format-arity's F1 skip: every macro the index holds in a C-family file, tree-wide (a call in one file may use + // a macro another file defines). Sorted, unique: the scanner binary-searches it. + std::vector treeMacros; + for( const Symbol& s : ing.symbols ) + { + if( s.kind == SymKind::Macro && ( s.lang == Lang::Cpp || s.lang == Lang::C || s.lang == Lang::ObjC ) ) + { + treeMacros.push_back( s.name ); + } + } + std::sort( treeMacros.begin(), treeMacros.end() ); + treeMacros.erase( std::unique( treeMacros.begin(), treeMacros.end() ), treeMacros.end() ); + for( std::uint32_t f = 0; f < ing.files.size(); ++f ) + { + if( !fileIsInBaseline( fileInBaseline, f ) ) + { + continue; + } + const std::string_view rel = relForHash( ing.files[f], root ); + const Lang lang = langOfPath( rel ); + if( lang != Lang::Cpp && lang != Lang::Python && !( lang == Lang::Bash && isTestScriptPath( rel ) ) ) + { + continue; + } + const std::uint64_t fileAnchor = defectFileAnchor( rel ); + std::optional bytes = docparse::detail::readWholeFile( diskPath( ing, f ) ); + if( !bytes ) + { + out.push_back( { { fileAnchor, kDefectFileUnreadable }, kNoNode, f, 0 } ); + continue; + } + out.push_back( { { fileAnchor, kDefectFilePresent }, kNoNode, f, 0 } ); + const std::string_view src = *bytes; + std::vector fnSpans; + if( f < byFile.size() ) + { + for( NodeId i : byFile[f] ) + { + const Symbol& s = ing.symbols[i]; + if( ( s.kind == SymKind::Function || s.kind == SymKind::Method ) && s.endByte <= src.size() ) + { + fnSpans.push_back( { s.sigStartByte, s.endByte } ); + } + } + } + std::vector sites; + if( lang == Lang::Cpp && defectshape::cppMayHoldShapes( src ) ) + { + sites = defectshape::scanCpp( src, fnSpans, &treeMacros ); + } + else if( lang == Lang::Python && src.find( ".format" ) != std::string_view::npos ) + { + sites = defectshape::scanPython( src ); + } + else if( lang == Lang::Bash && src.find( "grep" ) != std::string_view::npos ) + { + sites = defectshape::scanBashTestScript( src, fnSpans ); + } + for( const defectshape::Site& st : sites ) + { + ASSUME( st.startByte < src.size(), "a scanner reports a site at a byte of the file it scanned" ); + const NodeId owner = enclosingDefOf( ing, byFile, f, st.startByte ); + const std::uint64_t anchor = owner == kNoNode ? fileAnchor : qualityKey( ing, owner, root ); + const std::uint32_t line = static_cast( 1 + std::count( src.begin(), src.begin() + st.startByte, '\n' ) ); + out.push_back( { { anchor, defectSiteValue( st.facet, st.text ) }, owner, f, line } ); + } + } + std::stable_sort( out.begin(), out.end(), []( const DefectSiteAt& a, const DefectSiteAt& b ) { return a.key < b.key; } ); + return out; +} + inline Snapshot computeSnapshot( const IngestResult& ing, const Graph& g, std::string_view root, const std::vector* fileInBaseline ) { @@ -4609,6 +4867,14 @@ inline Snapshot computeSnapshot( const IngestResult& ing, const Graph& g, std::s snap.placeholderBySym = std::move( counts.placeholder ); } + // the defect-shape baseline: every offending site (a sorted multiset of anchor + site identity) and each + // scanned file's presence record. Filtered to the baseline files inside defectSitesOf (IDENTITY BASIS). + { + const std::vector sites = defectSitesOf( ing, root, fileInBaseline ); + snap.defectSites.resize( sites.size() ); + std::ranges::transform( sites, snap.defectSites.begin(), &DefectSiteAt::key ); + } + // §D#4 short-horizon-churn baseline: per-canonId RAW-body hash so the delta detects a rewrite that moved no // metric (a literal-only edit). Compared, never bar-checked — presence-or-difference IS the rewrite signal. snap.bodyHashBySym = bodyHashesBySym( ing, root ); @@ -4916,6 +5182,15 @@ inline int openBaselineSidecar( const std::string& path ) // than absorb — and it is written as two records the snapshot reader skips as unknown kinds (`dirty 1`, // `absorbed N`), exactly like the `head` stamp above: the fact has to outlive the process that knew it, // because the report it changes the meaning of is every LATER --quality-delta, not this run. +// The defect-shape records of a baseline sidecar: "defect ", both hex, one per record. +inline void writeDefectSiteLines( std::ostream& f, const std::vector& sites ) +{ + for( const DefectSiteKey& d : sites ) + { + f << "defect " << std::hex << d.anchor << ' ' << d.site << std::dec << '\n'; + } +} + inline bool writeBaseline( const Snapshot& s, const std::string& path, std::string_view headSha = {}, std::size_t absorbedGating = 0 ) { @@ -5018,6 +5293,7 @@ inline bool writeBaseline( const Snapshot& s, const std::string& path, std::stri { f << "api " << std::hex << h << std::dec << '\n'; } + writeDefectSiteLines( f, s.defectSites ); // Was an unconditional `return true`: a stream that failed to flush still reported a written baseline. // The descriptor answers for the bytes, so a full disk is now a failure the caller can report. return rw::pathguard::writeAllAndClose( fd, f.str() ); @@ -5160,7 +5436,7 @@ inline bool readBaseline( const std::string& path, Snapshot& out, BaselineReadSt if( is.fail() ) { DISCLOSE( stats, BaselineReadStats::DisclosureWhy::MalformedLine ); return; } m[h] = v; }; if( kind == "ccx" || kind == "loc" || kind == "nest" || kind == "params" || kind == "mask" || kind == "maskr" || kind == "stub" || kind == "body" || kind == "clone" || kind == "dead" || kind == "api" || kind == "head" || kind == "defs" - || kind == "producer" ) + || kind == "producer" || kind == "defect" ) { ++recognizedLineCount; // structure seen — this file IS a baseline } @@ -5213,6 +5489,19 @@ inline bool readBaseline( const std::string& path, Snapshot& out, BaselineReadSt { readSet( out.publicApi ); } + else if( kind == "defect" ) + { + DefectSiteKey d; + is >> std::hex >> d.anchor >> d.site >> std::dec; + if( is.fail() ) + { + DISCLOSE( stats, BaselineReadStats::DisclosureWhy::MalformedLine ); + } + else + { + out.defectSites.push_back( d ); + } + } else if( kind == "producer" ) { readProducerRecord( is, stats ); @@ -5240,6 +5529,7 @@ inline bool readBaseline( const std::string& path, Snapshot& out, BaselineReadSt std::sort( out.cloneGroups.begin(), out.cloneGroups.end() ); std::sort( out.dead.begin(), out.dead.end() ); std::sort( out.publicApi.begin(), out.publicApi.end() ); + std::sort( out.defectSites.begin(), out.defectSites.end() ); return true; } @@ -5761,7 +6051,7 @@ inline std::uint32_t churnEditWindowCommitCount( DiffHunkMemo& memo, const std:: struct Regression { std::string kind; // "complexity" | "duplication" | "dead-code" | "verbosity" | "nesting" | "params" | "api-surface" - // | "error-masking" | "short-horizon-churn" | "new-clone-of-reused-helper" (§D#4) | "placeholder" + // | "error-masking" | "short-horizon-churn" | "new-clone-of-reused-helper" (§D#4) | "placeholder" | "defect-shape" std::string sym; // canonical id (or, for duplication, the space-joined member ids) std::uint32_t was = 0; std::uint32_t now = 0; @@ -5769,7 +6059,8 @@ struct Regression // short-horizon-churn rows carry pathQualifiedKey instead (W1-S2): a bare canonId folds scope-less same-named symbols across files, so one ack would suppress — and one finding would name — the WRONG file's symbol bool isMinor = false;// materiality tier: true = below the kind's minor-delta bar → reported sev="minor", does not gate exit 2 std::string facet; // B10.2 — optional classification facet (attribute NAME chosen by the kind in main.cpp): - // short-horizon-churn: "self" | "ambient"; api-surface: "new-symbol" | "contract-change". Empty = no facet. + // short-horizon-churn: "self" | "ambient"; api-surface: "new-symbol" | "contract-change"; + // defect-shape: "format-arity" | "utf8-cut" | "dedup-first" | "vacuous-assert". Empty = no facet. bool isNewSymbol = false;// r26 ORIGIN axis: true = the finding exists ONLY because the code is new (emitted origin="new-symbol", // counted in new-symbol=, never gates); false = preexisting-worse. See the ORIGIN block in computeDelta. // P2.5 (r27) — the LOCATOR. `sym` is a canonical id whose display tail is often a bare, one-letter local @@ -5795,6 +6086,16 @@ struct Regression bool isZeroMagnitude() const noexcept { return was == 0 && now == 0; } }; +// THE EXIT PREDICATE, in one place: every surface that says what gates — the XML gating= count and per-row +// gating attribute, the --json / MCP "gating", the exit code, --ack-only=gating, the scoped-out would-gate +// count, the allow-dirty absorbed count and the stderr naming line — asks this. A row gates when it is major +// (not sev="minor") AND either preexisting-worse or a defect-shape format-arity row: new-symbol rows never +// gate, except defect-shape format-arity, because a placeholder/argument mismatch is a defect, not debt. +inline bool rowGates( const Regression& r ) noexcept +{ + return !r.isMinor && ( !r.isNewSymbol || ( r.kind == "defect-shape" && r.facet == defectshape::facetName( defectshape::Facet::FormatArity ) ) ); +} + // B10.2 — kind → the ATTRIBUTE NAME its facet value is emitted under. `Regression::facet` carries only the // VALUE, so exactly one place decides what to call it. That place used to be THREE places (the XML // quality-delta emitter, its --json twin, and the MCP quality_delta emitter each held the same conditional @@ -5813,6 +6114,7 @@ inline constexpr FacetAttr kFacetAttrs[] = { { "short-horizon-churn", "churn" }, // self / ambient { "api-surface", "surface" }, // new-symbol / contract-change { "duplication", "idiom" }, // the recognized clone-body shape (cloneidiom.h) + { "defect-shape", "defect" }, // format-arity / utf8-cut / dedup-first / vacuous-assert { "new-clone-of-reused-helper", "idiom" }, // T13/fix2 gave this kind duplication's idiom demotion (Regression::facet // is populated the same way, from the same CloneIdiomVerdict); this row was // missing, so a demoted row's idiom name never reached the reader even @@ -7165,6 +7467,35 @@ inline void addSchemeAliases( IdentityAliases& al, const IngestResult& ing, cons std::sort( al.schemeAmbiguousKeys.begin(), al.schemeAmbiguousKeys.end() ); } +// The defect-shape records: a renamed definition's sites are re-filed under its current anchor, add-never- +// overwrite like the per-symbol maps (an anchor the baseline already holds keeps its own records). Whether a site +// is NEW never depends on this — that is decided by its text, repo-wide — only the row's was= count does. +inline std::size_t healDefectSites( Snapshot& base, const IdentityAliases& al ) +{ + std::size_t healed = 0; + std::vector addSites; + for( const auto& [ from, to ] : al.toCurrent ) + { + const auto lo = std::lower_bound( base.defectSites.begin(), base.defectSites.end(), DefectSiteKey{ from, 0 } ); + const auto toLo = std::lower_bound( base.defectSites.begin(), base.defectSites.end(), DefectSiteKey{ to, 0 } ); + if( toLo != base.defectSites.end() && toLo->anchor == to ) + { + continue; + } + for( auto it = lo; it != base.defectSites.end() && it->anchor == from; ++it ) + { + addSites.push_back( { to, it->site } ); + ++healed; + } + } + if( !addSites.empty() ) + { + base.defectSites.insert( base.defectSites.end(), addSites.begin(), addSites.end() ); + std::sort( base.defectSites.begin(), base.defectSites.end() ); + } + return healed; +} + // Heal a BASELINE snapshot forward: an entry recorded under a pre-rename identity is re-filed under the // identity the current tree uses, so computeDelta's `was` lookups, its origin oracle and its churn join all // find it without a single edit to any of them. @@ -7224,6 +7555,8 @@ inline std::size_t remapSnapshotIdentity( Snapshot& base, const IdentityAliases& healSet( base.dead ); healSet( base.publicApi ); // base.cloneGroups is deliberately untouched — see the WHAT IS NOT REMAPPED note above. + + moved += healDefectSites( base, al ); return moved; } @@ -7637,6 +7970,73 @@ inline std::optional staleForChurn( std::uint64_t key, const Snapsh return ( snap.bodyHashBySym.find( key ) == snap.bodyHashBySym.end() ) ? std::optional( StaleAckWhy::TargetGone ) : std::nullopt; } +// defect-shape: the ack key is defectRowKey( anchor, facet, the anchor's site set of that facet ), so the facet +// and the very sites are part of the finding's identity (one ack never suppresses a different shape, or a +// different defect of the same shape, on the same definition). A key no current (anchor, facet) site set +// produces is finding-gone — the acked defect was fixed, or another site of that facet joined or replaced it; +// the anchor cannot be recovered from the mixed key, so target-gone is never claimed (the clone kinds' rule). +inline std::optional staleForDefectShape( std::uint64_t key, const Snapshot& snap ) +{ + const std::vector& sites = snap.defectSites; // sorted by (anchor, site): one anchor's records are contiguous + const auto self = []( const DefectSiteKey& d ) -> const DefectSiteKey& { return d; }; + for( auto it = sites.begin(); it != sites.end(); ) + { + const auto end = std::find_if( it, sites.end(), [ & ]( const DefectSiteKey& d ) { return d.anchor != it->anchor; } ); + const std::span one( it, end ); + for( std::size_t f = 0; f < defectshape::kFacetCount; ++f ) + { + const auto facet = static_cast( f ); + const std::vector vals = defectSiteValsOf( one, it->anchor, facet, self ); + if( !vals.empty() && defectRowKey( it->anchor, facet, vals ) == key ) + { + return std::nullopt; + } + } + it = end; + } + return StaleAckWhy::FindingGone; +} + +// Which oracle answers whether an ack of kind `base` is stale — one dispatch per finding kind. An unrecognized +// kind (a future addition, or a hand-edited line) answers nullopt: left unclassified, never guessed. +inline std::optional staleWhyForKind( std::string_view base, std::uint64_t key, const Snapshot& snap ) +{ + std::optional why; + if( base == "complexity" || base == "verbosity" || base == "nesting" || base == "params" ) + { + why = staleForMetricKind( base, key, snap ); + } + else if( base == "dead-code" ) + { + why = staleForSetMembership( key, snap, snap.dead ); + } + else if( base == "api-surface" ) + { + why = staleForSetMembership( key, snap, snap.publicApi ); + } + else if( base == "error-masking" ) + { + why = staleForConstructCount( key, snap, snap.maskBySym ); + } + else if( base == "placeholder" ) + { + why = staleForConstructCount( key, snap, snap.placeholderBySym ); + } + else if( base == "duplication" || base == "new-clone-of-reused-helper" ) + { + why = staleForCloneKind( key, snap ); + } + else if( base == "short-horizon-churn" ) + { + why = staleForChurn( key, snap ); + } + else if( base == "defect-shape" ) + { + why = staleForDefectShape( key, snap ); + } + return why; +} + inline std::vector computeStaleAcks( const gtl::btree_map& acks, const Snapshot& snap ) { std::vector out; @@ -7650,35 +8050,7 @@ inline std::vector computeStaleAcks( const gtl::btree_map why; - if( base == "complexity" || base == "verbosity" || base == "nesting" || base == "params" ) - { - why = staleForMetricKind( base, rec.key, snap ); - } - else if( base == "dead-code" ) - { - why = staleForSetMembership( rec.key, snap, snap.dead ); - } - else if( base == "api-surface" ) - { - why = staleForSetMembership( rec.key, snap, snap.publicApi ); - } - else if( base == "error-masking" ) - { - why = staleForConstructCount( rec.key, snap, snap.maskBySym ); - } - else if( base == "placeholder" ) - { - why = staleForConstructCount( rec.key, snap, snap.placeholderBySym ); - } - else if( base == "duplication" || base == "new-clone-of-reused-helper" ) - { - why = staleForCloneKind( rec.key, snap ); - } - else if( base == "short-horizon-churn" ) - { - why = staleForChurn( rec.key, snap ); - } + const std::optional why = staleWhyForKind( base, rec.key, snap ); // an unrecognized kind (a future addition, or a hand-edited line) is left unclassified rather than // guessed — same "degrade, do not fabricate" rule readAckRecords already applies to a malformed line. if( why.has_value() ) @@ -7963,6 +8335,139 @@ inline std::pair scopeDisclosure( const Scope& scope, return out; } +// ── defect-shape (the twelfth kind) ─────────────────────────────────────────────────────────────────── +// A site is NEW when its identity (facet + normalized text) occurs more often in the current tree than in +// the baseline, REPO-WIDE: an untouched, moved or renamed defect is not new, an edited one is, and a swap +// (one site fixed and another added in the same definition) still is. The excess of each identity is +// credited to the anchors that hold more copies of it now than at the baseline, in anchor-key order +// (deterministic). An anchor credited with a new site of a facet gets ONE row for that facet; was= and now= +// count the anchor's sites of that facet on each side. format-arity gates on ANY origin — a placeholder / +// argument mismatch is a defect, not debt; utf8-cut, dedup-first and vacuous-assert are report-only: +// sev="minor" by facet, not by size. A file the baseline could not read judges none of its sites. +// Called from computeDelta with its origin oracle (existedAtBaseline) and its locator (stampLoc). +template +inline void appendDefectShapeRows( std::vector& regs, const IngestResult& ing, const Graph& g, const Snapshot& base, + std::string_view root, ExistedFn&& existedAtBaseline, StampFn&& stampLoc ) +{ + const std::vector nowSites = defectSitesOf( ing, root, nullptr ); + const auto isRealSite = []( std::uint64_t v ) { return v != kDefectFilePresent && v != kDefectFileUnreadable; }; + std::vector baseVals, nowVals, baseUnreadable; + for( const DefectSiteKey& d : base.defectSites ) + { + if( isRealSite( d.site ) ) + { + baseVals.push_back( d.site ); + } + else if( d.site == kDefectFileUnreadable ) + { + baseUnreadable.push_back( d.anchor ); + } + } + for( const DefectSiteAt& d : nowSites ) + { + if( isRealSite( d.key.site ) ) + { + nowVals.push_back( d.key.site ); + } + } + std::sort( baseVals.begin(), baseVals.end() ); + std::sort( nowVals.begin(), nowVals.end() ); + std::sort( baseUnreadable.begin(), baseUnreadable.end() ); + const auto countOf = []( const std::vector& v, std::uint64_t x ) + { + const auto r = std::equal_range( v.begin(), v.end(), x ); + return static_cast( r.second - r.first ); + }; + // the repo-wide excess still to credit, per identity + std::vector> excess; + for( std::size_t k = 0; k < nowVals.size(); ) + { + const std::uint64_t v = nowVals[k]; + const std::uint32_t nN = countOf( nowVals, v ); + const std::uint32_t nB = countOf( baseVals, v ); + if( nN > nB ) + { + excess.push_back( { v, nN - nB } ); + } + k += nN; + } + const auto baseAnchorCount = [ & ]( std::uint64_t anchor, std::uint64_t site ) + { + const auto r = std::equal_range( base.defectSites.begin(), base.defectSites.end(), DefectSiteKey{ anchor, site } ); + return static_cast( r.second - r.first ); + }; + const auto facetCount = []( const auto& sites, std::uint64_t anchor, defectshape::Facet f, auto keyOf ) + { + std::uint32_t n = 0; + for( const auto& s : sites ) + { + const DefectSiteKey& k = keyOf( s ); + n += ( k.anchor == anchor && k.site != kDefectFilePresent && k.site != kDefectFileUnreadable && defectFacetOf( k.site ) == f ) ? 1u : 0u; + } + return n; + }; + struct Credited + { + std::uint64_t anchor = 0; + defectshape::Facet facet = defectshape::Facet::FormatArity; + const DefectSiteAt* first = nullptr; + }; + std::vector credited; + for( std::size_t k = 0; k < nowSites.size(); ) + { + const DefectSiteKey key = nowSites[k].key; + std::size_t e = k; + while( e < nowSites.size() && nowSites[e].key == key ) + { + ++e; + } + const std::uint32_t headA = static_cast( e - k ); + const DefectSiteAt& at = nowSites[k]; + k = e; + if( !isRealSite( key.site ) || at.fileId >= ing.files.size() + || std::binary_search( baseUnreadable.begin(), baseUnreadable.end(), defectFileAnchor( relForHash( ing.files[ at.fileId ], root ) ) ) ) + { + continue; + } + const std::uint32_t baseA = baseAnchorCount( key.anchor, key.site ); + const auto ex = std::lower_bound( excess.begin(), excess.end(), std::pair{ key.site, 0 } ); + if( headA <= baseA || ex == excess.end() || ex->first != key.site || ex->second == 0 ) + { + continue; + } + ex->second -= std::min( ex->second, headA - baseA ); + const defectshape::Facet f = defectFacetOf( key.site ); + const bool seen = std::any_of( credited.begin(), credited.end(), [ & ]( const Credited& c ) { return c.anchor == key.anchor && c.facet == f; } ); + if( !seen ) + { + credited.push_back( { key.anchor, f, &at } ); + } + } + for( const Credited& c : credited ) + { + const std::uint32_t was = facetCount( base.defectSites, c.anchor, c.facet, []( const DefectSiteKey& d ) -> const DefectSiteKey& { return d; } ); + const std::uint32_t now = facetCount( nowSites, c.anchor, c.facet, []( const DefectSiteAt& d ) -> const DefectSiteKey& { return d.key; } ); + const NodeId owner = c.first->owner; + const std::string rel( relForHash( ing.files[ c.first->fileId ], root ) ); + const bool isNew = ( owner != kNoNode ) + ? !existedAtBaseline( c.anchor ) + : !std::binary_search( base.defectSites.begin(), base.defectSites.end(), DefectSiteKey{ c.anchor, kDefectFilePresent } ); + const std::string sym = ( owner != kNoNode && owner < g.canonId.size() && !g.canonId[owner].empty() ) ? g.canonId[owner] : rel; + const std::uint64_t rowKey = defectRowKey( c.anchor, c.facet, defectSiteValsOf( nowSites, c.anchor, c.facet, []( const DefectSiteAt& d ) -> const DefectSiteKey& { return d.key; } ) ); + regs.push_back( { "defect-shape", sym, was, now, rowKey, !defectshape::facetGatesOnAnyOrigin( c.facet ), + std::string( defectshape::facetName( c.facet ) ), isNew } ); + if( owner != kNoNode ) + { + stampLoc( owner ); + } + else + { + regs.back().path = rel; + regs.back().line = c.first->line; + } + } +} + // current state vs baseline → only what got worse. // complexity/verbosity/nesting/params: a symbol the change pushed/kept OVER the bar (now > baseline AND // now > BAR; a NEW symbol counts with baseline 0). Each is a per-symbol metric whose overloads share a @@ -8638,6 +9143,8 @@ inline std::vector computeDelta( const IngestResult& ing, const Grap } } + appendDefectShapeRows( regs, ing, g, base, root, existedAtBaseline, stampLoc ); // the twelfth kind (see its doc) + // ── §D#4-2 short-horizon churn (GitClear +15% "new code rewritten within two weeks") ─────────────────── // A symbol flags iff THREE independent gates all hold (signal-to-noise round, 2026-07-13): // 1. its FILE had ≥ kShortHorizonMinCommits commits in the last kShortHorizonDays (git COMMIT TIMESTAMPS diff --git a/src/query.h b/src/query.h index 88e43be7e..00ab09a94 100644 --- a/src/query.h +++ b/src/query.h @@ -11,7 +11,7 @@ // EXPR := SOURCE | FILTER | CLOSURE | JOIN // SOURCE := name( "STR" ) symbols whose name == STR (unions same-name defs, like --callers) // | all every symbol (parens optional: `all` or `all()`) -// FILTER := kind( EXPR , KIND ) keep nodes of KIND (fn|method|cls|struct|iface|var|sec|macro|modscope) +// FILTER := kind( EXPR , KIND ) keep nodes of KIND (fn|method|cls|struct|iface|var|sec|macro|modscope|type|alias|functype) // | cx( EXPR , INT ) keep nodes with cyclomatic complexity >= INT // | fanin(EXPR , INT ) keep nodes with in-degree (caller count) >= INT // | file( EXPR , "RE" ) keep nodes whose ROOT-RELATIVE file path (the p= the verb prints) matches the @@ -63,6 +63,9 @@ inline bool kindOfWord( std::string_view w, SymKind& out ) noexcept if( w == "sec" ) { out = SymKind::Section; return true; } if( w == "macro" ) { out = SymKind::Macro; return true; } // macro-edges round: t="macro" is queryable like every other kind if( w == "modscope" ) { out = SymKind::ModuleScope; return true; } // #60: so a query can select the file-scope owners, or not(kind(all,modscope)) them out + if( w == "type" ) { out = SymKind::NamedType; return true; } // honesty-small-068: every t= the map emits is queryable + if( w == "alias" ) { out = SymKind::Alias; return true; } + if( w == "functype" ) { out = SymKind::FuncType; return true; } return false; } @@ -514,7 +517,7 @@ struct Eval SymKind k = SymKind::Other; if( !kindOfWord( kw, k ) ) { - fail( "unknown kind '" + kw + "' (use fn|method|cls|struct|iface|var|sec|macro|modscope)" ); + fail( "unknown kind '" + kw + "' (use fn|method|cls|struct|iface|var|sec|macro|modscope|type|alias|functype)" ); } result = filterKind( std::move( set ), k ); } diff --git a/src/receiverevidence.h b/src/receiverevidence.h new file mode 100644 index 000000000..70b2c47cd --- /dev/null +++ b/src/receiverevidence.h @@ -0,0 +1,894 @@ +#pragma once +// receiverevidence.h — FE-B (test/receiverevidencecheck.sh): what proves a call's RECEIVER, and what does not. +// +// A member call `x.m()` (or, where the receiver is implicit, a bare `m()`) used to bind to every in-repo definition +// spelled `m` that the name ladder reached, and the answer showed each such row as a plain edge — a confident claim +// with nothing behind it but the name. This header holds the two halves of the fix: +// +// 1. ReceiverEvidence::narrow — a RESOLVE rule for receivers the source types: `this`/`self`/`cls` inside a class (and +// the language's base-class receiver, isSuperRoot, through the bases only), a parameter or local whose class is +// written (a TS/Python/Go annotation, a Go method receiver, a JS `new Foo()` / Python `Foo()` / Go `Foo{}` initializer), a constructed receiver +// (`new Foo().m()`), a class-name receiver (`Foo.m()`), and a chain of fields whose classes are stated +// (`this.bucket = new Schemas()`, Python `self.x = Foo()`, a Go struct field, a Go EMBEDDED field whose methods +// are promoted). A hit is the call's whole answer, exactly like Rule 2. Ruby's own method lookup (mixins, typed and +// class receivers) is deliberately NOT modelled here: Ruby keeps only the language-neutral proof below. +// 2. ReceiverEvidence::ladderProves — for a call the rules above did not answer, which name-ladder candidates the +// language's own lookup can PROVE: an implicit receiver's own class and its bases, a free function in scope, a +// definition in the module a receiver alias names. A call left with no proven candidate is NAME-ONLY: its rows +// are kept, marked via="name" on every surface (graph.h Graph::outNameOnly), never dropped and never proven. +// +// What it does NOT do: infer a function's return type (`s = self.get_screen(); s.refresh()`), follow a property, or type +// a value that crossed a call boundary. Those receivers stay name-only — the honest answer, not a guess. + +#include "model.h" +#include "resolve.h" // includerDir; HashMap / SmallVec through it +#include "smallvec.h" + +#include +#include +#include +#include +#include +#include + +namespace rw +{ + +// The languages whose name-ladder rows the hedge covers. Every other language keeps its ladder unchanged (a disclosed +// floor in test/receiverevidencecheck.sh: Objective-C, PHP, Lua, Zig, Dart, Elixir, Scala, Bash and the rest). +inline bool receiverHedgeLang( Lang l ) noexcept +{ + switch( l ) + { + case Lang::JavaScript: case Lang::TypeScript: case Lang::Python: case Lang::Go: case Lang::Rust: case Lang::C: + case Lang::Cpp: case Lang::Java: case Lang::Kotlin: case Lang::CSharp: case Lang::Swift: case Lang::Ruby: + return true; + default: + return false; + } +} + +// FE-B: each Go file's package import paths, built by graph.h collectGoModules — the nearest go.mod's module path plus the +// directory below it, and the path a LOCAL `replace L => ./D` gives a file under D (L plus the directory below D). A +// module-alias call proves a candidate by one of these paths EXACTLY. The directory-tail rule is left only for a candidate +// with no path when no in-tree file carries the import path either (a tree with no module facts for it at all). +struct GoPackagePaths +{ + std::vector nearest; // fileId → "" when no go.mod with a module line sits at or above the file + std::vector replaced; // fileId → "" when no local replace maps the file's directory + HashMap known; // every non-empty path in either table + + // file `f`'s entry in `table` ("" when none) + static std::string_view pathIn( const std::vector& table, std::uint32_t f ) noexcept + { + return f < table.size() ? std::string_view( table[ f ] ) : std::string_view{}; + } + bool hasPath( std::uint32_t f ) const noexcept + { + return !pathIn( nearest, f ).empty() || !pathIn( replaced, f ).empty(); + } + // does `path` name file `f`'s package exactly + bool names( std::uint32_t f, std::string_view path ) const noexcept + { + return !path.empty() && ( pathIn( nearest, f ) == path || pathIn( replaced, f ) == path ); + } + void note( std::vector& table, std::uint32_t f, std::string path ) + { + EXPECTS( f < table.size(), "collectGoModules sizes both tables to ing.files" ); + if( !path.empty() ) + { + known.try_emplace( path, '\0' ); + } + table[ f ] = std::move( path ); + } +}; + +// The languages where a bare `m()` inside a method is an implicit `this.m()` (or reaches a free/top-level function). +inline bool implicitReceiverLang( Lang l ) noexcept +{ + return l == Lang::Java || l == Lang::Kotlin || l == Lang::CSharp || l == Lang::Swift || l == Lang::Cpp || l == Lang::Ruby; +} + +// the languages where a call written on a receiver reaches a function only as a METHOD (or through a module alias): a +// free function is never what `x.f()` calls. JS/TS (an object property may hold one), Kotlin (an extension function is a +// top-level `fun T.f()`) and C (a function-pointer field) are left out. +inline bool memberNeverReachesFreeFunction( Lang l ) noexcept +{ + return l == Lang::Python || l == Lang::Go || l == Lang::Cpp || l == Lang::Rust || l == Lang::Ruby || l == Lang::Java + || l == Lang::CSharp || l == Lang::Swift; +} + +// the languages where `Cls.m()` is a call on the CLASS (a static or an unbound method): C++ spells that `Cls::m()` (a +// qualifier, the canonical tier's), so a C++/C/Rust receiver spelled like a class is a variable or a member; Ruby's class +// receivers are its own lookup's question +inline bool classNameReceiverLang( Lang l ) noexcept +{ + return l == Lang::JavaScript || l == Lang::TypeScript || l == Lang::Python || l == Lang::Java || l == Lang::Kotlin + || l == Lang::CSharp || l == Lang::Swift; +} + +// a call written ON a receiver — every language's member shape: Python/C++/Ruby record a RecvKind, the rest memberCall +inline bool isMemberCallRef( const Reference& r ) noexcept +{ + return r.recv != RecvKind::None || r.memberCall; +} + +inline bool isThisRoot( std::string_view root ) noexcept +{ + return root == "this" || root == "self" || root == "Self"; +} + +// Is `root` (the call's receiver root, ReceiverEvidence::chainOf) the language's BASE-CLASS receiver — a lookup that walks +// the caller's bases only? Each spelling counts only where the grammar makes it that keyword: C# `base`; `super` in +// Java, Kotlin, Swift, JS and TS; Python's `super()` / `super(C, self)` call, which ingest records as RecvKind::SuperObj. +// Everywhere else the same text is an ordinary name — a Python/JS/Go/Java/… local `base`, a Go or C# local `super`, a +// Python name `super` that shadows the builtin — and proves nothing about the bases (its own type, if any, decides). +inline bool isSuperRoot( const Reference& r, std::string_view root ) noexcept +{ + if( r.recv == RecvKind::SuperObj ) + { + return true; + } + switch( r.lang ) + { + case Lang::CSharp: + return root == "base"; + case Lang::Java: case Lang::Kotlin: case Lang::Swift: case Lang::JavaScript: case Lang::TypeScript: + return root == "super"; + default: + return false; + } +} + +// The innermost function/method of `id`'s file whose span strictly holds `id`'s span; kNoNode when none. `byFile` is +// fileId → that file's functions/methods sorted by sigStartByte. Shared by ReceiverEvidence and graph.h FalseEdgeRules. +inline NodeId innermostEnclosingFn( const IngestResult& ing, const std::vector>& byFile, NodeId id ) +{ + const Symbol& inner = ing.symbols[ id ]; + if( inner.fileId >= byFile.size() ) + { + return kNoNode; + } + NodeId best = kNoNode; + for( NodeId c : byFile[ inner.fileId ] ) + { + const Symbol& s = ing.symbols[ c ]; + if( s.sigStartByte > inner.sigStartByte ) + { + break; // sorted by start: nothing later can hold it + } + if( c != id && s.endByte >= inner.endByte && ( s.sigStartByte < inner.sigStartByte || s.endByte > inner.endByte ) ) + { + best = c; // later starts are more inner + } + } + return best; +} + +struct ReceiverEvidence +{ + static constexpr std::size_t kWalkCap = 64; // classes visited per base walk; deterministic (sorted chaUp) + + const IngestResult& ing; + const HashMap>& chaUp; // class name → its direct base names (sorted) + const HashMap& classNames; // every in-repo class-like name + std::vector ownerClass; // per symbol: the class that owns it, "" none + std::vector staticMember; // per symbol: 1 = a JS/TS `static` member (the CLASS side) + HashMap> methodsByType; // "Class::name" → its callables + HashMap memberType; // "Class#field" → field class ("" = tombstone) + HashMap> embeds; // "Class" → its other bases: Go embedded classes (promotion) + HashMap nameAlias; // "#local" → the class name it imports + // FE-B (review B4): one binding of a local name — the class it names ("" = unknown: it hides an outer binding of the + // name and proves nothing) and the bytes it is visible in. end == 0: a record with no span (a Python name, a reassignment, + // a base pass Type record): somewhere in its definition, extent unknown. + struct LocalFact + { + std::uint32_t start = 0; + std::uint32_t end = 0; + std::string type; + }; + HashMap> localType; // "#var" → its bindings + HashMap> methodAlias; // "#var" → ( object var, method ) + HashMap moduleAlias; // "#name" → module as written ("" = tombstone) + std::vector> fnsByFile; // fileId → functions/methods sorted by start (enclosing walk) + std::vector> classesByFile; // fileId → class-like symbols sorted by start + mutable std::string key; + mutable std::vector frontier; + mutable rw::SmallVec found; + bool active = false; + const HashMap* localNames = nullptr; // graph.h FieldNarrowTables::localNameSet + const GoPackagePaths* goPackages = nullptr; // graph.h FalseEdgeRules::goPackages + + ReceiverEvidence( const IngestResult& i, const HashMap>& up, const HashMap& classes ) + : ing( i ), chaUp( up ), classNames( classes ) {} + + static bool classLike( SymKind k ) noexcept + { + return k == SymKind::Class || k == SymKind::Struct || k == SymKind::Interface; + } + static bool callableKind( SymKind k ) noexcept + { + return k == SymKind::Method || k == SymKind::Function; + } + const std::string& keyOf( std::uint32_t id, char sep, std::string_view name ) const + { + key.clear(); + key.append( std::to_string( id ) ); + key.push_back( sep ); + key.append( name ); + return key; + } + const std::string& typeKey( std::string_view type, std::string_view sep, std::string_view name ) const + { + key.assign( type ); + key.append( sep ); + key.append( name ); + return key; + } + + // ── build ───────────────────────────────────────────────────────────────────────────────────────────────────────── + static void tombstoneInsert( HashMap& m, const std::string& k, std::string_view v ) + { + const auto [ it, fresh ] = m.try_emplace( k, std::string( v ) ); + if( !fresh && it->second != v ) + { + it->second.clear(); // two different classes for one name: decide nothing + } + } + + void addLocalFact( const std::string& k, const Binding& b ) + { + const bool spanned = b.spanEnd > b.spanStart; + LocalFact f{ spanned ? b.spanStart : 0u, spanned ? b.spanEnd : 0u, b.typeName }; + std::vector& facts = localType[ k ]; + if( std::none_of( facts.begin(), facts.end(), [ & ]( const LocalFact& g ) { return g.start == f.start && g.end == f.end && g.type == f.type; } ) ) + { + facts.push_back( std::move( f ) ); + } + } + + void buildOwners() + { + const std::size_t N = ing.symbols.size(); + ownerClass.assign( N, {} ); + // the classes of each file by start; a symbol's owner is the innermost class whose span holds it + classesByFile.assign( ing.files.size(), {} ); + fnsByFile.assign( ing.files.size(), {} ); + for( const Symbol& s : ing.symbols ) + { + if( s.fileId >= ing.files.size() ) + { + continue; + } + if( classLike( s.kind ) ) + { + classesByFile[ s.fileId ].push_back( s.id ); + } + if( callableKind( s.kind ) ) + { + fnsByFile[ s.fileId ].push_back( s.id ); + } + } + const auto byStart = [ & ]( NodeId a, NodeId b ) { return ing.symbols[ a ].sigStartByte != ing.symbols[ b ].sigStartByte + ? ing.symbols[ a ].sigStartByte < ing.symbols[ b ].sigStartByte : a < b; }; + for( std::vector& v : classesByFile ) { std::sort( v.begin(), v.end(), byStart ); } + for( std::vector& v : fnsByFile ) { std::sort( v.begin(), v.end(), byStart ); } + for( const Symbol& s : ing.symbols ) + { + if( !s.scope.empty() ) + { + ownerClass[ s.id ] = s.scope; // the extractor's own scope (Python, C++, Kotlin, Ruby, JS prototype members) + continue; + } + if( s.fileId >= ing.files.size() || classLike( s.kind ) ) + { + continue; + } + if( const NodeId best = classAt( s.fileId, s.sigStartByte, s.endByte, s.id ); best != kNoNode ) + { + ownerClass[ s.id ] = ing.symbols[ best ].name; + } + } + // Go spells a method's class only on its receiver: the RecvType record that sets isFromAssignment + for( const Binding& b : ing.bindings ) + { + if( b.kind == LocalBindKind::RecvType && b.isFromAssignment && b.fromSymbol < N && !b.typeName.empty() ) + { + ownerClass[ b.fromSymbol ] = b.typeName; + } + } + ENSURES( ownerClass.size() == N && fnsByFile.size() == ing.files.size() && classesByFile.size() == ing.files.size(), + "one owner per symbol, one sorted list per file" ); + } + + // the innermost class-like symbol of `fileId` whose span holds [start, end) (never `self`); kNoNode when none + NodeId classAt( std::uint32_t fileId, std::uint32_t start, std::uint32_t end, NodeId self = kNoNode ) const + { + NodeId best = kNoNode; + if( fileId >= classesByFile.size() ) + { + return best; + } + for( NodeId c : classesByFile[ fileId ] ) + { + const Symbol& cs = ing.symbols[ c ]; + if( cs.sigStartByte > start ) + { + break; + } + if( cs.id != self && cs.endByte >= end ) + { + best = c; // later starts are more inner + } + } + return best; + } + void addBase( const std::string& owner, std::string_view base ) + { + std::vector& e = embeds[ owner ]; + if( std::find( e.begin(), e.end(), base ) == e.end() ) + { + e.emplace_back( base ); + std::sort( e.begin(), e.end() ); + } + } + void build() + { + buildOwners(); + staticMember.assign( ing.symbols.size(), 0u ); + for( const Symbol& s : ing.symbols ) + { + // a BODIED callable only: an interface's or an abstract class's bodyless signature is the contract a call + // dispatches through, never the body it reaches — that call stays with the ladder (declined_iface= reads it) + if( callableKind( s.kind ) && !ownerClass[ s.id ].empty() && receiverHedgeLang( s.lang ) && s.endByte > s.sigEndByte ) + { + methodsByType[ typeKey( ownerClass[ s.id ], "::", s.name ) ].push_back( s.id ); + } + } + const std::size_t N = ing.symbols.size(); + for( const Binding& b : ing.bindings ) + { + if( b.kind == LocalBindKind::ModuleAlias && b.fromSymbol == kNoNode && !b.var.empty() && !b.isFromAssignment ) + { + tombstoneInsert( moduleAlias, keyOf( b.fileId, '#', b.var ), b.typeName ); + continue; + } + if( b.kind == LocalBindKind::NameAlias && !b.var.empty() ) + { + tombstoneInsert( nameAlias, keyOf( b.fileId, '#', b.var ), b.typeName ); + continue; + } + if( b.kind == LocalBindKind::JsImport && !b.var.empty() && !b.importedName.empty() && b.importedName != b.var ) + { + tombstoneInsert( nameAlias, keyOf( b.fileId, '#', b.var ), b.importedName ); // `import { Context as Ctx }` + continue; + } + if( b.fromSymbol >= N ) + { + continue; + } + // a typed local the extractor also indexed as a symbol of its own (a Go `var c T` inside a function) belongs + // to the function that declares it + NodeId from = b.fromSymbol; + if( !callableKind( ing.symbols[ from ].kind ) && !classLike( ing.symbols[ from ].kind ) ) + { + if( const NodeId fn = enclosingFn( from ); fn != kNoNode ) + { + from = fn; + } + } + const bool typedLocal = b.kind == LocalBindKind::RecvType + || ( b.kind == LocalBindKind::Type && ( ing.symbols[ b.fromSymbol ].lang == Lang::TypeScript + || ing.symbols[ b.fromSymbol ].lang == Lang::Python ) ); + if( b.kind == LocalBindKind::StaticMember ) + { + if( ing.symbols[ from ].name == b.var ) // recorded inside the member's body: the member itself + { + staticMember[ from ] = 1; + } + } + else if( typedLocal && !b.var.empty() ) + { + addLocalFact( keyOf( from, '#', b.var ), b ); + } + else if( b.kind == LocalBindKind::MemberType && !b.var.empty() ) + { + const Symbol& from = ing.symbols[ b.fromSymbol ]; + const std::string& owner = classLike( from.kind ) ? from.name : ownerClass[ b.fromSymbol ]; + if( owner.empty() ) + { + continue; + } + tombstoneInsert( memberType, typeKey( owner, "#", b.var ), b.typeName ); + if( b.isFromAssignment ) + { + addBase( owner, b.typeName ); + } + } + else if( b.kind == LocalBindKind::MethodAlias && !b.var.empty() ) + { + const auto [ it, fresh ] = methodAlias.try_emplace( keyOf( from, '#', b.var ), b.typeName, b.importedName ); + if( !fresh && ( it->second.first != b.typeName || it->second.second != b.importedName ) ) + { + it->second.first.clear(); // rebound to something else: decide nothing + } + } + } + active = true; + } + + // ── queries ─────────────────────────────────────────────────────────────────────────────────────────────────────── + // the innermost function/method of the same file whose span strictly holds `id`; kNoNode when none + NodeId enclosingFn( NodeId id ) const { return innermostEnclosingFn( ing, fnsByFile, id ); } + // visit `from`, then each function enclosing it, innermost first (at most 8 scopes): `visit( scope )` returns true + // to stop there. Returns the scope it stopped at, kNoNode when no visit stopped the walk. + template + NodeId walkScopes( NodeId from, Visit&& visit ) const + { + NodeId cur = from; + for( int depth = 0; depth < 8 && cur != kNoNode; ++depth ) + { + if( visit( cur ) ) + { + return cur; + } + cur = enclosingFn( cur ); + } + return kNoNode; + } + // FE-B (review B4): the class local `var` holds at byte `site` of definition `scope`, by lexical scope — the innermost + // spanned binding that holds the site (a later start is more inner; a tie of two classes is unknown), checked against + // every binding with no span (its extent is unknown, so a disagreement is unknown). nullopt: no binding of `var` is + // visible there; "": one is, and it names no single class. + std::optional visibleLocal( NodeId scope, std::string_view var, std::uint32_t site ) const + { + const auto it = localType.find( keyOf( scope, '#', var ) ); + if( it == localType.end() ) + { + return std::nullopt; + } + const LocalFact* inner = nullptr; + bool innerSplit = false; + std::optional whole; + for( const LocalFact& f : it->second ) + { + if( f.end == 0 ) + { + whole = ( whole.has_value() && *whole != f.type ) ? std::string_view{} : std::string_view( f.type ); + continue; + } + if( site < f.start || site >= f.end ) + { + continue; + } + if( inner == nullptr || f.start > inner->start || ( f.start == inner->start && f.end < inner->end ) ) + { + inner = &f; + innerSplit = false; + } + else if( f.start == inner->start && f.end == inner->end && f.type != inner->type ) + { + innerSplit = true; + } + } + if( inner == nullptr ) + { + return whole; + } + const std::string_view seen = innerSplit ? std::string_view{} : std::string_view( inner->type ); + return ( whole.has_value() && *whole != seen ) ? std::string_view{} : seen; + } + // the local `var` visible at byte `site` of `from` or of a function enclosing it (the innermost that has one) + std::optional localAt( NodeId from, std::string_view var, std::uint32_t site ) const + { + std::optional hit; + walkScopes( from, [ & ]( NodeId scope ) + { + hit = visibleLocal( scope, var, site ); + return hit.has_value(); + } ); + return hit; + } + // the ( object var, method ) a method alias `var` names in `from` or a function enclosing it; `scope` = where it is bound + const std::pair* aliasedMethod( NodeId from, std::string_view var, NodeId& scope ) const + { + const std::pair* hit = nullptr; + if( const NodeId at = walkScopes( from, [ & ]( NodeId s ) + { + const auto it = methodAlias.find( keyOf( s, '#', var ) ); + if( it == methodAlias.end() ) + { + return false; + } + hit = it->second.first.empty() ? nullptr : &it->second; + return true; + } ); at != kNoNode ) + { + scope = at; + } + return hit; + } + // is `name` a parameter or local of `from` or of a function enclosing it (any binding the extractor recorded) + bool isLocalName( NodeId from, std::string_view name ) const + { + return localNames != nullptr && walkScopes( from, [ & ]( NodeId s ) + { + return localNames->contains( keyOf( s, '#', name ) ) || localType.contains( keyOf( s, '#', name ) ); + } ) != kNoNode; + } + // does `from` or a function enclosing it DECLARE `name` (a parameter or a local, typed or not) — not merely bind it: Java + // copies each field name onto its class's methods as a bound-only name (graph.h shadowJavaFieldsOntoMethods), which must + // not hide the field itself + bool declaresLocal( NodeId from, std::string_view name, std::uint32_t site ) const + { + return walkScopes( from, [ & ]( NodeId s ) + { + if( visibleLocal( s, name, site ).has_value() ) + { + return true; // a binding of the name is visible at the site (review B4: by its span) + } + if( localNames == nullptr ) + { + return false; + } + const auto it = localNames->find( keyOf( s, '#', name ) ); + return it != localNames->end() && ( it->second & kLocalNameDeclared ) != 0; + } ) != kNoNode; + } + // a receiver root that names a CLASS at this site: a class-name receiver language, a class of that name, and no local + // of the caller hiding it (`Interval = make(); Interval.validate( v )`) + bool namesClass( const Reference& r, std::string_view root, std::string_view cls ) const + { + return classNameReceiverLang( r.lang ) && classNames.contains( std::string( cls ) ) && !isLocalName( r.fromSymbol, root ); + } + // the class owning the caller — the caller's own owner, else the nearest enclosing function's (a closure in a method) + std::string_view callerClass( NodeId from ) const + { + const NodeId at = walkScopes( from, [ & ]( NodeId s ) { return !ownerClass[ s ].empty(); } ); + return at == kNoNode ? std::string_view{} : std::string_view( ownerClass[ at ] ); + } + // `type`'s direct bases: the inheritance names, then (Go) its embedded classes + template + void forEachBase( std::string_view type, Fn&& fn ) const + { + if( const auto it = chaUp.find( std::string( type ) ); it != chaUp.end() ) + { + for( const std::string& b : it->second ) { fn( std::string_view( b ) ); } + } + if( const auto it = embeds.find( std::string( type ) ); it != embeds.end() ) + { + for( const std::string& b : it->second ) { fn( std::string_view( b ) ); } + } + } + // is `ancestor` the class `type` or one of its (transitive) bases + bool inCone( std::string_view type, std::string_view ancestor ) const + { + if( type.empty() || ancestor.empty() ) + { + return false; + } + frontier.clear(); + frontier.push_back( type ); + for( std::size_t i = 0; i < frontier.size() && i < kWalkCap; ++i ) + { + if( frontier[ i ] == ancestor ) + { + return true; + } + forEachBase( frontier[ i ], [ & ]( std::string_view b ) + { + if( std::find( frontier.begin(), frontier.end(), b ) == frontier.end() ) { frontier.push_back( b ); } + } ); + } + return false; + } + // visit `type`'s level (depth 0), then its base levels shallowest first, each class once: `visit( level, depth )` + // returns true to stop. Returns whether a visit stopped the walk. + template + bool walkBaseLevels( std::string_view type, Visit&& visit ) const + { + std::vector level{ type }; + std::vector seen{ type }; + for( std::size_t depth = 0; depth < kWalkCap && !level.empty(); ++depth ) + { + if( visit( level, depth ) ) + { + return true; + } + std::vector next; + for( std::string_view t : level ) + { + forEachBase( t, [ & ]( std::string_view b ) + { + if( std::find( seen.begin(), seen.end(), b ) == seen.end() ) { seen.push_back( b ); next.push_back( b ); } + } ); + } + level.swap( next ); + } + return false; + } + // `type`'s own `name`, else the shallowest base level defining it (several bases at one level: their union). The + // answer is in `found`; false when nothing in the cone defines it. superOnly skips `type` itself (`super.m()`). + bool methodOf( std::string_view type, std::string_view name, bool superOnly ) const + { + EXPECTS( active, "methodOf reads the tables build() fills" ); + found.clear(); + if( type.empty() ) + { + return false; + } + return walkBaseLevels( type, [ & ]( const std::vector& level, std::size_t depth ) + { + if( superOnly && depth == 0 ) + { + return false; + } + for( std::string_view t : level ) + { + if( const auto it = methodsByType.find( typeKey( t, "::", name ) ); it != methodsByType.end() ) + { + for( NodeId c : it->second ) { found.push_back( c ); } + } + } + return !found.empty(); + } ); + } + // the class of field `field` on `type` (or the shallowest base declaring it); "" unknown + std::string_view fieldOf( std::string_view type, std::string_view field ) const + { + std::string_view fieldType; + walkBaseLevels( type, [ & ]( const std::vector& level, std::size_t ) + { + for( std::string_view t : level ) + { + if( const auto it = memberType.find( typeKey( t, "#", field ) ); it != memberType.end() ) + { + fieldType = it->second; // "" = tombstone: unknown + return true; + } + } + return false; + } ); + return fieldType; + } + + // The receiver chain of a member call as ( root, path, constructed class ), in every language's spelling. + struct Chain + { + std::string_view root; + std::string_view path; + std::string_view ctor; + bool valid = false; + }; + static Chain chainOf( const Reference& r ) noexcept + { + Chain c; + if( r.memberCall ) + { + c.root = r.memberRoot; + c.path = r.memberPath; + c.ctor = r.memberCtor; + c.valid = !c.root.empty() || !c.ctor.empty(); + return c; + } + switch( r.recv ) // Python / C++ / Ruby shapes (ingest_binds.h receiverOf) + { + case RecvKind::ThisObj: c.root = "self"; c.valid = true; break; + case RecvKind::NamedVar: c.root = r.recvVar; c.valid = !r.recvVar.empty(); break; + case RecvKind::FieldOfThis: c.root = "self"; c.path = r.fieldName; c.valid = !r.fieldName.empty(); break; + case RecvKind::FieldOfVar: c.root = r.recvVar; c.path = r.fieldName; c.valid = !r.recvVar.empty() && !r.fieldName.empty(); break; + case RecvKind::SuperObj: c.root = "super"; c.valid = true; break; + default: break; + } + return c; + } + + // The class the receiver ROOT names at this site; static/super set for a class-name or super receiver. "" unknown. + // a class name as the caller's FILE spells it, read through that file's import alias (`Ctx` → `Context`) + std::string_view unalias( std::uint32_t fileId, std::string_view name ) const + { + if( const auto it = nameAlias.find( keyOf( fileId, '#', name ) ); it != nameAlias.end() && !it->second.empty() ) + { + return it->second; + } + return name; + } + std::string_view rootClass( const Reference& r, const Chain& ch, bool& isStatic, bool& superOnly ) const + { + isStatic = false; + superOnly = false; + if( !ch.ctor.empty() ) + { + return unalias( r.fileId, ch.ctor ); + } + const std::string_view root = ch.root; + if( isThisRoot( root ) || ( r.lang == Lang::Python && root == "cls" ) ) + { + return callerClass( r.fromSymbol ); + } + if( isSuperRoot( r, root ) ) + { + superOnly = true; + return callerClass( r.fromSymbol ); + } + if( const std::optional t = localAt( r.fromSymbol, root, r.startByte ); t.has_value() && !t->empty() ) + { + return unalias( r.fileId, *t ); + } + // Java/C#/Kotlin/Swift (the implicit-receiver languages but C++, whose Rule 2b reads fields, and Ruby, whose fields + // are @ivars): a bare `field.m()` inside a method is `this.field.m()` — the field the caller's class (or a base) + // declares, when no parameter or local of that name hides it + if( implicitReceiverLang( r.lang ) && r.lang != Lang::Cpp && r.lang != Lang::Ruby && !declaresLocal( r.fromSymbol, root, r.startByte ) ) + { + const std::string_view owner = callerClass( r.fromSymbol ); + if( const std::string_view ft = owner.empty() ? std::string_view{} : fieldOf( owner, root ); !ft.empty() ) + { + return unalias( r.fileId, ft ); + } + } + const std::string_view cls = unalias( r.fileId, root ); + if( namesClass( r, root, cls ) ) + { + isStatic = true; + return cls; + } + return {}; + } + + // The two SIDES of a JS/TS class lookup (#373's Ruby class-object rule, here): a call on the CLASS object reaches its + // `static` members only, a call on an INSTANCE its other members only. True for every target in another language. + bool onSide( const Reference& r, NodeId target, bool classSide ) const noexcept + { + if( r.lang != Lang::JavaScript && r.lang != Lang::TypeScript ) + { + return true; + } + return target < staticMember.size() && ( staticMember[ target ] != 0 ) == classSide; + } + // Does `this` inside the caller name the CLASS? Only inside a static member. + bool callerIsStatic( NodeId from ) const noexcept + { + return from < staticMember.size() && staticMember[ from ] != 0; + } + + // RESOLVE: the definitions this member call's typed receiver reaches, in `found`; false when the receiver proves + // nothing (untyped, a type this tree does not define, or a type that defines no such member anywhere in its cone). + bool narrow( const Reference& r ) const + { + // C++, Ruby, Rust and C keep their own receiver rules (Rules 1, 2, 2b, 2c): this one adds the languages that had none + const bool typedLang = r.lang == Lang::JavaScript || r.lang == Lang::TypeScript || r.lang == Lang::Python || r.lang == Lang::Go + || r.lang == Lang::Java || r.lang == Lang::Kotlin || r.lang == Lang::CSharp || r.lang == Lang::Swift; + if( !active || r.fromSymbol >= ing.symbols.size() || !r.qualifier.empty() || !typedLang ) + { + return false; + } + if( !isMemberCallRef( r ) ) + { + return r.lang == Lang::Python && narrowAliasedMethod( r ); + } + const Chain ch = chainOf( r ); + if( !ch.valid ) + { + return false; + } + bool isStatic = false, superOnly = false; + std::string_view type = rootClass( r, ch, isStatic, superOnly ); + if( type.empty() ) + { + return false; + } + // the side the receiver names: the class object (a class-name receiver, or `this`/`super` inside a static member), + // else an instance; a stated field below always holds an instance + bool classSide = isStatic || ( ch.ctor.empty() && ( isThisRoot( ch.root ) || isSuperRoot( r, ch.root ) ) && callerIsStatic( r.fromSymbol ) ); + std::string_view rest = ch.path; + while( !rest.empty() ) // walk the stated field classes + { + const std::size_t dot = rest.find( '.' ); + const std::string_view field = rest.substr( 0, dot ); + rest = dot == std::string_view::npos ? std::string_view{} : rest.substr( dot + 1 ); + type = fieldOf( type, field ); + superOnly = false; + classSide = false; + if( type.empty() ) + { + return false; + } + } + if( !methodOf( type, r.calleeName, superOnly ) ) + { + return false; + } + // a wrong-side definition is never the answer: what is left is, and nothing left proves nothing (the ladder hedges) + std::size_t kept = 0; + for( std::size_t i = 0; i < found.size(); ++i ) + { + if( onSide( r, found[ i ], classSide ) ) + { + found[ kept++ ] = found[ i ]; + } + } + found.resize( kept ); + return !found.empty(); + } + + // Python `feed = parser.feed; … feed( data )`: the alias names a typed object's method + bool narrowAliasedMethod( const Reference& r ) const + { + if( r.recv != RecvKind::None || r.calleeName.empty() ) + { + return false; + } + NodeId scope = kNoNode; + const std::pair* alias = aliasedMethod( r.fromSymbol, r.calleeName, scope ); + if( alias == nullptr ) + { + return false; + } + if( isThisRoot( alias->first ) || alias->first == "cls" ) + { + return methodOf( callerClass( scope ), alias->second, false ); // `write = self.write`: the class's own member + } + const std::optional type = localAt( scope, alias->first, r.startByte ); + return type.has_value() && !type->empty() && methodOf( unalias( r.fileId, *type ), alias->second, false ); + } + + // does module alias `root` (bound in the caller's file) name the file defining `c` + bool moduleNames( const Reference& r, std::string_view root, NodeId c ) const + { + const auto it = moduleAlias.find( keyOf( r.fileId, '#', root ) ); + if( it == moduleAlias.end() || it->second.empty() ) + { + return false; + } + const std::string_view source = it->second; + const std::string_view target = rootRelPath( ing, ing.symbols[ c ].fileId ); + if( r.lang == Lang::Go ) + { + // an import path names a package: the candidate's own package import path (its go.mod's module path and the + // directory below it — a nested module's `quoted/lib` is `example.com/qm/lib` — or the path a local replace + // maps its directory to) proves it EXACTLY. A candidate with a path, or an import path some in-tree file + // carries, is proven by nothing else: a directory that merely ends the path is another package + const std::uint32_t cf = ing.symbols[ c ].fileId; + if( goPackages != nullptr && ( goPackages->hasPath( cf ) || goPackages->known.contains( key.assign( source ) ) ) ) + { + return goPackages->names( cf, source ); + } + // no module facts name this path or the candidate (a tree with no go.mod): its directory must end the path, + // segment-aligned + const std::string_view dir = includerDir( target ); + return !dir.empty() && source.size() >= dir.size() && source.ends_with( dir ) + && ( source.size() == dir.size() || source[ source.size() - dir.size() - 1 ] == '/' ); + } + if( !source.starts_with( "./" ) && !source.starts_with( "../" ) ) + { + return false; // a package specifier: outside the tree, or resolved by a bundler this rule does not model + } + // join the caller's directory with the relative specifier, then compare without the extension (or /index) + std::vector parts; + std::string_view base = includerDir( rootRelPath( ing, r.fileId ) ); + const auto push = [ & ]( std::string_view p ) + { + while( !p.empty() ) + { + const std::size_t slash = p.find( '/' ); + const std::string_view seg = p.substr( 0, slash ); + p = slash == std::string_view::npos ? std::string_view{} : p.substr( slash + 1 ); + if( seg.empty() || seg == "." ) { continue; } + if( seg == ".." ) { if( !parts.empty() ) { parts.pop_back(); } continue; } + parts.push_back( seg ); + } + }; + push( base ); + push( source ); + std::string joined; + for( std::string_view p : parts ) { if( !joined.empty() ) { joined.push_back( '/' ); } joined.append( p ); } + std::string_view stem = target; + if( const std::size_t dot = stem.rfind( '.' ); dot != std::string_view::npos && stem.find( '/', dot ) == std::string_view::npos ) + { + stem = stem.substr( 0, dot ); + } + return stem == joined || ( stem.ends_with( "/index" ) && stem.substr( 0, stem.size() - 6 ) == joined ); + } +}; + +} // namespace rw diff --git a/src/resolve.h b/src/resolve.h index 216e8bc42..5221412a5 100644 --- a/src/resolve.h +++ b/src/resolve.h @@ -923,6 +923,25 @@ inline std::string_view trimWs( std::string_view s ) noexcept return s; } +// The first token of a go.mod directive operand — a module path or a directory — as the go.mod grammar spells it: bare +// (up to whitespace), an interpreted string ("example.com/x") or a raw string (`example.com/x`), the quotes removed. "" +// when the operand is empty or its quote never closes. Both go.mod readers (this file's replace aliases, graph.h's module +// census) read a path through it, so a quoted spelling names the same module as the bare one. +inline std::string_view goModToken( std::string_view s ) noexcept +{ + s = trimWs( s ); + if( s.empty() ) + { + return {}; + } + if( s.front() == '"' || s.front() == '`' ) + { + const std::size_t close = s.find( s.front(), 1 ); + return close == std::string_view::npos ? std::string_view() : s.substr( 1, close - 1 ); + } + return s.substr( 0, s.find_first_of( " \t" ) ); +} + // Parse go.mod `replace OLD [ver] => NEW [ver]` directives (both single-line and grouped `replace ( … )`). // A filesystem NEW target (`./…`, `../…`, or absolute) is resolved relative to the go.mod's root dir; when it // ESCAPES `rootReal` (points at a sibling root) we mint a Go alias: module-path `OLD` → the on-disk dir. @@ -930,11 +949,6 @@ inline std::string_view trimWs( std::string_view s ) noexcept inline void parseGoModReplaces( const std::string& bytes, std::uint32_t fromRoot, const std::string& rootReal, std::vector& out ) { - const auto firstTok = []( std::string_view s ) noexcept - { - const std::size_t sp = s.find_first_of( " \t" ); - return sp == std::string_view::npos ? s : s.substr( 0, sp ); - }; bool inGroup = false; std::size_t i = 0; while( i < bytes.size() ) @@ -984,8 +998,8 @@ inline void parseGoModReplaces( const std::string& bytes, std::uint32_t fromRoot { continue; } - const std::string_view mod = firstTok( trimWs( body.substr( 0, arrow ) ) ); - const std::string_view tgt = firstTok( trimWs( body.substr( arrow + 2 ) ) ); + const std::string_view mod = goModToken( body.substr( 0, arrow ) ); // `"example.com/x" => "../x"` is x => ../x + const std::string_view tgt = goModToken( body.substr( arrow + 2 ) ); if( mod.empty() || tgt.empty() ) { continue; @@ -1494,7 +1508,7 @@ inline HashMap buildElixirModuleIndex( const IngestR for( const Symbol& s : ing.symbols ) { if( s.lang != Lang::Elixir || !s.scope.empty() || s.name.empty() || s.fileId >= F - || ( s.kind != SymKind::Other && s.kind != SymKind::Class && s.kind != SymKind::Struct && s.kind != SymKind::Interface ) ) + || ( s.kind != SymKind::Other && s.kind != SymKind::Class && !isStructOrNamedType( s.kind ) && s.kind != SymKind::Interface ) ) { continue; // only module/protocol/struct containers, never attributes or type declarations } @@ -4776,7 +4790,7 @@ inline void addTypeAliasBases( const IngestResult& ing, HashMap realClassNames; // alias names some class-like symbol at a non-alias site also carries for( const Symbol& s : ing.symbols ) { - const bool classLike = s.kind == SymKind::Class || s.kind == SymKind::Struct || s.kind == SymKind::Interface; + const bool classLike = s.kind == SymKind::Class || isStructOrNamedType( s.kind ) || s.kind == SymKind::Interface; if( classLike && aliasNames.find( s.name ) != aliasNames.end() ) { siteKey( key, s.fileId, s.line, s.name ); @@ -4809,7 +4823,7 @@ inline HashMap classNameSet( const IngestResult& ing ) for( const Symbol& s : ing.symbols ) { const bool rubyModule = ( s.lang == Lang::Ruby && s.kind == SymKind::Other ); - if( s.kind == SymKind::Class || s.kind == SymKind::Struct || s.kind == SymKind::Interface || rubyModule ) + if( s.kind == SymKind::Class || isStructOrNamedType( s.kind ) || s.kind == SymKind::Interface || rubyModule ) { classNames.try_emplace( s.name, '\0' ); } @@ -5103,7 +5117,7 @@ inline HashMap> buildUsingReexports( const } const Symbol& cls = ing.symbols[ ur.fromSymbol ]; const std::string_view base = namesplit::stripTemplateArgs( ur.qualifier ); - if( ( cls.kind != SymKind::Class && cls.kind != SymKind::Struct ) || base.empty() || base == ur.calleeName || base == cls.name ) + if( ( cls.kind != SymKind::Class && !isStructOrNamedType( cls.kind ) ) || base.empty() || base == ur.calleeName || base == cls.name ) { continue; // not at class scope, an inheriting constructor, or a class naming itself } @@ -7040,6 +7054,58 @@ struct Narrower } }; +// Rule 3 asked once per (caller file, name's candidate list) instead of once per call. rule3IncludeFile is a pure +// function of (caller file, candidate list, minCands) and walks the WHOLE list with a binary search per candidate over +// the caller's transitive include set, so a file that calls one name N times paid that walk N times for one answer +// (C: thousands of include ids, a name defined in dozens of files). The key is the candidate list's own address — a +// byName entry, which buildGraph's resolve loop never inserts into or reorders — plus the file, held until the next +// reference of ANOTHER file replaces it (references arrive file by file, so one file's answers are all that is ever +// resident; out of order only costs a recompute, never a wrong answer). minCands is fixed at its default (2): the +// lists a call computes per reference (a reachableByName survivor set, a FalseEdgeRules-narrowed list) have no stable +// address and are never memoized. A hit hands back exactly the ids the first computation wrote, in the same order. +struct Rule3FileMemo +{ + struct Answer + { + std::uint32_t begin = 0; // slice of `pool` + std::uint32_t count = 0; + bool fired = false; // rule3IncludeFile's return + }; + std::uint32_t fileId = kNoFile; // the caller file the answers below are for + HashMap*, Answer> answers; + std::vector pool; // every fired answer's survivor ids, back to back + + // == narrower.rule3IncludeFile( cands, callerFileId, out ): true iff it narrowed (then `out` holds the survivors); + // false leaves `out` untouched. + bool narrow( const Narrower& narrower, const rw::SmallVec& cands, std::uint32_t callerFileId, std::vector& out ) + { + if( callerFileId != fileId ) + { + fileId = callerFileId; + answers.clear(); + pool.clear(); + } + const auto [ it, fresh ] = answers.try_emplace( &cands ); + Answer& ans = it->second; + if( fresh ) + { + ans.fired = narrower.rule3IncludeFile( cands, callerFileId, out ); + if( ans.fired ) + { + ans.begin = static_cast( pool.size() ); + ans.count = static_cast( out.size() ); + pool.insert( pool.end(), out.begin(), out.end() ); + } + return ans.fired; + } + if( ans.fired ) + { + out.assign( pool.begin() + ans.begin, pool.begin() + ans.begin + ans.count ); + } + return ans.fired; + } +}; + // ── C++ template families: the canonical tier's fallback when a template-id qualifier keys no definition ────────── // Ingest keys a primary template's out-of-line member by the bare template name and a specialization by its canonical // template-id (ingest_names.h); a specialization header's base clause arrives as an inherit ref whose derived name is diff --git a/src/sarif.h b/src/sarif.h index 827e94b1f..f6d38ec04 100644 --- a/src/sarif.h +++ b/src/sarif.h @@ -96,6 +96,7 @@ struct SarifRunProperties std::size_t totalCount = 0; std::string select; // the raw --lint-select= argument, "" when the flag was absent std::string ignore; // the raw --lint-ignore= argument, "" when the flag was absent + std::string findingsNext; // XML twin: findings_next= — the call that counts the floored rules' rest; "" when nothing floored }; // One result — the SAME shape as main.cpp's file-scope LintOut, plus the enclosing-symbol name @@ -231,6 +232,13 @@ inline void writeSarifResult( std::FILE* out, const SarifFinding& f, std::string inline void writeSarifRunProperties( std::FILE* out, const SarifRunProperties& props ) { rw::emitTo( out, "\"properties\":{{\"findingsCapped\":{}", props.anyRuleCapped ? "true" : "false" ); + // knob-honesty-068: a floored run names the call that counts the rest (findings_next= on the XML root), so a CI + // consumer reading findingsCapped=true is handed the next step, not a dead end. Absent when nothing floored. + if( !props.findingsNext.empty() ) + { + std::fputs( ",\"findingsNext\":", out ); + jsonQuoted( out, props.findingsNext ); + } if( props.selectionActive ) { std::fputs( ",\"selected\":", out ); diff --git a/src/serialize.h b/src/serialize.h index 78f7de82e..d1a650f1a 100644 --- a/src/serialize.h +++ b/src/serialize.h @@ -10,6 +10,7 @@ #include "memguard.h" // #350: memguard::limitSpelling — memory_limit= is spelled as the --max-memory value that raises it #include "extentsuspect.h" // extent honesty: extent_suspect= reason spellings (extent::extentSuspectReasons) #include "nextverb.h" // P3 (L7): next= on the top-ranked row +#include "countfloor.h" // count-floor: implementorFloors — the implementors_floor= evidence #include "arch.h" // P3: builtinLayer() — the file-node layer= tag #include "graph.h" // H6/F2: definitionCountOfName — the ONE resolver behind --lego's defs= single-pick disclosure #include "graphlegend.h" // R-E fix (2026-08-19): rw::rootRelPathsLegend — the ONE root= definition @@ -33,8 +34,10 @@ #include #include // §H7 degrade seam: std::getenv for the non-release fault switch #include +#include // knob-honesty-068: the resume index's widest digit count (sigsCutContinuationReserve) #include #include +#include // back_inserter (reorderDocsAfterCode) #include #include @@ -865,20 +868,36 @@ inline constexpr std::string_view kForSigsShrunkNote = static_assert( kForDocExcerptRankCount == 24 && kForDocFullRankCount == 12, "kForDocsDroppedNote spells the tier thresholds (r>24; the ladder's r5..24): re-word it with these constants" ); -// The clauses a cut report owes, concatenated in a fixed order ("" when it owes none). -inline std::string sigsCutLegendNotes( bool isCapped, std::size_t shown, std::size_t total, std::size_t docsDropped ) -{ - std::string notes; - if( isCapped && shown == total ) - { - notes += kForSigsShrunkNote; - } - if( docsDropped > 0 ) - { - notes += kForDocsDroppedNote; - } - return notes; -} +// The docs reorder's reading (reorderDocsAfterCode), present-only on a tag that carries docs_after_code=. Says what it is +// NOT: no row was added or removed. No "--" (it rides inside an XML comment, G4). +inline constexpr std::string_view kForDocsAfterCodeNote = + " [docs_after_code=N: N doc rows of the shown set moved below its code rows; same rows, reordered, none added or removed; " + "a question naming docs keeps score order]"; + +// • next= on (knob-honesty-068): the call that serves the block uncut (sigsCutNextInvocation). Not the r=1 row's +// next= (a body or the widening page), so it says which rows it serves. +inline constexpr std::string_view kForSigsNextNote = " [sigs next=: one call serving all total= rows uncut]"; +inline constexpr std::string_view kForSigsNextBudgetNote = + " [sigs next_budget_tokens=: call this tool again with that budget_tokens to get all total= rows uncut]"; +// next_offset= is the resume index #362's budgeted candidate page will consume; NOTHING consumes it today — the for lens's own +// offset= pages the FILE-grain widening list, a different list — so the clause says so rather than implying a resume exists +// (rv-knob-honesty-068 N2). No consumer is added in this lane. +inline constexpr std::string_view kForSigsNextOffsetNote = + " [sigs next_offset=: the candidate index the cut starts at; no consumer yet (the for offset pages files; next= re-runs the list)]"; +// knob-honesty-068 (orchestrator rulings 2026-10-07, PROCESS rule 5: the recovery handle ALWAYS ships): a charged pays +// for its next= in rows ONLY where that makes the answer fit. Where it cannot — the rank 1..4 floor has nothing left to give, +// or the answer lands past its ceiling paid or not (dropping rows does not make it fit) — no row is dropped for it: the +// handle rides unpaid on the rows the cut alone left, and when the document lands past its ceiling over_ceiling="1" says so +// and this clause says why. Not "est_tokens exceeds budget_tokens": on a --detail bundle the bodies price at the body rate, +// so the byte allowance (budget_tokens x 2.36 x 1.15) can be the ceiling crossed. +// NO ANGLE BRACKETS (CHECKLIST 17): the clause rides the header legend, BEFORE the real tag, so a literal sigs tag spelled +// here satisfied every "the sigs block is intact" grep and moved sigsblock()'s span start into this comment on exactly the +// documents that carry it (rv-knob-honesty-068 C2). +inline constexpr std::string_view kForSigsUnpaidOverCeilingNote = + " [over_ceiling=1 also when the sigs next= rides unpaid: no row is dropped to pay for it on an answer that would not fit either way]"; + +// The clauses a cut report owes, concatenated in a fixed order ("" when it owes none) — defined below SigsCutReport +// (sigsCutReportLegend), because the continuation's clauses read the report itself. // ── B0 round 2 (H1): GLOBAL deterministic payload budget for the ranked --for bundle ───────────────── // The rank tiers above cut only ~1% of the measured LocBench payload: the worst bundles are dominated by @@ -1931,6 +1950,9 @@ struct MapAnnotations // next= that pages them. Every other map keeps the plain rank-order cut, and a map-scope map whose cut swapped // nothing carries neither attribute (byte-identical). Filled by assignment, like the trailing fields above. bool codeFirstRows = false; + // FE-B: the compact posture strips the map's via="name" comment (compactlegend.h, the via/x rows restate it), so the + // map does not write it at all — and the --max-tokens fit, which measures this render, prices the delivered bytes. + bool viaLegendStripped = false; }; // ── the code-first row pick: data_sections_cut= / next= (docs/EVALS.md "Map data Sections never crowd code out of the @@ -2365,6 +2387,17 @@ inline constexpr std::string_view kModScopeMapLegend = "-and-an-anonymous-callback-body's-calls-live;a-CALLER-never-a-callee(nothing-in-the-source-can-name-it)-with-no-body-to-expand;" "a-file-with-no-such-call-has-no-such-row -->"; +// honesty-small-068: the three Go named-type kinds' reading, appended ONLY when the corpus has one (the modscope clause's +// byte-identity rule: a corpus without such a row keeps every byte of this legend). What it does NOT mean: t=type is not +// "a struct" by what it IS: the kind says how the type is WRITTEN, so `type W Pair` and `type H pkg.HandlerFunc` are t=type +// even when the type underneath is a struct or a func (the parse cannot see into another named type), +// and only Go is split — another language's typedef/alias/enum still reads t=struct (said in model.h and the gate's +// arm F, not in every Go answer: the clause rides each Go map, so it carries only what a Go row needs). +inline constexpr std::string_view kNamedTypeMapLegend = + ""; + // EXTENT HONESTY (src/extentsuspect.h, gate test/extentcheck.sh) — the ONE reading of extent_suspect= on every ROW // surface: the map's , a bundle's and . Written only into a document that carries the attribute, right // where the reader meets it, so a corpus with nothing flagged stays byte-identical. The map adds the header count @@ -2599,6 +2632,117 @@ inline std::uint32_t counterAt( const std::vector* v, NodeId id ) return ( v && id < v->size() ) ? ( *v )[ id ] : 0u; } +// FE-B: one map 's rows over out-edges [from, to). A via="name" row (matched by name alone) that the symbol would +// print several times byte for byte — N same-named by-name candidates, one row each, carrying only n=/prov=/via= — is +// written ONCE, at its first edge, with x="N": the count, and --callees on the lists each candidate with its file +// (graphlegend.h kMapViaNameLegend / compactlegend.h's x row). Every other row, prov="split" arms included, is written as +// before. `scratch` is reused across symbols. +inline void writeMapCalleeRows( XmlWriter& w, const IngestResult& ing, std::uint32_t from, std::uint32_t to, const std::vector& outTargets, + const std::vector* outProv, const std::vector* outNameOnly, std::vector& esc, + std::vector& scratch ) +{ + const auto provOf = [ & ]( std::uint32_t e ) -> std::uint8_t { return ( outProv && e < outProv->size() ) ? ( *outProv )[ e ] : 0; }; + const auto nameOnly = [ & ]( std::uint32_t e ) { return outNameOnly && e < outNameOnly->size() && ( *outNameOnly )[ e ] != 0; }; + // the row's bytes among via="name" rows: name, prov= + const auto rowLess = [ & ]( std::uint32_t x, std::uint32_t y ) + { + const std::string& nx = ing.symbols[ outTargets[ x ] ].name; + const std::string& ny = ing.symbols[ outTargets[ y ] ].name; + return nx != ny ? nx < ny : provOf( x ) < provOf( y ); + }; + scratch.clear(); + for( std::uint32_t e = from; e < to; ++e ) + { + if( nameOnly( e ) ) + { + scratch.push_back( e ); + } + } + std::stable_sort( scratch.begin(), scratch.end(), rowLess ); // each group of equal rows stays in edge order + for( std::uint32_t e = from; e < to; ++e ) + { + std::size_t repeats = 1; + if( nameOnly( e ) ) + { + const auto group = std::equal_range( scratch.begin(), scratch.end(), e, rowLess ); + ASSUME( group.first != group.second, "every via=\"name\" edge of [from, to) was collected into scratch" ); + if( *group.first != e ) + { + continue; // a repeat of an earlier row: counted in that row's x= + } + repeats = std::size_t( group.second - group.first ); + } + w.write( " says K of this symbol's CALLS were guesses and cannot + // say WHICH edges; prov="split" names the arms, and the suspect set becomes the guessed edges and nothing else. + if( provOf( e ) != 0 ) + { + w.write( "\" prov=\"" ); + w.write( provLabel( provOf( e ) ) ); + } + if( nameOnly( e ) ) + { + w.write( "\" via=\"name" ); // FE-B: the same edge bit every other surface reads (viaNameLegendComment) + } + if( repeats > 1 ) + { + w.write( "\" x=\"" ); + w.write( std::to_string( repeats ) ); + } + w.write( "\"/>" ); + } +} + +// FE-B: may a row under one of `nodes` carry via="name"? True when any of their out-edges is name-only — an +// over-approximation of what a section renders (a block's 16-per-symbol cap may cut that row; a merged overload +// row prints one representative's edges), asked BEFORE the rows are written by a surface whose first-screen legend must +// define every attribute its payload can carry (the map, --for, --exemplar). +inline bool namesOnlyOutAny( const std::vector& outOff, const std::vector& outNameOnly, + std::span nodes ) noexcept +{ + for( const NodeId n : nodes ) + { + if( std::size_t( n ) + 1 >= outOff.size() ) + { + continue; + } + for( std::uint32_t e = outOff[ n ]; e < outOff[ n + 1 ] && e < outNameOnly.size(); ++e ) + { + if( outNameOnly[ e ] != 0 ) + { + return true; + } + } + } + return false; +} + +// THE HOP-SLOT RULE as one predicate (gate test/forsigspancheck.sh (H)): a candidate earns a packHops slot only with +// at least one callee edge that is NOT name-only (empty bits: a graph with no name-only edge, so every edge is proven). +// packHops' slot test and --for's header question "may a hop row carry via=name?" (verbs_for.h) both read it, so a hop +// the rule drops can no longer make the header define an attribute no row carries (test/docdemotecheck.sh (f)). +inline bool hopSlotHasProvenEdge( const std::vector& outOff, const std::vector& outNameOnly, + NodeId id ) noexcept +{ + const std::uint32_t outDeg = ( std::size_t( id ) + 1 < outOff.size() ) ? outOff[ id + 1 ] - outOff[ id ] : 0u; + if( outDeg == 0 ) + { + return false; + } + if( outNameOnly.empty() ) + { + return true; + } + ASSUME( outOff[ id + 1 ] <= outNameOnly.size(), "the hedge bits are one per flattened edge (graph.h buildGraph)" ); + return !std::all_of( outNameOnly.begin() + outOff[ id ], outNameOnly.begin() + outOff[ id + 1 ], + []( std::uint8_t bit ) { return bit != 0; } ); +} + inline void serialize( std::FILE* out, const IngestResult& ing, const std::vector& rank, const std::vector& outOff, const std::vector& outTargets, int topK, bool mostImportantLast = false, @@ -2658,7 +2802,10 @@ inline void serialize( std::FILE* out, const IngestResult& ing, const std::vecto const std::vector* declinedOut = nullptr, // of those, the calls the builtin-method name gate declined (graph.h g.gateDeclinedCalls) → the // kDeclinedGateMapLegend clause, absent when zero. - std::size_t gateDeclinedCalls = 0 ) + std::size_t gateDeclinedCalls = 0, + // FE-B: Graph::outNameOnly (parallel to outTargets) → via="name" on a row and the via legend + // comment; nullptr/empty ⇒ every edge is evidence-bound and the map is byte-identical. + const std::vector* outNameOnly = nullptr ) { const std::size_t* changedCount = ann.changedCount; const std::string* mapAtStamp = ann.atStamp; @@ -2784,6 +2931,12 @@ inline void serialize( std::FILE* out, const IngestResult& ing, const std::vecto { legend += kModScopeMapLegend; } + // honesty-small-068: the Go named-type kinds' clause, under the same absent-at-zero rule as modscope's above. + if( std::any_of( ing.symbols.begin(), ing.symbols.end(), []( const Symbol& sym ) + { return sym.kind == SymKind::NamedType || sym.kind == SymKind::Alias || sym.kind == SymKind::FuncType; } ) ) + { + legend += kNamedTypeMapLegend; + } std::size_t extentSuspectTotal = 0; for( const Symbol& sym : ing.symbols ) @@ -2888,6 +3041,15 @@ inline void serialize( std::FILE* out, const IngestResult& ing, const std::vecto const std::size_t declinedTotal = counterTotal( declinedOut ); // calls tier 3 declined: no edge, and no guess legend += declinedTotal > 0 ? kDeclinedMapLegend : ""; // charged to the map that carries declined= legend += gateDeclinedCalls > 0 ? kDeclinedGateMapLegend : ""; // only where the builtin-method gate declined a call + if( outNameOnly && !stubbed && !ann.viaLegendStripped + && namesOnlyOutAny( outOff, *outNameOnly, std::span( order.data(), std::min( keep, order.size() ) ) ) ) + { + // FE-B: the reading every other surface carries (graphlegend.h kViaNameLegend), in the map's short spelling — exactly + // when a kept row may print a via="name" (over-approximated by merged overloads, which print one member's edges). + // It also defines a merged row's x=. Not written under the compact posture, whose rewrite strips it (compactlegend.h) + // and whose element-qualified via/x rows restate it: the --max-tokens fit then prices what is delivered. + legend += kMapViaNameLegend; + } // C1 DRIFT FIX (Round C lane B, found by re-reading this header's own output). `precise=` means "how many // out-edges a SCIP index PINNED", and the emitter's own comment below says it is "emitted ONLY under // --scip". Both were true when outProv held only {0, 1}. A4-R5 then added value 2 (an FFI binding edge) @@ -3119,6 +3281,7 @@ inline void serialize( std::FILE* out, const IngestResult& ing, const std::vecto // The children, as ONE renderer both paths call. This was inline code writing through a writer bound to "the buffer, // or `out` when the buffer could not open". That serves a failed open, and cannot serve a buffer that opened and then // lost a write: by then the children were already spent into it, and the only bytes left to print had a hole in them. + std::vector hedgedScratch; // FE-B: writeMapCalleeRows' reused buffer const auto writeChildren = [ & ]( XmlWriter& w ) { // §P8 collision: this prologue spelled its LABEL `l=`, the two characters 22 other sites use for a LINE @@ -3385,27 +3548,7 @@ inline void serialize( std::FILE* out, const IngestResult& ing, const std::vecto } w.write( ">" ); - for( std::uint32_t e = outOff[id]; e < outOff[id + 1]; ++e ) - { - w.write( " says K of this - // symbol's CALLS were guesses and cannot say WHICH edges, so a consumer honouring the honesty - // signal had to distrust every child. prov="split" names the arms, and the suspect set - // becomes the guessed edges and nothing else. - if( outProv && e < outProv->size() && ( *outProv )[e] ) - { - w.write( "\" prov=\"" ); - w.write( provLabel( ( *outProv )[e] ) ); - } - w.write( "\"/>" ); - } + writeMapCalleeRows( w, ing, outOff[id], outOff[id + 1], outTargets, outProv, outNameOnly, esc, hedgedScratch ); w.write( "" ); } w.write( "" ); @@ -4129,8 +4272,31 @@ struct SigRowFacts std::string_view topNext = {}; // L-W (forpage.h): the r=1 row's next= when the caller decided // the answer is THIN — the file-grain widening page. Empty ⇒ // the body follow-up (--expand=FILE:NAME) exactly as before. + bool elideZero = false; // lean-answers lane: the --for lens (CLI and MCP) omits cx=/ccx=/in= + // when "0" — its legend says an absent one IS 0. Only where in= + // is measured on every row (fanIn supplied): without fanIn an + // absent in= means "not measured" (P2.4), so nothing is elided. }; +// Whether this row's zero cx=/ccx=/in= are omitted (SigRowFacts::elideZero): the lens asked AND in= is measured here. +inline bool sigRowElidesZero( const SigRowFacts& facts, NodeId id ) noexcept +{ + return facts.elideZero && facts.metrics && facts.fanIn != nullptr && id < facts.fanIn->size(); +} + +// The bytes the zero elision removed from one row: what the row would have spelled as ` cx="0"` / ` ccx="0"` / +// ` in="0"`. The --for ladder still CHARGES them (SigEntry::elidedBytes), so the rows the budget keeps are exactly the +// rows it kept before the elision — the change is a format change, never a different selection. +inline std::size_t sigRowZeroElidedBytes( const Symbol& s, const SigRowFacts& facts, NodeId id ) noexcept +{ + if( !sigRowElidesZero( facts, id ) ) + { + return 0; + } + return ( s.cx == 0 ? sizeof( " cx=\"0\"" ) - 1 : 0u ) + ( s.ccx == 0 ? sizeof( " ccx=\"0\"" ) - 1 : 0u ) + + ( ( *facts.fanIn )[ id ] == 0 ? sizeof( " in=\"0\"" ) - 1 : 0u ); +} + // P7 (terminality round A, lane R, 2026-09-05): a lens row's own file, spelled root-relative exactly as the // wrapper it replaced was — the ONE spelling both dialects' rows (p= / "p") and the r=1 next= use. inline std::string lensRowPath( const IngestResult& ing, std::uint32_t fileId, std::string_view rootArg ) @@ -4139,6 +4305,36 @@ inline std::string lensRowPath( const IngestResult& ing, std::uint32_t fileId, s : std::string( rw::sarif::rootRelativeUri( ing.files[ fileId ], rw::sarif::rootPrefixOf( rootArg ) ) ); } +// The descriptive tail of one "" row, after p=/layer= and through the closing '>': cx=/ccx=/in= under facts.metrics +// (each omitted at 0 where sigRowElidesZero), the Q3 lens + pure, then r=. Split out of sigRowHead so the head stays a head. +inline void sigRowTail( char* tail, std::size_t cap, const Symbol& s, const SigRowFacts& facts, NodeId id, const char* rankAttr ) +{ + if( facts.metrics && sigRowElidesZero( facts, id ) ) + { + // lean-answers lane: the same three facts, each omitted at 0 (the lens legend: absent = 0) + char cxAttr[ 24 ]; cxAttr[ 0 ] = '\0'; + char ccxAttr[ 24 ]; ccxAttr[ 0 ] = '\0'; + char inAttr[ 24 ]; inAttr[ 0 ] = '\0'; + if( s.cx != 0 ) { rw::formatTo( cxAttr, sizeof( cxAttr ), " cx=\"{}\"", s.cx ); } + if( s.ccx != 0 ) { rw::formatTo( ccxAttr, sizeof( ccxAttr ), " ccx=\"{}\"", s.ccx ); } + if( ( *facts.fanIn )[ id ] != 0 ) { rw::formatTo( inAttr, sizeof( inAttr ), " in=\"{}\"", ( *facts.fanIn )[ id ] ); } + rw::formatTo( tail, cap, "{}{}{}{}{}{}>", rw::cstr( cxAttr ), rw::cstr( ccxAttr ), rw::cstr( inAttr ), facts.lens, facts.pure, rankAttr ); + } + else if( facts.metrics ) + { + char inAttr[ 24 ]; inAttr[ 0 ] = '\0'; + if( facts.fanIn && id < facts.fanIn->size() ) + { + rw::formatTo( inAttr, sizeof( inAttr ), " in=\"{}\"", ( *facts.fanIn )[ id ] ); + } + rw::formatTo( tail, cap, " cx=\"{}\" ccx=\"{}\"{}{}{}{}>", s.cx, s.ccx, rw::cstr( inAttr ), facts.lens, facts.pure, rankAttr ); + } + else + { + rw::formatTo( tail, cap, "{}{}{}>", facts.lens, facts.pure, rankAttr ); + } +} + // P2.3/P2.4 — the exact "" opening tag of ONE signature row, defined once so the two-phase (globally // budgeted) emitter and the streaming emitter can never drift by a byte: the budget ledger measures exactly // the string this returns. @@ -4184,19 +4380,7 @@ inline std::string sigRowHead( const IngestResult& ing, NodeId id, const SigRowF rw::formatTo( rankAttr, sizeof( rankAttr ), " r=\"{}\"", facts.rank ); } char tail[ 224 ]; - if( facts.metrics ) - { - char inAttr[ 24 ]; inAttr[ 0 ] = '\0'; - if( facts.fanIn && id < facts.fanIn->size() ) - { - rw::formatTo( inAttr, sizeof( inAttr ), " in=\"{}\"", ( *facts.fanIn )[ id ] ); - } - rw::formatTo( tail, sizeof( tail ), " cx=\"{}\" ccx=\"{}\"{}{}{}{}>", s.cx, s.ccx, rw::cstr( inAttr ), facts.lens, facts.pure, rw::cstr( rankAttr ) ); - } - else - { - rw::formatTo( tail, sizeof( tail ), "{}{}{}>", facts.lens, facts.pure, rw::cstr( rankAttr ) ); - } + sigRowTail( tail, sizeof( tail ), s, facts, id, rw::cstr( rankAttr ) ); head += tail; // extent honesty (kExtentSuspectRowLegend): after r=, before next=, absent when every check held — so every // pre-existing adjacency on an unflagged row is byte-stable and the budget ledger still measures this string. @@ -4221,6 +4405,280 @@ inline std::string sigRowHead( const IngestResult& ing, NodeId id, const SigRowF return head; } +// ── e= — WHERE A DEFINITION ENDS (the --for lens rows; gate test/forsigspancheck.sh) ────────────────── +// l= is the line of a definition's NAME. A reader asking "which body holds line N" also needs the LAST line, and a +// graded answer lost credit for an item inside a function because its row carried only l=. e= is that last line: +// the parser's body-inclusive span end (Symbol::endByte), counted in the file's current bytes. +// +// It is printed ONLY when the extent is known. Unknown — e= absent, never 0 and never a guess — for: +// * a row that is not a code definition: a markdown heading, a config key (isCodeLang), a module-scope owner +// (synthetic: no body by construction); +// * a row extent_suspect= flags (its span may be a parse-recovery artifact: kExtentSuspectRowLegend); +// * a span the file's bytes cannot hold, or one that ends above its own name line. +// What e= does NOT mean: that every line in l..e belongs to this definition alone (a nested definition shares +// them), or that the definition starts at l= (a return type or decorator can sit above the name). +// +// The file's line breaks, built once per file by the caller and only when a row asks for e=. +inline std::vector lineBreaksOf( std::string_view src ) +{ + std::vector breaks; + for( std::size_t at = src.find( '\n' ); at != std::string_view::npos; at = src.find( '\n', at + 1 ) ) + { + breaks.push_back( std::uint32_t( at ) ); + } + return breaks; +} + +// The symbol-table half of defEndLine's rule (no file bytes): may this row carry e= at all? The legend's present-only bit. +inline bool mayCarryEndLine( const Symbol& s ) noexcept +{ + return isCodeLang( s.lang ) && s.kind != SymKind::Section && s.kind != SymKind::ModuleScope && s.extentSuspect == 0 + && s.endByte > s.sigStartByte; +} + +// The 1-based e= value of `s` against its file (`breaks` from lineBreaksOf over `srcSize` bytes), or 0 = unknown. +inline std::uint32_t defEndLine( const Symbol& s, const std::vector& breaks, std::size_t srcSize ) noexcept +{ + if( !mayCarryEndLine( s ) || s.endByte > srcSize ) + { + return 0; // not a code definition with a trusted span, or a span the current bytes cannot hold + } + const std::uint32_t lastByte = s.endByte - 1; // the definition's own last byte; a '\n' there ends ITS line + const std::size_t before = std::size_t( std::lower_bound( breaks.begin(), breaks.end(), lastByte ) - breaks.begin() ); + const std::uint32_t endLine = std::uint32_t( before ) + 1u; + ENSURES( endLine >= 1u, "a line number is 1-based" ); + return endLine >= s.line ? endLine : 0u; +} + +// The legend clauses defining e=, present-only (a document whose rows can carry one), in both --for dialects and the MCP +// twin; gate test/forsigspancheck.sh (L). Each says what the attribute does NOT mean: absent is unknown (never a 0), and +// l= stays the NAME's line. +inline constexpr std::string_view kForCompactEndLineLegend = + "; d e= its last line (absent=unknown, never 0; l= the name's line)"; +// The parenthesis is a list of examples, not the full set (a module-scope row has no e= either; CHANGELOG says so). Listing it +// would move every full-legend golden (docdemote, route, anchor, relevancefloor, compactlegend pins) for a cosmetic gain: deferred. +inline constexpr std::string_view kForEndLineLegend = + "; e= on a d row: the 1-based line where that definition ends, body-inclusive; absent when the extent is not known " + "(extent_suspect, docs, config), never 0; l= is the line of the definition's name, so a definition can start above l="; + +// The " e=\"N\"" run, spliced into a rendered head right after its l= value (the head always opens ` bytes, so the +// exemption costs nothing but its bytes wherever the sig side holds its DEFAULT share: no explicit ceiling, or one at or +// above kForPayloadBudgetBytes (the sig side is then frozen at the default regime's bytes — forbudgetmonotoncheck #1). +// A TIGHTER explicit ceiling (--token-budget below that share, MCP budget_tokens likewise) or a body ceiling promises +// est_tokens <= the budget: exempt, e= broke that promise; charged, it cost rows (the compact dialect's header leaves no +// slack — compactlegendcheck (P4)). There the answer carries no e= at all — no row and no legend clause — and is the +// answer it was before e= existed: a row is worth more than the end line of the rows around it. +// +// RIPWIRE_FOR_ENDLINES — an EXPERIMENTAL measurement switch, not a feature (owner, 2026-10-07: answer fully first, then +// measure with adjustable knobs). It moves only this one decision, so CLI --for (XML and JSON) and MCP `for` follow it alike: +// unset or "auto" — the rule above (the default; byte-identical to the switch not existing); +// "always" — e= under EVERY ceiling, a tighter explicit one and a body ceiling included. The shown row set does +// not change (e= is never charged to rows), so est_tokens <= the budget may NOT hold there — the very +// reason auto is the default; the round-2 knob arm reports it rather than hiding it; +// "never" — no e= anywhere, the default regime included. +// Any other value (case, spaces and the empty string included) is a recoverable config problem: it falls back to auto and +// says so once per process on stderr (siblift.h's shape) — never read as never. No flag and no legend change: the legend +// clause follows the rows. Gate: test/forsigspancheck.sh (N). +enum class ForEndLinesMode : std::uint8_t +{ + Auto, + Always, + Never, +}; +struct ForEndLinesSetting +{ + ForEndLinesMode mode = ForEndLinesMode::Auto; + bool unknown = false; // set to a value that is none of auto|always|never — mode is then Auto +}; +inline constexpr ForEndLinesSetting forEndLinesSettingParse( const char* env ) noexcept +{ + if( env == nullptr ) + { + return {}; + } + const std::string_view v( env ); + if( v == "auto" ) + { + return {}; + } + if( v == "always" ) + { + return { ForEndLinesMode::Always, false }; + } + if( v == "never" ) + { + return { ForEndLinesMode::Never, false }; + } + return { ForEndLinesMode::Auto, true }; +} +static_assert( forEndLinesSettingParse( nullptr ).mode == ForEndLinesMode::Auto && !forEndLinesSettingParse( nullptr ).unknown ); +static_assert( forEndLinesSettingParse( "never" ).mode == ForEndLinesMode::Never ); +static_assert( forEndLinesSettingParse( "Never" ).mode == ForEndLinesMode::Auto && forEndLinesSettingParse( "Never" ).unknown ); +static_assert( forEndLinesSettingParse( "" ).unknown ); +// Read once per process: the environment does not change under a run, and an MCP server asks per call. +inline ForEndLinesMode forEndLinesMode() +{ + static const ForEndLinesMode mode = [] + { + const ForEndLinesSetting s = forEndLinesSettingParse( std::getenv( "RIPWIRE_FOR_ENDLINES" ) ); + if( s.unknown ) + { + // the value itself is not echoed: environment bytes are not ours to print unescaped + rw::emitTo( stderr, "ripwire: RIPWIRE_FOR_ENDLINES is set to a value other than always|auto|never — --for e= follows the default rule (auto)\n" ); + } + ENSURES( !s.unknown || s.mode == ForEndLinesMode::Auto, "an unknown RIPWIRE_FOR_ENDLINES value falls back to auto, never to never" ); + return s.mode; + }(); + return mode; +} +// THE tight-explicit-ceiling predicate: the answer carries an explicit ceiling it promises (est_tokens <= it) and that ceiling is +// tighter than the default signature share — --token-budget / MCP budget_tokens set and below kForPayloadBudgetBytes, OR a body +// ceiling (--max-tokens with --detail; `bodyCeiling`, which has no default share to compare with). One definition for the two +// decisions that must not add an uncharged byte there: e= (below) and the code-above-docs reorder with its reading +// (docsAfterCodeFitsCeiling). budgetTokens 0 = no token budget; MCP has no body ceiling and passes false. +inline constexpr bool explicitCeilingTighterThanDefault( std::size_t budgetTokens, bool bodyCeiling ) noexcept +{ + return bodyCeiling || ( budgetTokens != 0 && budgetBytesForTokens( budgetTokens ) < kForPayloadBudgetBytes ); +} +// May the code-above-docs reorder (reorderDocsAfterCode) apply? Its `docs_after_code=` attribute and reading are UNCHARGED +// (a disclosure never costs a row), so under a tight explicit ceiling — where the answer promises est_tokens <= the ceiling +// and every row — neither the reorder nor its note applies: the answer is the one it was before the reorder existed. At or +// above the default share, or with no explicit ceiling, it applies. Independent of RIPWIRE_FOR_ENDLINES (that switch moves e= only). +inline constexpr bool docsAfterCodeFitsCeiling( std::size_t budgetTokens, bool bodyCeiling ) noexcept +{ + return !explicitCeilingTighterThanDefault( budgetTokens, bodyCeiling ); +} +inline constexpr bool endLinesFitCeilingFor( ForEndLinesMode mode, std::size_t budgetTokens, bool bodyCeiling ) noexcept +{ + switch( mode ) + { + case ForEndLinesMode::Always: return true; + case ForEndLinesMode::Never: return false; + case ForEndLinesMode::Auto: break; + } + return !explicitCeilingTighterThanDefault( budgetTokens, bodyCeiling ); +} +static_assert( endLinesFitCeilingFor( ForEndLinesMode::Auto, 0, false ) && !endLinesFitCeilingFor( ForEndLinesMode::Auto, 0, true ) ); +static_assert( docsAfterCodeFitsCeiling( 0, false ) && !docsAfterCodeFitsCeiling( 1200, false ) && docsAfterCodeFitsCeiling( 8000, false ) ); +static_assert( !docsAfterCodeFitsCeiling( 0, true ) && !docsAfterCodeFitsCeiling( 8000, true ) ); // a body ceiling is tight at any (or no) token budget +static_assert( endLinesFitCeilingFor( ForEndLinesMode::Always, 1, true ) && !endLinesFitCeilingFor( ForEndLinesMode::Never, 0, false ) ); +inline bool endLinesFitCeiling( std::size_t budgetTokens, bool bodyCeiling ) +{ + return endLinesFitCeilingFor( forEndLinesMode(), budgetTokens, bodyCeiling ); +} + +// ── CODE ABOVE DOCS, AS A REORDER OF THE SHOWN SET (filter.h CODE ABOVE DOCS; gate test/forsigspancheck.sh (R)) ───────── +// Runs AFTER the score order and the byte ladder have chosen which rows are shown, so it can never evict a row: the shown +// set is exactly the one plain score order shows, and only its order changes — every code row first, every doc row +// after, each group in its own score order. The rank numbers are the shown rows' OWN r= values, reassigned in the new +// order, so a gap still marks a budget-trimmed row and r=1 (with its next=) goes to the first row shown. `entries` is in +// globalRank order; EntryT has globalRank (kept: it maps the row back to its symbol), displayRank (the r= it is shown +// with) and dropped. `isDoc(e)` says which rows are docs; `rehead(e, newRank)` re-renders a row whose r= changed. +// Returns how many doc rows moved below a code row (0 ⇒ nothing changed; the caller discloses a non-zero count). +// The shown (not dropped) rows' indices in `entries` order, and how many doc rows among them sit above the LAST code row +// (the rows reorderDocsAfterCode moves; 0 ⇒ nothing to move). +template< class EntryT, class IsDoc > +inline std::pair, std::size_t> shownDocsAboveCode( const std::vector& entries, IsDoc&& isDoc ) +{ + std::vector live; + std::size_t docsSoFar = 0, docsAboveLastCode = 0; + for( std::size_t i = 0; i < entries.size(); ++i ) + { + if( entries[i].dropped ) + { + continue; + } + live.push_back( i ); + if( isDoc( entries[i] ) ) + { + ++docsSoFar; + } + else + { + docsAboveLastCode = docsSoFar; // every doc row seen so far sits above this code row + } + } + return { std::move( live ), docsAboveLastCode }; +} + +template< class EntryT, class IsDoc, class Rehead > +inline std::size_t reorderDocsAfterCode( std::vector& entries, IsDoc&& isDoc, Rehead&& rehead ) +{ + const auto [ live, moved ] = shownDocsAboveCode( entries, isDoc ); + if( moved == 0 ) + { + return 0; // no doc row above a code row (or no code row shown at all): byte-identical + } + std::vector ranks; // the shown rows' own r= values, ascending (entries is in globalRank order) + std::vector newOrder; + for( const std::size_t i : live ) + { + ranks.push_back( entries[i].globalRank ); + } + std::copy_if( live.begin(), live.end(), std::back_inserter( newOrder ), [ & ]( std::size_t i ) { return !isDoc( entries[i] ); } ); + std::copy_if( live.begin(), live.end(), std::back_inserter( newOrder ), [ & ]( std::size_t i ) { return isDoc( entries[i] ); } ); + ASSUME( newOrder.size() == ranks.size() ); + for( std::size_t k = 0; k < newOrder.size(); ++k ) + { + EntryT& e = entries[ newOrder[k] ]; + if( e.displayRank != ranks[k] ) + { + rehead( e, ranks[k] ); + e.displayRank = ranks[k]; + } + } + // dropped rows keep their globalRank as displayRank; the shown rows hold a permutation of their own ranks, so the + // display ranks stay unique and the sort is total (deterministic) + std::stable_sort( entries.begin(), entries.end(), []( const EntryT& a, const EntryT& b ) { return a.displayRank < b.displayRank; } ); + ENSURES( moved > 0, "a moved count is only returned when the order changed" ); + return moved; +} + // ── LB-A (r10 GitNexus round) — THE RELEVANCE FLOOR ────────────────────────────────────────────────── // `order` is already sorted by (score desc, id asc), so every row that scored ZERO forms one contiguous // TAIL. This walks that tail off the kept head and returns the shortened count. @@ -4470,14 +4928,125 @@ inline std::size_t gateSigRowsRankFirst( std::vector& entries, std::vect // What the block cut, for the callers that splice its legend clauses (the block's own tag carries the numbers). // shown/total are the tag's shown=/total= (total = rows handed to the gate); docsDropped counts SHOWN rows whose doc // comment the rank tiers or the ladder removed (the tag's docs_dropped=). isCapped mirrors the tag's capped="1". +struct SigsCutContinuation; // below: the caller's request for a cut's continuation (knob-honesty-068) struct SigsCutReport { + // IN (knob-honesty-068): the caller's continuation request, read by packSignatures / packSignaturesJson BEFORE they reset + // the report — carried here rather than as one more defaulted trailing parameter, so a train merge that adds another + // defaulted parameter to these 20+-parameter signatures cannot silently bind a value to the wrong one. nullptr ⇒ none. + const SigsCutContinuation* continuationRequest = nullptr; std::size_t shown = 0; std::size_t total = 0; std::size_t docsDropped = 0; bool isCapped = false; + std::size_t docsAfterCode = 0; // reorderDocsAfterCode's moved count: docs_after_code= (0 ⇒ absent) + std::string next; // knob-honesty-068: the cut's continuation (sigsCutNextInvocation), set only on a capped + // block whose caller asked for a PASTEABLE one; the tag's next= / the JSON "sigs_next" + std::size_t nextBudgetTokens = 0; // …or, on a surface with no pasteable argv (MCP), the budget_tokens a re-call needs: + // the tag's next_budget_tokens=; 0 ⇒ none + std::size_t lastShownRank = 0; // the 1-based candidate rank of the last row printed (rows are a rank PREFIX: the ladder + // drops tail-first) — the resume index; next_offset= when rows were dropped + bool hasContinuation = false; // the caller asked for the continuation (next=/next_budget_tokens=/next_offset=) + bool terseContinuation = false; // a CHARGED pasteable one under a hard ceiling: next= alone (no next_offset=, no + // clause — the bundle's own next= reading defines it); see SigsCutContinuation + std::size_t continuationUnpaidBytes = 0; // a CHARGED one's bytes no row paid for — the rank 1..4 floor could not give them up, + // or the caller forbade the payment (payFromRows=false): it rides anyway (the recovery + // handle always ships) and the caller labels any overshoot }; +// ── THE CUT'S CONTINUATION (knob-honesty-068; PROCESS rule 5: every cut disclosed AND recoverable via next=) ────────── +// A capped said shown=/total= and named no call that serves the rows it cut: the only next= in the bundle was the +// r=1 row's (a body, or the file-grain widening page), so the trimmed tail was a dead end. The block now carries its own +// next= — the SAME ranked lens re-run with the sig side widened until nothing is cut: +// --for=TASK --signatures-only [RANKING FLAGS] [--pack-top-n=N] --token-budget=T [--json] +// --signatures-only gives the signatures the WHOLE explicit ceiling (verbs_for.h forSigSideCeiling: no auto-body share), +// and T is sized from THIS block's untrimmed bytes plus the bytes the caller says ride beside it (`fixedBytes`), at the +// budget conversion's own rate, rounded up to a 100-token step — a ceiling, so over-sizing it costs nothing (the re-run is +// as big as its content, bounded by the same candidate pool). --pack-top-n rides only when the caller set it (it sets +// total=). The value depends on the task and the untrimmed rows only — never on the ceiling that cut them — so the block +// stays byte-identical at the default and at any explicit ceiling above it (forbudgetmonotoncheck's invariant). +// WHO PAYS FOR ITS BYTES — two regimes, one predicate per caller (`charged`): +// • EXEMPT (the default ceiling; an explicit one wide enough that the sig side is frozen at the default's): the plan never +// reserves it — the e= / docs_after_code convention, a disclosure never costs a row the ceiling did not already cut — so +// the ranked set is the one it was and forbudgetmonotoncheck's byte identity holds. Downstream sections pay (the body +// leftover, the tail funded last); est_tokens measures it. +// • CHARGED (an explicit ceiling whose sig side is ALL of it: a --token-budget at or under the default sig share, any +// explicit one beside --signatures-only, MCP budget_tokens, --json --token-budget): nothing downstream can pay there, and +// an explicit --token-budget is a HARD ceiling (est_tokens <= budget, fornotesbudgetcheck), so the attribute is reserved +// inside the block like shown=/total=, and it rides TERSE there: next= alone — no next_offset=, no bracket clause; the +// bundle's own next= reading defines it ("the follow-up to paste"). Measured why (knob-honesty-068 report): with the +// clauses, the compact dialect — whose sig ledger leans on the header rungs — landed past the ceiling where the full +// one fitted (compactlegendcheck P4), and the rungs cannot shed a clause that is the cut's only disclosure. The e= +// ruling's shape: under a tight ceiling the extras go, the one thing that must stay (here the call) stays and is paid +// for. MCP keeps its clause (next_budget_tokens= has no other reading). +// …PAID ONLY WHERE PAYING IS WHAT MAKES THE ANSWER FIT (orchestrator ruling C3, 2026-10-07/08, CLI and MCP alike): the +// caller renders with payFromRows=false first (the rows the cut alone leaves, the whole handle unpaid) and serves that +// when it fits; it pays only when that answer lands past its ceiling and the paid one does not. Past it either way, the +// unpaid answer ships with over_ceiling="1" on the root (kForSigsUnpaidOverCeilingNote). +// THE RESUME INDEX, next_offset= (present when rows were DROPPED, not only shrunk): the candidate index the cut starts at — +// the last printed row's 1-based rank in the (score desc, id asc) candidate order, so a pseudo-symbol slot that prints no +// row cannot skew it the way a printed-row count would. The root's at= stamps the index it was cut from; a continuation +// whose at= differs was served by a different index (re-run from the start). +// MCP: the client continues by re-calling the TOOL, so the continuation is that tool's own argument, not a CLI argv — the +// tag carries the machine form: next_budget_tokens=T, the budget_tokens a re-call of the same tool needs. +// COMPATIBLE WITH #362 (the budgeted-bundle candidate page, issue #294): when that page lands, the resumable form is +// `--for=TASK --token-budget=N --limit=L --offset=`, which serves ONLY the cut rows (walking pages until +// has_more="0"); it replaces the VALUE of next= built here, not the attributes — next_offset= is already that page's +// resume index, and #362's page root carries the same next=/next_offset= pair on . +struct SigsCutContinuation +{ + std::string_view task; // the --for task, echoed verbatim (nextFlag quotes it for a shell) + int packTopN = 0; // the caller's explicit --pack-top-n (0 = the default pool): it sets total=, so it is echoed + std::string_view rankFlags = {}; // the caller's RANKING flags, pre-spelled (" --no-route --adaptive"…): the re-run must rank + // the same set, or "the rows this block cut" are rows of a different list + std::size_t fixedBytes = 0; // what the re-run's ceiling must carry besides the block: the charged header + sibling blocks + bool charged = false; // reserve it (and its legend clause) INSIDE the block's budget — see the regime rule above + bool json = false; // the --json dialect: the re-run is --json too + bool pasteHandle = true; // false on MCP: the client re-calls the tool, so no CLI argv — the machine form, + // next_budget_tokens=, is the argument a re-call of the same tool needs + std::size_t ledgerGapBytes = 0; // a CHARGED one's plan also reserves this: the sig room the caller's dialect was given beyond + // what its header honestly costs (the --for compact dialect, verbs_for.h compactLedgerGapBytes), + // so a capped compact block is cut to the rows a PAID full one keeps + bool payFromRows = true; // a CHARGED one: pay for it in rows (the plan's ledger gap + paySigsContinuationFromRows). + // false = the caller found that paying cannot make its answer fit (over its ceiling either + // way; orchestrator ruling C3, 2026-10-07): the block keeps the rows the cut alone leaves — + // no ledger gap, no payment — and the whole continuation rides unpaid (continuationUnpaidBytes) +}; + +inline std::size_t sigsCutNextTokens( std::size_t blockBytes, std::size_t fixedBytes ) noexcept +{ + constexpr std::size_t kSlackBytes = 512; // the re-run's own spliced attributes (est_tokens=, budget_tokens=, over-reserves) + constexpr std::size_t kTokenStep = 100; + const std::size_t needBytes = blockBytes + fixedBytes + kSlackBytes; + std::size_t tokens = std::size_t( double( needBytes ) / ( kMinBytesPerToken * kBudgetHeadroom ) ) + 1; + tokens = ( tokens + kTokenStep - 1 ) / kTokenStep * kTokenStep; + std::size_t grantedBytes = budgetBytesForTokens( tokens ); + while( grantedBytes < needBytes ) // the conversion floors; never hand back a ceiling below the need + { + tokens += kTokenStep; + grantedBytes = budgetBytesForTokens( tokens ); + } + ENSURES( grantedBytes >= needBytes, "the re-run's ceiling covers the untrimmed block plus what rides beside it" ); + return tokens; +} + +inline std::string sigsCutNextInvocation( const SigsCutContinuation& c, std::size_t blockBytes ) +{ + std::string inv = nextFlag( "--for=", c.task ); + inv += " --signatures-only"; + inv += c.rankFlags; + if( c.packTopN > 0 ) + { + inv += " --pack-top-n=" + std::to_string( c.packTopN ); + } + inv += " --token-budget=" + std::to_string( sigsCutNextTokens( blockBytes, c.fixedBytes ) ); + if( c.json ) + { + inv += " --json"; + } + return inv; +} + inline std::size_t sigsDecimalDigits( std::size_t n ) noexcept { std::size_t d = 1; @@ -4532,12 +5101,122 @@ inline SigsCutReport sigsCutReportOf( const std::vector& entries, std::s { cut.shown += e.dropped ? 0u : 1u; cut.docsDropped += ( !e.dropped && e.hadDoc && e.doc.empty() ) ? 1u : 0u; + cut.lastShownRank = e.dropped ? cut.lastShownRank : std::max( cut.lastShownRank, e.globalRank ); } ASSUME( cut.docsDropped <= plan.docsDroppable ); // the reservation is an upper bound (the floor keeps its doc) ENSURES( cut.shown <= cut.total ); return cut; } +// knob-honesty-068: the cut's continuation, filled from the caller's request once the cut is known. Rows dropped (not only +// shrunk) ⇒ next_offset= too (the resume index). `tokens` sizes the re-run (sigsCutNextTokens). +inline void sigsCutAttachContinuation( SigsCutReport& cut, const SigsCutContinuation* req, const std::string& invocation, std::size_t tokens ) +{ + if( req == nullptr || !cut.isCapped ) + { + return; + } + cut.hasContinuation = true; + cut.terseContinuation = req->charged && req->pasteHandle; + if( req->pasteHandle ) + { + cut.next = invocation; + } + else + { + cut.nextBudgetTokens = tokens; + } +} +inline bool sigsCutHasNextOffset( const SigsCutReport& cut ) noexcept +{ + return cut.hasContinuation && !cut.terseContinuation && cut.isCapped && cut.shown < cut.total && cut.lastShownRank > 0; +} +// the legend clauses a cut report owes, in a fixed order: shrunk-not-dropped, the continuation's (knob-honesty-068), docs_dropped, +// docs_after_code (train 26b: lean's reorder reading joins the knob's report legend) +inline std::string sigsCutReportLegend( const SigsCutReport& cut ) +{ + std::string notes; + if( cut.isCapped && cut.shown == cut.total ) + { + notes += kForSigsShrunkNote; + } + if( cut.isCapped && !cut.next.empty() && !cut.terseContinuation ) + { + notes += kForSigsNextNote; + } + if( cut.isCapped && cut.nextBudgetTokens > 0 ) + { + notes += kForSigsNextBudgetNote; + } + if( sigsCutHasNextOffset( cut ) ) + { + notes += kForSigsNextOffsetNote; + } + if( cut.docsDropped > 0 ) + { + notes += kForDocsDroppedNote; + } + if( cut.docsAfterCode > 0 ) // the docs reorder's reading (reorderDocsAfterCode; the --for and MCP `for` splices) + { + notes += kForDocsAfterCodeNote; + } + return notes; +} +// the same clauses from the four numbers a caller without a continuation holds (--from-trace, --pack-task) +inline std::string sigsCutLegendNotes( bool isCapped, std::size_t shown, std::size_t total, std::size_t docsDropped ) +{ + SigsCutReport cut; + cut.isCapped = isCapped; + cut.shown = shown; + cut.total = total; + cut.docsDropped = docsDropped; + return sigsCutReportLegend( cut ); +} +// The bytes a CHARGED continuation adds, an upper bound fixed before the plan: the pasteable form is terse (the call alone); +// the MCP machine form keeps its attribute, the resume index at its widest digits, and the clauses that define them. +inline std::size_t sigsCutContinuationReserve( const SigsCutContinuation* req, std::size_t valueAttrBytes, std::size_t offsetAttrBytes, + bool withClauses ) +{ + if( req == nullptr || !req->charged ) + { + return 0; + } + if( req->pasteHandle ) + { + return valueAttrBytes; // terse: next= alone (sigsCutAttachContinuation) + } + const std::size_t clauses = withClauses ? kForSigsNextBudgetNote.size() + kForSigsNextOffsetNote.size() : 0u; + return valueAttrBytes + offsetAttrBytes + clauses; +} +// The plan-side share of a CHARGED continuation: the dialect's ledger gap (SigsCutContinuation::ledgerGapBytes). It rides the +// marker's reservation, so it is spent only when the ladder fires — an uncapped block is untouched. +inline bool sigsCutPaysFromRows( const SigsCutContinuation* req ) noexcept +{ + return req != nullptr && req->charged && req->payFromRows; +} +inline std::size_t sigsCutPlanReserve( const SigsCutContinuation* req ) noexcept +{ + return sigsCutPaysFromRows( req ) ? req->ledgerGapBytes : 0u; +} +// A CHARGED continuation is paid FROM THE ROWS, byte for byte: the same ladder runs on past the cut until the block has given +// up `cost` bytes, so block + continuation never exceeds the block the cut alone left — in either dialect, whatever the plan's +// slack (reserving it in the plan let that slack absorb it and the bundle grow). Returns the bytes the rank 1..4 floor could +// NOT give up (0 = paid in full); the continuation rides regardless (orchestrator ruling 2026-10-07) and the caller labels it. +// Called only where the caller lets rows pay (sigsCutPaysFromRows): its callers render that way only after the unpaid answer +// landed past its ceiling, and serve the result only when it fits (ruling C3), so no row is dropped unless that buys the fit. +template +inline std::size_t paySigsContinuationFromRows( std::vector& entries, std::vector& files, std::size_t& total, + std::size_t cost, CostFn entryCost ) +{ + if( cost == 0 ) + { + return 0; + } + const std::size_t target = total > cost ? total - cost : 0u; + trimSigLadder( entries, files, total, target, entryCost ); + return total > target ? total - target : 0u; // not bounded by `cost`: a ladder action may append an ellipsis (trimSigLadder) +} + // The open tag: `` untrimmed; ` shown= total= capped="1"` when rows were cut or shrunk; ` docs_dropped=` // when a shown row lost its doc comment. The bytes it adds are the ones planSigsTrim reserved. inline std::string sigsOpenTag( const SigsCutReport& cut ) @@ -4554,6 +5233,23 @@ inline std::string sigsOpenTag( const SigsCutReport& cut ) rw::formatTo( nb, sizeof( nb ), " docs_dropped=\"{}\"", cut.docsDropped ); tag += nb; } + if( cut.docsAfterCode > 0 ) // the docs reorder (reorderDocsAfterCode): uncharged, like e= — a disclosure never costs a row + { + rw::formatTo( nb, sizeof( nb ), " docs_after_code=\"{}\"", cut.docsAfterCode ); + tag += nb; + } + // knob-honesty-068: the cut's continuation — the resume index, then the call (pasteable, or the MCP machine form) + if( sigsCutHasNextOffset( cut ) ) + { + rw::formatTo( nb, sizeof( nb ), " next_offset=\"{}\"", cut.lastShownRank ); + tag += nb; + } + tag += nextAttrXml( cut.next ); + if( cut.nextBudgetTokens > 0 ) + { + rw::formatTo( nb, sizeof( nb ), " next_budget_tokens=\"{}\"", cut.nextBudgetTokens ); + tag += nb; + } tag += ">"; return tag; } @@ -4589,6 +5285,13 @@ inline void pushShownSigId( std::vector* shownIdsOut, const std::vector< } } +// lean-answers lane: packSignatures' row spelling, passed as a NAMED type rather than a trailing bool, so a parameter +// another change appends after it cannot bind to it positionally (a bool argument does not convert to this aggregate). +struct SigRowSpelling +{ + bool elideZeroMetrics = false; // omit a zero cx=/ccx=/in= on a row (SigRowFacts::elideZero); the legend says absent = 0 +}; + inline void packSignatures( std::FILE* out, const IngestResult& ing, const std::vector& rank, int topN, std::size_t budgetBytes, bool metrics = false, const std::vector* fanIn = nullptr, @@ -4610,9 +5313,11 @@ inline void packSignatures( std::FILE* out, const IngestResult& ing, const std:: // 56% over a tight --token-budget the JSON mode honored. std::string_view rootArg = {}, // R-E (2026-08-17): same single-root-only root // argument serialize() takes — see its comment. - bool hasRelevanceFloor = false, // LB-A: drop the zero-score TAIL of the kept head rather - // than padding the quota with it (relevanceFlooredKeep - // above). Off ⇒ byte-identical to the pre-LB-A path. + SigLensRules lensRules = SigLensRules::None, // the --for lens's rules (SigLensRules): RelevanceFloor = + // LB-A, drop the zero-score TAIL of the kept head rather than + // padding the quota with it (relevanceFlooredKeep above); + // EndLine = e= on every rank-adaptive row (defEndLine). None ⇒ + // byte-identical to the path before either rule. std::size_t* droppedPositiveOut = nullptr, // A2 (survey card, 2026-09-03): how many // POSITIVE-scored candidates (rank>0) within the kept // head never reached the emitted — cut either @@ -4634,13 +5339,19 @@ inline void packSignatures( std::FILE* out, const IngestResult& ing, const std:: std::string_view topRowNext = {}, // L-W (forpage.h): the r=1 row's next= when the caller // judged the answer THIN (the widening page); "" ⇒ the // --expand body follow-up, byte-identical to before. - SigsCutReport* cutOut = nullptr ) // cut-fix lane A: the tag's shown/total/docs_dropped/capped, + SigsCutReport* cutOut = nullptr, // cut-fix lane A: the tag's shown/total/docs_dropped/capped, // for the caller's legend splices. Lens path only; zeroed - // (nothing cut) on every other path. + // (nothing cut) on every other path. knob-honesty-068: its + // continuationRequest is an INPUT, read before the reset. + SigRowSpelling spelling = {} ) // lean-answers lane: the --for lens's zero cx=/ccx=/in= omitted + // (SigRowFacts::elideZero); its legend says absent = 0. Default ⇒ + // byte-identical (pack-task, from-trace and the map stay as they were). { + const SigsCutContinuation* const cutNext = cutOut != nullptr ? cutOut->continuationRequest : nullptr; if( cutOut ) { *cutOut = SigsCutReport {}; + cutOut->continuationRequest = cutNext; } if( droppedPositiveOut ) { @@ -4677,7 +5388,7 @@ inline void packSignatures( std::FILE* out, const IngestResult& ing, const std:: } sortutil::radixSortByScoreDescId( order, rank ); std::size_t keep = std::min( topN > 0 ? std::size_t( topN ) : S, S ); - if( hasRelevanceFloor ) + if( hasSigLensRule( lensRules, SigLensRules::RelevanceFloor ) ) { keep = relevanceFlooredKeep( order, rank, keep ); // LB-A: shrink, never pad } @@ -4743,6 +5454,8 @@ inline void packSignatures( std::FILE* out, const IngestResult& ing, const std:: std::uint32_t globalRank = 0; // 1-based global rank — the ladder's only rank input std::size_t fileSlot = 0; // index into sigFiles (P7: the ladder releases the file's notes at liveCount 0) std::string head; // the exact "" opening tag + std::size_t elidedBytes = 0; // lean-answers lane: the zero cx=/ccx=/in= bytes `head` omits, still CHARGED by + // entryCost so the ladder keeps exactly the rows it kept before the elision std::string doc; // RAW doc text after the rank tiers ("" ⇒ no child) std::string sig; // RAW one-line signature after the rank tiers std::string notes; // W3-N2: this symbol's note children, PRE-RENDERED (the JSON sibling's shape) @@ -4750,6 +5463,12 @@ inline void packSignatures( std::FILE* out, const IngestResult& ing, const std:: bool positive = false; // A2: rank[id] > 0 at collection time (the disclosure's own definition of "positive") bool hadDoc = false; // the source HAS a doc comment here (before the rank tiers) — docs_dropped= counts // the shown rows where this is true and `doc` is empty at emission + std::uint32_t endLine = 0; // e= (defEndLine), 0 = not printed; spliced at emission, OUTSIDE `head` and so + // outside every byte the gate and the ladder charge (writeSigHeadWithEnd) + std::uint32_t displayRank = 0; // the r= this row is SHOWN with: globalRank, unless reorderDocsAfterCode moved it + NodeId id = 0; // the row's symbol, and the head's other inputs — re-rendered when displayRank moves + std::string lensRun; // the churn/amp/clone/tested attribute run (qbuf) + bool pureSig = false; }; std::vector sigFiles; std::vector entries; @@ -4791,6 +5510,8 @@ inline void packSignatures( std::FILE* out, const IngestResult& ing, const std:: std::vector& syms = buckets[f]; std::sort( syms.begin(), syms.end(), [ & ]( NodeId a, NodeId b ) { return ing.symbols[a].sigStartByte < ing.symbols[b].sigStartByte; } ); + const std::vector lineBreaks = hasSigLensRule( lensRules, SigLensRules::EndLine ) ? lineBreaksOf( src ) + : std::vector{}; SigFile sf; sf.fileId = f; @@ -4856,7 +5577,8 @@ inline void packSignatures( std::FILE* out, const IngestResult& ing, const std:: } } - std::string head = sigRowHead( ing, id, SigRowFacts{ metrics, fanIn, qbuf, pure, globalRank, topRowNext }, esc, rootArg ); // d1: rank fact (ladder path) + const SigRowFacts rowFacts{ metrics, fanIn, qbuf, pure, globalRank, topRowNext, spelling.elideZeroMetrics }; + std::string head = sigRowHead( ing, id, rowFacts, esc, rootArg ); // d1: rank fact (ladder path) std::string doc = docCommentBefore( src, a ); redactInPlace( doc, redact ); @@ -4874,11 +5596,17 @@ inline void packSignatures( std::FILE* out, const IngestResult& ing, const std:: e.globalRank = globalRank; e.fileSlot = fileSlot; e.head = std::move( head ); + e.elidedBytes = sigRowZeroElidedBytes( ing.symbols[ id ], rowFacts, id ); e.doc = std::move( doc ); e.sig = std::move( sig ); e.notes = renderNoteChildren( noteIndex, symbolNoteTarget( noteIndex, ing, s ), esc ); // L3/D5 key + W3-N2 pre-render e.positive = rank[id] > 0.0f; // A2: this symbol's own score, at collection time e.hadDoc = hadDoc; + e.endLine = hasSigLensRule( lensRules, SigLensRules::EndLine ) ? defEndLine( s, lineBreaks, src.size() ) : 0u; + e.displayRank = globalRank; + e.id = id; + e.lensRun = qbuf; + e.pureSig = pureSig; entries.push_back( std::move( e ) ); ++sf.liveCount; } @@ -4898,7 +5626,7 @@ inline void packSignatures( std::FILE* out, const IngestResult& ing, const std:: { return 0; } - std::size_t c = e.head.size() + 4; // "" + "" + std::size_t c = e.head.size() + e.elidedBytes + 4; // "" + "" (+ the elided zeros, see SigEntry) if( !e.doc.empty() ) { c += 11 + escapeXml( e.doc, esc ).size(); // "" + "" @@ -4924,8 +5652,18 @@ inline void packSignatures( std::FILE* out, const IngestResult& ing, const std:: // the tag's own attributes cost bytes — budget the trimmed state INCLUDING them (planSigsTrim; capture-audit // 2026-09-04 for the shown=/total= marker, cut-fix lane A for docs_dropped=). + // knob-honesty-068: the continuation a capped tag carries — sized from the UNTRIMMED block, known before the plan, so + // a CHARGED one rides the marker's reservation (attribute + its legend clause) and an exempt one costs the plan nothing + const std::string cutNextInv = cutNext != nullptr ? sigsCutNextInvocation( *cutNext, total ) : std::string(); + const std::size_t cutNextTokens = cutNext != nullptr ? sigsCutNextTokens( total, cutNext->fixedBytes ) : 0u; + const std::size_t cutNextCost = sigsCutContinuationReserve( + cutNext, + ( cutNext != nullptr && cutNext->pasteHandle ) ? nextAttrXml( cutNextInv ).size() + : sizeof( " next_budget_tokens=\"\"" ) - 1 + sigsDecimalDigits( cutNextTokens ), + sizeof( " next_offset=\"\"" ) - 1 + sigsDecimalDigits( std::numeric_limits::max() ), /*withClauses=*/true ); const SigsTrimPlan plan = planSigsTrim( entries, totalRows, gateCut, total, payloadBudgetBytes, - sizeof( " shown=\"\" total=\"\" capped=\"1\"" ) - 1, sizeof( " docs_dropped=\"\"" ) - 1 ); + sizeof( " shown=\"\" total=\"\" capped=\"1\"" ) - 1 + sigsCutPlanReserve( cutNext ), + sizeof( " docs_dropped=\"\"" ) - 1 ); if( plan.ladderFires ) { // one ladder ACTION on one entry, tail-first; every action re-checks the budget so the ladder @@ -4933,6 +5671,13 @@ inline void packSignatures( std::FILE* out, const IngestResult& ing, const std:: // ladder itself is trimSigLadder() above, shared verbatim with the JSON sibling (§A4a). trimSigLadder( entries, sigFiles, total, plan.effectiveBudget, entryCost ); } + // knob-honesty-068: a CHARGED continuation is paid from the rows (paySigsContinuationFromRows); what the floor + // cannot pay rides anyway and is reported for the caller's over_ceiling label + // …only where the caller lets rows pay (payFromRows): otherwise the whole of it rides unpaid on the cut's own rows + const std::size_t cutNextOwed = plan.capped ? cutNextCost : 0u; + const std::size_t cutNextUnpaid = sigsCutPaysFromRows( cutNext ) + ? paySigsContinuationFromRows( entries, sigFiles, total, cutNextOwed, entryCost ) + : cutNextOwed; // A2: the exact count, computed AFTER the ladder has made its final drop decisions (droppedPositiveCount // above — the shared arithmetic with the JSON sibling). Pure bookkeeping, no output bytes either way. @@ -4969,7 +5714,20 @@ inline void packSignatures( std::FILE* out, const IngestResult& ing, const std:: { *cappedOut = plan.ladderFires; } - const SigsCutReport cut = sigsCutReportOf( entries, totalRows, plan ); + SigsCutReport cut = sigsCutReportOf( entries, totalRows, plan ); + // CODE ABOVE DOCS: the shown set is final here (the ladder ran on score order); only its order may change + if( hasSigLensRule( lensRules, SigLensRules::DocsAfterCode ) ) + { + cut.docsAfterCode = reorderDocsAfterCode( entries, + [ & ]( const SigEntry& e ) { return ing.symbols[ e.id ].lang == Lang::Markdown; }, + [ & ]( SigEntry& e, std::uint32_t newRank ) + // the moved row keeps the row spelling it was charged and shown with (lean-answers' zero elision included) + { e.head = sigRowHead( ing, e.id, SigRowFacts{ metrics, fanIn, e.lensRun.c_str(), e.pureSig ? " pure=\"1\"" : "", newRank, topRowNext, + spelling.elideZeroMetrics }, esc, rootArg ); } ); + } + cut.continuationRequest = cutNext; + cut.continuationUnpaidBytes = cutNextUnpaid; + sigsCutAttachContinuation( cut, cutNext, cutNextInv, cutNextTokens ); // nothing unless capped and asked for if( cutOut ) { *cutOut = cut; @@ -4997,7 +5755,7 @@ inline void packSignatures( std::FILE* out, const IngestResult& ing, const std:: continue; } pushShownSigId( shownIdsOut, order, e.globalRank ); // lane 2 - w.write( e.head.c_str() ); + writeSigHeadWithEnd( w, e.head, e.endLine ); // e= after l=, when the lens asked and the extent is known if( !e.doc.empty() ) { w.write( "" ); w.write( escapeXml( e.doc, esc ) ); w.write( "" ); } w.write( escapeXml( e.sig, esc ) ); w.write( e.notes ); // L3: symbol notes on this (inert when null) @@ -5563,6 +6321,12 @@ struct CalleeCallsSink // have a query. The ordering is a property of a CUT listing, not of a rendering, so the condition is // now the rank alone and packBodies threads one in (`calleeRank`); gate test/callsrankordercheck.sh. const std::vector* rank = nullptr; + // FE-B: Graph::outNameOnly (parallel to outTargets), so a callee row whose edge is name-only carries via="name" — the + // same bit --callees prints. nullptr / empty ⇒ no row is marked. + const std::vector* nameOnly = nullptr; + // FE-B: set once the block's caller has been charged the via="name" legend comment it will write (packBodies/packHops + // write it ahead of the rows, after the walk) — so the budget the walk honours already holds those bytes + bool* viaLegendCharged = nullptr; }; // The wrapper, written in front of the rows it describes (they have to be walked before `shown` @@ -5639,6 +6403,8 @@ inline std::vector calleeWalkOrder( NodeId id, const std::vector lines; // every definition line of that name; joined ascending at append time + bool via = false; // FE-B: these edges are name-only — merged only with rows of the same bit }; inline void collectCalleeNameRow( std::vector& rows, const Symbol& cs, - std::size_t& used, const CalleeCallsSink& sink ) + std::size_t& used, const CalleeCallsSink& sink, bool via = false ) { char lb[ 16 ]; rw::formatTo( lb, sizeof( lb ), "{}", cs.line ); bool merged = false; for( MergedCalleeNameRow& r : rows ) { - if( r.name == cs.name ) + if( r.name == cs.name && r.via == via ) { r.lines.push_back( cs.line ); merged = true; break; @@ -5713,10 +6480,10 @@ inline void collectCalleeNameRow( std::vector& rows, const } if( !merged ) { - rows.push_back( MergedCalleeNameRow { cs.name, { cs.line } } ); + rows.push_back( MergedCalleeNameRow { cs.name, { cs.line }, via } ); } - // the comma and the digits a merge appends, or the whole row it opens - used += merged ? std::strlen( lb ) + 1 : cs.name.size() + 16; + // the comma and the digits a merge appends, or the whole row it opens (FE-B: plus its 11-byte via="name") + used += merged ? std::strlen( lb ) + 1 : cs.name.size() + 16 + ( via ? 11u : 0u ); if( sink.recorded ) { sink.recorded->push_back( EmittedBodyCall { cs.name, cs.line, std::string() } ); // §H5: no sig to record @@ -5742,10 +6509,26 @@ inline void appendMergedCalleeNameRows( std::string& callsBody, std::vector"; + callsBody += "\""; + callsBody += viaNameAttr( r.via ); // FE-B + callsBody += "/>"; } } +// FE-B: is the row id → cid name-only (the walk reorders, so the hedge bit is found by the target). The first +// hedged row of an answer charges the via legend it pulls in to `used`, before the row. +inline bool calleeRowVia( const std::vector& outOff, const std::vector& outTargets, NodeId id, NodeId cid, + std::size_t& used, const CalleeCallsSink& sink ) +{ + const bool via = sink.nameOnly != nullptr && edgeNameOnlyInCsr( outOff, outTargets, *sink.nameOnly, id, cid ); + if( via && sink.viaLegendCharged != nullptr && !*sink.viaLegendCharged ) + { + used += viaNameLegendComment().size(); + *sink.viaLegendCharged = true; + } + return via; +} + // §P10.1: the disclosed block // for one body's 1-hop callee signatures — extracted out of packBodies so the disclosure logic doesn't // inflate packBodies' own complexity/LOC. `total` is outOff[id+1]-outOff[id] — outTargets is deduped-per- @@ -5783,11 +6566,12 @@ inline void emitCalleeCallsBlock( std::string& out, NodeId id, const std::vector continue; } const Symbol& cs = ing.symbols[cid]; + const std::string_view viaAttr = viaNameAttr( calleeRowVia( outOff, outTargets, id, cid, used, sink ) ); // FE-B: via="name" or "" // COMPACT: the names-only rendering — see collectCalleeNameRow above for what it does and does not do. if( sink.namesOnly ) { - collectCalleeNameRow( nameRows, cs, used, sink ); + collectCalleeNameRow( nameRows, cs, used, sink, !viaAttr.empty() ); ++shown; continue; } @@ -5802,10 +6586,10 @@ inline void emitCalleeCallsBlock( std::string& out, NodeId id, const std::vector { continue; } - char hb[ 32 ]; rw::formatTo( hb, sizeof( hb ), "\" l=\"{}\">", cs.line ); + char hb[ 48 ]; rw::formatTo( hb, sizeof( hb ), "\" l=\"{}\"{}>", cs.line, viaAttr ); callsBody += "& nodes, std::size_t budgetBytes, const std::vector& outOff, const std::vector& outTargets, + const std::vector& outNameOnly, // FE-B: Graph::outNameOnly — the rows' via="name" bool compress = false, RedactCounts* redact = nullptr, const HashMap* ranges = nullptr, const notes::NoteIndex* noteIndex = nullptr, // L3: field notes — surfaces children on each @@ -6178,11 +6963,14 @@ inline void packBodies( std::FILE* out, const IngestResult& ing, const std::vect // false (every caller but --expand) ⇒ byte-identical. std::string_view rootArg = {}, // R-E (2026-08-17): same single-root-only root // argument serialize() takes — see its comment. - const std::vector* calleeRank = nullptr ) // orders each body's CUT listing: the query relevance + const std::vector* calleeRank = nullptr, // orders each body's CUT listing: the query relevance // on --for/--pack-task/--from-trace, calleeNameSpecificity on // --expand; nullptr (--around/--exemplar) ⇒ node-id order. // See CalleeCallsSink::rank. + bool viaLegendInHead = false ) // FE-B: the caller's own first-screen legend already defines via="name" + // (namesOnlyOutAny above), so this section neither repeats nor charges it { + bool viaLegendCharged = viaLegendInHead; // FE-B: the via="name" legend has been charged against the budget (CalleeCallsSink) // budgetBytes == 0 ⇒ UNLIMITED (A3-F2): the MCP `exemplar` verb has no byte budget, and 0 must never // mean "cap at zero bytes" (the cap fired before the first body and emitted a bare ). // Matches buildRecall's "0 = no cap" convention; the CLI always passes a real budget (default 64 KB). @@ -6479,7 +7267,7 @@ inline void packBodies( std::FILE* out, const IngestResult& ing, const std::vect // total=/shown=/capped= block — see emitCalleeCallsBlock above; `calleeRank` decides which // rows survive when it CUTS one, which is far from rare here (CalleeCallsSink::rank). emitCalleeCallsBlock( piece, id, outOff, outTargets, ing, contentOf, esc, used, budgetBytes, - CalleeCallsSink{ redact, record ? &record->calls : nullptr, /*namesOnly=*/false, calleeRank } ); + CalleeCallsSink{ redact, record ? &record->calls : nullptr, /*namesOnly=*/false, calleeRank, &outNameOnly, &viaLegendCharged } ); const std::string bodyNotes = renderNoteChildren( noteIndex, symbolNoteTarget( noteIndex, ing, s ), esc ); // L3/D5 piece += bodyNotes; used += bodyNotes.size(); // W3-N2: same charge-never-trim rule @@ -6551,6 +7339,10 @@ inline void packBodies( std::FILE* out, const IngestResult& ing, const std::vect { w.write( kOverCeilingBodyLegend ); // the same rule, for } + if( !viaLegendInHead && children.find( " via=\"name\"" ) != std::string::npos ) + { + w.write( viaNameLegendComment() ); // FE-B: the same rule, for a row's via="name" + } w.write( children ); w.write( "" ); w.flush(); @@ -6583,11 +7375,14 @@ inline void packBodies( std::FILE* out, const IngestResult& ing, const std::vect inline void packHops( std::FILE* out, const IngestResult& ing, const std::vector& nodes, std::size_t budgetBytes, const std::vector& outOff, const std::vector& outTargets, + const std::vector& outNameOnly, // FE-B: Graph::outNameOnly — the rows' via="name" RedactCounts* redact = nullptr, std::size_t* outShown = nullptr, // rows actually emitted; nullptr ⇒ not recorded const std::vector* rank = nullptr, // query relevance, for ordering a CUT callee listing - std::string_view rootArg = {} ) // R-E: same single-root-only root= every verb takes + std::string_view rootArg = {}, // R-E: same single-root-only root= every verb takes + bool viaLegendInHead = false ) // FE-B: packBodies' parameter of the same name { + bool viaLegendCharged = viaLegendInHead; // FE-B: the via="name" legend has been charged against the budget (CalleeCallsSink) if( budgetBytes == 0 ) // 0 ⇒ UNLIMITED, packBodies' own convention { budgetBytes = SIZE_MAX; @@ -6648,8 +7443,12 @@ inline void packHops( std::FILE* out, const IngestResult& ing, const std::vector // stays, and that is the HONEST trade rather than the cheap one: `capped="1"` alone cannot say // WHY a candidate has no row, and folding "has no edges" into "the budget stopped" would make // a fact about the graph look like a fact about the budget. - const std::uint32_t outDeg = ( id + 1 < outOff.size() ) ? outOff[ id + 1 ] - outOff[ id ] : 0u; - if( outDeg == 0 ) + // THE HOP-SLOT RULE (gate test/forsigspancheck.sh (H)): a slot needs at least one PROVEN callee edge. A candidate + // whose every out-edge was bound by name alone (an off-topic getter whose one callee row was `bag.lookup()` on an + // untyped local, measured on a graded answer) spent the slot on a hedged edge; it now counts with noedge=, whose + // reading is "no RESOLVED callee found" — a name-only binding is a hedge, not a resolution. No out-edge at all + // counts the same way (hopSlotHasProvenEdge). + if( !hopSlotHasProvenEdge( outOff, outNameOnly, id ) ) { ++noEdgeCount; continue; @@ -6672,7 +7471,7 @@ inline void packHops( std::FILE* out, const IngestResult& ing, const std::vector // the 1-hop callee signatures — the identical block a body carries, charged against the same // running `used` so the row identity bytes and the edge bytes share one budget. emitCalleeCallsBlock( row, id, outOff, outTargets, ing, contentOf, esc, used, budgetBytes, - CalleeCallsSink{ redact, /*recorded=*/nullptr, /*namesOnly=*/true, rank } ); + CalleeCallsSink{ redact, /*recorded=*/nullptr, /*namesOnly=*/true, rank, &outNameOnly, &viaLegendCharged } ); row += ""; children += row; ++shownCount; @@ -6694,6 +7493,10 @@ inline void packHops( std::FILE* out, const IngestResult& ing, const std::vector rw::formatTo( open, sizeof( open ), "", shownCount, requestedCount, shownCount < requestedCount ? 1 : 0 ); } + if( !viaLegendInHead && children.find( " via=\"name\"" ) != std::string::npos ) + { + w.write( viaNameLegendComment() ); // FE-B: exactly when a hop's row carries via="name" + } w.write( open ); w.write( children ); w.write( "" ); @@ -7247,6 +8050,17 @@ inline constexpr std::string_view kForSectionStubLegend = "pre-cap row count, shown=\"0\" capped=\"1\" (nothing rendered here), next= names the sections=lego,compose flag " "that restores both sections byte-identically in one call"; +// count-floor: the ranked block's count attributes (packLego's graphImplementors mode), defined present-only by the +// same post-render splice as the stub clause above, in both --for dialects and the MCP twin. No "--" (G4). +inline constexpr std::string_view kForLegoCountLegend = + "; iface implementors=N counts every type an extends/implements clause binds to it (the lego verb's count), never the rows " + "listed: implementors_shown=K only K impl rows here, implementors_next= lists all; implementors_floor=1 an extends clause " + "spelled like it bound nowhere, so N may be short (not proof of another), floor_next= lists every extends site"; + +// The prefix every attribute kForLegoCountLegend defines starts with (implementors_shown=/_next=/_floor=): a rendered +// ranked block holding it needs the clause. Attribute values are escaped, so ` implementors_` can only open one. +inline constexpr std::string_view kLegoCountAttrPrefix = " implementors_"; + // L2 fault-injection fix (independent review, 2026-09-19): packLego/packCompose's out-params give the stub // its total= on the buffered (memstream) path, but the ranked --for lens's OPEN_MEMSTREAM DEGRADE PATH // (verbs_for.h, legoPreRendered/composePreRendered==false — RIPWIRE_FAULT_CHARGE_BUFFER=1 forces it) never @@ -7676,14 +8490,23 @@ inline void packLego( std::FILE* out, const IngestResult& ing, const std::vector std::string_view graphCountFloorAttr = {}, // M15: the TARGETED root's gauge + marker // (graphCountFloorAttrXml( g ) — the caller owns the graph); // the ranked --for section passes nothing and keeps its shape - std::size_t* outPreCapCount = nullptr ) // L2 (round-1 lever B1): the STUB's total= — this + std::size_t* outPreCapCount = nullptr, // L2 (round-1 lever B1): the STUB's total= — this // function's OWN post-dedup ifaces.size(), before the topN // cut below. Written whenever non-null (both modes), so the // stub can never assert a count this function did not itself // compute (no second, drifting tally — the notes_total // precedent this repo already avoids: legoTotal at the JSON // call site is a DIFFERENT, pre-dedup count, on purpose). -{ + const std::vector>* graphImplementors = nullptr ) + // count-floor: RANKED mode only — the UNSCOPED graph map. The bundle's + // `implementors` is scoped and then narrowed to the rendered sigs' files + // (legoImplementorsOnSurface, narrowLegoToRenderedSigs), so its row count + // is what is LISTED, not how many there are: `` answered a tree with six. With this map the count is + // the targeted --lego=TYPE's own and a short list says so + // (implementors_shown= + implementors_next=). Null keeps the old shape. +{ + EXPECTS( graphImplementors == nullptr || focusId == kNoNode ); // the targeted verb's map IS the graph's const std::string rootPrefix = rootArg.empty() ? std::string() : rw::sarif::rootPrefixOf( rootArg ); const auto pathRel = [ & ]( std::uint32_t fileId ) -> std::string_view { @@ -7732,6 +8555,10 @@ inline void packLego( std::FILE* out, const IngestResult& ing, const std::vector if( outPreCapCount != nullptr ) { *outPreCapCount = ifaces.size(); } // L2: post-dedup, PRE-topN — the stub's total= const std::size_t keep = std::min( topN > 0 ? std::size_t( topN ) : ifaces.size(), ifaces.size() ); + // count-floor (countfloor.h implementorFloors): an extends/implements clause spelled like a kept interface that the graph + // bound to no definition of that name — implementors= cannot count it. Both modes: absent unless such a clause exists. + const std::vector>& countMap = graphImplementors != nullptr ? *graphImplementors : implementors; + const std::vector implFloor = implementorFloors( ing, countMap, std::span( ifaces.data(), keep ) ); XmlWriter w( out ); std::vector esc; @@ -7757,16 +8584,32 @@ inline void packLego( std::FILE* out, const IngestResult& ing, const std::vector // defs= is that fact on the row — the same disclosure --owners and --layout already carry for the // same resolver, and the branch the family gate (singledefcheck.sh) accepts in place of a refusal. // Ranked mode (--for) has no selector to be ambiguous ABOUT, so it stays byte-identical. - char hdr[ 64 ]; + const std::vector& impls = implementors[id]; + const std::size_t cap = ( focusId != kNoNode ) ? impls.size() // targeted: uncapped + : ( impls.size() < 16 ? impls.size() : 16 ); + const std::size_t implementorTotal = id < countMap.size() ? std::max( countMap[id].size(), impls.size() ) : impls.size(); + std::string hdr; if( focusId != kNoNode ) { - rw::formatTo( hdr, sizeof( hdr ), "\" defs=\"{}\" implementors=\"{}\">", - definitionCountOfName( ing, id ), implementors[id].size() ); + hdr = "\" defs=\"" + std::to_string( definitionCountOfName( ing, id ) ) + "\" implementors=\"" + std::to_string( impls.size() ) + "\""; } else { - rw::formatTo( hdr, sizeof( hdr ), "\" implementors=\"{}\">", implementors[id].size() ); + hdr = "\" implementors=\"" + std::to_string( implementorTotal ) + "\""; + if( cap < implementorTotal ) + { + // a cut, not a floor: the rest exist and the named verb lists every one of them + const std::string selector = std::string( pathRel( isym.fileId ) ) + ":" + isym.name; + hdr += " implementors_shown=\"" + std::to_string( cap ) + "\""; + hdr += rw::nextAttrXml( nextFlag( "--lego=", selector ), "implementors_next" ); + } + } + if( implFloor[k] ) + { + hdr += " implementors_floor=\"1\""; + hdr += rw::nextAttrXml( nextFlag( "--uses=", isym.name ), "floor_next" ); } + hdr += ">"; w.write( "& impls = implementors[id]; - const std::size_t cap = ( focusId != kNoNode ) ? impls.size() // targeted: uncapped - : ( impls.size() < 16 ? impls.size() : 16 ); for( std::size_t j = 0; j < cap; ++j ) { const Symbol& im = ing.symbols[ impls[j] ]; @@ -8640,7 +9480,8 @@ inline void serializeJson( std::FILE* out, const IngestResult& ing, const std::v const std::vector* locPinOut = nullptr, // Phase 4: same as serialize()'s std::size_t externalCalls = 0, // Phase 5: same as serialize()'s const std::vector* declinedOut = nullptr, // tier-3 declines: same as serialize()'s - std::size_t /*gateDeclinedCalls*/ = 0 ) // serialize()'s legend clause; JSON carries no legend, so unread here + std::size_t /*gateDeclinedCalls*/ = 0, // serialize()'s legend clause; JSON carries no legend, so unread here + const std::vector* outNameOnly = nullptr ) // FE-B: same as serialize()'s → "via":"name" { const std::size_t S = ing.symbols.size(); const std::string rootPrefix = rootArg.empty() ? std::string() : rw::sarif::rootPrefixOf( rootArg ); @@ -8819,6 +9660,10 @@ inline void serializeJson( std::FILE* out, const IngestResult& ing, const std::v { w.write( ",\"prov\":" ); writeJsonStr( w, provLabel( (*outProv)[e] ), esc ); } + if( outNameOnly && e < outNameOnly->size() && ( *outNameOnly )[e] != 0 ) + { + w.write( ",\"via\":\"name\"" ); // FE-B: the XML via="name", 1:1 + } w.write( "}" ); } w.write( "]}" ); @@ -8932,6 +9777,11 @@ struct JsonSigEntry bool dropped = false; bool positive = false; // A2: rank[id] > 0 at collection time — the XML sibling's own field bool hadDoc = false; // the source has a doc comment here (before the rank tiers) — the XML sibling's field + std::uint32_t endLine = 0; // "e" (defEndLine), 0 = no key; spliced at emission, outside `head` and every charged + // byte (the XML sibling's SigEntry::endLine, same exemption) + std::uint32_t displayRank = 0; // the XML sibling's fields for the docs reorder: the "r" this row is shown with, + NodeId id = 0; // its symbol, and the head inputs a moved row is re-rendered from + bool pureSig = false; }; // §B1.3: how many notes this array-emitter matched, and how many survived the ladder — the caller pairs @@ -9001,8 +9851,28 @@ struct JsonSigLens // `notes` array on the row/file the XML // sibling hangs children on. // nullptr ⇒ INERT (byte-identical). + bool endLines = false; // the XML sibling's SigLensRules::EndLine: an "e" + // key (defEndLine) on every row whose extent is + // known — --for only; off ⇒ byte-identical. + bool docsAfterCode = false; // the XML sibling's SigLensRules::DocsAfterCode + // (reorderDocsAfterCode) — --for only. }; +// "e" right after "l" in a JSON row head (the XML sibling's writeSigHeadWithEnd; the head always opens {"l":N). 0 ⇒ untouched. +inline void spliceJsonEndLine( std::string& head, std::uint32_t endLine ) +{ + if( endLine == 0 ) + { + return; + } + ASSUME( head.starts_with( "{\"l\":" ) ); + const std::size_t lEnd = head.find( ',' ); + ASSUME( lEnd != std::string::npos ); + char eKey[ 24 ]; + rw::formatTo( eKey, sizeof( eKey ), ",\"e\":{}", endLine ); + head.insert( lEnd, eKey ); +} + // One row's `{"l":…` opening through its flag fields — everything EXCEPT doc/sig, which the ladder mutates // and phase 2 appends. Mirrors sigRowHead()'s role on the XML side. inline std::string jsonSigRowHead( const IngestResult& ing, NodeId id, std::uint32_t fileId, @@ -9149,6 +10019,7 @@ inline std::size_t collectJsonSigEntries( const IngestResult& ing, const std::ve std::vector& syms = buckets[f]; std::sort( syms.begin(), syms.end(), [ & ]( NodeId a, NodeId b ) { return ing.symbols[a].sigStartByte < ing.symbols[b].sigStartByte; } ); + const std::vector lineBreaks = lens.endLines ? lineBreaksOf( src ) : std::vector{}; JsonSigFile sf; sf.fileId = f; @@ -9205,6 +10076,10 @@ inline std::size_t collectJsonSigEntries( const IngestResult& ing, const std::ve e.noteCount = appendJsonNoteArray( e.notes, lens.noteIndex, symbolNoteTarget( lens.noteIndex, ing, s ) ); // §B1.3 e.positive = rank && (*rank)[id] > 0.0f; // A2: the XML sibling's own field, same definition e.hadDoc = hadDoc; + e.endLine = lens.endLines ? defEndLine( s, lineBreaks, src.size() ) : 0u; + e.displayRank = globalRank; + e.id = id; + e.pureSig = pureSig; outEntries.push_back( std::move( e ) ); ++sf.liveCount; } @@ -9248,8 +10123,10 @@ inline void packSignaturesJson( std::FILE* out, const IngestResult& ing, const s std::vector* shownIdsOut = nullptr, // lane 2: the emitted rows' ids — see packSignatures SigsCutReport* cutOut = nullptr ) // cut-fix lane A: the XML tag's shown/total/docs_dropped/ // capped, for the caller's root keys (sigs_shown/ - // sigs_total/docs_dropped); see packSignatures + // sigs_total/docs_dropped); see packSignatures. + // knob-honesty-068: continuationRequest is an INPUT { + const SigsCutContinuation* const cutNext = cutOut != nullptr ? cutOut->continuationRequest : nullptr; const bool rankAdaptivePayload = lens.rankAdaptivePayload; if( outCapped ) { @@ -9258,6 +10135,7 @@ inline void packSignaturesJson( std::FILE* out, const IngestResult& ing, const s if( cutOut ) { *cutOut = SigsCutReport {}; + cutOut->continuationRequest = cutNext; // knob-honesty-068: the request survives the reset } if( droppedPositiveOut ) { @@ -9340,19 +10218,46 @@ inline void packSignaturesJson( std::FILE* out, const IngestResult& ing, const s // reserved INSIDE this array's budget exactly as the XML tag's attributes are inside its block's — they used to be // absent, and a JSON consumer could not tell 24 rows of 40 from 24 of 24. // (Without the rank-adaptive payload every globalRank is 0: no row is droppable and no doc is ever removed.) + // knob-honesty-068: the "sigs_next" key, charged or exempt by the XML twin's rule (SigsCutContinuation; no legend here) + const std::string cutNextInv = cutNext != nullptr ? sigsCutNextInvocation( *cutNext, total ) : std::string(); + const std::size_t cutNextTokens = cutNext != nullptr ? sigsCutNextTokens( total, cutNext->fixedBytes ) : 0u; + const std::size_t cutNextCost = sigsCutContinuationReserve( cutNext, nextFieldJson( cutNextInv, "sigs_next" ).size(), + sizeof( ",\"sigs_next_offset\":" ) - 1 + sigsDecimalDigits( std::numeric_limits::max() ), + /*withClauses=*/false ); const SigsTrimPlan plan = planSigsTrim( entries, totalRows, gateCut, total, payloadBudgetBytes, - sizeof( ",\"sigs_shown\":,\"sigs_total\":" ) - 1, sizeof( ",\"docs_dropped\":" ) - 1 ); + sizeof( ",\"sigs_shown\":,\"sigs_total\":" ) - 1 + sigsCutPlanReserve( cutNext ), + sizeof( ",\"docs_dropped\":" ) - 1 ); if( plan.ladderFires ) { trimSigLadder( entries, sigFiles, total, plan.effectiveBudget, jsonSigEntryCost ); } + // knob-honesty-068: a charged continuation is paid from the rows (the XML twin's rule); the floor's unpaid rest rides anyway + // and this dialect's own predicate (forLensJsonOverCeiling: bytes past the allowance) labels any overshoot + // …only where the caller lets rows pay (payFromRows): otherwise the whole of it rides unpaid on the cut's own rows + const std::size_t cutNextOwed = plan.capped ? cutNextCost : 0u; + const std::size_t cutNextUnpaid = sigsCutPaysFromRows( cutNext ) + ? paySigsContinuationFromRows( entries, sigFiles, total, cutNextOwed, jsonSigEntryCost ) + : cutNextOwed; if( outCapped ) { *outCapped = plan.ladderFires; // the LADDER's verdict (the budget_bytes= stanza names its ceiling); cutOut carries capped } - if( cutOut ) { - *cutOut = sigsCutReportOf( entries, totalRows, plan ); + SigsCutReport cut = sigsCutReportOf( entries, totalRows, plan ); + if( lens.docsAfterCode ) // the XML twin's reorder, after the ladder: the shown set is final, only its order changes + { + cut.docsAfterCode = reorderDocsAfterCode( entries, + [ & ]( const JsonSigEntry& e ) { return ing.symbols[ e.id ].lang == Lang::Markdown; }, + [ & ]( JsonSigEntry& e, std::uint32_t newRank ) + { e.head = jsonSigRowHead( ing, e.id, ing.symbols[ e.id ].fileId, lens, e.pureSig, rootArg, newRank ); } ); + } + cut.continuationRequest = cutNext; + cut.continuationUnpaidBytes = cutNextUnpaid; + sigsCutAttachContinuation( cut, cutNext, cutNextInv, cutNextTokens ); // knob-honesty-068: nothing unless capped and asked for + if( cutOut ) + { + *cutOut = cut; + } } if( droppedPositiveOut ) { @@ -9387,6 +10292,7 @@ inline void packSignaturesJson( std::FILE* out, const IngestResult& ing, const s } first = false; std::string row = e.head; + spliceJsonEndLine( row, e.endLine ); // "e" after "l", outside every charged byte (0 ⇒ untouched) if( !e.doc.empty() ) { appendJsonStrField( row, ",\"doc\":", e.doc ); diff --git a/src/skillscan.h b/src/skillscan.h index 246276256..8d3a62955 100644 --- a/src/skillscan.h +++ b/src/skillscan.h @@ -93,6 +93,31 @@ inline std::string_view trimRight( std::string_view s ) noexcept return s; } +// A finding's excerpt cap: at most kSkillExcerptMaxBytes bytes in all, "..." included. +inline constexpr std::size_t kSkillExcerptMaxBytes = 120; + +// The trimmed, clipped excerpt of one skill line. The text is UNTRUSTED and arbitrary UTF-8, so the cut never lands inside a +// multibyte character: it backs off to the code-point boundary, the same rule as serialize.h's truncateUtf8WithEllipsis +// (that header is not reachable from here), and the ASCII "..." is added only when bytes were really dropped — a line of +// exactly the cap is kept whole. +inline std::string clipSkillExcerpt( std::string_view lineText ) +{ + std::string excerpt( trimRight( lineText ) ); + if( excerpt.size() <= kSkillExcerptMaxBytes ) + { + return excerpt; + } + std::size_t cut = kSkillExcerptMaxBytes - 3; // room for the "..." + while( cut > 0 && ( static_cast( excerpt[cut] ) & 0xC0 ) == 0x80 ) + { + --cut; + } + excerpt.resize( cut ); + excerpt += "..."; + ENSURES( excerpt.size() <= kSkillExcerptMaxBytes, "the clipped excerpt fits the cap, ellipsis included" ); + return excerpt; +} + // ── INJECTION phrase table (CRITICAL, with a generic-word WARN fallback) ──────────────────────── // // A4-F12 fix: bare substrings ("disregard", "you are now", "new persona") false-positive on @@ -1153,6 +1178,28 @@ static_assert( kSkillInjectionSpanBytes < kSkillJoinedOverlapBytes && kSkillJoin "a joined-body window overlap must exceed the longest INJECTION match, or a match across a window edge is lost" ); inline constexpr std::size_t kSkillScanStackBytes = 256 * 1024 * 1024; // the grep scan threads' size (search.h kGrepScanStackBytes) +// Sort findings (line, rule, severity WORST first) STABLY, then keep the first row per (line, rule): so a (line, rule) reported +// twice keeps its worst severity, and the earlier row on a tie. Keeping the first row after a non-stable sort on (line, rule) +// alone left the survivor to whatever the sort put first — a WARN could hide a CRITICAL and quiet the exit from 2 to 1. +// Today every rule's severity is a function of the line alone, so the severity key only ever breaks ties; it is the invariant, +// not a behaviour change, and the skillscan gate's dedupe arm holds it. Used by the per-line / joined-body dedupe in +// scanSkillTextOn and by mergeScriptPasses. +inline void dedupeFindingsKeepWorst( std::vector& findings ) +{ + std::stable_sort( findings.begin(), findings.end(), []( const SkillFinding& a, const SkillFinding& b ) noexcept + { + if( a.line != b.line ) + { + return a.line < b.line; + } + const int byRule = std::string_view( a.rule ).compare( b.rule ); + return byRule != 0 ? byRule < 0 : a.sev > b.sev; + } ); + findings.erase( std::unique( findings.begin(), findings.end(), []( const SkillFinding& a, const SkillFinding& b ) noexcept + { return a.line == b.line && std::string_view( a.rule ) == std::string_view( b.rule ); } ), + findings.end() ); +} + // Scan the raw text of a skill markdown file line by line, on a thread settled at `stackBytes`. Findings are sorted // (line, rule). // `wholeFileIsCode` (a shell script, see SkillFileKind): there is no frontmatter, every line is command context and no @@ -1210,9 +1257,7 @@ inline std::vector scanSkillTextOn( std::string_view text, std::si // helper: add a finding with a clipped excerpt const auto addFinding = [ & ]( SkillSeverity sev, int lineNum, const char* rule, std::string_view lineText, const char* why = nullptr ) { - std::string excerpt( trimRight( lineText ) ); - if( excerpt.size() > 120 ) { excerpt.resize( 117 ); excerpt += "..."; } - findings.push_back( { sev, lineNum, rule, std::move( excerpt ), why } ); + findings.push_back( { sev, lineNum, rule, clipSkillExcerpt( lineText ), why } ); }; // helper: the regex boundary's answer as a plain hit, failing CLOSED on an undecided match — the line gets a // CRITICAL scan-incomplete finding (deduped per line below), so an unscannable skill can never read "clean". @@ -1555,19 +1600,11 @@ inline std::vector scanSkillTextOn( std::string_view text, std::si addFinding( p.sev, joinedLineOf( pos ), p.rule, m.str( 0 ) ); } - // ── sort by (line, rule) for deterministic output, then dedupe on (line, rule) ──────────────── + // ── sort by (line, rule, worst severity first) for deterministic output, then dedupe on (line, rule) ──────────────── // The per-line pass and the joined-body pass can both find the SAME instance (e.g. a phrase that sits // entirely on one line is found per-line, then found again — same line, same rule — in the joined // buffer); collapse those to a single finding so output stays deterministic and non-redundant. - std::sort( findings.begin(), findings.end(), []( const SkillFinding& a, const SkillFinding& b ) noexcept - { - if( a.line != b.line ) { return a.line < b.line; -} - return std::string_view( a.rule ) < std::string_view( b.rule ); } ); - findings.erase( std::unique( findings.begin(), findings.end(), []( const SkillFinding& a, const SkillFinding& b ) noexcept - { - return a.line == b.line && std::string_view( a.rule ) == std::string_view( b.rule ); - } ), findings.end() ); + dedupeFindingsKeepWorst( findings ); return findings; } @@ -1695,18 +1732,7 @@ inline SkillFileKind skillFileKindOf( std::string_view path, std::string_view te inline void mergeScriptPasses( std::vector& findings, std::vector code ) { findings.insert( findings.end(), std::make_move_iterator( code.begin() ), std::make_move_iterator( code.end() ) ); - std::stable_sort( findings.begin(), findings.end(), []( const SkillFinding& a, const SkillFinding& b ) noexcept - { - if( a.line != b.line ) - { - return a.line < b.line; - } - const int byRule = std::string_view( a.rule ).compare( b.rule ); - return byRule != 0 ? byRule < 0 : a.sev > b.sev; - } ); - findings.erase( std::unique( findings.begin(), findings.end(), []( const SkillFinding& a, const SkillFinding& b ) noexcept - { return a.line == b.line && std::string_view( a.rule ) == std::string_view( b.rule ); } ), - findings.end() ); + dedupeFindingsKeepWorst( findings ); } inline std::vector scanSkillText( std::string_view text, SkillFileKind kind = SkillFileKind::Markdown ) diff --git a/src/taskroute.h b/src/taskroute.h index cc49a51cb..85f6435db 100644 --- a/src/taskroute.h +++ b/src/taskroute.h @@ -5,6 +5,7 @@ // weights at runtime. Integer scores and stable tie-breaking keep the result byte-deterministic. #include +#include #include #include #include @@ -143,30 +144,56 @@ inline int wordScore( std::string_view lower, std::initializer_list…` completions and `…` -// blocks this way)? Both the Claude Code and Codex UserPromptSubmit router hooks (under hooks/, filenames -// ending "-route.sh") skip calling this classifier at all on such input — a bash-level guard mirroring the -// PreToolUse tool-call router's own pre-classification notification check (hooks/, filename ending -// "-toolroute.sh") — but --help-task is also callable directly: by a test, by an MCP client, by a future -// integration that does not go through either hook. So the same discipline lives here too, rather than -// only at the shell layer. A background-task report is mostly prose quoting machine output (a sanitizer -// excerpt, a diff, an XML `` block): before this guard existed, that prose could mint a -// `--from-trace=-`/`--grep=`/`--connect=` recommendation out of text that named no task at all (the -// routing-noise round, docs/EVALS.md). Narrow and literal on purpose — this is a shape check on the -// harness's own wake-up markers, not a guess at what a user prompt looks like: only the FIRST non- -// whitespace bytes are tested, so a user prompt that happens to mention `` in the -// middle of a sentence ("what does mean in the hook?") is unaffected. +// Is `task` a harness/system event rather than something a user typed — a background-task wake-up, an +// injected reminder, an MCP channel event or a sub-agent hand-back, delivered to UserPromptSubmit through the +// same channel as a real prompt (Claude Code routes `…` completions, +// `…` blocks, `…` events and +// `…` hand-backs this way; the last two are issue #381)? Both the +// Claude Code and Codex UserPromptSubmit router hooks (under hooks/, filenames ending "-route.sh") skip +// calling this classifier at all on such input — a bash-level guard (rw_is_harness_event, a mirrored block +// that lists the same four wrappers) mirroring the PreToolUse tool-call router's own pre-classification +// notification check (hooks/, filename ending "-toolroute.sh", which deliberately does NOT carry the two +// newer wrappers: a tool input is the agent's own action, and the guard there is a substring match) — but +// --help-task is also callable directly: by a test, by an MCP client, by a future integration that does not +// go through either hook. So the same discipline lives here too, rather than only at the shell layer. A +// background-task report is mostly prose quoting machine output (a sanitizer excerpt, a diff, an XML +// `` block): before this guard existed, that prose could mint a `--from-trace=-`/`--grep=`/ +// `--connect=` recommendation out of text that named no task at all (the routing-noise round, +// docs/EVALS.md). Narrow and literal on purpose — this is a shape check on the harness's own wrappers, not +// a guess at what a user prompt looks like: only the FIRST non-whitespace bytes are tested, so a user prompt +// that happens to mention `` in the middle of a sentence ("what does +// mean in the hook?") is unaffected, and ``, `` and +// `` are not the wrappers (the attribute-carrying tags must be followed by a space or `>`). +struct HarnessEventTag +{ + std::string_view tag; // the bytes the prompt must start with + bool whole; // true: `tag` already ends in `>` and matches as is; false: the tag may carry + // attributes, so it must be followed by a space or `>` +}; +inline constexpr std::array kHarnessEventTags{ { + { "", true }, + { "", true }, + { "= '\t' && task[i] <= '\r' ) ) ) { ++i; } const std::string_view rest = task.substr( i ); - return rest.starts_with( "" ) || rest.starts_with( "" ); + return std::any_of( kHarnessEventTags.begin(), kHarnessEventTags.end(), [&rest]( const HarnessEventTag &t ) { + if( !rest.starts_with( t.tag ) ) + { + return false; + } + return t.whole || ( rest.size() > t.tag.size() && ( rest[t.tag.size()] == ' ' || rest[t.tag.size()] == '>' ) ); + } ); } inline bool looksLikeTrace( std::string_view lower ) noexcept diff --git a/src/tracelocus.h b/src/tracelocus.h index 9426f8cdc..74d04c899 100644 --- a/src/tracelocus.h +++ b/src/tracelocus.h @@ -973,8 +973,7 @@ struct FromTraceResult // The size the compact layer delivers for one assembled candidate (its own size when the layer would not rewrite it). [[nodiscard]] inline std::size_t traceDeliveredBytes( std::string_view candidate ) { - const std::size_t delivered = rw::compactDeliveredBytes( candidate, "from-trace" ); - return delivered > 0 ? delivered : candidate.size(); + return rw::compactDeliveredBytesOrWritten( candidate, "from-trace" ); } // The signature/body section, sized against the fixed part the reader actually receives. The first render prices the @@ -1219,7 +1218,7 @@ inline FromTraceResult fromTraceBundleText( const IngestResult& ing, const Graph /*rankAdaptivePayload=*/true, sigsBudget, in.notes, // L3: field-notes surfacing (inert when null) in.rootArg, // R-R: root-relative - /*hasRelevanceFloor=*/false, /*droppedPositiveOut=*/nullptr, /*shownIdsOut=*/nullptr, + rw::SigLensRules::None, /*droppedPositiveOut=*/nullptr, /*shownIdsOut=*/nullptr, /*cappedOut=*/nullptr, /*topRowNext=*/{}, &sigsCut ); // cut-fix lane A: the tag's cut readings (docs_dropped=, shrunk-not-dropped), the --for twin's clauses // verbatim, as a comment right after the block they define: this lens's header is priced before the section @@ -1232,7 +1231,7 @@ inline FromTraceResult fromTraceBundleText( const IngestResult& ing, const Graph } const std::vector bodyIds = traceBodyIds( part, hop ); // LB-A: innermost frame, then the top hop row - packBodies( m, ing, bodyIds, in.bodyBudgetBytes, g.outOff, g.outTargets, in.compress, in.redact, + packBodies( m, ing, bodyIds, in.bodyBudgetBytes, g.outOff, g.outTargets, g.outNameOnly, in.compress, in.redact, /*ranges=*/nullptr, in.notes, // L3: the rank-1 body surfaces notes too /*outEmitted=*/nullptr, /*truncateOversizedFirst=*/true, /*withFileContext=*/false, in.rootArg, // R-R: root-relative diff --git a/src/valuerefindex.h b/src/valuerefindex.h index 251723360..cdf1c79e4 100644 --- a/src/valuerefindex.h +++ b/src/valuerefindex.h @@ -57,6 +57,16 @@ inline bool vrIsWrapperDecorator( std::string_view into ) noexcept return std::ranges::find( kWrappers, last ) != std::end( kWrappers ); } +// Two references in served order: by path, then by position in the file. +inline bool vrBindLess( const IngestResult& ing, const Reference& a, const Reference& b ) +{ + if( a.fileId != b.fileId ) + { + return ing.files[a.fileId] < ing.files[b.fileId]; + } + return a.startByte < b.startByte; +} + // The index every verb queries. Built from one IngestResult in O(refs + symbols); no graph needed. class ValueRefIndex { @@ -75,7 +85,11 @@ class ValueRefIndex for( std::uint32_t i = 0; i < ing.references.size(); ++i ) { const Reference& r = ing.references[i]; - if( r.role == RefRole::Value ) + if( r.role == RefRole::Value && scopeOf( r ) == kValueRefDepthCutScope ) + { + m_depthCuts.push_back( i ); // the file's depth-cut record: no row, a disclosure (depthCutsFor) + } + else if( r.role == RefRole::Value ) { m_values.push_back( i ); if( !r.recvVar.empty() ) @@ -138,6 +152,9 @@ class ValueRefIndex // `@property` or `@functools.wraps` hands the function back to the same name, so it is still reached only by a // call. A registering decorator (`@app.route`, `@register`) is the value use this exclusion exists for. The row // itself is still served (ruling 5); the wrapper FLOOR for rows is a follow-up. + // A reference made INSIDE `def` itself (`static void tick( void ) { timer_set( tick ); }`) does not count either: it + // can only run once something else reaches `def`, exactly as the call graph drops a recursive self-call (the CSR has + // no self-loops). The row is still served and is still a use site (--safe-delete's uses=). bool isValueReferenced( NodeId def ) const { const auto it = m_valuesByTarget.find( def ); @@ -147,10 +164,46 @@ class ValueRefIndex } return std::ranges::any_of( it->second, [ & ]( std::uint32_t k ) { - return !vrIsWrapperDecorator( m_ing.references[ m_values[k] ].fieldName ); + const Reference& r = m_ing.references[ m_values[k] ]; + return r.fromSymbol != def && !vrIsWrapperDecorator( r.fieldName ); } ); } + // The value walk's depth cut (ingest_valuerefs.h kVrMaxDepth) as it bears on an answer about `defs`: how many files + // of the defs' language families stopped their walk at the cap (a value reference below it was never captured, so a + // value_refs= count is a FLOOR there), and the first such record by path then line (the clue, value_refs_depth_at=). + // `everyFamily` asks about every armed family (--dead-code's whole-tree answer). Only functions and methods have + // value references, so a def of another kind brings no family in, and no def brings none. + struct DepthCuts + { + std::uint32_t files = 0; + std::uint32_t first = UINT32_MAX; // a reference index, UINT32_MAX when files == 0 + }; + DepthCuts depthCutsFor( std::span defs, bool everyFamily = false ) const + { + DepthCuts out; + for( const std::uint32_t i : m_depthCuts ) + { + const Reference& c = m_ing.references[i]; + const VrLangFamily fam = valueRefFamily( c.lang ); + const bool relevant = everyFamily || std::ranges::any_of( defs, [ & ]( NodeId d ) + { + const Symbol& s = m_ing.symbols[d]; + return ( s.kind == SymKind::Function || s.kind == SymKind::Method ) && valueRefFamily( s.lang ) == fam; + } ); + if( !relevant ) + { + continue; + } + ++out.files; + if( out.first == UINT32_MAX || vrBindLess( m_ing, c, m_ing.references[ out.first ] ) ) + { + out.first = i; + } + } + return out; + } + // The resolved targets of value reference `refIdx` (an index into ing.references). std::vector targetsOf( std::uint32_t refIdx ) const { @@ -309,6 +362,7 @@ class ValueRefIndex } private: + // A value key "#N" (an argument position) or "#name" (a keyword / a parameter default) against a parameter Through. static bool paramMatches( std::string_view valueKey, const Reference& through ) noexcept { @@ -435,7 +489,7 @@ class ValueRefIndex } const Symbol& f = m_ing.symbols[ r.fromSymbol ]; const bool fIsFn = f.kind == SymKind::Function || f.kind == SymKind::Method; - const bool fClass = ( f.kind == SymKind::Class || f.kind == SymKind::Struct ) && f.name == m.scope; + const bool fClass = ( f.kind == SymKind::Class || isStructOrNamedType( f.kind ) ) && f.name == m.scope; // a statement of the class body (owned by the class, or by an annotated attribute of it); a Python NESTED class's // body does not see the outer class's names, a C++ one does const bool inBody = !fIsFn && f.fileId == m.fileId @@ -558,6 +612,7 @@ class ValueRefIndex HashMap> m_fnByName; std::vector m_pyMember; // per symbol: 1 = a Python def directly in its class (empty: no Python) std::vector m_values; // Value reference indices, ascending + std::vector m_depthCuts; // the files' depth-cut records (no row), ascending std::vector> m_targets; // parallel to m_values HashMap> m_valuesByTarget; // target def → positions in m_values HashMap> m_valueByContainer; // "file#container" → Value refs @@ -584,21 +639,14 @@ struct ValueRefRow struct ValueRefRows { std::vector rows; // every row, served order; the window is the caller's + ValueRefIndex::DepthCuts depthCut; // the walk's depth cut over the answered defs' families (value_refs_depth_capped=) }; -inline bool vrBindLess( const IngestResult& ing, const Reference& a, const Reference& b ) -{ - if( a.fileId != b.fileId ) - { - return ing.files[a.fileId] < ing.files[b.fileId]; - } - return a.startByte < b.startByte; -} - // --callers side: every value reference resolving to one of `defs`. inline ValueRefRows valueRefCallerRows( const IngestResult& ing, const ValueRefIndex& idx, std::span defs ) { ValueRefRows out; + out.depthCut = idx.depthCutsFor( defs ); for( const std::uint32_t r : idx.valueRefsTo( defs ) ) { ValueRefRow row; @@ -653,15 +701,84 @@ inline bool vrCalleeRowLess( const IngestResult& ing, const ValueRefRow& a, cons { return ing.files[ta.fileId] < ing.files[tb.fileId]; } - return ta.line < tb.line; + if( ta.line != tb.line ) + { + return ta.line < tb.line; + } + return a.to < b.to; // two same-named definitions on one line (C++ overloads): their rows render alike, the order is still total } +// The function stores AND calls through the slot: the stored row (binding site v, function target) carries through=, the +// first written callee. A second call through the same slot and callee is that row again, never a new one. False when +// no stored row joins (the call goes through a slot another function or the file filled). +inline bool vrJoinStoredRow( std::vector& rows, std::uint32_t v, NodeId target, const std::string& through ) +{ + const auto same = std::ranges::find_if( rows, [ & ]( const ValueRefRow& r ) + { + return r.ref == v && r.to == target && ( r.through.empty() || r.through == through ); + } ); + if( same == rows.end() ) + { + return false; + } + if( same->through.empty() ) + { + same->through = through; + } + return true; +} + +// The rows a call THROUGH a slot reaches: one per (to, through), bind= its first binding site, sites= the number of +// DISTINCT binding sites — two calls through one slot never count one site twice. +struct VrViaRows +{ + std::vector rows; + std::vector> sites; // parallel to rows, ascending Value refs + + void add( const IngestResult& ing, std::uint32_t v, NodeId target, const std::string& through ) + { + const auto grouped = std::ranges::find_if( rows, [ & ]( const ValueRefRow& r ) { return r.to == target && r.through == through; } ); + if( grouped == rows.end() ) + { + ValueRefRow row; + row.ref = v; + row.to = target; + row.through = through; + rows.push_back( std::move( row ) ); + sites.push_back( { v } ); + return; + } + std::vector& s = sites[ static_cast( grouped - rows.begin() ) ]; + const auto at = std::ranges::lower_bound( s, v ); + if( at == s.end() || *at != v ) + { + s.insert( at, v ); + } + if( vrBindLess( ing, ing.references[v], ing.references[grouped->ref] ) ) + { + grouped->ref = v; + } + } + void appendTo( std::vector& out ) + { + for( std::size_t i = 0; i < rows.size(); ++i ) + { + rows[i].sites = static_cast( sites[i].size() ); + out.push_back( std::move( rows[i] ) ); + } + } +}; + // --callees side: the functions `fns` store/pass as values (through= absent unless the same function also calls // through that very slot), and the functions they may call through a parameter or a container (through= the written -// callee; one row per (to, through), bind= its first site, sites= the count). +// callee; one row per (to, through), bind= its first site, sites= the number of distinct binding sites). +// Rows are per BINDING SITE and WRITTEN callee, never per call through it: two calls `tbl.k(); tbl.k();` through one slot +// are one row, and `TABLE.k()` called twice through a slot one site fills is sites=1, not 2. FLOOR: one slot called under two +// spellings (`tbl.k()` and `tbl["k"]()`) is two written callees, so two rows. inline ValueRefRows valueRefCalleeRows( const IngestResult& ing, const ValueRefIndex& idx, std::span fns ) { ValueRefRows out; + out.depthCut = idx.depthCutsFor( fns ); for( const std::uint32_t v : idx.madeIn( fns, RefRole::Value ) ) { for( const NodeId t : idx.targetsOf( v ) ) @@ -672,7 +789,7 @@ inline ValueRefRows valueRefCalleeRows( const IngestResult& ing, const ValueRefI out.rows.push_back( std::move( row ) ); } } - std::vector via; + VrViaRows via; for( const std::uint32_t t : idx.madeIn( fns, RefRole::Through ) ) { const Reference& tr = ing.references[t]; @@ -680,41 +797,14 @@ inline ValueRefRows valueRefCalleeRows( const IngestResult& ing, const ValueRefI { for( const NodeId target : idx.targetsOf( v ) ) { - // the same function stores AND calls through the slot: the stored row carries through= - auto same = std::find_if( out.rows.begin(), out.rows.end(), [ & ]( const ValueRefRow& r ) - { - return r.ref == v && r.to == target && r.through.empty(); - } ); - if( same != out.rows.end() ) + if( !vrJoinStoredRow( out.rows, v, target, tr.fieldName ) ) { - same->through = tr.fieldName; - continue; + via.add( ing, v, target, tr.fieldName ); } - auto grouped = std::find_if( via.begin(), via.end(), [ & ]( const ValueRefRow& r ) - { - return r.to == target && r.through == tr.fieldName; - } ); - if( grouped != via.end() ) - { - if( vrBindLess( ing, ing.references[v], ing.references[grouped->ref] ) ) - { - grouped->ref = v; - } - ++grouped->sites; - continue; - } - ValueRefRow row; - row.ref = v; - row.to = target; - row.through = tr.fieldName; - via.push_back( std::move( row ) ); } } } - for( ValueRefRow& r : via ) - { - out.rows.push_back( std::move( r ) ); - } + via.appendTo( out.rows ); std::sort( out.rows.begin(), out.rows.end(), [ & ]( const ValueRefRow& a, const ValueRefRow& b ) { return vrCalleeRowLess( ing, a, b ); } ); return out; } @@ -772,15 +862,22 @@ class UsesValueFilter std::vector m_sites; }; -// --path / path_between: with NO directed call path (`unreachable`), how often `dstDefs` are used as values; 0 when a -// path exists, so the attribute is absent and the answer byte-identical. -inline std::size_t toValueRefsCount( const IngestResult& ing, bool unreachable, std::span dstDefs, const ValueRefIndex* cached = nullptr ) +// --path / path_between: with NO directed call path (`unreachable`), how often `dstDefs` are used as values (0 when a +// path exists, so the attribute is absent and the answer byte-identical) and the depth cut over their language families +// (ValueRefRows::depthCut: a count read off a cut file is a floor, and the answer says so, as --callers' does). +struct ToValueRefs +{ + std::size_t count = 0; + ValueRefIndex::DepthCuts depthCut; +}; +inline ToValueRefs toValueRefs( const IngestResult& ing, bool unreachable, std::span dstDefs, const ValueRefIndex* cached = nullptr ) { if( !unreachable ) { - return 0; + return {}; } - return cached != nullptr ? valueRefCallerRows( ing, *cached, dstDefs ).rows.size() : valueRefCallerRows( ing, ValueRefIndex( ing ), dstDefs ).rows.size(); + const ValueRefRows rows = cached != nullptr ? valueRefCallerRows( ing, *cached, dstDefs ) : valueRefCallerRows( ing, ValueRefIndex( ing ), dstDefs ); + return { rows.rows.size(), rows.depthCut }; } } // namespace rw diff --git a/src/valuerefs.h b/src/valuerefs.h index 7a9a5c81b..427e68033 100644 --- a/src/valuerefs.h +++ b/src/valuerefs.h @@ -171,8 +171,40 @@ inline std::string valueRefsJson( const IngestResult& ing, const ValueRefRows& r return out; } +// The depth cut over the answered definitions (ValueRefRows::depthCut, ingest_valuerefs.h kVrMaxDepth): files of their +// language family whose value walk stopped at the cap — value_refs_depth_capped="N" — and the first cut, file:line — +// value_refs_depth_at=. "" when there is none, so an answer over an uncut tree is byte-identical. +inline std::string valueRefsDepthAttrXml( const IngestResult& ing, const ValueRefIndex::DepthCuts& cut, const VrRender& rr ) +{ + if( cut.files == 0 ) + { + return {}; + } + EXPECTS( cut.first < ing.references.size(), "a depth cut counted over N > 0 files names its first record" ); + const Reference& r = ing.references[cut.first]; + std::vector esc; + return " value_refs_depth_capped=\"" + std::to_string( cut.files ) + "\" value_refs_depth_at=\"" + + std::string( escapeXml( vrPath( ing, r.fileId, rr ) + ":" + std::to_string( r.line ), esc ) ) + "\""; +} +inline std::string valueRefsDepthKeyJson( const IngestResult& ing, const ValueRefIndex::DepthCuts& cut, const VrRender& rr ) +{ + if( cut.files == 0 ) + { + return {}; + } + EXPECTS( cut.first < ing.references.size(), "a depth cut counted over N > 0 files names its first record" ); + const Reference& r = ing.references[cut.first]; + return ",\"value_refs_depth_capped\":" + std::to_string( cut.files ) + ",\"value_refs_depth_at\":\"" + + jsonStr( vrPath( ing, r.fileId, rr ) + ":" + std::to_string( r.line ) ) + "\""; +} + inline std::string valueRefsCountAttrXml( std::size_t n ) { return countFieldOrEmpty( "value_refs", n, /*json=*/false ); } inline std::string valueRefsCountKeyJson( std::size_t n ) { return countFieldOrEmpty( "value_refs", n, /*json=*/true ); } +// The legend clause for the depth attributes, exactly when an answer about these rows carries them. +inline const char* valueRefsDepthLegendFor( const ValueRefRows& rows ) noexcept +{ + return valueRefsDepthLegend( rows.depthCut.files > 0 ); +} } // namespace rw diff --git a/src/verbs_change.h b/src/verbs_change.h index 9da3174cc..b8d3afe04 100644 --- a/src/verbs_change.h +++ b/src/verbs_change.h @@ -1627,7 +1627,8 @@ std::optional runCrossRef( const MainDispatch& d ) { result.nearMiss = didYouMean( d.ing, whereisSel ); } - crossref::writeWhereisPage( stdout, result, cfg.detail ? SIZE_MAX : crossref::kWhereisHits, cfg.pageLimit, cfg.pageOffset ); + crossref::writeWhereisPage( stdout, result, cfg.detail ? SIZE_MAX : crossref::kWhereisHits, cfg.pageLimit, cfg.pageOffset, + crossref::whereisListingOf( cfg.whereisListing ) ); return 0; } return std::nullopt; diff --git a/src/verbs_for.h b/src/verbs_for.h index dfe6a6967..ddcfa33fc 100644 --- a/src/verbs_for.h +++ b/src/verbs_for.h @@ -422,6 +422,13 @@ struct ForLensJsonInputs // MainDispatch) — REQUIRED like fileTail above, for the same reason: a defaulted bool is how a degrade // silently drops off this dialect while the XML twin keeps carrying it. bool notesDegraded; + // code above docs (serialize.h reorderDocsAfterCode): the XML twin's verdict, REQUIRED for the same reason + bool docsAfterCode; + // knob-honesty-068: what the "sigs_next" continuation echoes (serialize.h SigsCutContinuation) — REQUIRED like the two + // above: a capped JSON bundle names the call that serves its cut rows exactly as the XML twin's does. + std::string_view task; + int packTopN; + std::string_view rankFlags; // forRankShapingFlags( cfg ), owned by the caller for the call's duration }; // The lens bundle's opening keys. Every note is absent-unless-present — the same silence-means-nothing- @@ -543,6 +550,12 @@ struct ForLensHeaderParts bool modScopePresent = false; // #60: does any ranked row name a file's MODULE SCOPE (t="modscope", // n=)? It reaches this bundle as a row and, more often, // as a hop row. Same over-approximation, same reason. + bool endLinePresent = false; // may a row carry e= (serialize.h defEndLine)? A code row with a known + // extent among the row head — read from the same head walk as + // modScopePresent; a file the emitter then fails to read is the residual. + bool viaPresent = false; // FE-B: may a row of this bundle carry via="name"? Some head row + // (the same row head modScopePresent reads) has a name-only out-edge + // (serialize.h namesOnlyOutAny). Over-approximates the 16-per-symbol cap. // ── THE DROPPABLE LEGEND, as ONE bit ────────────────────────────────────────────────────────────── // confidenceNote / tailLegend / idRouteLegend moved in lock step at every read and every write, and the @@ -736,14 +749,17 @@ inline std::string legendDroppedNote( bool compact, bool thin, bool routeWasOn, inline constexpr std::string_view kForCompactLegendRoot = "bundle=/bodies=/reason= the body posture; "; inline constexpr std::string_view kForCompactLegendRows = - "d: cx= ccx= complexity, in= callers, churn= amp= change, clone= tested= 1, sc= scope, id=p::sc::n; " + "d: cx= ccx= complexity, in= callers (absent cx/ccx/in = 0), churn= amp= change, clone= tested= 1, sc= scope, id=p::sc::n; " "total= shown= capped=1 if cut"; // …and the same clause for a bundle whose served rows carry NO scope (a corpus of free functions, a markdown // tree): 22 B of sc= reading removed, nothing else. Two constants rather than one built at runtime because this // dialect's whole contract is a pinned byte count per schema, and a pin reads a constant. inline constexpr std::string_view kForCompactLegendRowsNoScope = - "d: cx= ccx= complexity, in= callers, churn= amp= change, clone= tested= 1; " + "d: cx= ccx= complexity, in= callers (absent cx/ccx/in = 0), churn= amp= change, clone= tested= 1; " "total= shown= capped=1 if cut"; +static_assert( kForCompactLegendRows.find( rw::kForZeroAbsentCompactNote ) != std::string_view::npos + && kForCompactLegendRowsNoScope.find( rw::kForZeroAbsentCompactNote ) != std::string_view::npos, + "the compact zero reading is the exempt note, verbatim (graphlegend.h forZeroNoteBytes)" ); // L1 fix round (2026-09-19, rv-r1-L1 HIGH-1): task= (the root's echo of the query), next= (the follow-up on a row), pure= // (a row flag) and the rows rode every default answer with no reading in either dialect; // legendcoveragecheck's default rows now fail on that. Charged like the rows clause; runForLens caps the compact charge at @@ -780,7 +796,8 @@ inline constexpr std::string_view kForCompactLegendHdr = // The data notes in their compact spelling: the numbers stay, the sentence goes. Unknown shapes pass // through VERBATIM — a note this table does not know is never shortened into something it did not say. -inline std::string compactForNote( std::string_view note ) +// ONE note (" [...]"); compactForNote below splits a channel that carries several. +inline std::string compactOneForNote( std::string_view note ) { // " [relevance floor: kept 7 of 40 - the other 33 scored zero…]" → " [floor: kept 7 of 40]" if( note.starts_with( " [relevance floor: kept " ) ) @@ -815,6 +832,22 @@ inline std::string compactForNote( std::string_view note ) return std::string( note ); } +// A note channel can carry SEVERAL notes back to back (the doc-mention channel also carries its cap note, +// mention.h absorbCapDisclosure). Each " [...]" is compacted on its own, so a later note is never +// swallowed by an earlier note's rewrite (which keeps only the first note's numbers). +inline std::string compactForNote( std::string_view note ) +{ + std::string out; + while( !note.empty() ) + { + const std::size_t next = note.find( "] [" ); + const std::size_t len = next == std::string_view::npos ? note.size() : next + 1; + out += compactOneForNote( note.substr( 0, len ) ); + note.remove_prefix( len ); + } + return out; +} + // Does THIS answer's root carry route=? The reading of a code is present-only in both dialects, so both ask the // question the same way: the ladder's rung (c) may have dropped the attribute (withRouteAttr), and a run the // router never ran for (no-route, or a verb with no route at all) never had one. Read off the built root open @@ -870,6 +903,8 @@ inline void appendCompactForLegend( std::string& h, const ForLensHeaderParts& p, { p.composePresent, kForCompactLegendCompose }, { p.legoPresent, kForCompactLegendLego }, { p.layerPresent, kForCompactLegendLayer }, { p.modScopePresent, rw::kForCompactModScopeClause }, // #60 + { p.endLinePresent, rw::kForCompactEndLineLegend }, + { p.viaPresent, rw::kForCompactViaNameClause }, // FE-B }; for( const auto& [ on, clause ] : kPresentOnly ) { @@ -983,7 +1018,7 @@ inline std::string forLensHeaderText( const ForLensHeaderParts& p, bool withRout "invisible neighbours without hurting, no measured recall lift; see bench/ANSWERQUALITY.md]"; } h += ": reusable building blocks + quality facts for what you're about to touch " - "(cx=complexity ccx=cognitive in=reuse-count churn=recent-commits amp=change-amplification clone=1(duplicated) tested=1) " + "(cx=complexity ccx=cognitive in=reuse-count, each absent when 0; churn=recent-commits amp=change-amplification clone=1(duplicated) tested=1) " "— prefer composing/reusing these; watch the high-churn/high-amp/cloned ones"; // BOTH readings are present-only (PR #215 review): sc= when a served row carries one, route= when the root // carries the attribute. A reading with nothing to define is bytes the bundle could have spent on a row, and @@ -1019,6 +1054,14 @@ inline std::string forLensHeaderText( const ForLensHeaderParts& p, bool withRout { h.append( rw::kForModScopeClause ); // #60: present-only, on the same bit the compact strip reads } + if( p.endLinePresent ) + { + h.append( rw::kForEndLineLegend ); // e=: present-only, on the same bit the compact strip reads + } + if( p.viaPresent ) + { + h.append( rw::forViaNameClause() ); // FE-B: present-only, on the same bit the compact strip reads + } if( p.legendDropped ) { // L1: the droppable clauses went to the ceiling — say so, and name exactly the ones THIS document had @@ -1282,6 +1325,12 @@ struct ForLensRootFinish std::size_t tokenBudget = 0; int maxTokens = 0; bool bodyCeiling = false; + // knob-honesty-068 (orchestrator rulings 2026-10-07): a charged continuation rode unpaid — the rank 1..4 floor could + // not give its bytes up, or no row was dropped for it because the answer lands past its ceiling paid or not (ruling C3) — + // (SigsCutReport::continuationUnpaidBytes) AND the document lands past its ceiling: byte allowance or budget_tokens. + // Decided by the caller on the finished document (a value, never a search of the text); labels it over_ceiling="1" + // with kForSigsUnpaidOverCeilingNote, which is true where kOverCeilingLegend's est_tokens reading may not be. + bool sigsUnpaidOver = false; }; // PR #215 review: the finished header AND the number it prints, handed back together. finishForLensHeader @@ -1365,9 +1414,13 @@ inline ForLensPricedHeader finishForLensHeaderPriced( std::string header, const // clause unconditionally would charge every budgeted bundle for an attribute it does not carry. Monotone — // adding bytes only RAISES est_tokens — so one extra stage is exact and the flag never oscillates. The // definition rides the legend of the document that carries the attribute. - if( forLensOverCeiling( f.lastRungFired, f.tokenBudget, f.maxTokens, f.bodyCeiling, priced.first ) ) + const bool namedCeilingOver = forLensOverCeiling( f.lastRungFired, f.tokenBudget, f.maxTokens, f.bodyCeiling, priced.first ); + if( namedCeilingOver || f.sigsUnpaidOver ) { - spliceBefore( header, " -->", /*fromEnd=*/true, f.overCeilingLegend ); + // each clause rides only where its reading is true: the est_tokens/last-rung one when that predicate holds, the + // unpaid one when the recovery handle rode unpaid on an answer past its ceiling (knob-honesty-068) — both when both hold + spliceBefore( header, " -->", /*fromEnd=*/true, namedCeilingOver ? f.overCeilingLegend : std::string_view() ); + spliceBefore( header, " -->", /*fromEnd=*/true, f.sigsUnpaidOver ? rw::kForSigsUnpaidOverCeilingNote : std::string_view() ); overAttr = " over_ceiling=\"1\""; priced = priceFixpoint( header.size() + f.nonHeaderMarkupBytes, overAttr.size() ); } @@ -1408,7 +1461,11 @@ inline std::string forLensJsonTailStanza( const rw::FileTail& tail, std::size_t return stanza; } -inline int emitForLensJson( std::FILE* out, const std::string& header, const ForLensJsonInputs& in ) +// knob-honesty-068 (orchestrator ruling C3, completed 2026-10-08): one RENDER of the --json bundle. `sigsNextPayFromRows` is +// the charged "sigs_next" payment mode (serialize.h SigsCutContinuation::payFromRows); with `sigsNextTryOther` set, a render +// whose capped, charged bundle lands past its ceiling writes NOTHING and asks for the other mode (emitForLensJson below). +inline int emitForLensJsonPass( std::FILE* out, const std::string& header, const ForLensJsonInputs& in, bool sigsNextPayFromRows, + bool* sigsNextTryOther ) { using namespace rw; @@ -1447,12 +1504,19 @@ inline int emitForLensJson( std::FILE* out, const std::string& header, const For constexpr std::size_t kJsonSurfaceCountsBytes = 96; const std::size_t bundleBudget = in.tokenBudget > 0 ? budgetBytesForTokens( in.tokenBudget ) : kForPayloadBudgetBytes; - const std::size_t fixedBytes = header.size() + kJsonEnvelopeBytes + kJsonSurfaceCountsBytes + const std::size_t fixedBytes = header.size() - std::min( header.size(), rw::forZeroNoteBytes( header ) ) // lean-answers: exempt + + kJsonEnvelopeBytes + kJsonSurfaceCountsBytes + ( in.noteIndex ? kJsonNotesStanzaBytes : 0 ); const std::size_t sigsBudget = bundleBudget > fixedBytes ? bundleBudget - fixedBytes : 1; + // knob-honesty-068: the cut's continuation. This dialect serves no bodies, so its sig side IS the ceiling: charged under + // an explicit --token-budget, exempt at the default (the XML twin's rule, SigsCutContinuation) + const rw::SigsCutContinuation cutNext{ in.task, in.packTopN, in.rankFlags, fixedBytes, /*charged=*/in.tokenBudget > 0, /*json=*/true, + /*pasteHandle=*/true, /*ledgerGapBytes=*/0u, sigsNextPayFromRows }; const JsonSigLens lens{ /*metrics=*/true, in.fanIn, in.impure, in.churnPerFile, in.cloneMember, - in.tested, in.amp, /*rankAdaptivePayload=*/true, in.noteIndex }; + in.tested, in.amp, /*rankAdaptivePayload=*/true, in.noteIndex, + /*endLines=*/rw::endLinesFitCeiling( in.tokenBudget, /*bodyCeiling=*/false ), // serialize.h + in.docsAfterCode }; JsonSigNoteCounts noteCounts; const auto packSigs = [ & ]( std::FILE* dst, std::size_t budget, bool* outCapped, std::size_t* outDroppedPositive, std::vector* outShownIds, rw::SigsCutReport* outCut ) @@ -1460,7 +1524,8 @@ inline int emitForLensJson( std::FILE* out, const std::string& header, const For in.rootArg, /*hasRelevanceFloor=*/true, // LB-A: same admission rule as the XML twin (R-R: root-relative p/id) outDroppedPositive, // A2: exact count, see droppedPositiveCount (serialize.h) outShownIds, // lane 2: the emitted rows' ids — the tail excludes these files - outCut ); }; // cut-fix lane A: the XML tag's shown/total/docs_dropped + outCut ); }; // cut-fix lane A: the XML tag's shown/total/docs_dropped; + // knob-honesty-068: carries cutNext IN ("sigs_next") // §B1.4: built once, used on both the degrade path below and the normal return — these three are plain // size_t values already computed by the caller (no rendering, no redaction seam), so unlike est_tokens @@ -1487,6 +1552,7 @@ inline int emitForLensJson( std::FILE* out, const std::string& header, const For bool sigsCapped = false; // the LADDER's verdict: decides the budget_bytes= stanza rw::SigsCutReport sigsCut; // cut-fix lane A: what the array cut (gate or ladder) — "capped" and the sigs_* keys + sigsCut.continuationRequest = &cutNext; // knob-honesty-068: IN — "sigs_next" / "sigs_next_offset" on a capped array std::size_t sigsDroppedPositive = 0; // A2: set only by the memstream-buffered render below (nullptr on the ENOMEM degrade path) std::vector jsonShownIds; // lane 2: the sigs rows actually emitted (the XML twin's shownSigIds) std::string sigsJson; @@ -1550,11 +1616,20 @@ inline int emitForLensJson( std::FILE* out, const std::string& header, const For if( sigsCut.isCapped ) { sigsCutStanza += ",\"sigs_shown\":" + std::to_string( sigsCut.shown ) + ",\"sigs_total\":" + std::to_string( sigsCut.total ); + if( rw::sigsCutHasNextOffset( sigsCut ) ) // knob-honesty-068: the resume index, then the call serving the cut rows + { + sigsCutStanza += ",\"sigs_next_offset\":" + std::to_string( sigsCut.lastShownRank ); + } + sigsCutStanza += rw::nextFieldJson( sigsCut.next, "sigs_next" ); } if( sigsCut.docsDropped > 0 ) { sigsCutStanza += ",\"docs_dropped\":" + std::to_string( sigsCut.docsDropped ); } + if( sigsCut.docsAfterCode > 0 ) // the XML tag's docs_after_code= (the reorder's disclosure; uncharged, like the XML attribute) + { + sigsCutStanza += ",\"docs_after_code\":" + std::to_string( sigsCut.docsAfterCode ); + } const bool sigsCappedKey = sigsCut.isCapped; // "capped": the XML tag's capped="1" (the ladder's verdict OR a gate cut) // §C1 + §C2 — the CHARGE, measured from the bytes this function is about to write rather than from the @@ -1637,6 +1712,13 @@ inline int emitForLensJson( std::FILE* out, const std::string& header, const For overCeiling = ",\"over_ceiling\":true"; } ASSUME( overCeiling.size() == 0 || overCeiling.size() == 20 ); // the 20 the charge above reserved for it + // knob-honesty-068 (ruling C3, completed): a capped, charged bundle past its ceiling in THIS payment mode is not served from + // this render. Write nothing; the caller tries the other mode (emitForLensJson: unpaid, then paid, then unpaid served). + if( sigsNextTryOther != nullptr && cutNext.charged && sigsCut.hasContinuation && !overCeiling.empty() ) + { + *sigsNextTryOther = true; + return 0; + } std::fputs( header.c_str(), out ); std::fwrite( kJsonBundleSigsKey.data(), 1, kJsonBundleSigsKey.size(), out ); std::fwrite( surfaceCountsStanza.data(), 1, surfaceCountsStanza.size(), out ); @@ -1653,6 +1735,42 @@ inline int emitForLensJson( std::FILE* out, const std::string& header, const For return 0; } +// The --json bundle (orchestrator ruling C3, completed 2026-10-08): a charged "sigs_next" is paid for in rows ONLY where that is +// what makes the bundle fit — runForLens's three modes over one ranking: unpaid (served when it fits), paid (served when IT +// fits), unpaid again (served over its ceiling, over_ceiling disclosed: no row dropped for a handle that cannot make it fit). +// A render that hands back wrote nothing; the redaction tally is put back to its entry value before each later render, so the +// rows the summary counts are the ones written. Rendering only — the ranking is the caller's. +inline int emitForLensJson( std::FILE* out, const std::string& header, const ForLensJsonInputs& in ) +{ + if( in.tokenBudget == 0 ) // only an explicit --token-budget charges the handle, so only then can a render hand back + { + return emitForLensJsonPass( out, header, in, /*sigsNextPayFromRows=*/true, nullptr ); + } + const std::optional tallyAtEntry = in.redact != nullptr ? std::optional( *in.redact ) : std::nullopt; + const auto restoreTally = [ & ] + { + if( tallyAtEntry ) + { + *in.redact = *tallyAtEntry; + } + }; + bool unpaidOver = false; + const int unpaidRc = emitForLensJsonPass( out, header, in, /*sigsNextPayFromRows=*/false, &unpaidOver ); + if( !unpaidOver ) + { + return unpaidRc; + } + restoreTally(); + bool paidOver = false; + const int paidRc = emitForLensJsonPass( out, header, in, /*sigsNextPayFromRows=*/true, &paidOver ); + if( !paidOver ) + { + return paidRc; + } + restoreTally(); + return emitForLensJsonPass( out, header, in, /*sigsNextPayFromRows=*/false, nullptr ); +} + // ── T3: the terminal-by-default auto section (pre-registered: docs/EVALS.md §4) ───────────────── // The single biggest measured non-terminal chain is map-then-read: the agent runs --for, then opens the // file the map named. The terminal bundle that already served bodies (--pack-task) went uncalled for a @@ -1793,6 +1911,20 @@ void restrictBodiesToRouteAnchor( const rw::IngestResult& ing, std::vector cuts), pre-spelled for the +// capped block's continuation (serialize.h SigsCutContinuation::rankFlags) — the re-run must rank the same list. +inline std::string forRankShapingFlags( const rw::Config& cfg ) +{ + std::string f; + f += cfg.anchor ? " --anchor" : ""; + f += cfg.noRoute ? " --no-route" : ""; + f += cfg.adaptive ? " --adaptive" : ""; + f += cfg.noMentionBoost ? " --no-mention-boost" : ""; + f += cfg.cochangeBoost ? " --cochange-boost" : ""; + f += cfg.noDocMention ? " --no-doc-mention" : ""; + return f; +} + std::size_t forSigSideCeiling( bool autoBundleMode, int packTopN, std::size_t bundleBudget ) { if( autoBundleMode && packTopN == 0 ) @@ -1859,7 +1991,7 @@ std::vector computeAutoBodyCandidateIds( const rw::IngestResult& ing ForAutoBodiesResult buildForAutoBodies( const rw::Config& cfg, const rw::IngestResult& ing, const rw::Graph& g, const std::vector& lensSurfaceIds, const std::vector& lensRank, std::size_t committedBytes, std::size_t bundleBudget, rw::RedactCounts* redactPtr, - const std::vector& anchorDefs ) + const std::vector& anchorDefs, bool viaLegendInHead ) { ForAutoBodiesResult out; // R-E (2026-08-17 harvest): same single-root condition every other verb's root= uses (sarif.h). @@ -1922,9 +2054,9 @@ ForAutoBodiesResult buildForAutoBodies( const rw::Config& cfg, const rw::IngestR // shell the "budget" branch below already gets for free, rather than a second hand-rolled tag. out.attr = " bundle=\"auto\" bodies=\"0\" reason=\"no_candidates\""; out.section = rw::chargeSection( [ & ]( std::FILE* f ) - { rw::packBodies( f, ing, autoBodyIds, /*budgetBytes=*/1, g.outOff, g.outTargets, cfg.compress, redactPtr, + { rw::packBodies( f, ing, autoBodyIds, /*budgetBytes=*/1, g.outOff, g.outTargets, g.outNameOnly, cfg.compress, redactPtr, /*ranges=*/nullptr, /*noteIndex=*/nullptr, nullptr, /*truncateOversizedFirst=*/false, - /*withFileContext=*/false, fabRootArg, &lensRank ); }, + /*withFileContext=*/false, fabRootArg, &lensRank, viaLegendInHead ); }, rw::kBytesPerTokenBody ); if( !out.section.isRendered ) { @@ -1944,9 +2076,9 @@ ForAutoBodiesResult buildForAutoBodies( const rw::Config& cfg, const rw::IngestR const std::size_t autoBodyBudget = std::max( 1, std::min( leftBytes, cfg.packBudgetBytes ) ); rw::EmittedBodies autoEmitted; out.section = rw::chargeSection( [ & ]( std::FILE* f ) - { rw::packBodies( f, ing, autoBodyIds, autoBodyBudget, g.outOff, g.outTargets, cfg.compress, redactPtr, + { rw::packBodies( f, ing, autoBodyIds, autoBodyBudget, g.outOff, g.outTargets, g.outNameOnly, cfg.compress, redactPtr, /*ranges=*/nullptr, /*noteIndex=*/nullptr, &autoEmitted, /*truncateOversizedFirst=*/false, - /*withFileContext=*/false, fabRootArg, &lensRank ); }, + /*withFileContext=*/false, fabRootArg, &lensRank, viaLegendInHead ); }, rw::kBytesPerTokenBody ); if( !out.section.isRendered ) @@ -2062,17 +2194,10 @@ inline std::size_t enrichmentLegendBytesEmitted( const ForEnrichmentPlan& plan, return plan.compact ? kForCompactLegendHops.size() : kForCompactLegendBodies.size(); } -ForAutoBodiesResult buildForCompactHops( const rw::Config& cfg, const rw::IngestResult& ing, const rw::Graph& g, - const std::vector& lensSurfaceIds, const std::vector& lensRank, - std::size_t committedBytes, std::size_t bundleBudget, rw::RedactCounts* redactPtr ) +// The compact route's rows: the positive-score head of the ranked surface, capped at kPackTaskBodyCandidates. One +// spelling, read by buildForCompactHops and by the header's via="name" question (FE-B), so the two cannot disagree. +inline std::vector forCompactHopIds( const std::vector& lensSurfaceIds, const std::vector& lensRank ) { - ForAutoBodiesResult out; - // R-E (2026-08-17 harvest): same single-root condition every other verb's root= uses (sarif.h). - const bool fcSingleRoot = ing.realPaths.empty() && cfg.roots.size() == 1; - const std::string_view fcRootArg = fcSingleRoot ? cfg.roots[0] : std::string_view(); - - // candidates: EXACTLY the head buildForAutoBodies would have bodied — same rule, same order, so the - // two shapes describe the same symbols and only differ in how much of each they serve. std::vector hopIds; for( rw::NodeId sid : lensSurfaceIds ) { @@ -2082,6 +2207,36 @@ ForAutoBodiesResult buildForCompactHops( const rw::Config& cfg, const rw::Ingest } hopIds.push_back( sid ); } + return hopIds; +} +// --detail=N's bodies: the top min(N, forTopN) symbols by (score desc, id asc) — the order the sigs use. One spelling, read +// by the --detail section and by the header's via="name" question (FE-B). +inline std::vector forDetailIds( const rw::IngestResult& ing, const std::vector& lensRank, int detail, int forTopN ) +{ + const std::size_t S = ing.symbols.size(); + std::vector ids( S ); + for( rw::NodeId i = 0; i < S; ++i ) + { + ids[i] = i; + } + rw::sortutil::radixSortByScoreDescId( ids, lensRank ); + ids.resize( std::min( { std::size_t( detail ), std::size_t( forTopN ), S } ) ); + return ids; +} + +ForAutoBodiesResult buildForCompactHops( const rw::Config& cfg, const rw::IngestResult& ing, const rw::Graph& g, + const std::vector& lensSurfaceIds, const std::vector& lensRank, + std::size_t committedBytes, std::size_t bundleBudget, rw::RedactCounts* redactPtr, + bool viaLegendInHead ) +{ + ForAutoBodiesResult out; + // R-E (2026-08-17 harvest): same single-root condition every other verb's root= uses (sarif.h). + const bool fcSingleRoot = ing.realPaths.empty() && cfg.roots.size() == 1; + const std::string_view fcRootArg = fcSingleRoot ? cfg.roots[0] : std::string_view(); + + // candidates: EXACTLY the head buildForAutoBodies would have bodied — same rule, same order, so the + // two shapes describe the same symbols and only differ in how much of each they serve. + const std::vector hopIds = forCompactHopIds( lensSurfaceIds, lensRank ); std::size_t leftBytes = bundleBudget > committedBytes ? bundleBudget - committedBytes : 0; if( cfg.tokenBudget == 0 ) @@ -2113,7 +2268,8 @@ ForAutoBodiesResult buildForCompactHops( const rw::Config& cfg, const rw::Ingest : std::size_t( 1 ) ); out.section = rw::chargeSection( [ & ]( std::FILE* f ) - { rw::packHops( f, ing, hopIds, hopBudget, g.outOff, g.outTargets, redactPtr, /*outShown=*/nullptr, &lensRank, fcRootArg ); }, + { rw::packHops( f, ing, hopIds, hopBudget, g.outOff, g.outTargets, g.outNameOnly, redactPtr, /*outShown=*/nullptr, &lensRank, fcRootArg, + viaLegendInHead ); }, // g.outNameOnly also feeds the hop-slot rule (serialize.h noProvenEdge) // MARKUP rate, not the body rate — and this is an honesty choice, not a copy-paste slip. The body // rate (3.80 B/tok) prices SOURCE TEXT; the compact section contains none, only tags, identifiers // and line numbers, which tokenize like the rest of the bundle. Charging structured markup at the @@ -2143,12 +2299,13 @@ ForAutoBodiesResult buildForCompactHops( const rw::Config& cfg, const rw::Ingest ForAutoBodiesResult buildForEnrichment( const rw::Config& cfg, const rw::IngestResult& ing, const rw::Graph& g, const std::vector& lensSurfaceIds, const std::vector& lensRank, const ForEnrichmentPlan& plan, const std::vector& anchorDefs, - rw::RedactCounts* redactPtr, std::size_t committedBytes, std::size_t bundleBudget ) + rw::RedactCounts* redactPtr, std::size_t committedBytes, std::size_t bundleBudget, + bool viaLegendInHead ) // FE-B: the header defines via="name" (ForLensHeaderParts::viaPresent) { const std::size_t committed = committedBytes + plan.attrReserve; ForAutoBodiesResult out = plan.compact - ? buildForCompactHops( cfg, ing, g, lensSurfaceIds, lensRank, committed, bundleBudget, redactPtr ) - : buildForAutoBodies( cfg, ing, g, lensSurfaceIds, lensRank, committed, bundleBudget, redactPtr, anchorDefs ); + ? buildForCompactHops( cfg, ing, g, lensSurfaceIds, lensRank, committed, bundleBudget, redactPtr, viaLegendInHead ) + : buildForAutoBodies( cfg, ing, g, lensSurfaceIds, lensRank, committed, bundleBudget, redactPtr, anchorDefs, viaLegendInHead ); if( plan.compact ) { out.markupBytes = out.section.xml.size(); @@ -2214,7 +2371,11 @@ inline std::string renderForHdrRowsXml( const rw::IngestResult& ing, const std:: return x; } -std::optional runForLens( const MainDispatch& d ) +// knob-honesty-068 (orchestrator ruling C3, completed 2026-10-08): one RENDER of the --for lens over the ranking runForLens +// computed once (`lr`). `sigsNextPayFromRows` is the charged continuation's payment mode (serialize.h +// SigsCutContinuation::payFromRows); with `sigsNextTryOther` set, a run whose capped, charged document lands past its ceiling +// writes NOTHING to stdout and asks for the run in the other payment mode (runForLens below decides which answer ships). +std::optional runForLensPass( const MainDispatch& d, rw::LensRanking lr, bool sigsNextPayFromRows, bool* sigsNextTryOther ) { using namespace rw; const Config& cfg = d.cfg; @@ -2258,9 +2419,12 @@ std::optional runForLens( const MainDispatch& d ) // ROUTING + anchoring + the B8 mention anchor + the opt-in B3 co-change prior all live in // computeLensRanking (shared with runPackTask so the ranking is defined once). Compose order with // --anchor: ROUTE picks the base lens rank, then ANCHOR expands it; mention/co-change run after. - LensRanking lr = computeLensRanking( d, cfg.forTask, forCompactPosture( cfg ), - /*fullDistribution=*/!cfg.candidates ); // deep-tail: the bundle serves the file-grain tail; candidates has no tail and keeps the H2 pruning + // (the ranking itself — computeLensRanking — runs ONCE, in runForLens: every payment-mode render shares it) std::vector lensRank = std::move( lr.rank ); + const bool forBodyCeiling = cfg.maxTokens > 0 && cfg.detail > 0; // the kShapingVerbs carve-out, read once (e= and the docs reorder share it via serialize.h explicitCeilingTighterThanDefault) + // code above docs (filter.h): the routed path on a question that does not ask about docs — the shown set's REORDER + const bool forDocsAfterCode = !cfg.noRoute && !taskAsksAboutDocs( cfg.forTask ) && !std::getenv( "RIPWIRE_NO_DOCS_AFTER_CODE" ) + && rw::docsAfterCodeFitsCeiling( cfg.tokenBudget > 0 ? std::size_t( cfg.tokenBudget ) : 0u, forBodyCeiling ); // not under a tight explicit ceiling (uncharged note) const std::string routeNoteRaw = std::move( lr.routeNote ); // verbatim; lands ONLY in route= (attribute-escaped) + the JSON twin — L1: the comment no longer echoes it const std::string mentionNote( std::move( lr.mentionNote ) ); const std::string boostNote( std::move( lr.boostNote ) ); @@ -2473,7 +2637,7 @@ std::optional runForLens( const MainDispatch& d ) // header has a MEASURED hard byte constraint (fornotesbudgetcheck), where 150 bytes of prose is // 2% of a 1550-token ceiling. Gate: test/budgetpolicycheck.sh arm (B). const bool forGateBudget = cfg.tokenBudget > 0; - const bool forBodyCeiling = cfg.maxTokens > 0 && cfg.detail > 0; // the kShapingVerbs carve-out, read once + const bool forEndLines = rw::endLinesFitCeiling( forGateBudget ? std::size_t( cfg.tokenBudget ) : 0u, forBodyCeiling ); // e= rides? if( forGateBudget ) { forConf.attrs += " budget_tokens=\"" + std::to_string( cfg.tokenBudget ) + "\""; @@ -2508,7 +2672,7 @@ std::optional runForLens( const MainDispatch& d ) // where budget_bytes= goes and for its reason (see there). // M10: --for reads git for the per-file churn= column (folded onto the bundle below, mined once in - // main.cpp's gitCoChangeAndChurnCached pass) and, before this fix, carried no anchor — an agent + // main.cpp's resolveHistoryWalk pass) and, before this fix, carried no anchor — an agent // quoting churn= into a handoff had nothing checkable to pin it to. Single-root only (same gate // flRootArg itself uses): "" on multi-root or a non-git root, same silence-means-omitted convention // every other stamped verb follows. ALWAYS present when non-empty, like confidence= — a root fact, @@ -2615,10 +2779,41 @@ std::optional runForLens( const MainDispatch& d ) // which is the residual a header built before its payload cannot avoid. const std::size_t forRowHead = std::max( std::size_t( forTopN > 0 ? forTopN : 0 ), rw::kPackTaskBodyCandidates ); bool forModScopePresent = false; - for( std::size_t i = 0; i < lensSurfaceIds.size() && i < forRowHead && !forModScopePresent; ++i ) + bool forEndLinePresent = false; + for( std::size_t i = 0; i < lensSurfaceIds.size() && i < forRowHead && !( forModScopePresent && forEndLinePresent ); ++i ) { const rw::NodeId sid = lensSurfaceIds[i]; - forModScopePresent = lensRank[sid] > 0.0f && ing.symbols[sid].kind == rw::SymKind::ModuleScope; + if( lensRank[sid] <= 0.0f ) + { + continue; + } + forModScopePresent = forModScopePresent || ing.symbols[sid].kind == rw::SymKind::ModuleScope; + forEndLinePresent = forEndLinePresent || ( forEndLines && rw::mayCarryEndLine( ing.symbols[sid] ) ); + } + // FE-B: may a row of this bundle carry via="name"? Only the sections that render can, and each one's + // node set is known before the header: the compact route's hop ids, T3's auto-body candidates, or --detail's head + // (autoBundleMode excludes --detail). Their name-only out-edges decide it — over-approximating only the byte ladder + // and the 16-per-symbol cap inside those sections, never the rows the header itself lists. + bool forViaPresent = false; + if( !g.outNameOnly.empty() ) + { + std::vector viaIds; + if( autoBundleMode && plan.compact ) + { + viaIds = forCompactHopIds( lensSurfaceIds, lensRank ); + // only the hops packHops gives a slot can carry a row (the hop-slot rule drops a hop whose every callee + // edge is name-only, so its via="name" rows never print) + std::erase_if( viaIds, [ & ]( rw::NodeId id ) { return !rw::hopSlotHasProvenEdge( g.outOff, g.outNameOnly, id ); } ); + } + else if( autoBundleMode && plan.autoBodies ) + { + viaIds = computeAutoBodyCandidateIds( ing, lensSurfaceIds, lensRank, routeAnchorDefs ); + } + else if( cfg.detail > 0 ) + { + viaIds = forDetailIds( ing, lensRank, cfg.detail, forTopN ); + } + forViaPresent = rw::namesOnlyOutAny( g.outOff, g.outNameOnly, viaIds ); } ForLensHeaderParts headerParts{ cfg.forTask, rootOpenStr, taskNote, adaptiveNote, mentionNote, boostNote, docMentionNote, sibliftNote, expandNote, floorNote, @@ -2626,7 +2821,7 @@ std::optional runForLens( const MainDispatch& d ) cfg.anchor, plan.autoBodies, plan.compact, cfg.legend == "compact", /*tailLegend=*/true, /*idRouteLegend=*/true, /*legendDropped=*/false, flRootArg, /*hdrLegend=*/!forHdrRows.empty(), forScPresent, forComposePresent, - forLegoPresent, forLayerPresent, forModScopePresent }; + forLegoPresent, forLayerPresent, forModScopePresent, forEndLinePresent, forViaPresent }; const auto buildForHeader = [ & ]( bool withRouteAttr, bool withTaskEcho, std::string_view extraNotes ) { return forLensHeaderText( headerParts, withRouteAttr, withTaskEcho, extraNotes ); }; std::string headerStr = buildForHeader( /*withRouteAttr=*/true, /*withTaskEcho=*/true, {} ); @@ -2747,7 +2942,8 @@ std::optional runForLens( const MainDispatch& d ) &forClone, testedPtr, ampPtr, redactPtr, cfg.packBudgetBytes, cfg.tokenBudget, notesPtr, legoTotal, composeTotal, routesTotal, flRootArg, - &forFileTail, d.notesDegraded } ); + &forFileTail, d.notesDegraded, forDocsAfterCode, cfg.forTask, + cfg.packTopN, forRankShapingFlags( cfg ) } ); // §B0: this early return skipped the end-of-function tally below, so a --for --json run redacted // SILENTLY — the one stderr line that tells the user a secret was in their tree never appeared. reportRedactions( stderr, redactCounts ); @@ -2793,7 +2989,7 @@ std::optional runForLens( const MainDispatch& d ) // tally). Overwritten below if the §P3×§P4 narrow-and-re-render fires, so it always reflects the set // that would actually be restored. std::size_t legoPreCapCount = 0, composePreCapCount = 0; - legoPreRendered = preRender( [ & ]( std::FILE* lm ) { packLego( lm, ing, legoScoped, lensRank, 12, redactPtr, impurePtr, kNoNode, /*withPaths=*/true, flRootArg, {}, &legoPreCapCount ); }, + legoPreRendered = preRender( [ & ]( std::FILE* lm ) { packLego( lm, ing, legoScoped, lensRank, 12, redactPtr, impurePtr, kNoNode, /*withPaths=*/true, flRootArg, {}, &legoPreCapCount, &g.implementors ); }, legoStr ); if( !legoPreRendered ) { @@ -2893,12 +3089,18 @@ std::optional runForLens( const MainDispatch& d ) const rw::ForIdRouteLegendParts idRouteParts = rw::forIdRouteLegendParts( headerParts.idRouteLegend, headerParts.scPresent, !routeNoteRaw.empty() ); const std::size_t idRouteLegendEmitted = compactLegendOn ? idRouteParts.route.size() : idRouteParts.bytes(); - const std::size_t exemptBytes = adaptiveNote.size() + autoLegendBytes + confidenceExemptBytes + tailLegendEmitted + idRouteLegendEmitted; + // e= (forsigspancheck): its legend clause is a disclosure on the same contract — exempt, so the ranked rows a ceiling + // admits are the ones it admitted without it (owner ruling: e= is exempt from the row budget). + const std::size_t endLineLegendEmitted = !headerParts.endLinePresent ? 0u + : ( compactLegendOn ? rw::kForCompactEndLineLegend.size() : rw::kForEndLineLegend.size() ); + const std::size_t exemptBytes = adaptiveNote.size() + autoLegendBytes + confidenceExemptBytes + tailLegendEmitted + idRouteLegendEmitted + + endLineLegendEmitted; if( exemptBytes > headerStr.size() ) { DISCLOSE( "runForLens: header exemptions exceed the emitted header — the sig ledger would underflow; charging the header whole" ); } std::size_t chargedHeaderBytes = exemptBytes > headerStr.size() ? headerStr.size() : headerStr.size() - exemptBytes; + chargedHeaderBytes -= std::min( chargedHeaderBytes, rw::forZeroNoteBytes( headerStr ) ); // lean-answers: the zero reading never costs a row // L1 fix round (rv-r1-L1 HIGH-1 / MED-5): THE DEFAULT NEVER CHARGES ITS SIGNATURES MORE HEADER THAN --legend=full WOULD. // The compact header now defines task=/next=/pure=/ (kForCompactLegendFacts), and charging those bytes cost the // default a signature row --legend=full carried (compactlegendcheck (P1): 19 vs 20 rows at --token-budget=3000); making @@ -2906,16 +3108,23 @@ std::optional runForLens( const MainDispatch& d ) // tokens: 995 vs 816). So the compact charge is capped at the FULL dialect's charge for the same header, computed by the // same rule: rows(default) ⊇ rows(full) by construction, and a default that pays more header than full never gets more // room for rows than full had. + std::size_t compactLedgerGapBytes = 0; // knob-honesty-068: how much MORE sig room this compact header was given than full's if( compactLegendOn ) { ForLensHeaderParts fullParts = headerParts; fullParts.compactLegend = false; - const std::size_t fullHeaderBytes = forLensHeaderText( fullParts, /*withRouteAttr=*/true, /*withTaskEcho=*/true, {} ).size(); + const std::string fullHeader = forLensHeaderText( fullParts, /*withRouteAttr=*/true, /*withTaskEcho=*/true, {} ); + const std::size_t fullHeaderBytes = fullHeader.size(); const std::size_t fullExempt = adaptiveNote.size() + enrichmentLegendBytesEmitted( plan, false ) + confidenceEarlyAttrsBytes + confidenceEarlyNoteBytes + forAtAttrStr.size() - + rw::kForFileTailLegend.size() + idRouteParts.bytes(); - const std::size_t fullCharged = fullExempt > fullHeaderBytes ? fullHeaderBytes : fullHeaderBytes - fullExempt; - chargedHeaderBytes = std::min( chargedHeaderBytes, fullCharged ); + + rw::kForFileTailLegend.size() + idRouteParts.bytes() + + ( headerParts.endLinePresent ? rw::kForEndLineLegend.size() : 0u ); + std::size_t fullCharged = fullExempt > fullHeaderBytes ? fullHeaderBytes : fullHeaderBytes - fullExempt; + fullCharged -= std::min( fullCharged, rw::forZeroNoteBytes( fullHeader ) ); // lean-answers: exempt, as above + chargedHeaderBytes = std::min( chargedHeaderBytes, fullCharged ); + // knob-honesty-068, train 26b: the gap is read AFTER both exemptions (the e= clause and lean's zero note) on + // BOTH headers, so it measures only the sig room the compact dialect was given beyond its honest cost + compactLedgerGapBytes = fullCharged - chargedHeaderBytes; // >= 0 by the min above } const std::size_t fixedBytes = chargedHeaderBytes + legoStr.size() + composeStr.size() + routeStr.size() + 6; // + "" // the auto bundle's SECTION SPLIT — the sig side's claim is capped so an explicit ceiling wider @@ -2975,8 +3184,30 @@ std::optional runForLens( const MainDispatch& d ) // headerStr is already flushed to stdout by the time that path runs and cannot be edited retroactively // (the same reason est_tokens is "omitted", not "wrong", on that path — see its DISCLOSE). std::size_t forDroppedPositive = 0; + // knob-honesty-068: a capped names the call that serves it uncut (serialize.h SigsCutContinuation, where the + // two regimes are argued): CHARGED exactly when an explicit ceiling's sig side is the whole ceiling (nothing + // downstream can pay, and the ceiling is hard); exempt at the default and when the sig side is frozen at the + // default's share (forSigSideCeiling), so those ranked sets — and forbudgetmonotoncheck's identity — are untouched. + // A charged one is paid from the rows byte for byte (serialize.h paySigsContinuationFromRows), so a capped bundle never + // grows past the bytes the cut alone left it. THE COMPACT DIALECT ALSO PAYS FROM FULL'S ROOM: its sig ledger charges + // less header than it emits and leans on the header rungs to land inside the ceiling, so a charged one also reserves + // the room the compact charge was given beyond full's (compactLedgerGapBytes) — in the plan, so only a block the ladder + // already trims pays it: a PAID capped compact answer serves the rows the full one does and lands no further over its + // ceiling (compactlegendcheck P4). (Rows a PAID full answer would serve: where full's UNPAID answer fits and the compact + // one's does not — a compact header larger than full's, round 3 — full keeps a row the compact answer pays; that is the + // one exemption compactlegendcheck (P1-B) rules, orchestrator option B.) An uncapped answer is untouched. AT THE FLOOR (rank 1..4, nothing left to shed) the + // handle still ships and an overshoot is labelled (rootFinish.sigsUnpaidOver below; orchestrator ruling 2026-10-07). + // AND ONLY WHERE PAYING IS WHAT MAKES IT FIT (ruling C3, completed 2026-10-08): runForLens renders unpaid first + // (sigsNextPayFromRows=false: no ledger gap, no payment — the rows the cut alone leaves) and pays only when that + // answer lands past its ceiling and the paid one does not. + const bool forSigsNextCharged = cfg.tokenBudget > 0 && sigSideCeiling == bundleBudget; + const std::string forRankFlags = forRankShapingFlags( cfg ); + const rw::SigsCutContinuation forSigsNext{ cfg.forTask, cfg.packTopN, forRankFlags, fixedBytes, forSigsNextCharged, /*json=*/false, + /*pasteHandle=*/true, /*ledgerGapBytes=*/forSigsNextCharged ? compactLedgerGapBytes : 0u, + sigsNextPayFromRows }; bool forSigsCapped = false; // did the H1 ladder trim ? — decides the budget_bytes= legend clause below rw::SigsCutReport forSigsCut; // cut-fix lane A: the tag's shown/total/docs_dropped — its clauses below + forSigsCut.continuationRequest = &forSigsNext; // knob-honesty-068: IN — the capped block's continuation sigsPreRendered = preRender( [ & ]( std::FILE* sm ) { packSignatures( sm, ing, lensRank, forTopN, cfg.packBudgetBytes, true, fanInPtr, impurePtr, redactPtr, @@ -2985,12 +3216,14 @@ std::optional runForLens( const MainDispatch& d ) sigsBudget, // H1: global payload budget (trim ladder; payload="capped" marker) notesPtr, // L3: field-notes surfacing (inert when null) flRootArg, // R-E: root-relative p= - /*hasRelevanceFloor=*/true, // LB-A: shrink past the zero-score tail, never pad + rw::forLensRules( forDocsAfterCode, forEndLines ), // LB-A floor, e= (endLinesFitCeiling), docs reorder &forDroppedPositive, // A2: exact count, see droppedPositiveCount &shownSigIds, // lane 2: the rows actually emitted — the tail excludes THESE files &forSigsCapped, // did the ladder fire? — the budget_bytes= clause rides only then forTopRowNext, // L-W: the widening page on a thin answer, else the body - &forSigsCut ); // cut-fix lane A: which cut readings the tag owes + &forSigsCut, // cut-fix lane A: which cut readings the tag owes; + // knob-honesty-068: carries forSigsNext IN + SigRowSpelling{ .elideZeroMetrics = true } ); // lean-answers lane: zero cx=/ccx=/in= omitted (legend: absent = 0) }, sigsStr ); if( !sigsPreRendered ) @@ -3069,7 +3302,7 @@ std::optional runForLens( const MainDispatch& d ) // cut-fix lane A: the tag's two cut readings (docs_dropped=, shrunk-not-dropped) ride the same splice, present // only when the tag carries the case (serialize.h sigsCutLegendNotes), so the reserve below already covers them. const std::string sigsCeilingNote = ( forDefaultCeiling ? std::string( rw::kForBudgetBytesNote ) : std::string() ) - + rw::sigsCutLegendNotes( forSigsCut.isCapped, forSigsCut.shown, forSigsCut.total, forSigsCut.docsDropped ); + + rw::sigsCutReportLegend( forSigsCut ); // + the continuation's and the docs reorder's readings (docs_after_code=) // ── the INDEXING-cap disclosure (mention.h CapDisclosure), at the same splice point and for the // same reason: a --for header is charged against the payload ceiling, so a disclosure folded into // the notes above is paid for in ranked rows. Measured on sixteen real invocations, that cost three @@ -3091,7 +3324,7 @@ std::optional runForLens( const MainDispatch& d ) if( sigsPreRendered && legoPreRendered && !legoStr.empty() && narrowLegoToRenderedSigs( ing, legoScoped, sigsStr, flRootArg.empty() ? std::string_view() : rw::sarif::rootPrefixOf( flRootArg ) ) ) { - legoStr = captureXml( [ & ]( std::FILE* f ) { packLego( f, ing, legoScoped, lensRank, 12, redactPtr, impurePtr, kNoNode, /*withPaths=*/true, flRootArg, {}, &legoPreCapCount ); } ); + legoStr = captureXml( [ & ]( std::FILE* f ) { packLego( f, ing, legoScoped, lensRank, 12, redactPtr, impurePtr, kNoNode, /*withPaths=*/true, flRootArg, {}, &legoPreCapCount, &g.implementors ); } ); } // L2 (round-1 lever B1, §9.3 disclosed cut): collapse / to a counted stub BY DEFAULT — @@ -3157,6 +3390,12 @@ std::optional runForLens( const MainDispatch& d ) { sectionsStubNote = rw::kForSectionStubLegend; } + // count-floor: the lego COUNT clause rides only on a document whose served carries its attributes (a short + // list or a floor) — the same post-render, present-only splice, so an answer without them keeps every byte. + if( legoPreRendered && !legoWillStub && legoStr.find( rw::kLegoCountAttrPrefix ) != std::string::npos ) + { + sectionsStubNote += rw::kForLegoCountLegend; + } if( legoPreRendered && legoWillStub ) { legoStr = legoPricing.stubXml; @@ -3205,15 +3444,7 @@ std::optional runForLens( const MainDispatch& d ) // the lens. N=0 emits nothing → byte-identical to a run without --detail. if( cfg.detail > 0 ) { - const std::size_t S = ing.symbols.size(); - detailIds.resize( S ); - for( NodeId i = 0; i < S; ++i ) - { - detailIds[i] = i; - } - rw::sortutil::radixSortByScoreDescId( detailIds, lensRank ); // (score desc, id asc) — same order as the sigs - const std::size_t detN = std::min( { std::size_t( cfg.detail ), std::size_t( forTopN ), S } ); - detailIds.resize( detN ); + detailIds = forDetailIds( ing, lensRank, cfg.detail, forTopN ); // (score desc, id asc) — same order as the sigs // Composes with --max-tokens: when set, it bounds the body byte budget (same conservative rate the // map path uses). §F1: --token-budget SHAPES this lens (D10 — trims to fit, always exit 0), so it // has to bound the bodies as well; before this it bounded ONLY and the bodies rode along on @@ -3233,9 +3464,9 @@ std::optional runForLens( const MainDispatch& d ) detailBodyBudget = std::min( detailBodyBudget, leftBytes ); } detailSection = rw::chargeSection( [ & ]( std::FILE* f ) - { packBodies( f, ing, detailIds, detailBodyBudget, g.outOff, g.outTargets, cfg.compress, redactPtr, + { packBodies( f, ing, detailIds, detailBodyBudget, g.outOff, g.outTargets, g.outNameOnly, cfg.compress, redactPtr, /*ranges=*/nullptr, notesPtr, /*outEmitted=*/nullptr, /*truncateOversizedFirst=*/true, - /*withFileContext=*/false, flRootArg, &lensRank ); }, // L3: --detail bodies surface notes too (part of the --for bundle) + /*withFileContext=*/false, flRootArg, &lensRank, headerParts.viaPresent ); }, // L3: --detail bodies surface notes too (part of the --for bundle) rw::kBytesPerTokenBody ); if( !detailSection.isRendered ) { @@ -3262,10 +3493,11 @@ std::optional runForLens( const MainDispatch& d ) // reserved ahead of the body walk (the kAutoAttrReserve pattern) so // the disclosure always fits; the DEFAULT regime reserves nothing — // the tail rides on top there and the bodies stay byte-identical. - bundleBudget ); + bundleBudget, headerParts.viaPresent ); if( enrich.surfaceOff || enrich.legendOff ) { headerParts.autoBundle = headerParts.compactBundle = false; + headerParts.viaPresent = false; // FE-B: the section that could carry it is gone (autoBundleMode excludes --detail) headerStr = buildForHeader( /*withRouteAttr=*/true, /*withTaskEcho=*/true, {} ); } } @@ -3456,6 +3688,24 @@ std::optional runForLens( const MainDispatch& d ) /*hasRouteAttr=*/!routeNoteRaw.empty(), kNotes ); headerStr = std::move( chosen.header ); rootFinish.lastRungFired = ( chosen.rung == rw::CeilingRung::OverCeiling ); + // knob-honesty-068 (orchestrator rulings 2026-10-07): the recovery handle ALWAYS ships. Read off the FINISHED + // document (the header this ladder chose + every byte stdout receives): does it land past its ceiling — the byte + // allowance, or the token budget the root names? The label only adds bytes, so a document over before it is over + // after it. + const ForLensPricedHeader asChosen = finishForLensHeaderPriced( headerStr, rootFinish ); + const bool landsOver = asChosen.header.size() + emittedNonHeaderBytes > ladderCeiling + || asChosen.estTokens > cfg.tokenBudget; + // C3 (completed): a capped, charged answer that lands past its ceiling in THIS payment mode is not served from this + // run — nothing has reached stdout yet: hand back and let runForLens try the other mode (unpaid → paid: does paying + // make it fit? paid → unpaid: it did not, so no row is dropped for the handle). + if( sigsNextTryOther != nullptr && forSigsNextCharged && forSigsCut.hasContinuation && ( landsOver || rootFinish.lastRungFired ) ) + { + *sigsNextTryOther = true; + return std::nullopt; + } + // the handle rode unpaid (the floor could not give its bytes up, or this is the unpaid run) and the document lands + // past its ceiling: over_ceiling="1" + kForSigsUnpaidOverCeilingNote + rootFinish.sigsUnpaidOver = forSigsCut.continuationUnpaidBytes > 0 && landsOver; } headerStr = finishForLensHeader( std::move( headerStr ), rootFinish ); @@ -3469,10 +3719,14 @@ std::optional runForLens( const MainDispatch& d ) } else { + rw::SigsCutReport degradeSigsCut; // knob-honesty-068: carries the continuation request IN, as on the buffered path + degradeSigsCut.continuationRequest = &forSigsNext; packSignatures( stdout, ing, lensRank, forTopN, cfg.packBudgetBytes, true, fanInPtr, impurePtr, redactPtr, &forChurn, &forClone, testedPtr, ampPtr, /*rankAdaptivePayload=*/true, sigsBudget, notesPtr, flRootArg, - /*hasRelevanceFloor=*/true, nullptr, nullptr, nullptr, // LB-A: the direct-emission degrade path selects identically - forTopRowNext ); // L-W: same next= rule on the degrade path + rw::forLensRules( forDocsAfterCode, forEndLines ), nullptr, nullptr, nullptr, // the degrade path selects identically + forTopRowNext, // L-W: same next= rule on the degrade path + °radeSigsCut, // knob-honesty-068: and the same + SigRowSpelling{ .elideZeroMetrics = true } ); // lean-answers lane: the same row spelling } if( legoPreRendered ) { @@ -3485,7 +3739,7 @@ std::optional runForLens( const MainDispatch& d ) // the same bytes --sections=lego would restore. Same scope+identity (§P3; un-narrowed — the sigs // bytes are unknown here). legoStubTotal == 0 emits nothing, packLego's own no-op. ASSUME( !legoWillStub, "runForLens: an unmeasured lego section was marked for collapse" ); - packLego( stdout, ing, legoScoped, lensRank, 12, redactPtr, impurePtr, kNoNode, /*withPaths=*/true, flRootArg ); + packLego( stdout, ing, legoScoped, lensRank, 12, redactPtr, impurePtr, kNoNode, /*withPaths=*/true, flRootArg, {}, nullptr, &g.implementors ); } if( composePreRendered ) { @@ -3517,10 +3771,10 @@ std::optional runForLens( const MainDispatch& d ) // directly at the SAME budget — uncharged for that one run, with an alert, never a fabricated number. if( cfg.detail > 0 ) { - rw::emitChargedSection( stdout, detailSection, [ & ]{ packBodies( stdout, ing, detailIds, detailBodyBudget, g.outOff, g.outTargets, + rw::emitChargedSection( stdout, detailSection, [ & ]{ packBodies( stdout, ing, detailIds, detailBodyBudget, g.outOff, g.outTargets, g.outNameOnly, cfg.compress, redactPtr, /*ranges=*/nullptr, notesPtr, /*outEmitted=*/nullptr, /*truncateOversizedFirst=*/true, - /*withFileContext=*/false, flRootArg, &lensRank ); } ); + /*withFileContext=*/false, flRootArg, &lensRank, headerParts.viaPresent ); } ); } else if( autoSection.isRendered && !autoSection.xml.empty() ) { @@ -3545,6 +3799,49 @@ std::optional runForLens( const MainDispatch& d ) return std::nullopt; } +// The --for lens (orchestrator ruling C3, completed 2026-10-08: drop a row to pay for a charged next= ONLY when dropping +// it is what makes the answer fit). The ranking runs once; the render runs in up to three payment modes over it, each deciding +// on its FINISHED document before the first byte reaches stdout: +// 1. unpaid: every row the cut leaves + the handle. Fits (or nothing is capped and charged): served, no row dropped. +// 2. paid: rows dropped to pay for the handle. Fits: served, because paying is what made it fit. +// 3. unpaid again, served whatever its size: it cannot fit either way, so it keeps the cut's rows, the handle rides unpaid, +// over_ceiling="1" + kForSigsUnpaidOverCeilingNote. +// So the served answer never has fewer rows than an alternative that also fits. The redaction tally is put back to its entry +// value before every render, so the summary counts the rows written once. A render that hands back wrote nothing. +std::optional runForLens( const MainDispatch& d ) +{ + const rw::Config& cfg = d.cfg; + if( cfg.forTask.empty() ) + { + return std::nullopt; + } + rw::LensRanking lr = computeLensRanking( d, cfg.forTask, forCompactPosture( cfg ), + /*fullDistribution=*/!cfg.candidates ); // deep-tail: the bundle serves the file-grain tail; candidates has no tail and keeps the H2 pruning + // only an explicit --token-budget charges the handle (forSigsNextCharged), so only then can a render hand back + if( cfg.tokenBudget == 0 ) + { + return runForLensPass( d, std::move( lr ), /*sigsNextPayFromRows=*/true, nullptr ); + } + const rw::RedactCounts tallyAtEntry = d.redactCounts; + bool unpaidOver = false; + std::optional rc = runForLensPass( d, lr, /*sigsNextPayFromRows=*/false, &unpaidOver ); + if( !unpaidOver ) + { + return rc; + } + ASSUME( !rc ); // the unpaid render that handed back wrote nothing + d.redactCounts = tallyAtEntry; + bool paidOver = false; + rc = runForLensPass( d, lr, /*sigsNextPayFromRows=*/true, &paidOver ); + if( !paidOver ) + { + return rc; + } + ASSUME( !rc ); // the paid render that handed back wrote nothing + d.redactCounts = tallyAtEntry; + return runForLensPass( d, std::move( lr ), /*sigsNextPayFromRows=*/false, nullptr ); +} + std::optional runTargetedViews( const MainDispatch& d ) { using namespace rw; @@ -3589,9 +3886,10 @@ std::optional runTargetedViews( const MainDispatch& d ) // graphUnindexedLegendComment) because kLegoLegend is one closed literal: the attribute below is // conditional on g.unindexedFiles, so its definition has to be too. // H1: the unproven_defs= clause takes the same route for the same reason (graphlegend.h unprovenDefsVerbComment). - rw::emitTo( stdout, "{}{}{}{}", rw::ctxRootOpen( {}, {}, tvRootArg ).c_str(), rw::kLegoLegend, + rw::emitTo( stdout, "{}{}{}{}{}", rw::ctxRootOpen( {}, {}, tvRootArg ).c_str(), rw::kLegoLegend, rw::graphUnindexedLegendComment( rw::graphGaugeClauses( g ) ).c_str(), - rw::unprovenDefsVerbComment( rw::UnprovenDefsVerb::Lego, legoUnprovenDefs > 0, "", - ex( reqNote ).c_str(), kindNote.c_str(), symTag( pick.targetKind ), rw::kExemplarSelectionRule ); + "list is complete). Copy its shape, not its text.{} -->", + ex( reqNote ).c_str(), kindNote.c_str(), symTag( pick.targetKind ), rw::kExemplarSelectionRule, + exemplarVia ? rw::forViaNameClause() : std::string() ); // R-E fix (2026-08-19): root= — same reason as --lego above. p= went root-relative in the first R-E // landing with no attribute naming the root, on the one verb whose whole job is "open this file". const std::string exemplarRootAttr = tvSingleRoot ? ( " root=\"" + ex( tvRootArg ) + "\"" ) : std::string(); @@ -3667,9 +3968,9 @@ std::optional runTargetedViews( const MainDispatch& d ) fin( pick.winner ), wsym.ccx, exemplarRootAttr.c_str(), ts( pick.winner ) ? " tested=\"1\"" : "", pick.lowConfidence ? " low_confidence=\"1\"" : "", pick.overCcxBar ? " over_ccx_bar=\"1\"" : "" ); - packBodies( stdout, ing, { pick.winner }, cfg.packBudgetBytes, g.outOff, g.outTargets, cfg.compress, redactPtr, + packBodies( stdout, ing, { pick.winner }, cfg.packBudgetBytes, g.outOff, g.outTargets, g.outNameOnly, cfg.compress, redactPtr, /*ranges=*/nullptr, /*noteIndex=*/nullptr, /*outEmitted=*/nullptr, /*truncateOversizedFirst=*/true, - /*withFileContext=*/false, tvRootArg ); + /*withFileContext=*/false, tvRootArg, /*calleeRank=*/nullptr, exemplarVia ); rw::emitRaw( stdout, "" ); reportRedactions( stderr, redactCounts ); return 0; diff --git a/src/verbs_grep.h b/src/verbs_grep.h index b8db5adc4..ee9285689 100644 --- a/src/verbs_grep.h +++ b/src/verbs_grep.h @@ -102,15 +102,21 @@ void emitGrepHandleLegend( bool enabled ) "means no content hash could be proven. -->" ); } -void emitGrepEncRows( const rw::IngestResult& ing, const rw::Graph& g, std::span hits, - const GrepEncOptions& opt ) +void emitGrepEncRows( const rw::IngestResult& ing, const rw::Graph& g, std::span rows, + std::span floors, const GrepEncOptions& opt ) { using namespace rw; GrepHandleAttrs handleAttrs( ing, g, opt.handles, opt.root ); - for( const GrepEncRow& row : grepEnclosingRows( ing, g, hits ) ) + EXPECTS( floors.size() == rows.size() ); // emitGrepReport computes both from the same page (grepEncFloors) + for( std::size_t r = 0; r < rows.size(); ++r ) { + const GrepEncRow& row = rows[r]; const auto en = rw::escapeXml( row.chain, opt.esc ); rw::emitTo( stdout, " 1 ) { rw::emitTo( stdout, " defs=\"{}\"", row.defCount ); @@ -144,6 +150,28 @@ void emitGrepEncRows( const rw::IngestResult& ing, const rw::Graph& g, std::span } } +// count-floor: callers= beside evidence of a miss for THAT row is a floor (countfloor.h). Computed once per page, before +// the legend, so the full dialect defines callers_floor= exactly when a row carries it; the MCP twin calls the same +// function over the same rows (mcpverbs.h grepEnclosingJson), so the two surfaces cannot disagree. +std::vector grepEncFloors( const rw::IngestResult& ing, const rw::Graph& g, std::span rows ) +{ + std::vector> rowIds; + rowIds.reserve( rows.size() ); + for( const rw::GrepEncRow& row : rows ) + { + rowIds.push_back( row.ids ); + } + return rw::callerFloors( ing, g, rowIds ); +} + +// count-floor (countfloor.h): the full dialect's reading of a row's own floor marker — no attribute=value literal (this +// legend's rule) — spliced only when a row on the page carries it, so a page without one keeps every byte. +inline constexpr const char* kGrepEncFloorClause = + "callers_floor= (value 1, present only on such a row) marks a count the index holds evidence of a miss for: a declined or " + "unbound call spelled like the name, a use as a value, or a kind used by reading or naming it (a variable, a class, an " + "interface); it is not proof more callers exist, and its absence means the index holds no such evidence. floor_next= is " + "the call that lists what that count could not see (the uses verb, or this literal scan where reads are never indexed); "; + // R1a emission, lifted out of emitGrepReport for the same reason. What to suggest is // search.h's grepZeroHitSuggestions (shared with the MCP twin); this is pure serialization. void emitGrepSuggest( const rw::IngestResult& ing, const std::string& pat, bool regex, std::vector& esc ) @@ -668,6 +696,9 @@ int emitGrepReport( const rw::Config& cfg, const rw::IngestResult& ing, const rw const PageWindow grepPage = pageWindow( hitCount, rowCap, cfg.pageOffset ); const std::vector hits = grepEnrich( ing, std::span( found.raw ).subspan( grepPage.begin, grepPage.end - grepPage.begin ), cfg.grepBefore, cfg.grepAfter ); + const std::vector encRows = grepEnclosingRows( ing, g, std::span( hits ) ); // R1b, before the legend + const std::vector encFloors = grepEncFloors( ing, g, encRows ); + const bool anyEncFloor = std::ranges::any_of( encFloors, []( const CallerFloor& f ) { return f.isFloor; } ); std::vector esc; const auto ex = [ & ]( std::string_view s ) -> std::string { return std::string( escapeXml( s, esc ) ); }; // CDATA-safe a context block: split any ]]> (would prematurely close the CDATA) and scrub XML-illegal @@ -815,6 +846,7 @@ int emitGrepReport( const rw::Config& cfg, const rw::IngestResult& ing, const rw // parse this verb's header counters by grep, and a quoted numeric example here would be matched first — the quality-delta legend's rule. "After the hit rows, rows list each DISTINCT enclosing symbol NAME of THIS page (first-appearance order, bounded by the page) with callers= its 1-hop DISTINCT-caller count, " "unioned across same-named defs like the callers verb (a FLOOR — dynamic dispatch contributes no edge), defs= how many defs the name grouped (only when more than one), cx= complexity; " + "{}" // count-floor: kGrepEncFloorClause, present-only (a row on this page carries callers_floor=) "amp=/tested= join only when a metrics co-run already computed that lens. On a zero-hit answer a element may follow: SUGGESTIONS, never matches — near= the nearest indexed " "symbol name (did-you-mean), next= a ready-to-paste conceptual fallback; absent for regex/non-word-like patterns or when nothing plausible exists. " // T1: the completeness claim, defined where it appears (no attribute=value literal in these @@ -867,7 +899,7 @@ int emitGrepReport( const rw::Config& cfg, const rw::IngestResult& ing, const rw // P3 (L7): next= on the root, defined where the reader meets it "next= is the one pasteable follow-up: the at verb on the top hit; the next page (compact legend) when cut; " "the conceptual lens on a zero-hit answer. " - "{} -->", rw::kPageRaiseCapClause ); + "{} -->", anyEncFloor ? kGrepEncFloorClause : "", rw::kPageRaiseCapClause ); } emitGrepHandleLegend( cfg.grepHandles ); // G3: terms=/scope=/suppressed= — only when AND/NOT was actually given, so a plain --grep answer @@ -1008,7 +1040,7 @@ int emitGrepReport( const rw::Config& cfg, const rw::IngestResult& ing, const rw // ── R1b: the block — the map's context on the answer, no second call (helper above) ────── const GrepEncOptions encOpt{ amp, tested, cfg.grepHandles, cfg.roots.size() == 1 ? cfg.roots[0] : std::string_view(), esc }; - emitGrepEncRows( ing, g, std::span( hits ), encOpt ); + emitGrepEncRows( ing, g, encRows, encFloors, encOpt ); // ── R1a: the zero-hit follow-up — suggestions, labeled as such, never matches (helper above) ─── // §R-J: also suppressed when the AUX block found the pattern — a real hit in queries/cpp/tags.scm is not diff --git a/src/verbs_lint.h b/src/verbs_lint.h index 008fcaa48..5e31c6472 100644 --- a/src/verbs_lint.h +++ b/src/verbs_lint.h @@ -5,6 +5,7 @@ #include // std::errc — from_chars' result #include // stdout / stderr — the two streams the shims below name #include // std::format_string — the shims' format contract (see test/printffmtparitycheck.sh) +#include "nextverb.h" // nextFlag — findings_next='s pasteable spelling (knob-honesty-068) #include "infra/emit.h" // rw::emitTo — THE emitter: std::print where the library has , std::format+fputs // where it does not, chosen by feature test and DISCLOSED as emit= on --version. The // pilot conversion used to spell the fallback here; the choice now lives in one header. @@ -497,6 +498,61 @@ RuleTally tallyLintRule( const std::vector& outs, const std::string& ru // reason: mergeAtomsPack below fills it too. struct RuleCap { std::string rule; bool isUserRule; }; +// knob-honesty-068: THE FLOORED ANSWER'S CONTINUATION. A rule that spent its per-rule budget (count_capped="1") used to be a +// dead end: disclosed as a floor, with no call that counts the rest, because the budget had no flag. findings_next= is that +// call — the floored rules ONLY (by --lint-select, so the re-run spends nothing on rules that were already totals), under a +// 10x budget (lintNextMaxPerRule), keeping --lint-rules=DIR when a user rule floored and --naming-locals when it was on (it +// changes the naming-* counts), and --sarif on a SARIF run (its findingsNext). A re-run that is still floored names its own, +// so the chain ends. `emitted` is the H8 +// predicate: a rule the selection dropped never floors this answer, so it never joins the call. "" ⇒ nothing floored. +// Spelled IN FULL whatever its length (nextverb.h: a next= is never dropped or truncated); nextFlag quotes a value a +// shell would split (a rules directory with a space, a user rule id with a quote). +template +std::string lintFindingsNext( const rw::Config& cfg, const std::vector& saturatedRules, EmittedFn&& emitted, std::size_t spentPerRule ) +{ + std::vector names; + names.reserve( saturatedRules.size() ); + for( const RuleCap& rc : saturatedRules ) + { + if( emitted( rc ) && std::find( names.begin(), names.end(), rc.rule ) == names.end() ) + { + names.push_back( rc.rule ); // a built-in and a user rule of one name select as one PREFIX + } + } + if( names.empty() ) + { + return {}; + } + std::string joined; + for( const std::string& n : names ) + { + joined += joined.empty() ? "" : ","; + joined += n; + } + std::string inv; + if( cfg.lint ) + { + inv += "--lint"; + } + if( !cfg.lintRulesDir.empty() ) + { + inv += inv.empty() ? "" : " "; + inv += rw::nextFlag( "--lint-rules=", cfg.lintRulesDir ); + } + if( cfg.namingLocals ) + { + inv += " --naming-locals"; + } + inv += " " + rw::nextFlag( "--lint-select=", joined ); + inv += " --lint-max-per-rule=" + std::to_string( rw::lintNextMaxPerRule( spentPerRule, std::size_t( rw::kLintMaxPerRuleMost ) ) ); + if( cfg.sarif ) + { + inv += " --sarif"; // the SARIF run's findingsNext re-runs in the dialect it was read from (rv-knob-honesty-068 N4) + } + ENSURES( !inv.empty() && inv.front() == '-', "a findings_next= is a pasteable flag list (--lint or --lint-rules leads)" ); + return inv; +} + // --with-profile (the SYZYGY advice-mode pairing: static shape × PMU weight — Hundt et al., CGO 2006, // already cited in fieldaffinity.h): one parsed row of the #PROF_TSV block a RIPWIRE_PROFILE build's // report emits (profileScope.h::print_tsv — scope, file, line, then whatever data columns that run's @@ -705,15 +761,36 @@ buildHeatAnnotations( std::string_view withProfile, const rw::IngestResult& ing, // passes that run after it -- lintSymbolLevelChecks and the naming lens -- each opened the very files this // walk had just read and closed, one at a time on the main thread, to look at spans of the same text. They // now read from here and fall back to their own open only for a file the walk skipped. +// knob-honesty-068: the atoms/cache packs collect under an ENGINE budget 20x the per-rule one (kAtomsQueryBudget / +// kCacheQueryBudget = 100000 against kLintMaxPerRule = 5000), because their exclusion and loop-context streams must outrun +// the rows they emit. A raised --lint-max-per-rule keeps that ratio, or a pack row would stay floored by an engine budget +// the flag cannot reach and its findings_next= would promise a total no re-run delivers. At the default this is exactly +// the two constants, so a default run is byte-identical. +inline std::size_t lintPackQueryBudget( std::size_t packDefault, std::size_t maxPerRule ) noexcept +{ + constexpr std::size_t kEngineRatio = 20; + static_assert( rw::atoms::kAtomsQueryBudget == kEngineRatio * rw::kLintMaxPerRule && rw::cachelint::kCacheQueryBudget == kEngineRatio * rw::kLintMaxPerRule, + "lintPackQueryBudget keeps the packs' default engine:per-rule ratio — re-derive kEngineRatio with the constants" ); + // CodeRabbit 5468003465: the flag's ceiling keeps this product, and the 10x continuation's, inside size_t (a 32-bit + // size_t wrapped at --lint-max-per-rule=300000000); the continuation never names a value the flag refuses. + static_assert( std::size_t( rw::kLintMaxPerRuleMost ) <= std::numeric_limits::max() / ( kEngineRatio * 10 ), + "--lint-max-per-rule's ceiling keeps the pack budget and its 10x continuation representable" ); + static_assert( rw::lintNextMaxPerRule( std::size_t( rw::kLintMaxPerRuleMost ) / 10 + 1, std::size_t( rw::kLintMaxPerRuleMost ) ) + == std::size_t( rw::kLintMaxPerRuleMost ), + "findings_next= saturates at the flag's own ceiling, never above it" ); + EXPECTS( maxPerRule <= std::size_t( rw::kLintMaxPerRuleMost ), "the per-rule budget is a parsed --lint-max-per-rule (cli.h refuses above its ceiling) or the default" ); + return maxPerRule > packDefault / kEngineRatio ? maxPerRule * kEngineRatio : packDefault; +} + std::vector> builtInLintCaptures( const rw::IngestResult& ing, const std::vector& checks, - std::vector& keptBytes ) + std::vector& keptBytes, std::size_t maxPerRule = rw::kLintMaxPerRule ) { PROFILE_SCOPE_DESCRIBE( "lint: astQueryGrouped (built-in + atoms + cache + unreachable)" ); const std::vector atomChecks = rw::atoms::atomsSpecs(); const std::vector cacheChecks = rw::cachelint::cacheSpecs(); - return rw::astQueryGrouped( ing, { { &checks, rw::kLintMaxPerRule, nullptr }, - { &atomChecks, rw::atoms::kAtomsQueryBudget, nullptr }, - { &cacheChecks, rw::cachelint::kCacheQueryBudget, nullptr }, + return rw::astQueryGrouped( ing, { { &checks, maxPerRule, nullptr }, + { &atomChecks, lintPackQueryBudget( rw::atoms::kAtomsQueryBudget, maxPerRule ), nullptr }, + { &cacheChecks, lintPackQueryBudget( rw::cachelint::kCacheQueryBudget, maxPerRule ), nullptr }, { nullptr, rw::kUnreachableMaxHits, nullptr, rw::AstWalk::UnreachableCode } }, &keptBytes ); } @@ -721,9 +798,10 @@ std::vector> builtInLintCaptures( const rw::IngestResu // the pack can emit. Lifted out of runLint for the same reason lintSymbolLevelChecks was. void mergeAtomsPack( const rw::IngestResult& ing, std::vector& ms, std::vector& saturatedRules, std::vector& allRuleNames, - std::vector captures ) + std::vector captures, std::size_t maxPerRule ) { - const rw::atoms::AtomsRun pack = rw::atoms::atomsOfConfusionFromCaptures( ing, rw::kLintMaxPerRule, std::move( captures ) ); + const rw::atoms::AtomsRun pack = rw::atoms::atomsOfConfusionFromCaptures( ing, maxPerRule, std::move( captures ), + lintPackQueryBudget( rw::atoms::kAtomsQueryBudget, maxPerRule ) ); for( const rw::AstMatch& hit : pack.findings ) { ms.push_back( hit ); } for( const std::string& tag : pack.saturatedTags ) { saturatedRules.push_back( { tag, false } ); } for( const std::string_view rule : rw::atoms::kAtomRuleNames ) { allRuleNames.emplace_back( rule ); } @@ -734,9 +812,10 @@ void mergeAtomsPack( const rw::IngestResult& ing, std::vector& ms, // disclosures, and its rule names for the tally. Same shape as mergeAtomsPack for the same reasons. void mergeCachePack( const rw::IngestResult& ing, std::vector& ms, std::vector& saturatedRules, std::vector& allRuleNames, - std::vector captures ) + std::vector captures, std::size_t maxPerRule ) { - const rw::cachelint::CacheRun pack = rw::cachelint::cacheFriendliness( ing, rw::kLintMaxPerRule, std::move( captures ) ); + const rw::cachelint::CacheRun pack = rw::cachelint::cacheFriendliness( ing, maxPerRule, std::move( captures ), + lintPackQueryBudget( rw::cachelint::kCacheQueryBudget, maxPerRule ) ); for( const rw::AstMatch& hit : pack.findings ) { ms.push_back( hit ); } for( const std::string& tag : pack.saturatedTags ) { saturatedRules.push_back( { tag, false } ); } for( const std::string_view rule : rw::cachelint::kCacheRuleNames ) { allRuleNames.emplace_back( rule ); } @@ -748,9 +827,9 @@ void mergeCachePack( const rw::IngestResult& ing, std::vector& ms, // the naming rules are symbol-level built-ins that were declared there before the pack existed. Lifted out // of runLint for the same reason mergeAtomsPack and lintSymbolLevelChecks were. void mergeNamingLens( const rw::IngestResult& ing, std::vector& ms, std::vector& saturatedRules, bool namingLocals, - const std::vector* preRead ) + const std::vector* preRead, std::size_t maxPerRule ) { - for( std::string& namingRule : rw::naminglens::appendNamingFindings( ing, rw::kLintMaxPerRule, ms, namingLocals, preRead ) ) + for( std::string& namingRule : rw::naminglens::appendNamingFindings( ing, maxPerRule, ms, namingLocals, preRead ) ) { saturatedRules.push_back( { std::move( namingRule ), false } ); } @@ -1032,6 +1111,9 @@ void emitRunLintSarif( const MainDispatch& d, props.totalCount = lintSel.totalCount; props.select.assign( cfg.lintSelect ); props.ignore.assign( cfg.lintIgnore ); + props.findingsNext = lintFindingsNext( cfg, saturatedRules, [ & ]( const RuleCap& rc ) + { return !lintSel.active || rw::lintcatalog::lintSelectionKeeps( lintSel, rc.rule ); }, + rw::lintMaxPerRuleOf( cfg.lintMaxPerRule ) ); rw::sarif::emitLintSarif( stdout, sarifRules, sarifFindings, props, d.root ); } @@ -1555,6 +1637,8 @@ std::optional runLint( const MainDispatch& d ) std::vector ms; // combined findings (built-in tags + user rule ids); shared by both sources std::vector saturatedRules; // see the RuleCap declaration at file scope for why the key is a PAIR + // knob-honesty-068: the per-rule budget this run spends (--lint-max-per-rule=N, else kLintMaxPerRule) + const std::size_t lintMaxPerRule = rw::lintMaxPerRuleOf( cfg.lintMaxPerRule ); // All BUILT-IN rule names in declaration order — drives the per-rule tally in the XML header. // Symbol-level checks are appended after the query-based checks; order matches the conceptual list. @@ -1589,7 +1673,7 @@ std::optional runLint( const MainDispatch& d ) // The corpus text the one grouped walk read, kept alive for the symbol-level passes below so they // do not re-open the same files a second and third time. Lives exactly as long as this lint block. std::vector corpusBytes; - std::vector> grouped = builtInLintCaptures( ing, checks, corpusBytes ); + std::vector> grouped = builtInLintCaptures( ing, checks, corpusBytes, lintMaxPerRule ); ms = std::move( grouped[0] ); for( const AstQuerySpec& check : checks ) // saturation is measured on the RAW captures, before the post-filters below thin them { @@ -1601,7 +1685,7 @@ std::optional runLint( const MainDispatch& d ) ++rawForRule; } } - if( rawForRule >= kLintMaxPerRule ) + if( rawForRule >= lintMaxPerRule ) { saturatedRules.push_back( { check.tag, false } ); } @@ -1803,9 +1887,9 @@ std::optional runLint( const MainDispatch& d ) // Each merges its own findings, its own floor disclosures and — for the packs whose rule list is // owned by the pack — its own rule names. All run here, inside the one --lint guard, so the sort // below covers every built-in finding regardless of its source. - { PROFILE_SCOPE_DESCRIBE( "lint: mergeAtomsPack" ); mergeAtomsPack( ing, ms, saturatedRules, allRuleNames, std::move( grouped[1] ) ); } - { PROFILE_SCOPE_DESCRIBE( "lint: mergeNamingLens" ); mergeNamingLens( ing, ms, saturatedRules, cfg.namingLocals, &corpusBytes ); } - { PROFILE_SCOPE_DESCRIBE( "lint: mergeCachePack" ); mergeCachePack( ing, ms, saturatedRules, allRuleNames, std::move( grouped[2] ) ); } + { PROFILE_SCOPE_DESCRIBE( "lint: mergeAtomsPack" ); mergeAtomsPack( ing, ms, saturatedRules, allRuleNames, std::move( grouped[1] ), lintMaxPerRule ); } + { PROFILE_SCOPE_DESCRIBE( "lint: mergeNamingLens" ); mergeNamingLens( ing, ms, saturatedRules, cfg.namingLocals, &corpusBytes, lintMaxPerRule ); } + { PROFILE_SCOPE_DESCRIBE( "lint: mergeCachePack" ); mergeCachePack( ing, ms, saturatedRules, allRuleNames, std::move( grouped[2] ), lintMaxPerRule ); } // Re-sort the combined findings (AST + symbol-level) for deterministic output. std::sort( ms.begin(), ms.end(), [ & ]( const AstMatch& x, const AstMatch& y ) @@ -1839,7 +1923,7 @@ std::optional runLint( const MainDispatch& d ) lintPrintErr( "ripwire: --lint-rules={}: no rules loaded\n", cfg.lintRulesDir ); return 1; } - const auto [ userFindings, saturatedUserRuleIds, uncompiledIds, regexRefused, regexUndecided ] = runLintRules( ing, userRules ); + const auto [ userFindings, saturatedUserRuleIds, uncompiledIds, regexRefused, regexUndecided ] = runLintRules( ing, userRules, lintMaxPerRule ); if( refuseUndecidedMatchRegex( "--lint-rules", regexRefused, regexUndecided, undecidedPredicateFirstSite( cfg, ing, regexUndecided ), "rules loaded from " + std::string( cfg.lintRulesDir ) + " whose queries carry a #match?/#not-match? predicate: " + rulesWithMatchPredicate( userRules ) ) ) @@ -1950,6 +2034,9 @@ std::optional runLint( const MainDispatch& d ) return !lintSel.active || rw::lintcatalog::lintSelectionKeeps( lintSel, rc.rule ); }; const bool anyRuleCapped = std::any_of( saturatedRules.begin(), saturatedRules.end(), lintRuleEmitted ); + // knob-honesty-068: the call that counts the floored rules' rest, over the SAME emitted-rules predicate (H8) + const std::string findingsNext = lintFindingsNext( cfg, saturatedRules, lintRuleEmitted, lintMaxPerRule ); + ASSUME( findingsNext.empty() != anyRuleCapped, "findings_next= rides exactly the answers findings_capped=\"1\" floors (one predicate)" ); // §L7: per-rule LANGUAGE applicability — a rule whose registered languages (lintcatalog.h) never // intersect the corpus' own languages is not "measured zero", it is structurally inert here. @@ -2048,6 +2135,13 @@ std::optional runLint( const MainDispatch& d ) "malformed or misspelled pattern) — its count=\"0\" never ran at all, a different claim from applicable=\"0\" above " "(a well-formed query whose declared language just is not in this corpus) and from an ordinary count=\"0\" (a " "well-formed query that ran and found nothing); absent ⇒ the query compiled. -->" ); + if( !findingsNext.empty() ) + { + // present-only (absent ⇒ nothing floored), like the nest_refused= clause below; compactlegend.h restates it + lintPrintOut( "" ); + } if( lintNestRefused ) { lintPrintOut( "{}{}", rw::callHierarchyLegendOpen( wantCallers, chNextIsBare, cfg.columnar ).c_str(), rw::capLegendClause( rw::computePageDisclosure( pw.end - pw.begin, result.size(), pw.end, cfg.pageLimit, cfg.pageOffset, chDiscloseCap ).active ), rw::declinedCallsLegendWithGate( chRows.declinedCalls > 0, g.gateDeclinedCalls > 0 ), // exactly when the root carries declined_calls= rw::declinedIfaceLegend( chRows.declinedIface > 0 ), // likewise, exactly when declined_iface= is there ( std::string( rw::unprovenDefsLegend( chRows.unprovenDefs > 0 ) ) // H1: likewise, exactly when unproven_defs= is there - + rw::crossKindLegend( !chRows.crossKind.empty() ) ).c_str(), // hono-07: likewise for cross_kind= + + rw::crossKindLegend( !chRows.crossKind.empty() ) // hono-07: likewise for cross_kind= + + rw::viaNameLegend( chHasVia, false, cfg.columnar ) ).c_str(), // FE-B: likewise for via="name" rw::modScopeLegend( chHasModScope ), // #60: likewise, exactly when a t="modscope" row is - ( rw::graphCountDisclosure( rw::graphGaugeClauses( g ) ) + rw::valueRefsLegend( !chRows.valueRefs.rows.empty(), wantCallers ) ).c_str(), + ( rw::graphCountDisclosure( rw::graphGaugeClauses( g ) ) + rw::valueRefsLegend( !chRows.valueRefs.rows.empty(), wantCallers ) + + rw::valueRefsDepthLegendFor( chRows.valueRefs ) ).c_str(), // exactly when the root carries the depth disclosure rw::rootRelPathsLegend( chSingleRoot ), rw::multiRootTableLegend( ing.rootLabels.size() >= 2 ) ); } @@ -239,8 +248,8 @@ std::optional runCallHierarchy( const MainDispatch& d ) // The tier-3 declines count= does not include (callhierarchy.h), on every dialect; absent at zero. const std::string chDeclinedAttr = rw::declinedCallsAttrXml( chRows.declinedCalls ) + rw::declinedIfaceAttrXml( chRows.declinedIface ); // Reference-as-value round: value_refs= beside count= (never in it), absent at zero; the window after the rows. - const std::string chValueRefsAttr = rw::valueRefsCountAttrXml( chRows.valueRefs.rows.size() ); const rw::VrRender chVr { chSingleRoot, chRootPrefix }; + const std::string chValueRefsAttr = ( rw::valueRefsCountAttrXml( chRows.valueRefs.rows.size() ) + rw::valueRefsDepthAttrXml( ing, chRows.valueRefs.depthCut, chVr ) ); // + the depth cut that makes it a floor const std::string chVrNext = "--uses=" + std::string( sym ); // --format=columnar (RESEARCH lever 1): the same page window, re-encoded as a path-table + parallel @@ -268,7 +277,12 @@ std::optional runCallHierarchy( const MainDispatch& d ) cfg.pageLimit, cfg.pageOffset, chDiscloseCap ) + rw::graphCountFloorAttrXml( g ) // §H4 §3.4 — every dialect carries the marker + chNextAttr; // P3 (L7): the same next= on the columnar root - emitColumnarSymbolRows( stdout, ing, tag, attr, page, chRootPrefix, &chTested.testReach ); + std::vector pageVia( page.size(), 0 ); // FE-B: the rows' hedge, a dense column when any row carries it + for( std::size_t i = 0; i < page.size(); ++i ) + { + pageVia[ i ] = rowNameOnly( g, matches, page[ i ], wantCallers ) ? 1 : 0; + } + emitColumnarSymbolRows( stdout, ing, tag, attr, page, chRootPrefix, &chTested.testReach, nullptr, &pageVia ); return 0; } @@ -289,12 +303,17 @@ std::optional runCallHierarchy( const MainDispatch& d ) if( chSingleRoot ) { rw::emitTo( stdout, ",\"root\":\"{}\"", jsonStr( cfg.roots[0] ).c_str() ); } rw::emitTo( stdout, ",\"hop_tested\":{},\"hop_untested\":{}{}{}", chTested.tested, chTested.untested, rw::declinedCallsKeyJson( chRows.declinedCalls ) + rw::declinedIfaceKeyJson( chRows.declinedIface ), // A6; then the XML root's declined_calls=/declined_iface= - rw::valueRefsCountKeyJson( chRows.valueRefs.rows.size() ) ); + ( rw::valueRefsCountKeyJson( chRows.valueRefs.rows.size() ) + rw::valueRefsDepthKeyJson( ing, chRows.valueRefs.depthCut, chVr ) ) ); rw::emitTo( stdout, "{}{}", pageDisclosure( pab, sizeof( pab ), pw.end - pw.begin, result.size(), pw.end, cfg.pageLimit, cfg.pageOffset, chDiscloseCap, kJsonPageSyntax ), rw::graphCountFloorAttrJson( g ).c_str() ); // §H4 §3.4 — the JSON dialect's spelling of the same marker rw::emitTo( stdout, ",\"{}\":[", tag ); - printJsonSymbolRows( ing, result, pw.begin, pw.end, chRootPrefix, &chTested.testReach ); + std::vector chVia( result.size(), 0 ); // FE-B: the same hedge bit the XML rows carry + for( std::size_t i = pw.begin; i < pw.end; ++i ) + { + chVia[ i ] = rowNameOnly( g, matches, result[ i ], wantCallers ) ? 1 : 0; + } + printJsonSymbolRows( ing, result, pw.begin, pw.end, chRootPrefix, &chTested.testReach, nullptr, &chVia ); rw::emitTo( stdout, "]{}}}", rw::valueRefsJson( ing, chRows.valueRefs, wantCallers, chVr, "vrs", chVrNext ) ); return 0; } @@ -319,8 +338,9 @@ std::optional runCallHierarchy( const MainDispatch& d ) const std::string_view rp = chSingleRoot ? rw::sarif::rootRelativeUri( ing.files[ s.fileId ], chRootPrefix ) : std::string_view( ing.files[ s.fileId ] ); // A6: tested="1" only (never a literal 0) — the same absence-meaningful convention tested= // already follows everywhere else (serialize.h/verbs_for.h), so an untested row costs 0 bytes. - rw::emitTo( stdout, "", symTag( s.kind ), ex( s.name ).c_str(), ex( rp ).c_str(), s.line, - macroRoleAttr( s.kind ), rw::isTestedByReach( ing, chTested.testReach, result[i] ) ? " tested=\"1\"" : "" ); + rw::emitTo( stdout, "", symTag( s.kind ), ex( s.name ).c_str(), ex( rp ).c_str(), s.line, + macroRoleAttr( s.kind ), rw::isTestedByReach( ing, chTested.testReach, result[i] ) ? " tested=\"1\"" : "", + viaNameAttr( rowNameOnly( g, matches, result[i], wantCallers ) ) ); // FE-B: a name-only edge says so on its row } rw::emitTo( stdout, "{}", rw::valueRefsXml( ing, chRows.valueRefs, wantCallers, chVr, chVrNext ), tag ); return 0; @@ -552,7 +572,7 @@ struct UseSite { std::uint32_t fileId; std::uint32_t line; rw::RefRole role; std // that never matched the row's own root-relative p=, the map's id=, or a git path — the M6/L1/M0-5 finding. inline std::pair, std::size_t> collectUseSites( const rw::IngestResult& ing, const UsesSelector& sel, std::span isChosenCaller, - std::string_view rootForId = {}, std::span valueDefs = {} ) + std::string_view rootForId = {}, std::span valueDefs = {}, const rw::ValueRefIndex* valueIndex = nullptr ) { using namespace rw; std::vector sites; @@ -560,7 +580,7 @@ collectUseSites( const rw::IngestResult& ing, const UsesSelector& sel, std::span const ElixirResolver elixirResolver( ing ); // Reference-as-value round: valuerefs.h UsesValueFilter — role="value" sites the resolver binds to the selector's // definitions (the same rows --callers shows), never a Through, never a duplicate read row at a value site. - const UsesValueFilter valueFilter( ing, sel.siteMatchName, valueDefs ); + const UsesValueFilter valueFilter( ing, sel.siteMatchName, valueDefs, valueIndex ); for( std::uint32_t refIndex = 0; refIndex < ing.references.size(); ++refIndex ) { const Reference& r = ing.references[refIndex]; @@ -689,8 +709,12 @@ std::optional runUses( const MainDispatch& d ) const std::vector isChosenCaller = ( sel.fileQualified || sel.scopeNarrowed ) ? usesChosenCallers( ing, g, defs ) : std::vector{}; // the sorted use-sites, plus the un-narrowed call-role total the disclosure reports. + // The value index is this verb's own, so the depth cut the --callers answer discloses (valuerefs.h) is read off the + // SAME index the role="value" rows come from: a file nested past the walk's cap holds a use below it that no row shows. + const rw::ValueRefIndex usVri( ing ); auto [ sites, callSitesOfName ] = collectUseSites( ing, sel, isChosenCaller, - usSingleRoot ? std::string_view( cfg.roots[0] ) : std::string_view{}, defs ); + usSingleRoot ? std::string_view( cfg.roots[0] ) : std::string_view{}, defs, &usVri ); + const rw::ValueRefIndex::DepthCuts usDepthCut = usVri.depthCutsFor( defs ); rw::rankUseSites( ing, g, sites ); // cut-fix C: most-depended-on sites first, so the cap drops the lightest // §A6b(ii): a file: qualifier naming a file with NO definition of the name is a WRONG SELECTOR — its @@ -749,7 +773,8 @@ std::optional runUses( const MainDispatch& d ) "{}{}{}-->{}{}", rw::kUsesLegendOpen, ( rw::unprovenDefsVerbLegend( rw::UnprovenDefsVerb::Uses, usUnprovenDefs > 0 ) // H1: exactly when the root carries unproven_defs= + rw::declinedCallsLegendWithGate( usDeclinedCalls > 0, g.gateDeclinedCalls > 0 ) // exactly when it carries declined_calls= - + rw::usesValueRoleLegend( std::any_of( sites.begin(), sites.end(), []( const UseSite& u ) { return u.role == RefRole::Value; } ) ) ).c_str(), + + rw::usesValueRoleLegend( std::any_of( sites.begin(), sites.end(), []( const UseSite& u ) { return u.role == RefRole::Value; } ) ) + + rw::valueRefsDepthLegend( usDepthCut.files > 0 ) ).c_str(), // exactly when the root carries the depth disclosure rw::capLegendClause( rw::computePageDisclosure( pageRows, sites.size(), upw.end, cfg.pageLimit, cfg.pageOffset, usDiscloseCap ).active ), rw::graphCountDisclosure( rw::graphGaugeClauses( g ) ).c_str(), rw::rootRelPathsLegend( usSingleRoot ), @@ -773,16 +798,18 @@ std::optional runUses( const MainDispatch& d ) const std::string attr = "of=\"" + ex( sym ) + "\" defs=\"" + std::to_string( defs.size() ) + "\" external=\"" + ( external ? "1" : "0" ) + "\" count=\"" + std::to_string( sites.size() ) + "\"" + rw::unprovenDefsAttrXml( usUnprovenDefs ) // H1: where the XML root carries it - + selectorAttrs + usRootAttr + upage + rw::graphCountFloorAttrXml( g ); // §H4 §3.4 + + selectorAttrs + usRootAttr + upage + rw::graphCountFloorAttrXml( g ) // §H4 §3.4 + + rw::valueRefsDepthAttrXml( ing, usDepthCut, rw::VrRender{ usSingleRoot, usRootPrefix } ); // the depth cut that makes count= a floor; absent when none emitColumnarUseSites( stdout, ing, attr, ufiles, ulines, uroles, uins, usRootPrefix ); return 0; } - rw::emitTo( stdout, "", + rw::emitTo( stdout, "", ex( sym ).c_str(), defs.size(), external ? 1 : 0, sites.size(), rw::unprovenDefsAttrXml( usUnprovenDefs ).c_str(), // H1: beside the count it qualifies; absent at zero selectorAttrs.c_str(), usRootAttr.c_str(), upage, - rw::graphCountFloorAttrXml( g ).c_str() ); + rw::graphCountFloorAttrXml( g ).c_str(), + rw::valueRefsDepthAttrXml( ing, usDepthCut, rw::VrRender{ usSingleRoot, usRootPrefix } ).c_str() ); // the depth cut that makes count= a floor; absent when none rw::writeMultiRootTable( stdout, ing ); // M12: the roots list this element's own root= cannot carry for( std::size_t siteIndex = upw.begin; siteIndex < upw.end; ++siteIndex ) { @@ -847,8 +874,70 @@ struct SafeDeleteLegendFlags std::size_t declinedCalls = 0; // declined_calls= on the root (graph.h declinedCallsNaming) bool gateDeclined = false; // the builtin-method gate declined a call in this graph std::size_t valueRefs = 0; // value_refs= on the root (valuerefs.h): SYM is used as a value + bool callersFloor = false; // callers_floor= on the root (countfloor.h): callers= may be short for THIS definition + bool usesFloor = false; // uses_floor= on the root: this kind's reads/type mentions are not indexed in this run + bool hasSitesL = false; // a row is on this page + bool valueRefDepthCut = false; // the depth disclosure is on the root: the value walk stopped at its depth cap }; +// count-floor (countfloor.h) + CALLSITE-LINE: the safe-delete legend's clauses for callers_floor=, uses_floor=, next= and +// sites_l=, each exactly when its attribute rides the root/rows, each saying what it does NOT mean. +inline std::string safeDeleteFloorLegend( const SafeDeleteLegendFlags& flags, std::string_view risk ) +{ + std::string clause; + if( flags.callersFloor ) + { + clause += "callers_floor=\"1\": callers= may be SHORT for this definition, because the index holds evidence of a miss " + "for it: a call the resolver declined to bind that named it, a call or macro site spelled like it that bound to " + "no definition at all, a use as a value, or a kind that is read or named more than called (a variable, a " + "class or struct, an interface) — impact_reaches= is walked over the same edges and is short the same way. It does NOT " + "mean more callers exist (an unbound x.f() may be another type's f); absent, the index holds no such evidence. "; + } + if( flags.usesFloor ) + { + clause += "uses_floor=\"1\": uses= could not see this kind's reads, writes or type mentions in this run (only calls, values, " + "imports and extends sites are indexed for it), so a zero there is not a count of its uses. "; + } + if( flags.callersFloor && risk == "none-found" ) + { + clause += "none-found beside callers_floor= is not a safety reading: the calls it names may reach this definition. "; + } + if( flags.callersFloor || flags.usesFloor ) + { + clause += "next= is the call that lists what the floored counts could not: the uses verb (every call, value and, where " + "indexed, read site spelled like sym=, bound or not), or the literal grep where this language's reads are never indexed. "; + } + if( flags.hasSitesL ) + { + clause += "sites_l= on a c row is the ascending LINE list of that caller's call sites spelled like sym= (the rows the uses " + "verb prints) — the lines to open, beside p=, which is where the CALLER is defined. It does NOT mean each line " + "binds to this definition (a same-named call to another definition in that caller is listed too); absent when no " + "call there is spelled like sym= (a renamed import). Two calls on one line are one site. "; + } + return clause; +} + +// The one risk= sentence in force (a reader needs their own value spelled out, not a glossary of four). +struct SafeDeleteRiskReading +{ + std::string_view risk; + const char* sentence; +}; +inline constexpr SafeDeleteRiskReading kSafeDeleteRiskReadings[] = { + { "none-found", "none-found: zero callers AND zero uses — an ABSENCE of evidence, never evidence of absence. " }, + { "untested-radius", "untested-radius: callers or uses exist, and NONE of the transitive blast radius is test-covered. " }, + { "uses-exist", "uses-exist: callers or uses exist, and at least part of the radius is test-covered. " }, + { "unmodelled", "unmodelled: zero callers AND zero uses were READ, but this kind is used by reading or naming it and those " + "counts cannot see that — no reading either way, never a none-found. " }, +}; +inline const char* safeDeleteRiskSentence( std::string_view risk ) noexcept +{ + const auto it = std::ranges::find( kSafeDeleteRiskReadings, risk, &SafeDeleteRiskReading::risk ); + const auto end = std::end( kSafeDeleteRiskReadings ); // outside the promise: a self-check names values, it calls nothing + ASSUME( it != end, "safeDeleteRiskSentence: runSafeDelete assigns only the four values listed" ); + return it != end ? it->sentence : ""; +} + inline void emitSafeDeleteLegend( std::size_t defCount, std::size_t unprovenDefs, std::size_t ambiguousCallers, std::string_view risk, const SafeDeleteLegendFlags& flags ) { @@ -864,6 +953,8 @@ inline void emitSafeDeleteLegend( std::size_t defCount, std::size_t unprovenDefs declinedClause += "none-found beside declined_calls= is not a safety reading: those calls may reach this definition. "; } declinedClause += rw::valueRefsSafeDeleteLegend( flags.valueRefs > 0 ); // exactly when the root carries value_refs= + declinedClause += rw::valueRefsDepthLegend( flags.valueRefDepthCut ); // exactly when it carries the depth disclosure + declinedClause += safeDeleteFloorLegend( flags, risk ); // count-floor + CALLSITE-LINE: present-only clauses rw::emitTo( stdout, "{}", rw::unprovenDefsVerbLegend( rw::UnprovenDefsVerb::Path, pthUnprovenDefs > 0 ).c_str(), - rw::toValueRefsLegend( pthToValueRefs > 0 ), + "graph holds none. {}{}{}{}-->{}", ( rw::unprovenDefsVerbLegend( rw::UnprovenDefsVerb::Path, pthUnprovenDefs > 0 ) + rw::viaNameLegend( pthHasVia ) ).c_str(), + ( std::string( rw::toValueRefsLegend( pthToValueRefs.count > 0 ) ) + rw::valueRefsDepthLegend( pthToValueRefs.depthCut.files > 0 ) ).c_str(), + rw::pathGapsLegend( pthGaps.any() ), rw::graphCountFloorBrief( rw::graphGaugeClauses( g ) ).c_str(), rw::rootRelPathsLegend( pthSingleRoot ) ); rw::emitTo( stdout, " 1 || dstDefs.size() > 1 ) ? "; several defs share these names — qualify as file:name to pick one" : "" ); - } - rw::emitTo( stdout, ">" ); - for( NodeId n : path ) - { - const Symbol& s = ing.symbols[n]; + const rw::PathGapsXml pthUnreached = path.empty() + ? rw::pathUnreachedXml( ing, pthGaps, pthSingleRoot, pthRootPrefix, + { "--connect=" + std::string( srcN ) + "," + std::string( dstN ), "--uses/--impact", + ( srcDefs.size() > 1 || dstDefs.size() > 1 ) ? "; several defs share these names — qualify as file:name to pick one" : "" } ) + : rw::PathGapsXml{}; + rw::emitTo( stdout, "{}>{}", pthUnreached.rootAttrs, pthUnreached.rows ); + for( std::size_t i = 0; i < path.size(); ++i ) + { + const Symbol& s = ing.symbols[ path[i] ]; const std::string_view rp = pthSingleRoot ? rw::sarif::rootRelativeUri( ing.files[ s.fileId ], pthRootPrefix ) : std::string_view( ing.files[ s.fileId ] ); - rw::emitTo( stdout, "", symTag( s.kind ), ex( s.name ).c_str(), ex( rp ).c_str(), s.line ); + // FE-B: a hop whose edge INTO it is name-only says so on its row (the first row is no hop) + rw::emitTo( stdout, "", symTag( s.kind ), ex( s.name ).c_str(), ex( rp ).c_str(), s.line, + viaNameAttr( i > 0 && edgeNameOnly( g, path[ i - 1 ], path[ i ] ) ) ); } rw::emitTo( stdout, "" ); return 0; @@ -2307,8 +2468,20 @@ struct ImpactView const std::vector& depth; // 0.6.5: transitiveCallersDepth's hop per node — the row's d= std::span byDepth; // 0.6.5: graph.h depthCounts over the FULL reach set — the root's by_depth= const rw::ValueRefRows& valueRefs; // reference-as-value round: SYM's binding sites — never in reaches= + const std::vector& provenReach; // FE-B (D3): graph.h provenCallerReach — a row outside it is via="name" }; +// FE-B: the window's hedge bits in row order (impactRowNameOnly), for the dialects that carry them per entry or per column +inline std::vector impactViaColumn( const ImpactView& v, std::size_t begin, std::size_t end ) +{ + std::vector via( v.show.size(), 0 ); + for( std::size_t i = begin; i < end; ++i ) + { + via[ i ] = rw::impactRowNameOnly( v.provenReach, v.show[ i ] ) ? 1 : 0; + } + return via; +} + // --format=columnar (RESEARCH lever 1): same page window, path-table + parallel arrays. // V1-1: a fixed 160-byte buffer truncated mid-attribute on long escaped symbol names (invalid XML, the // F6 class), and this branch hand-rolled shown=/capped= without the paging half — the one @@ -2335,7 +2508,7 @@ int emitImpactColumnar( const ImpactView& v ) + "\" radius_untested=\"" + std::to_string( v.radiusUntested ) + "\"" + rw::declinedCallsAttrXml( v.declinedCalls ) // tier-3 declines into the radius + rw::declinedIfaceAttrXml( v.declinedIface ) - + rw::valueRefsCountAttrXml( v.valueRefs.rows.size() ) // value uses (rows: the XML form) + + ( rw::valueRefsCountAttrXml( v.valueRefs.rows.size() ) + rw::valueRefsDepthAttrXml( v.ing, v.valueRefs.depthCut, rw::VrRender{ v.singleRoot, v.rootPrefix } ) ) // value uses (rows: the XML form) + std::string( v.rootAttr ) + pageDisclosure( ipab, sizeof( ipab ), shownRows, v.show.size(), v.page.end, v.pageLimit, v.pageOffset, true ) @@ -2353,7 +2526,9 @@ int emitImpactColumnar( const ImpactView& v ) : " lens=\"shown_importers,importers_capped,importers_next\"" ) + rw::renderDisclosure( v.prD, rw::DiscloseAs::XmlAttrs ) // W2-F + rw::nextAttrXml( rw::nextFlag( "--safe-delete=", v.sym ) ); // P3 (L7): the XML root's next=, same set - emitColumnarSymbolRows( stdout, v.ing, "impact", attr.c_str(), rows, v.rootPrefix, v.testReach, &v.depth ); + const std::vector via = impactViaColumn( v, v.page.begin, v.page.end ); + const std::vector rowVia( via.begin() + v.page.begin, via.begin() + v.page.end ); // FE-B: in `rows` order + emitColumnarSymbolRows( stdout, v.ing, "impact", attr.c_str(), rows, v.rootPrefix, v.testReach, &v.depth, &rowVia ); return 0; } @@ -2382,14 +2557,15 @@ int emitImpactJson( const ImpactView& v ) rw::countFieldOrEmpty( "tsconfig_unread", std::size_t( v.imports.tsconfigUnread ), /*json=*/true ) ); rw::emitTo( stdout, ",\"radius_tested\":{},\"radius_untested\":{}{}{}", v.radiusTested, v.radiusUntested, rw::declinedCallsKeyJson( v.declinedCalls ) + rw::declinedIfaceKeyJson( v.declinedIface ), // A6; then the XML root's declined_calls=/declined_iface= - rw::valueRefsCountKeyJson( v.valueRefs.rows.size() ) ); + ( rw::valueRefsCountKeyJson( v.valueRefs.rows.size() ) + rw::valueRefsDepthKeyJson( v.ing, v.valueRefs.depthCut, rw::VrRender{ v.singleRoot, v.rootPrefix } ) ) ); if( v.singleRoot ) { rw::emitTo( stdout, ",\"root\":\"{}\"", jsonStr( v.rootRaw ).c_str() ); } // R-E rw::emitTo( stdout, "{}{}{},\"impact\":[", pageDisclosure( ipab, sizeof( ipab ), shownRows, v.show.size(), v.page.end, v.pageLimit, v.pageOffset, true, kJsonPageSyntax ), rw::graphCountFloorAttrJson( v.g ).c_str(), // §H4 §3.4 rw::renderDisclosure( v.prD, rw::DiscloseAs::JsonKeys ).c_str() ); // W2-F: ONE keyset - printJsonSymbolRows( v.ing, v.show, v.page.begin, v.page.end, v.rootPrefix, v.testReach, &v.depth ); + const std::vector via = impactViaColumn( v, v.page.begin, v.page.end ); // FE-B: the rows' hedge, per entry + printJsonSymbolRows( v.ing, v.show, v.page.begin, v.page.end, v.rootPrefix, v.testReach, &v.depth, &via ); rw::emitTo( stdout, "],\"import_reach\":[" ); rw::emitImportRowsJson( stdout, v.ing, v.importPage, v.rootPrefix, v.importLazyPage ); rw::emitTo( stdout, "]{}}}", rw::valueRefsJson( v.ing, v.valueRefs, true, rw::VrRender{ v.singleRoot, v.rootPrefix }, "vrs", @@ -2413,7 +2589,7 @@ int emitImpactXml( const ImpactView& v ) rw::byDepthAttrXml( v.byDepth ), // 0.6.5: partitions reaches= by hop depth v.imports.xmlAttrs.c_str(), v.radiusTested, v.radiusUntested, ( rw::declinedCallsAttrXml( v.declinedCalls ) + rw::declinedIfaceAttrXml( v.declinedIface ) ).c_str(), // tier-3 declines into the radius - rw::valueRefsCountAttrXml( v.valueRefs.rows.size() ), // value uses: never in reaches=; absent at zero + ( rw::valueRefsCountAttrXml( v.valueRefs.rows.size() ) + rw::valueRefsDepthAttrXml( v.ing, v.valueRefs.depthCut, rw::VrRender{ v.singleRoot, v.rootPrefix } ) ), // value uses: never in reaches=; absent at zero std::string( v.rootAttr ).c_str(), pageDisclosure( ipab, sizeof( ipab ), shownRows, v.show.size(), v.page.end, v.pageLimit, v.pageOffset, true ), @@ -2425,9 +2601,10 @@ int emitImpactXml( const ImpactView& v ) const std::string_view rp = v.singleRoot ? rw::sarif::rootRelativeUri( v.ing.files[ s.fileId ], v.rootPrefix ) : std::string_view( v.ing.files[ s.fileId ] ); // A6: tested="1" only (never a literal 0) — see kTestedRowLegend. 0.6.5: d= the row's hop depth, run-length (graph.h depthRunAttrXml). - rw::emitTo( stdout, "", symTag( s.kind ), ex( s.name ).c_str(), ex( rp ).c_str(), s.line, + rw::emitTo( stdout, "", symTag( s.kind ), ex( s.name ).c_str(), ex( rp ).c_str(), s.line, rw::depthRunAttrXml( v.show, v.depth, i, v.page.begin ), - rw::isTestedByReach( v.ing, *v.testReach, v.show[i] ) ? " tested=\"1\"" : "" ); + rw::isTestedByReach( v.ing, *v.testReach, v.show[i] ) ? " tested=\"1\"" : "", + viaNameAttr( rw::impactRowNameOnly( v.provenReach, v.show[i] ) ) ); // FE-B (D3): reached only through a name-only edge } rw::emitImportRowsXml( stdout, v.ing, v.importPage, v.rootPrefix, v.importLazyPage ); rw::emitTo( stdout, "{}", rw::valueRefsXml( v.ing, v.valueRefs, true, rw::VrRender{ v.singleRoot, v.rootPrefix }, @@ -2464,6 +2641,7 @@ std::optional runImpact( const MainDispatch& d ) // 0.6.5: the SAME walk, keeping each node's hop depth — the rows' d= and the root's by_depth=. std::vector imDepth; const std::vector reach = rw::transitiveCallersDepth( g, seeds, &imDepth ); + const std::vector imProven = rw::provenCallerReach( g, seeds ); // FE-B (D3): the all-proven part of the reach const auto [ rank, prIters, prConverged ] = rankGraph( g ); const rw::RankDisclosure prD{ prIters, prConverged, true }; // W2-F: the listing is PageRank-ordered within a depth std::vector show = reach; @@ -2514,14 +2692,17 @@ std::optional runImpact( const MainDispatch& d ) rw::emitTo( stdout, "{}{}. {}{}{}{}{}{}{}{}{}{}{}{}{}-->", rw::kImpactLegendOpen, rw::kPageRaiseCapClause, reach.empty() ? "" : rw::impactDepthLegend( cfg.columnar ), // 0.6.5: exactly when rows (d=) and by_depth= exist cfg.columnar ? rw::kImpactImportTierColumnarLegend : rw::kImpactImportTierLegend, - rw::impactTsImportLegend( imports.importsUnresolved, imports.tsconfigUnread ).c_str(), // #220: exactly when the root carries them + ( rw::impactTsImportLegend( imports.importsUnresolved, imports.tsconfigUnread ) // #220: exactly when the root carries them + + rw::viaNameLegend( std::any_of( show.begin() + imPage.begin, show.begin() + imPage.end, // FE-B: exactly when a shown row is via="name" + [ & ]( NodeId n ) { return rw::impactRowNameOnly( imProven, n ); } ), true, cfg.columnar ) ).c_str(), rw::testedLensLegend( cfg.columnar ), rw::kImpactTestedPartitionLegend, // A6: the columnar form reads its dense column rw::kTestedLensBlindSpotLegend, // F-02: rides with the partition rw::unprovenDefsVerbLegend( rw::UnprovenDefsVerb::Impact, imUnprovenDefs > 0 ).c_str(), // H1: exactly when the root carries unproven_defs= rw::declinedCallsLegendWithGate( imDeclinedCalls > 0, g.gateDeclinedCalls > 0 ), // exactly when the root carries declined_calls= rw::declinedIfaceLegend( imDeclinedIface > 0 ), // likewise, exactly when declined_iface= is there rw::modScopeLegend( imHasModScope ), // #60: likewise, exactly when a t="modscope" row is - rw::valueRefsReachLegend( !imValueRefs.rows.empty() ), // exactly when the root carries value_refs= + ( std::string( rw::valueRefsReachLegend( !imValueRefs.rows.empty() ) ) // exactly when the root carries value_refs= + + rw::valueRefsDepthLegendFor( imValueRefs ) ).c_str(), // ... and the depth disclosure rw::graphCountDisclosure( rw::graphGaugeClauses( g ) ).c_str(), rw::renderDisclosure( prD, rw::DiscloseAs::LegendClause ).c_str() ); } // P2.1 + §P8 G1: the rank-ordered listing's 40 is a DEFAULT now, not a ceiling — see the §P10.3 note @@ -2532,7 +2713,8 @@ std::optional runImpact( const MainDispatch& d ) imPage, // the same window the legend predicate above reads — one expression, not two imports, importPage, importLazyPage, prD, imSingleRoot, imRootPrefix, imRootAttr, imSingleRoot ? cfg.roots[0] : std::string_view(), cfg.pageLimit, cfg.pageOffset, - &imTestReach, imRadiusTested, imRadiusUntested, imDeclinedCalls, imDeclinedIface, g, imDepth, imByDepth, imValueRefs }; + &imTestReach, imRadiusTested, imRadiusUntested, imDeclinedCalls, imDeclinedIface, g, imDepth, imByDepth, imValueRefs, + imProven }; if( cfg.columnar ) { return emitImpactColumnar( view ); } if( cfg.json ) { return emitImpactJson( view ); } @@ -2708,7 +2890,7 @@ std::optional runAround( const MainDispatch& d ) aroundAnn.seed = { ing.symbols[ focus ].name, cfg.aroundDepth, cfg.aroundFanout, definitionCountOfName( ing, focus ), eg.fanoutCut, eg.depthTruncated }; aroundAnn.seed.unprovenDefs = arUnprovenDefs; // H1: unproven_defs= beside defs= on the root, its clause in the map legend - serialize( stdout, ing, rank, g.outOff, g.outTargets, int( eg.nodes.size() ), cfg.mostImportantLast, cfg.metrics, fanInPtr, &g.ambOut, false, g.outProv.empty() ? nullptr : &g.outProv, cboPtr, testedPtr, lcom4Ptr, ampPtr, &g.unresolvedOut, g.bindLabel.empty() ? nullptr : &g.bindLabel, /*autoOrder=*/false, /*outEstTokens=*/nullptr, aroundCompose.tokens + aroundRoutes.tokens + wrap.tokens, aroundAnn, /*statsFirstScreen=*/false, aroundRootArg, &g.locPinOut, g.externalCalls, &g.declinedOut ); + serialize( stdout, ing, rank, g.outOff, g.outTargets, int( eg.nodes.size() ), cfg.mostImportantLast, cfg.metrics, fanInPtr, &g.ambOut, false, g.outProv.empty() ? nullptr : &g.outProv, cboPtr, testedPtr, lcom4Ptr, ampPtr, &g.unresolvedOut, g.bindLabel.empty() ? nullptr : &g.bindLabel, /*autoOrder=*/false, /*outEstTokens=*/nullptr, aroundCompose.tokens + aroundRoutes.tokens + wrap.tokens, aroundAnn, /*statsFirstScreen=*/false, aroundRootArg, &g.locPinOut, g.externalCalls, &g.declinedOut, /*gateDeclinedCalls=*/0, g.outNameOnly.empty() ? nullptr : &g.outNameOnly ); if( !g.composeEdges.empty() ) { diff --git a/src/verbs_quality.h b/src/verbs_quality.h index fd47911bd..b446a4b4a 100644 --- a/src/verbs_quality.h +++ b/src/verbs_quality.h @@ -443,7 +443,7 @@ std::size_t partitionByScope( const rw::quality::Scope& scope, std::vector refuseForeignAckSelection( const rw::Config& cfg, const rw::q // this prose either: several gates grep the header counters (regressions=, gating=, stale=) and a // quoted example here would be matched ahead of the real one. -// Always. The verb, the eleven kinds, the three axes, the exit predicate, and the two counters that are +// Always. The verb, the twelve kinds, the three axes, the exit predicate, and the two counters that are // printed even at zero. Every row in the document — finding rows and stale-ack rows alike — carries // kind=, so it is defined here rather than in either conditional row dictionary. // P8 (L7): the bar= literals in emitRow mirror quality.h's constants — pinned here so a moved bar cannot drift the row @@ -530,17 +530,17 @@ static_assert( rw::quality::kCcxBar == 15 && rw::quality::kLocBar == 60 && rw::q inline constexpr const char* kQdLegendCore = "", // Reference-as-value round: beside its hyphenated siblings above, exactly when the root carries it. - valueRefExcluded > 0 ? "value-ref-excluded=N (absent when 0): internal functions kept off this list because a table, " - "field or argument holds them as a VALUE (matched by name; it is not a proven call; the callers verb " - "lists the sites). One entity, one reason: a def a reason above already excluded is counted there only. " - : "", + ( std::string( valueRefExcluded > 0 ? "value-ref-excluded=N (absent when 0): internal functions kept off this list because " + "a table, field or argument in ANOTHER function or at file scope holds them as a VALUE " + "(matched by name; it is not a proven call; the callers verb lists the sites); a function " + "that only stores itself is still listed. One entity, one reason: a def a reason above " + "already excluded is counted there only. " + : "" ) + + rw::valueRefsDepthLegend( dcDepthCut.files > 0 ) ).c_str(), // exactly when the root carries the depth disclosure rw::graphCountFloorBrief( rw::graphGaugeClauses( g ) ).c_str() ); // §P15/§P16: candidates is already deterministically sorted (path asc, line asc, name asc) and used to // print every candidate unconditionally — completeness was the whole contract, matching --uses' shape, @@ -2179,7 +2230,8 @@ std::optional runQualityViews( const MainDispatch& d ) // 0.6.6 D4: runner-root-excluded= is absent at 0, so a tree with no Python test/decorated root is byte-identical const std::string runnerRootAttr = ( runnerRootExcluded == 0 ? std::string() : std::format( " runner-root-excluded=\"{}\"", runnerRootExcluded ) ) + ( decoratedExcluded == 0 ? std::string() : std::format( " decorated-excluded=\"{}\"", decoratedExcluded ) ) - + ( valueRefExcluded == 0 ? std::string() : std::format( " value-ref-excluded=\"{}\"", valueRefExcluded ) ); + + ( valueRefExcluded == 0 ? std::string() : std::format( " value-ref-excluded=\"{}\"", valueRefExcluded ) ) + + rw::valueRefsDepthAttrXml( ing, dcDepthCut, rw::VrRender{ qvSingleRoot, qvRootPrefix } ); // absent when no cut rw::emitTo( stdout, "", candidates.size(), registerMacroExcluded, runnerRootAttr, dcFilterAttr.c_str(), diff --git a/test/anchorcheck.sh b/test/anchorcheck.sh index 932ab6cb5..834f5ca0d 100755 --- a/test/anchorcheck.sh +++ b/test/anchorcheck.sh @@ -108,6 +108,20 @@ QUERY="frobnicate widget cache" # ladder trimmed used to appear in neither section), and the clause defining the tail says so. Verified before # re-pinning: with every comment and est_tokens= normalized out, old and new documents are byte-identical — # this fixture's head covers every file, so its tail is unchanged and every ranking byte is unmoved. +# RE-PIN 2026-10-08 (train 26b: lean-answers merged onto for-spine-span): 3807 -> 3783 B, est_tokens="1449" -> "1439". +# The two lanes' changes compose: e= rows and clause (for-spine-span) plus the zero elision and its clause (lean). Verified +# mechanically: undoing ONLY the zero elision (zeros back after e=/p=, clause removed, est_tokens=/at= masked) gives the +# train-side golden exactly. +# RE-PIN 2026-10-07 (lane for-spine-span-068, by design): 3557 -> 3807 B (+250 B), est_tokens="1349" -> "1449". CAUSE: --for +# rows with a known extent carry e="N" (the definition's last line) right after l= (3 rows here), and the full +# legend gains the clause defining e=. Verified before re-pinning (tmp re-pin script, base binary 0c115310 == old golden): +# with e= attributes, that clause and est_tokens normalized out, old and new documents are byte-identical — no +# ranking, body or route byte moved. +# RE-PIN 2026-10-07 (lane/lean-answers-068, --for zero elision): 3557 -> 3533 B, est_tokens="1349" -> "1339". ONE +# identified change: a lens row omits cx=/ccx=/in= when 0 and the full legend says ", each absent when 0;". Verified +# before re-pinning: the base binary (255dc199) reproduces the previous golden byte for byte, and putting cx="0"/ccx="0"/ +# in="0" back on every r= row, deleting that clause and masking est_tokens= gives the previous golden exactly — no +# ranking, body or anchor byte moved (anchor-neutrality, this golden's purpose, untouched). # L1 (2026-09-19): the CLI default legend is compact; this arm compares against a golden recorded from the full default, so it asks for it. "$BIN" anchorfix --no-cache --for="$QUERY" --no-route --legend=full >"$TMP/plain_full.xml" 2>/dev/null diff -q "$TMP/plain_full.xml" "$ROOT/test/anchorfix/golden_for.xml" >/dev/null \ diff --git a/test/anchorfix/golden_for.xml b/test/anchorfix/golden_for.xml index 896989756..09364161d 100644 --- a/test/anchorfix/golden_for.xml +++ b/test/anchorfix/golden_for.xml @@ -1,4 +1,4 @@ -frobnicate the widget cache after a bulk insert — the LEXICAL ANCHOR for the gate's query ("frobnicate the widget cache"): its name carries every query token, so it tops the BM25 ranking. Its directvoid frobnicateWidgetCache()widget cache lookup — a third lexical hit (two query tokensint widgetCacheLookup( int key )widget cache init — a second, weaker lexical hit (two query tokensvoid widgetCacheInit()frobnicate the widget cache after a bulk insert — the LEXICAL ANCHOR for the gate's query ("frobnicate the widget cache"): its name carries every query token, so it tops the BM25 ranking. Its directvoid frobnicateWidgetCache()widget cache lookup — a third lexical hit (two query tokensint widgetCacheLookup( int key )widget cache init — a second, weaker lexical hit (two query tokensvoid widgetCacheInit()]*)>', text ): attrs = dict( re.findall( r'(\w+)="([^"]*)"', m.group( 1 ) ) ) if 'n' not in attrs or 'l' not in attrs: continue + # lean-answers lane: the --for lens omits a zero in= on an r= row and its legend says so ("absent cx/ccx/in = 0"), + # so on THAT answer an absent in= reads 0 — the same fact in the new spelling; pack-task still prints every in=. + if zeroAbsent and 'r' in attrs and 'in' not in attrs: attrs[ 'in' ] = '0' out[ ( attrs.get( 'p', '' ), attrs['l'], attrs['n'] ) ] = attrs.get( 'in' ) return out -a, b = rows( sys.argv[1] ), rows( sys.argv[2] ) +forText = open( sys.argv[1], encoding = 'utf-8' ).read() +if 'absent cx/ccx/in = 0' not in forText and 'each absent when 0' not in forText: + print( 'MISMATCH legend: the --for answer omits zeros without saying absent = 0' ) +zeroAbsent = True +a = rows( sys.argv[1] ) +zeroAbsent = False +b = rows( sys.argv[2] ) shared = sorted( set( a ) & set( b ) ) bad = [ ( k, a[k], b[k] ) for k in shared if a[k] != b[k] ] print( 'shared=%d' % len( shared ) ) @@ -150,6 +159,8 @@ else while read -r nm fp val; do [ -n "$nm" ] || continue ref="$( drows "$TMP/for.xml" | grep -oE "n=\"$nm\"[^>]* p=\"$fp\"[^>]* in=\"[0-9]+\"" | grep -oE 'in="[0-9]+"' | head -1 | tr -cd '0-9' )" + # lean-answers lane: a --for r= row with no in= is a 0 (its legend: absent cx/ccx/in = 0), not "no row" + [ -n "$ref" ] || { drows "$TMP/for.xml" | grep -qE "n=\"$nm\"[^>]* p=\"$fp\"[^>]* r=\"[0-9]+\"" && ref=0; } [ -n "$ref" ] || continue [ "$val" = "$ref" ] || { mism=$(( mism + 1 )); echo " mismatch $nm ($fp): trace=$val for=$ref"; } done <<< "$( drows "$TMP/trace.xml" | grep -oE 'n="[A-Za-z_][A-Za-z0-9_]*"[^>]* p="[^"]*"[^>]* in="[0-9]+"' \ diff --git a/test/cachefuzzcheck.sh b/test/cachefuzzcheck.sh index a7a959db4..8d69d600e 100755 --- a/test/cachefuzzcheck.sh +++ b/test/cachefuzzcheck.sh @@ -931,7 +931,7 @@ for i in range(n): site32("def.ppAlt", 9 * 4); site32("def.params", 13 * 4) skip(14 * 4 + 6 + 2 * 4); site("def.kind"); site("def.lang"); s(); s(); skip(8) # #150: +1 u8 (scopeRootsStd); cache v26: +2 u32 (fnScopeStart/End) before kind for _ in range(u32()): # refs - skip(4); site("ref.lang"); s(); skip(2); s(); site("ref.recv"); s(); skip(1); s(); s(); site("ref.role"); site32("ref.argCount", 4); skip(12); s() # line, argCount, argCountKnown, viaArrow, #150's qualifierRootsStd, FE-A's memberCall u8 + memberRoot str + skip(4); site("ref.lang"); s(); skip(2); s(); site("ref.recv"); s(); skip(1); s(); s(); site("ref.role"); site32("ref.argCount", 4); skip(12); s(); s(); s(); s(); skip(1) # line, argCount, argCountKnown, viaArrow, #150's qualifierRootsStd, FE-A's memberCall u8 + memberRoot str, FE-B's memberPath + memberCtor + memberVia strs, memberFactory u8 for _ in range(u32()): # includes skip(3 + 4 + 1); s() for _ in range(u32()): # binds diff --git a/test/candheadcheck.sh b/test/candheadcheck.sh index 70635a451..e1fd70d54 100755 --- a/test/candheadcheck.sh +++ b/test/candheadcheck.sh @@ -86,8 +86,9 @@ fi # ── (b) THE POLLUTANTS SURVIVE IN — reorder, not filter ────────────────────────────────────── # 9 = the seven pollutant overloads plus the gold's own class + constructor rows; the file-grouping wrapper prints its path ONCE per file, so counting rows (not p="…" occurrences) -# is what actually counts ranked ROWS. -rowcount="$( tr '>' '\n' <"$TMP/lens" | grep -c ', got $rowcount" diff --git a/test/chainguardcheck.sh b/test/chainguardcheck.sh index dbaa209d1..c6c157bf8 100755 --- a/test/chainguardcheck.sh +++ b/test/chainguardcheck.sh @@ -211,17 +211,20 @@ GO2="$( "$BIN" "$FIX2" --callees=go --no-cache 2>/dev/null | grep -o '"$TMP/m1" 2>/dev/null diff --git a/test/codexpromptroutecheck.sh b/test/codexpromptroutecheck.sh index d2b8f097c..849a78e3d 100755 --- a/test/codexpromptroutecheck.sh +++ b/test/codexpromptroutecheck.sh @@ -134,6 +134,51 @@ jq -e -s 'any(.[]; .event == "RouteObservation" and .outcome == "adopted" and .p && ok "adopted rows record the recommended verb as observed, not a leading modifier flag" \ || no "adopted row recorded a modifier flag as the observed verb" +# MCP channel events (``) and sub-agent hand-backs (``) reach +# UserPromptSubmit like a prompt but are harness events (issue #381): no classifier call, no context, a +# status=skip-system row. The stub ripwire above recommends for any text without "abstain", so a routed +# prompt is visible as output. Own RIPWIRE_HOME so the counted log above is undisturbed. Near-misses +# (``, ``, ``) and a mid-sentence mention still route. +CH="$TMP/chan"; mkdir -p "$CH" +chan_case() # chan_case LABEL SESSION PROMPT WANT(skip|route) +{ + _cj="$( jq -cn --arg p "$3" --arg c "$TMP/repo" --arg s "$2" '{prompt:$p,cwd:$c,session_id:$s}' )" + _co="$( printf '%s\n' "$_cj" | PATH="$TMP/bin:$PATH" RIPWIRE_HOME="$CH" "$HOOK" )" + _cst="$( jq -rs --arg sh "$( printf '%s' "$2" | cksum | cut -d' ' -f1 )" '[.[] | select(.session_hash == $sh)] | last | .status // "none"' "$CH/routing.jsonl" 2>/dev/null )" + if [ "$4" = skip ]; then + [ -z "$_co" ] && [ "$_cst" = "skip-system" ] && ok "$1: skipped (status=skip-system, nothing injected)" \ + || no "$1: expected skip, got status=[$_cst] out=[${_co:+set}]" + else + [ -n "$_co" ] && [ "$_cst" = "recommend" ] && ok "$1: still routes" \ + || no "$1: expected a route, got status=[$_cst] out=[${_co:+set}]" + fi +} +CHT='understand parseArgs without broad reads' +chan_case "channel event" cx1 "$CHT" skip +chan_case "channel event, leading blank line" cx2 "$( printf ' \n\t%s' "$CHT" )" skip +chan_case "bare tag" cx3 "$CHT" skip +chan_case "agent-message hand-back" cx4 "$CHT" skip +chan_case "agent-message, leading blank line" cx5 "$( printf '\n\n %s' "$CHT" )" skip +chan_case "leading \\v before " cx11 "$( printf '\v%s' "$CHT" )" skip +chan_case "leading \\f before " cx12 "$( printf ' \f%s' "$CHT" )" skip +chan_case "\\v then near-miss " cx13 "$( printf '\v %s' "$CHT" )" route +chan_case "near-miss " cx6 " $CHT" route +chan_case "near-miss " cx7 " $CHT" route +chan_case "near-miss " cx8 " $CHT" route +chan_case "mid-sentence mention" cx9 "what does mean? $CHT" route +chan_case "mid-sentence mention" cx10 "what does mean? $CHT" route +# A Unicode space before the wrapper is not stripped (D-N1): the hook's strip is the six ASCII bytes, as src/taskroute.h's, +# never bash's locale-dependent [:space:] (which matches U+00A0 in a UTF-8 locale on macOS bash 3.2). Run under a UTF-8 +# locale; without one installed the arm SKIPs by name. +UTF8LOC="" +for _l in en_US.UTF-8 C.UTF-8; do locale -a 2>/dev/null | grep -qix "$_l" && { UTF8LOC="$_l"; break; }; done +if [ -z "$UTF8LOC" ]; then + echo " SKIP U+00A0 arms: no UTF-8 locale installed (locale -a lists neither en_US.UTF-8 nor C.UTF-8)" +else + LC_ALL="$UTF8LOC" LANG="$UTF8LOC" chan_case "U+00A0 before under $UTF8LOC routes" cx14 "$( printf '\302\240%s' "$CHT" )" route + LC_ALL="$UTF8LOC" LANG="$UTF8LOC" chan_case "U+00A0 before under $UTF8LOC routes" cx15 "$( printf '\302\240 %s' "$CHT" )" route +fi + OFF="$( printf '%s\n' "{\"prompt\":\"$PROMPT\",\"cwd\":\"$TMP/repo\",\"session_id\":\"route-off\"}" | \ PATH="$TMP/bin:$PATH" RIPWIRE_HOME="$TMP/off" RIPWIRE_ROUTE_METER=0 "$HOOK" )" [ -n "$OFF" ] && [ ! -e "$TMP/off" ] \ diff --git a/test/compactlegendcheck.sh b/test/compactlegendcheck.sh index 9a103f962..7e575a652 100755 --- a/test/compactlegendcheck.sh +++ b/test/compactlegendcheck.sh @@ -44,13 +44,17 @@ # the map header, and every absence-marked row field of the map legend, read from source, has a compact reading — so the # next one cannot land undefined. # -# Usage: RIPWIRE_BIN=build/ripwire bash test/compactlegendcheck.sh +# Usage: bash test/compactlegendcheck.sh [BIN] | RIPWIRE_BIN=build/ripwire bash test/compactlegendcheck.sh +# BIN is $1, else RIPWIRE_BIN, else build/ripwire; the first output line names the binary used (a mutant run against the +# wrong binary gave false greens when this gate read RIPWIRE_BIN only — knob-honesty-068 delta review N7). set -u ROOT="$( cd "$( dirname "$0" )/.." && pwd )" . "$ROOT/test/lib/clean-env.sh" -BIN="${RIPWIRE_BIN:-$ROOT/build/ripwire}" +BIN="${1:-${RIPWIRE_BIN:-$ROOT/build/ripwire}}" [ "${BIN#/}" = "$BIN" ] && BIN="$ROOT/$BIN" +echo "compactlegendcheck: BIN=$BIN" +[ -x "$BIN" ] || { echo "compactlegendcheck: no ripwire binary at $BIN — build first"; exit 2; } FIX="$ROOT/test/fixture" TMP="$( mktemp -d )"; trap 'rm -rf "$TMP"' EXIT # HERMETIC HOME (0.6.6 review M2): the (U)/(UG) probe runs bare --scan-skills, which walks the skill homes under $HOME @@ -288,6 +292,19 @@ echo "=== (A-PIN) --legend=full is BYTE-IDENTICAL to the pre-L1 default (pinned # outside-import/use,super-past-the-tree;...)" (was "(builtin/stdlib-name-...,external-import,...)"), +106 B — and the # est_tokens= that prices them, 858 -> 901 (header and root). Checked: the new output differs from the pin by exactly # those two clauses and that figure; the six other pins are unchanged. +# RE-ANCHORED 2026-10-08 (train 26b: lean-answers merged onto for-spine-span), for.xml only: 4201 -> 4117 B, est_tokens= +# 1683 -> 1650. The two lanes' changes compose (e= rows and clause, then the zero elision and its clause); undoing ONLY +# the zero elision (est_tokens=/at= masked) gives the previous pin exactly. at= is stored masked, as before. The six other +# pins are unchanged. +# RE-ANCHORED BY HAND 2026-10-04 (lane for-spine-span-068, by design), for.xml only: the four rows gain e="N" (the +# definition's last line) right after l=, the full legend gains the present-only e= clause ("; e= on a d row: the 1-based +# line where that definition ends, …", 221 B), and est_tokens= moves 1580 -> 1683 with the bytes (3937 -> 4194 B). +# Checked: removing the four e= attributes and that clause and masking est_tokens leaves the pin byte-identical (at= +# masked); no docs_after_code (the fixture has no markdown row). The six other pins are unchanged. +# RE-ANCHORED BY HAND 2026-10-04 (lane/lean-answers-068), for.xml only: a --for lens row omits a zero cx=/ccx=/in= and the +# v1 legend says "each absent when 0;". Checked mechanically: restoring the zeros on every r= row and removing that clause +# gives the previous pin byte for byte with at= and est_tokens= masked (est_tokens= prices the shorter bytes). The six +# other pins are unchanged. PIN_DIR="$ROOT/test/compactlegendfix/pre_l1_full" # the one normalisation, in python on BOTH sides so no sed dialect decides it (BSD sed appends a final newline, GNU # sed does not): at="…" masked, trailing newlines dropped. @@ -500,6 +517,12 @@ probeFor() # --impact=distance probe; 797 on the base binary b343b988). The answer now carries by_depth= on the root and d= on its # first row (the listing runs nearest first, graph.h orderByDepthThenRank), and the compact legend reads both: the # present-only row (+64 B with its separator) and by_depth= (+63 B). Both are absent at reaches="0". No other schema moved. +# RE-PINNED 2026-10-04 (lane count-floor-068, CALLSITE-LINE): ripwire.safe-delete/v1 720 -> 820 (measured 805, the +# --safe-delete=distance probe; 715 on the base binary 255dc199). Each caller row now carries sites_l=, its call-site LINES +# beside p= (the caller's definition line): an edit decision needs the lines to open, and graded answers that named every +# caller were scored wrong for printing only definition lines. The compact legend reads it (+90 B with its separator: what +# it is, and that it is not proof each line binds to this definition). callers_floor=/uses_floor=/risk=unmodelled are +# present-only and ride neither this probe nor any other pinned one. No other schema moved. # RE-PINNED BACK 2026-09-30 (train22 fixups, review M2): ripwire.scan-skills/v1 520 -> 380 (measured 369). An earlier commit on # this branch pinned 520 from the developer's own HOME (a Codex install's .py helpers made the answer carry # code_not_flow_scanned=); the gate now runs under an empty HOME of its own, so the probe measures the same bytes everywhere. @@ -510,6 +533,24 @@ probeFor() # its definition as two contracts — so the schema had no XML answer to pin. It now answers about the definition. # the pins follow the definitions, measured + 10 rounded up to 10. # schema pin measured +# RE-PINNED 2026-10-04 (lane/lean-answers-068), two new definitions, each measured on this gate's fixture against the +# base binary (255dc199) — no prose was added to any reading that already rode: +# ripwire.for/v1 738->761: " (absent cx/ccx/in = 0)" (+23 B) — the --for lens omits a zero cx=/ccx=/in=. +# ripwire.whereis/v1 611->887: the default listing's readings — listing=, the element (and the +# generic next= reading it pulls in), head_date= (the tip/date hoist) and complete='s "(of its listing=)" (+276 B). The +# answer they ride is the lean one: its rows shrink from every reference to the definitions alone. +# RE-PINNED 2026-10-07 (lane/lean-answers-068 fix round 1, review B1 + its non-blocking item 2), measured on this gate's +# fixture: ripwire.whereis/v1 887->1023: the listing= reading gains "; default: defs only if strictly shorter than all" +# (+49 B: the default now serves the defs page only when it is strictly shorter than the all page) and "; a def the +# parser does not model (define_method, setattr, assignment) is a counted ref" (+87 B) — +136 B, nothing else moved. +# Both ride only a page that carries listing=. The OLD path keeps its own pin: the whereis-listing=all twin after the +# universe loop holds the whole-list page's legend at 700 B (measured 692), so it cannot grow inside this headroom. +# RE-PINNED 2026-10-08 (lane/lean-answers-068 fix round 2, review D1), measured on this gate's fixture: ripwire.whereis/v1 +# 1023->1037, pin 1040->1050 (measured + 10 rounded up to 10): the listing= reading "default: defs only if strictly shorter +# than all" becomes "default: defs if it lists more defs than all, else if shorter" (+14 B) because the default now serves +# the defs page whenever it lists MORE definitions than the all page under the row cap, whatever its bytes. Rides only a +# page that carries listing=; nothing else moved. The OLD path keeps its pin: the whereis-listing=all twin holds the +# whole-list page's legend at 700 B (measured 692, unchanged). PIN_TABLE=' ripwire.edit-check/v1 610 595 ripwire.map/v1 910 892 @@ -566,24 +607,57 @@ ripwire.callers/v1 330 317 ripwire.callees/v1 500 484 ripwire.uses/v1 510 500 ripwire.batch/v1 250 238 -ripwire.safe-delete/v1 720 708 +ripwire.safe-delete/v1 820 805 ripwire.at/v1 180 161 ripwire.from-trace/v1 1300 1281 ripwire.plan-lint/v1 570 551 ripwire.merge-scout/v1 570 558 -ripwire.whereis/v1 630 611 +ripwire.whereis/v1 1050 1037 ripwire.community/v1 730 719 ripwire.layout/v1 1220 1203 ripwire.pack-task/v1 990 974 ripwire.pack-top-n/v1 760 745 ripwire.expand/v1 280 265 ripwire.expand-file/v1 360 342 -ripwire.for/v1 750 738 +ripwire.for/v1 775 761 ' pinFor() { printf '%s\n' "$PIN_TABLE" | awk -v schema="$1" '$1 == schema { printf "%s", $2; exit }' } +# (U-twin) the pre-2026-10-04 ripwire.safe-delete/v1 pin, kept (PROCESS rule 4: a re-pin keeps a twin for the old path). +# The 720 -> 820 re-pin above paid for ONE reading, the caller row's sites_l= (+90 B with its separator). Everything else +# in that prose legend still fits the OLD 720 B: the same --safe-delete=distance probe, with exactly that reading cut out +# of a copy of the compact answer, is measured by the same `leg prose` operand the (U) arm uses. The cut is taken only when +# a caller row carries sites_l= AND the reading occurs once; any other premise FAILs (a cut that removes nothing proves nothing). +( cd "$REPO" && "$BIN" . --safe-delete=distance --legend=compact >"$TMP/sdt.c" 2>/dev/null "$TMP/sdt.full" 2>/dev/null ", "", doc, flags = re.S ) +if not re.search( r']*sites_l="[0-9]', body ): + print( "NOPREMISE no caller row carries sites_l=" ); sys.exit( 0 ) +if doc.count( reading ) != 1: + print( "NOPREMISE the sites_l= reading occurs %d times, not once" % doc.count( reading ) ); sys.exit( 0 ) +open( sys.argv[2], "w", encoding = "utf-8" ).write( doc.replace( reading, "", 1 ) ) +print( len( reading.encode() ) ) +PY +)" +if [ "$rcSdt" -ne 0 ]; then + no "(U-twin) --safe-delete=distance exited $rcSdt — the twin cannot measure" +else + case "$sdtCut" in + NOPREMISE*) no "(U-twin) safe-delete twin cannot measure: ${sdtCut#NOPREMISE }" ;; + *) sdtRest="$( leg prose "$TMP/sdt.cut" "$TMP/sdt.full" )" + if [ -n "$sdtRest" ] && [ "$sdtRest" -eq "$sdtRest" ] 2>/dev/null && [ "$sdtRest" -le 720 ]; then + ok "(U-twin) safe-delete compact prose minus the sites_l= reading ($sdtCut B) is $sdtRest B <= 720 B (the pre-CALLSITE-LINE pin)" + else + no "(U-twin) safe-delete compact prose minus the sites_l= reading ($sdtCut B) is ${sdtRest:-?} B > 720 B — the rest re-inflated" + fi ;; + esac +fi nXml=0; nXmlBad=0; nRefuse=0; nSkip=0; loopBytes=0; xmlVerbs=""; nDefBad=0 # (UG) rv-r1-L1-2: EVERY XML verb the flag universe reaches, EVERY instance of every attribute its DEFAULT answer carries, # defined `name=` in that answer's own legend (legendcoveragecheck (G)'s predicate, on this gate's fixture). No floor. @@ -710,6 +784,18 @@ else fi [ "$nRefuse" -ge 60 ] && ok "(U) $nRefuse non-XML flags refuse --legend=compact (empty stdout, non-zero exit); $nSkip write/serve/exec flags not probed" \ || no "(U) only $nRefuse non-XML flags refused compact (want ≥ 60)" +# (U-TWIN) the whole-list whereis page (--whereis-listing=all, the pre-listing answer) keeps its OWN pin: the default's pin +# rose to 1040 B for the listing= readings, and without this twin the old path could grow inside that headroom unseen. +# 700 = measured 692 (the base's 611 plus the head_date= reading) + 10, rounded up to 10. +( cd "$REPO" && "$BIN" . --whereis=distance --whereis-listing=all >"$TMP/wall.c" 2>/dev/null "$TMP/wall.f" 2>/dev/null ]*>' "$TMP/wall.c" | grep -q ' listing=' \ + && [ -n "$wallLb" ] && [ "$wallLb" -gt 0 ] 2>/dev/null && [ "$wallLb" -le 700 ]; then + ok "(U-TWIN) --whereis=distance --whereis-listing=all compact PROSE legend is $wallLb B (<= its 700 B pin; the default's pin is 1040)" +else + no "(U-TWIN) --whereis-listing=all compact PROSE legend is '${wallLb:-none}' B (pin 700; exit $rcWall): $( leg legend "$TMP/wall.c" | head -c 200 )" +fi echo echo "=== (F) the DEFAULT is the default posture; --legend=full restores MORE legend over the SAME payload (L1) ===" @@ -764,7 +850,50 @@ ds, df = rows( sys.argv[ 1 ] ); fs, ff = rows( sys.argv[ 2 ] ) lostSym = sorted( fs - ds ); lostFiles = sorted( ff - df ) print( len( ds ), len( fs ), len( lostSym ) + len( lostFiles ), ( lostSym + lostFiles )[ :3 ] ) PY -p1n=0; p1bad=0; p1more=0 +# THE ONE EXEMPTION (knob-honesty-068 round 3, orchestrator ruling option B, 2026-10-08; PROCESS rule 4 — re-ruled by the +# orchestrator, not reworded by the lane). Ruling C3 pays for a capped next= in rows ONLY when that is what makes the +# answer fit. At a tight budget the compact header can finish LARGER than --legend=full's (full's rung zero drops its long prose +# clauses; compact's own drop cannot pay, verbs_for.h "AND THE DROP MUST PAY"), and then full keeps a row unpaid that the compact +# answer must pay to fit: P1, P4 and C3 cannot all hold on that argv (lane report, fix round 2 "BLOCKING"). Option D (serve +# full's header there) was built and measured — branch lane/compact-header-068 — and leaves the default answer's own legend +# short of definitions (G4: bodies=/bundle=/reason=/task=/pure= undefined in full's header; legendcoveragecheck (G)/(A)/(E) +# red), so the ruling fell back to B: a lost row is EXEMPT only where ALL of these hold on that very argv — +# • the default's header (root open tag + the legend comments right after it) is strictly LARGER than --legend=full's; +# • the default fits its own budget (est_tokens <= the budget, no over_ceiling="1") — P4 then holds outright; +# • the default's carries next= — the row went to pay for the call that recovers it (C3), not to anything else. +# Every other lost row is still a P1 failure (the TWIN: P1 on every argv the exemption does not name), each exempt run is +# printed (never silent), and arm (P1-B) below pins the predicate itself, on synthetic headers and on two short-root argv. +cat > "$TMP/p1d.py" <<'PY' +import re, sys +def doc( path ): + return open( path, encoding = "utf-8", errors = "replace" ).read() +def header( t ): # the root open tag + the comments right after it (the legend); -1 when there is no root + m = re.match( r"\s*]*>", t ) + if not m: return -1 + i = m.end() + while t.startswith( "", i ); i = len( t ) if j < 0 else j + 3 + return i +def est( t ): + m = re.search( r']*\sest_tokens="([0-9]+)"', t ); return int( m.group( 1 ) ) if m else -1 +d, f = doc( sys.argv[ 1 ] ), doc( sys.argv[ 2 ] ) +over = 1 if re.match( r'\s*]*\sover_ceiling="1"', d ) else 0 +nxt = 1 if re.search( r']*\snext="', d ) else 0 +print( header( d ), header( f ), est( d ), est( f ), over, nxt ) +PY +# p1HeaderLarger DEFAULT_HDR FULL_HDR — the exemption's header comparison, ONE spelling (arm (P1-B) pins it, and a mutant of it). +p1HeaderLarger(){ [ "$1" -gt "$2" ]; } +# p1Exempt DEF FULL BUDGET — 0 when a lost row is the ruled exemption; leaves the facts it read in $p1facts either way. Every +# field is checked present and numeric before any comparison (CHECKLIST 14): an unreadable answer is never exempt. +p1Exempt(){ + set -- $( python3 "$TMP/p1d.py" "$1" "$2" ) "$3" + if [ $# -ne 7 ] || ! printf '%s' "$1$2$3$4$5$6$7" | grep -qE '^[0-9]+$'; then + p1facts="unreadable answer ($*)"; return 1 + fi + p1facts="header $1 B vs full's $2 B, est_tokens $3 (full $4) of $7, over_ceiling=$5, sigs next=$6" + p1HeaderLarger "$1" "$2" && [ "$3" -le "$7" ] && [ "$5" -eq 0 ] && [ "$6" -eq 1 ] +} +p1n=0; p1bad=0; p1more=0; p1exempt=0 for task in "rank symbols by pagerank" "token budget ceiling ladder" "parse command line flags" "legend posture compact" "escapeXml"; do for tb in 700 1500 3000 6000; do "$BIN" "$ROOT/src" --for="$task" --token-budget=$tb >"$TMP/p1.def" 2>/dev/null @@ -772,13 +901,18 @@ for task in "rank symbols by pagerank" "token budget ceiling ladder" "parse comm set -- $( python3 "$TMP/rows.py" "$TMP/p1.def" "$TMP/p1.full" | tr -d "[](),'" ) p1n=$(( p1n + 1 )) if [ "${3:-1}" -ne 0 ]; then - p1bad=$(( p1bad + 1 )); no "(P1) --for='$task' --token-budget=$tb: the default LOST $3 answer row(s) that --legend=full carries (default $1, full $2 symbol rows): ${4:-} ${5:-} ${6:-}" + p1lost="the default LOST $3 answer row(s) that --legend=full carries (default $1, full $2 symbol rows): ${4:-} ${5:-} ${6:-}" + if p1Exempt "$TMP/p1.def" "$TMP/p1.full" "$tb"; then + p1exempt=$(( p1exempt + 1 )); printf ' .. (P1 exempt, ruling B) --for=%s --token-budget=%s: %s; %s\n' "'$task'" "$tb" "$p1lost" "$p1facts" + else + p1bad=$(( p1bad + 1 )); no "(P1) --for='$task' --token-budget=$tb: $p1lost — not the ruled exemption ($p1facts)" + fi fi [ "${1:-0}" -gt "${2:-0}" ] && p1more=$(( p1more + 1 )) grep -q ']*capped="1"' "$TMP/p1.full" && head -c 300 "$TMP/p1.def" | grep -qF 'schema="ripwire.for/v1"' && p1bind=$(( ${p1bind:-0} + 1 )) done done -[ "$p1bad" -eq 0 ] && [ "$p1n" -eq 20 ] && ok "(P1) rows(default) ⊇ rows(--legend=full) on all $p1n budgeted --for runs (5 tasks x 4 budgets)" +[ "$p1bad" -eq 0 ] && [ "$p1n" -eq 20 ] && ok "(P1) rows(default) ⊇ rows(--legend=full) on all $p1n budgeted --for runs (5 tasks x 4 budgets) but the $p1exempt the ruled exemption names (header larger than full's, fits its budget, the row paid for )" # THE BINDING GUARD (L1 fix round): it asked for a run where the default carries MORE rows. Since the fix round the compact # header defines task=/next=/pure=/ and its sig charge is capped at the full dialect's (verbs_for.h runForLens), so # the default buys no row it cannot pay for and "more" is no longer the claim. What the superset arm needs is that the @@ -786,6 +920,83 @@ done [ "${p1bind:-0}" -gt 0 ] && ok "(P1) the arm binds: on ${p1bind} of $p1n runs the budget cut the full answer's and the default is the compact posture (default carries more rows on $p1more)" \ || no "(P1) on none of $p1n runs did the budget cut the full answer's under a compact default — the superset arm proved nothing" +# (P1-B) THE EXEMPTION, PINNED (knob-honesty-068 round 3, ruling B). Three parts: +# 1. the header comparison on synthetic sizes — larger is exempt, a tie and a smaller header are not (a mutant of +# p1HeaderLarger goes red here even where no live argv lands on the boundary); +# 2. FIXED ARMS on a SHORT RELATIVE root (`src`, run from $ROOT): the sweep above runs on "$ROOT/src", and that path rides the +# header the rows are budgeted against, so a checkout path longer than ~52 chars hid these two (green locally, red on CI's +# shorter path at 4acbcd22: default 7 vs full 8, 9 vs 10). Each must keep every row full keeps OR be the ruled exemption, +# and P4 holds on both whatever the branch: inside the budget, or no further over it than full; +# 3. the TWIN on the same root: at --token-budget=6000 the compact header is the SMALLER one, so the exemption cannot apply +# and P1 holds strictly. +# …and a sentinel: MCP `for` has ONE dialect (it declares no `legend` field, src/mcprefusal.h; the compact layer never runs on +# it), so no compact header exists there to exceed full's; it goes red the day MCP `for` gains a compact dialect. +p1bbad=0 +if p1HeaderLarger 1201 1000 && ! p1HeaderLarger 1000 1000 && ! p1HeaderLarger 900 1000; then + ok "(P1-B) the exemption's header comparison: a larger default header is exempt, a tie and a smaller one are not" +else + p1bbad=$(( p1bbad + 1 )); no "(P1-B) the exemption's header comparison is not 'strictly larger' (1201>1000 exempt, 1000=1000 and 900<1000 not)" +fi +# …and the other two conditions, on synthetic answers (each a near miss the predicate could wave through): the positive is a +# larger header that fits and paid for next=; over the budget, labelled over_ceiling="1", or with no is not exempt. +p1syn(){ printf '' "$1" "$2" "$3" "$4" >"$TMP/p1s.def"; } +printf '' >"$TMP/p1s.full" +p1synbad="" +p1syn 1450 "" "a much longer legend comment" ' next="x"'; p1Exempt "$TMP/p1s.def" "$TMP/p1s.full" 1500 || p1synbad="$p1synbad positive($p1facts)" +p1syn 1550 "" "a much longer legend comment" ' next="x"'; p1Exempt "$TMP/p1s.def" "$TMP/p1s.full" 1500 && p1synbad="$p1synbad over-budget" +p1syn 1450 ' over_ceiling="1"' "a much longer legend comment" ' next="x"'; p1Exempt "$TMP/p1s.def" "$TMP/p1s.full" 1500 && p1synbad="$p1synbad over_ceiling" +p1syn 1450 "" "a much longer legend comment" ""; p1Exempt "$TMP/p1s.def" "$TMP/p1s.full" 1500 && p1synbad="$p1synbad no-next" +p1syn 1450 "" "tiny" ' next="x"'; p1Exempt "$TMP/p1s.def" "$TMP/p1s.full" 1500 && p1synbad="$p1synbad smaller-header" +if [ -z "$p1synbad" ]; then + ok "(P1-B) the exemption on synthetic answers: exempt only when the header is larger AND it fits AND rides (4 near misses refused)" +else + p1bbad=$(( p1bbad + 1 )); no "(P1-B) the exemption on synthetic answers misjudged:$p1synbad" +fi +p1b_arm(){ # p1b_arm TASK BUDGET MODE(either|strict) + ( cd "$ROOT" && "$BIN" src --for="$1" --token-budget="$2" ) >"$TMP/p1b.def" 2>/dev/null + ( cd "$ROOT" && "$BIN" src --for="$1" --token-budget="$2" --legend=full ) >"$TMP/p1b.full" 2>/dev/null + local task="$1" tb="$2" mode="$3" + set -- $( python3 "$TMP/rows.py" "$TMP/p1b.def" "$TMP/p1b.full" | tr -d "[](),'" ) + local drows="${1:-}" frows="${2:-}" lost="${3:-}" + set -- $( python3 "$TMP/p1d.py" "$TMP/p1b.def" "$TMP/p1b.full" ) + if [ $# -ne 6 ] || ! printf '%s' "$1$2$3$4$drows$frows$lost" | grep -qE '^[0-9]+$'; then + p1bbad=$(( p1bbad + 1 )); no "(P1-B) --for='$task' --token-budget=$tb on root src: could not read both answers (a root, est_tokens=, rows) — got '$*' rows='$drows/$frows/$lost'"; return + fi + local dh="$1" fh="$2" de="$3" fe="$4" why="" + [ "$de" -gt "$tb" ] && [ "$de" -gt "$fe" ] && why="$why P4: est_tokens $de over the budget AND over full's $fe;" + if [ "$mode" = strict ]; then + [ "$lost" -ne 0 ] && why="$why P1: lost $lost row(s) full carries (default $drows, full $frows);" + p1HeaderLarger "$dh" "$fh" && why="$why twin premise: the compact header ($dh B) is no longer the smaller one (full's $fh B);" + branch="P1 strict, header $dh B < full's $fh B" + elif [ "$lost" -ne 0 ]; then + if p1Exempt "$TMP/p1b.def" "$TMP/p1b.full" "$tb"; then branch="exempt ($p1facts)" + else why="$why P1: lost $lost row(s) full carries (default $drows, full $frows) and is not the ruled exemption ($p1facts);"; fi + else + branch="P1 holds" + fi + if [ -n "$why" ]; then + p1bbad=$(( p1bbad + 1 )); no "(P1-B) --for='$task' --token-budget=$tb on root src:$why" + else + ok "(P1-B) --for='$task' --token-budget=$tb on root src: rows $drows (full $frows), est $de (full $fe): $branch" + fi +} +p1b_arm "rank symbols by pagerank" 1500 either +p1b_arm "parse command line flags" 1500 either +p1b_arm "rank symbols by pagerank" 6000 strict +printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize"}' \ + '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"for","arguments":{"path":"src","task":"parse command line flags","budget_tokens":1500}}}' \ + | ( cd "$ROOT" && "$BIN" --mcp 2>/dev/null ) | tail -1 >"$TMP/p1b.mcp" +if grep -q ' answer — the sentinel proves nothing" +fi +[ "$p1bbad" -eq 0 ] && ok "(P1-B) the ruled exemption is pinned: its comparison, the short-root fixed arms, the scope twin and the MCP sentinel" + # (P2) the default map's --token-budget gate decides on the price the default PRINTS. It used to decide on the full # dialect's price before the compact layer ran, so a map that fits once compacted was withheld (exit 3). Budget = the # compact price itself: the default must answer (exit 0), --legend=full must still withhold (exit 3), and the full price @@ -843,6 +1054,45 @@ for tb in 850 1000 1100 1200; do fi done [ "$p4bad" -eq 0 ] && ok "(P4) at --token-budget 850/1000/1100/1200 the compact --for answer is inside its budget or no further over it than the full one" +# (P4 floor twin — knob-honesty-068; orchestrator rulings 2026-10-07, PROCESS rules 4/5) A capped under a hard ceiling +# names the call that recovers its cut (next=) and pays for it from its rows where that makes the answer fit. At 500 tokens +# this fixture's block is at its rank 1..4 floor in BOTH dialects and the answer is past its ceiling paid or not: no row is +# dropped for the handle (ruling C3), it still ships, unpaid, and the overshoot is DISCLOSED — root over_ceiling="1" with the +# unpaid clause defining it, est_tokens pricing the bytes delivered (P3's rule), and the P4 claim itself (compact no further +# over than full). Near miss at 750: paying is what makes it fit — est_tokens <= budget in both dialects, next= rides, no +# over_ceiling= and no unpaid clause (a label riding every capped answer would pass the 500 half alone). +p4floor_bad=0 +for tb in 500 750; do + for lg in compact full; do + ( cd "$P4" && "$BIN" . --for="widget routine dispatcher" --token-budget=$tb --legend=$lg 2>/dev/null ) >"$TMP/p4f.$lg" + done + pc="$( grep -o 'est_tokens="[0-9]*"' "$TMP/p4f.compact" | head -1 | tr -dc '0-9' )"; pf="$( grep -o 'est_tokens="[0-9]*"' "$TMP/p4f.full" | head -1 | tr -dc '0-9' )" + for lg in compact full; do + f="$TMP/p4f.$lg"; pb="$( wc -c <"$f" | tr -d ' ' )"; pe="$( grep -o 'est_tokens="[0-9]*"' "$f" | head -1 | tr -dc '0-9' )" + root="$( grep -oE '^]*>' "$f" | head -1 )"; sigs="$( grep -oE ']*>' "$f" | tail -1 )" + hasnext=0; case "$sigs" in *' capped="1"'*' next="--for='*) hasnext=1;; esac + over=0; [ "${root#* over_ceiling=\"1\"}" != "$root" ] && over=1 + clause=0; grep -qF '[over_ceiling=1 also when the sigs next= rides unpaid: no row is dropped to pay for it on an answer that would not fit either way]' "$f" && clause=1 + if [ -z "$pe" ] || [ "$hasnext" -ne 1 ]; then + p4floor_bad=$(( p4floor_bad + 1 )); no "(P4 floor twin) --token-budget=$tb --legend=$lg: est_tokens=${pe:-?}, next= present=$hasnext — a capped block must carry its next=" + continue + fi + if [ "$tb" -eq 500 ]; then + truth=$(( pb * 100 / 250 )); floor=$(( pb * 100 / 255 )); diff=$(( pe - truth )); [ "$diff" -lt 0 ] && diff=$(( -diff )) + shown="$( printf '%s' "$sigs" | grep -oE ' shown="[0-9]+"' | tr -dc '0-9' )" + if [ "$over" -ne 1 ] || [ "$clause" -ne 1 ] || [ "${shown:-99}" -gt 4 ] || [ $(( diff * 100 )) -gt $(( truth * 12 )) ] || [ "$pe" -lt "$floor" ]; then + p4floor_bad=$(( p4floor_bad + 1 )); no "(P4 floor twin) --token-budget=500 --legend=$lg: over_ceiling=$over clause=$clause shown=${shown:-?} est_tokens=$pe for $pb B — the floor's unpaid next= must ride labelled and honestly priced" + fi + elif [ "$pe" -gt "$tb" ] || [ "$over" -ne 0 ] || [ "$clause" -ne 0 ]; then + p4floor_bad=$(( p4floor_bad + 1 )); no "(P4 floor twin) near miss --token-budget=$tb --legend=$lg: est_tokens=$pe over_ceiling=$over clause=$clause — a paid next= must fit with no label" + fi + done + lim=$tb; [ "${pf:-0}" -gt "$lim" ] && lim=$pf + if [ -z "$pc" ] || [ "$pc" -gt "$lim" ]; then + p4floor_bad=$(( p4floor_bad + 1 )); no "(P4 floor twin) --token-budget=$tb: compact est_tokens=${pc:-?} over the budget AND over the full answer's ${pf:-?}" + fi +done +[ "$p4floor_bad" -eq 0 ] && ok "(P4 floor twin) at 500 both dialects ship next= unpaid at the rank 1..4 floor with over_ceiling=\"1\", the unpaid clause and an honest est_tokens, the 750 run's paid next= fits with no label, compact never further over than full" # (P5) the BUDGET LEDGER survives compaction (orchestrator rule, METHODOLOGY §9.3/§9.4: never cut silently). --pack-task's # full legend ends with "budget=N bytes (T-token target, ceiling C) | ranking: … | bodies: … | callers: … | notes: … | tests: @@ -938,7 +1188,11 @@ echo # RE-ANCHORED 2026-09-26 (lane impact-depth-065, depth-labelled --impact): 7,500 → 7,700 B, measured 7,622 (7,495 on the # base binary b343b988). The loop's --impact=distance probe now carries by_depth= and d=, and its compact legend reads both # (+127 B, the (U) table's ripwire.impact/v1 row); the other nine verbs unmoved. Same rule: the next multiple of 100 B. -echo "=== (L) the canonical ten-verb edit loop: compact legend bill ≤ 7,700 B (34,431 B in full on the fixture) ===" +# RE-ANCHORED 2026-10-08 (train 26b): 7,700 → 7,800 B, measured 7,711. Attributed on this fixture by merge step: 7,622 at +# the train's phase-A head, 7,688 with receiver evidence merged (its present-only via= readings; under the old ceiling), +# 7,711 with lean-answers merged: +23 B, all on the --for probe, the " (absent cx/ccx/in = 0)" reading the lane pinned +# in the ripwire.for/v1 row. The other verbs are unmoved by that step. Same rule: the next multiple of 100 B. +echo "=== (L) the canonical ten-verb edit loop: compact legend bill ≤ 7,800 B (34,716 B in full on the fixture) ===" loopBytes=0; fullBytes=0 for v in "--for=geometry distance" "--callers=distance" "--impact=distance" "--uses=distance" "--edit-check=total_area" \ "--quality-delta" "--test-gate=geometry.cpp" "--affected=geometry.cpp" "--safe-delete=total_area" "--slice=total_area"; do @@ -947,8 +1201,8 @@ for v in "--for=geometry distance" "--callers=distance" "--impact=distance" "--u b="$( leg bytes "$TMP/l.c" )"; f="$( leg bytes "$TMP/l.f" )" loopBytes=$(( loopBytes + b )); fullBytes=$(( fullBytes + f )) done -[ "$loopBytes" -le 7700 ] && ok "(L) ten-verb loop: $loopBytes B of compact legend (full: $fullBytes B)" \ - || no "(L) ten-verb loop pays $loopBytes B of compact legend (> 7,700 B; full: $fullBytes B)" +[ "$loopBytes" -le 7800 ] && ok "(L) ten-verb loop: $loopBytes B of compact legend (full: $fullBytes B)" \ + || no "(L) ten-verb loop pays $loopBytes B of compact legend (> 7,800 B; full: $fullBytes B)" echo echo "=== (M) MCP: legend:\"compact\" on edit_check answers in ≤ 900 B on a clean tree; every XML verb takes the argument, within its per-verb legend pin ===" diff --git a/test/compactlegendfix/pre_l1_full/for.xml b/test/compactlegendfix/pre_l1_full/for.xml index 60a453fec..ba0c0ada4 100644 --- a/test/compactlegendfix/pre_l1_full/for.xml +++ b/test/compactlegendfix/pre_l1_full/for.xml @@ -1 +1 @@ -double distance( Point a, Point b )double distance( Point a, Point b )double diagonal( Point a, Point b )double perimeter( const Point* pts, int n )# Geometry Fixture## Symbols# Geometry Fixture A tiny polyglot corpus for ripwire regression tests — kept small and stable so the golden snapshot only moves when ripwire's output genuinely changes. ## Symbols `distance` and `perimeter` live in the C++ files \ No newline at end of file +double distance( Point a, Point b )double distance( Point a, Point b )double diagonal( Point a, Point b )double perimeter( const Point* pts, int n )# Geometry Fixture## Symbols# Geometry Fixture A tiny polyglot corpus for ripwire regression tests — kept small and stable so the golden snapshot only moves when ripwire's output genuinely changes. ## Symbols `distance` and `perimeter` live in the C++ files \ No newline at end of file diff --git a/test/completecheck.sh b/test/completecheck.sh index 873422baf..d73ac4cd5 100755 --- a/test/completecheck.sh +++ b/test/completecheck.sh @@ -439,12 +439,16 @@ wwhere zqOther | grep -q ']*p="src/other.c" l="1" kind="def"' # 18f) the INVARIANT: no complete="1" answer carries a HEAD row for a path that differs from HEAD CHANGED="$( git -C "$WT" diff --name-only HEAD; git -C "$WT" ls-files --others --exclude-standard )" bad=0 +# lean-answers lane: the invariant reads BOTH listings — the default (def rows) and the whole list (every row), so the +# default's shorter row set never makes it pass vacuously. for s in zqKeep zqFresh zqOldName zqNewName zqDoomed zqGone zqUntracked zqOther zqUser; do - out="$( wwhere "$s" )" + for lst in defs all; do + out="$( wwhere "$s" --whereis-listing=$lst )" printf '%s' "$out" | wroot | grep -q 'complete="1"' || continue for p in $CHANGED; do - printf '%s' "$out" | grep -q "]*p=\"$p\"" && { bad=1; no "whereis (18f): $s claims complete=\"1\" beside a stale HEAD row for changed $p"; } + printf '%s' "$out" | grep -q "]*p=\"$p\"" && { bad=1; no "whereis (18f): $s ($lst) claims complete=\"1\" beside a stale HEAD row for changed $p"; } done + done done [ $bad -eq 0 ] && ok 'whereis (18f): no complete="1" answer carries a HEAD row for a path the working tree changed' @@ -532,13 +536,24 @@ printf 'def helper( x ):\n return x\n' >"$TL/pkg/lib.py" printf 'from pkg.lib import helper as real\n\ndef helper( x ):\n return real( x )\n\ndef only_in_test():\n return helper( 1 )\n' >"$TL/tests/test_lib.py" ( cd "$TL" && git init -q -b main . && git add -A \ && git -c user.name=fx -c user.email=fx@example.invalid -c commit.gpgsign=false commit -qm seed ) >/dev/null 2>&1 -TLO="$( "$BIN" "$TL" --whereis=helper --no-cache 2>/dev/null )" +# lean-answers lane: the "every hit printed" half reads the WHOLE list, so it asks for it (--whereis-listing=all, the +# pre-listing answer); the twin below holds the same facts on the default listing, where the refs are counted. +TLO="$( "$BIN" "$TL" --whereis=helper --no-cache --whereis-listing=all 2>/dev/null )" FIRSTDEF="$( printf '%s' "$TLO" | grep -o ']*kind="def"[^>]*>' | head -1 )" TESTDEF="$( printf '%s' "$TLO" | grep -o ']*p="tests/test_lib.py" l="3" kind="def"[^>]*>' )" { printf '%s' "$FIRSTDEF" | grep -q 'p="pkg/lib.py" l="1" kind="def" t=' && printf '%s' "$TESTDEF" | grep -q 'kind="def" test_local="1"' \ && [ "$( printf '%s' "$TLO" | sed 's///' | grep -o '//' | head -c 900; echo; } +# TWIN on the default listing (lean-answers lane): the same order and mark on the def rows, and every hit accounted for — +# printed (the def rows) or counted (), the two summing to hits=. +TLD="$( "$BIN" "$TL" --whereis=helper --no-cache 2>/dev/null )" +{ printf '%s' "$TLD" | grep -o ']*kind="def"[^>]*>' | head -1 | grep -q 'p="pkg/lib.py" l="1" kind="def" t=' \ + && printf '%s' "$TLD" | grep -o ']*p="tests/test_lib.py" l="3" kind="def"[^>]*>' | grep -q 'kind="def" test_local="1"' \ + && [ "$(( $( printf '%s' "$TLD" | sed 's///' | grep -o '//' | head -c 900; echo; } printf '%s' "$TLO" | grep -q 'TEST-LOCAL: \|test_local=1: ' \ && ok 'whereis (18n): test_local= is defined in the legend where it rides' || no 'whereis (18n): test_local= rides undefined' "$BIN" "$TL" --whereis=only_in_test --no-cache 2>/dev/null | grep -q 'test_local' \ diff --git a/test/connectcorecheck.sh b/test/connectcorecheck.sh index 9ae1ae4c6..c65f73db4 100755 --- a/test/connectcorecheck.sh +++ b/test/connectcorecheck.sh @@ -20,7 +20,13 @@ set -u ROOT="$( cd "$( dirname "$0" )/.." && pwd )" +# The ASan/UBSan pass below builds with ASAN_CXX: an explicit CXX as given, else Homebrew llvm@22 when installed, else c++ +# (scripts/asanprobe.sh: Apple clang 17 / Command Line Tools 26.3 on macOS 26.7 deadlocks every ASan binary before main). +# The plain harness keeps using CXX, so its toolchain is the one the caller asked for. +. "$ROOT/scripts/asanprobe.sh" +EXPLICIT_CXX="${CXX:-}" CXX="${CXX:-c++}" +ASAN_CXX="$( ripwire_asan_pick_cxx "$EXPLICIT_CXX" )" # §CI-P3: ask THIS front end how it spells C++23 rather than assuming the Clang-17 spelling — an # AppleClang 15 (LLVM 16) macos-14 runner rejects `-std=c++23` outright and took this gate with it @@ -28,7 +34,7 @@ CXX="${CXX:-c++}" . "$ROOT/scripts/cxxstd.sh" CXXSTD="$( ripwire_cxx_std_flag "$CXX" )" HARNESS="$ROOT/test/connectcore_harness.cpp" -WORK="$( mktemp -d )"; trap 'rm -rf "$WORK"' EXIT +WORK="$( mktemp -d )"; trap 'gate_bounded_reap; rm -rf "$WORK"' EXIT; gate_bounded_arm BIN="$WORK/connectcoreharness" echo "connectcorecheck: CXX=$CXX" @@ -51,11 +57,16 @@ fi # ── ASan/UBSan pass: the same harness under the G1 sanitizer stack (integer BFS/MST must be clean) ─────────── ASAN_BIN="$WORK/connectcoreharness_asan" -if "$CXX" "$CXXSTD" -O1 -g -fsanitize=address,undefined -fno-sanitize-recover=all \ +ASAN_SKIPPED="" +if ! ripwire_asan_probe "$ASAN_CXX" "$WORK"; then + # A named, expected missing premise (checklist 14/17): a toolchain whose ASan runtime cannot start a program. + echo " SKIP ASan/UBSan pass: $RIPWIRE_ASAN_PROBE_WHY. Set CXX to Homebrew llvm@22's clang++, or update the Command Line Tools/Xcode (CONTRIBUTING.md, Sanitizer build)." + ASAN_SKIPPED=1 +elif "$ASAN_CXX" "$( ripwire_cxx_std_flag "$ASAN_CXX" )" -O1 -g -fsanitize=address,undefined -fno-sanitize-recover=all \ -I"$ROOT/src/infra" -I"$ROOT/third_party" -I"$ROOT/src" \ "$HARNESS" "$ROOT/src/infra/diagnostics.cpp" -o "$ASAN_BIN" 2> "$WORK/asan_cc.log"; then - if "$ASAN_BIN" > /dev/null; then - echo " PASS ASan/UBSan run clean" + if gate_bounded 120 "$ASAN_BIN" > /dev/null; then + echo " PASS ASan/UBSan run clean ($ASAN_CXX)" else echo " FAIL ASan/UBSan run failed"; exit 2 fi @@ -63,5 +74,9 @@ else echo " WARN sanitizer build unavailable on this toolchain (skipped)"; sed 's/^/ /' "$WORK/asan_cc.log" fi -echo "connectcorecheck: ALL PASS" +if [ -n "$ASAN_SKIPPED" ]; then + echo "connectcorecheck: PASS (plain harness only; the ASan/UBSan pass was skipped above)" +else + echo "connectcorecheck: ALL PASS" +fi exit 0 diff --git a/test/countfloorcheck.sh b/test/countfloorcheck.sh new file mode 100644 index 000000000..caaff53ca --- /dev/null +++ b/test/countfloorcheck.sh @@ -0,0 +1,427 @@ +#!/usr/bin/env bash +# countfloorcheck.sh — a ZERO (or any count) the index cannot know is complete is printed as a FLOOR beside the +# number, with the call that finds the rest; a count the index CAN vouch for stays a plain total. Plus the +# call-site line on a --safe-delete caller row. +# +# WHY. Graded answers called eight of this tool's printed counts false claims, and every one of them was a +# number the graph really held, read as a total: +# * `` (grep) while six calls the resolver DECLINED to bind named it; +# * `` (grep) about a module variable that another file READS — a variable is +# never called, so a call count is blind to its every use; +# * `--safe-delete=` answering callers="0" uses="0" risk="none-found" about a C struct whose type +# mentions the index does not capture at all (thirty files use it); +# * `` (the --for bundle) on a tree with six — the bundle printed the +# number of rows it LISTED after narrowing to its own files, not how many implementors there are; +# * and --safe-delete's caller rows carried the caller's DEFINITION line where the edit needs the CALL line. +# A root's counts_floor="1" and a legend's "a zero means none found" are blanket sentences far from the number; +# the reader takes the number. The marker now sits beside it, only where the index holds evidence of a miss. +# +# Arms (temp corpora, no git; every positive has a near-miss negative the new code could plausibly mishandle): +# A C struct, safe-delete: callers_floor + uses_floor + risk="unmodelled" + next= the literal grep; +# and the clue LEADS to the uses (the grep names the file that declares a `struct cell`). +# B (negative) a static C function nobody calls: a provably complete zero stays callers="0" risk="none-found", +# dead_code_candidate="1", no floor marker, no next=. +# R (negatives) recursion: a self-call is bound to the definition itself (C static fn, Python self.m() and module fn, +# mutual recursion); (near miss) a recursive TS method with an outside same-named call bound nowhere still floors. +# C CALLSITE-LINE: --safe-delete=width_of rows carry sites_l= (the call lines) beside p= (the definition line): +# a caller with three calls on three lines lists all three; one call split over two lines lists its line. +# D (near-miss) a single-definition symbol called once still prints its call site (sites_l= present, one line). +# E grep on a Python module VARIABLE: callers_floor="1" floor_next="--uses=BACKEND", and --uses=BACKEND +# (the clue) lists the reading file. +# F grep on a method whose only call the resolver DECLINED (two same-named defs in other dirs, an +# untyped receiver): callers="0" callers_floor="1". +# F2 the same, from a caller that also BINDS a same-named call elsewhere — the declined evidence alone floors it. +# G grep on a TS method whose only call is spelled like it and bound NOWHERE (unresolved, no decline). +# H grep on a C function stored in a table and never called directly (a value use). +# I (negative) grep on a C function with only direct, bound calls: callers="2", no marker. +# J (negative) grep on a markdown heading: callers="0" stays plain (nothing calls or reads a heading). +# K --for bundle : implementors= is the lego verb's own count (6) even when fewer rows are listed, with +# implementors_shown= + implementors_next=; (negative) all rows listed => no shown/next attribute. +# L implementors_floor: an inherit clause naming the interface that bound nowhere (a cross-language base) +# floors the count on the bundle AND the targeted verb; (negative) without it, no marker. +# L2 (negatives) a bound Rust `impl Trait for T` and a twice-opened Ruby class do not floor (the graph re-keys +# their derived type); (near miss) a Rust impl for a type the tree never defines still floors. +# M MCP twins: grep's `enclosing` JSON carries callers_floor/floor_next exactly where the CLI does; the MCP +# `for` lego block equals the CLI's. +# GO grep on the Go kinds honesty-small-068 added (t="type" `type N string`, t="functype"): read as a struct +# is (a conversion is one call, a type mention is never counted) => callers_floor="1" + floor_next=, like the +# Go struct beside them (the kinds' rows in countfloor.h kUseFormOfKind, train 26b). +# N legend: every new attribute is defined in the answer that carries it (compact and full dialects). +# O determinism (x2) + xmllint. +# +# Usage: bash test/countfloorcheck.sh [BIN] | RIPWIRE_BIN=asan/ripwire bash test/countfloorcheck.sh + +set -u +ROOT="$( cd "$( dirname "$0" )/.." && pwd )" +BIN="${1:-${RIPWIRE_BIN:-$ROOT/build/ripwire}}" +[ "${BIN#/}" = "$BIN" ] && BIN="$ROOT/$BIN" +fail=0 +ok(){ printf ' PASS %s\n' "$*" || { fail=1; printf ' FAIL could not write the PASS line for: %s\n' "$*"; }; return 0; } +no(){ printf ' FAIL %s\n' "$*"; fail=1; } + +[ -x "$BIN" ] || { echo "no ripwire binary at $BIN — build first"; exit 2; } +command -v python3 >/dev/null 2>&1 || { echo "countfloorcheck: SKIP — python3 not found (needed for the MCP JSON arm)"; exit 0; } +command -v perl >/dev/null 2>&1 || { echo "countfloorcheck: SKIP — perl not found (needed to read past legend comments)"; exit 0; } + +W="$( mktemp -d )"; trap 'rm -rf "$W"' EXIT +mkdir -p "$W/c" "$W/py/pkg/css" "$W/py/pkg/other" "$W/ts" "$W/md" + +# ── corpora ────────────────────────────────────────────────────────────────────────────────────────────── +cat > "$W/c/a.h" <<'EOF' +struct cell { int ch; int attr; }; +int width_of(const char *s); +EOF +cat > "$W/c/a.c" <<'EOF' +#include "a.h" +static int helper_dead(int x) { return x + 1; } +int width_of(const char *s) +{ + int n = 0; + while (*s) { n++; s++; } + return n; +} +int pad(const char *s) +{ + int w = width_of(s); + w += width_of("x"); + w += width_of("yz"); + return w; +} +static int twice(int v) { return v * 2; } +int user(void) +{ + struct cell c; + c.ch = twice(1); + return pad("a") + + width_of("yy"); +} +EOF +cat > "$W/c/ops.c" <<'EOF' +struct ops { int (*run)(int); }; +static int run_fn(int v) { return v + 41; } +static const struct ops table = { .run = run_fn }; +int dispatch(int v) { return table.run(v); } +EOF +cat > "$W/py/pkg/constants.py" <<'EOF' +import os +BACKEND = os.environ.get("APP_BACKEND_ENV", None) +EOF +cat > "$W/py/pkg/app.py" <<'EOF' +from . import constants + +def get_backend(): + d = constants.BACKEND + return d +EOF +cat > "$W/py/pkg/css/query.py" <<'EOF' +class Selection: + def tail(self): + raise ValueError("no items match last") +EOF +cat > "$W/py/pkg/other/cache.py" <<'EOF' +class LRU: + def tail(self): + return "lru tail" +EOF +cat > "$W/py/pkg/use.py" <<'EOF' +def use_param(q): + return q.tail() +EOF +# F2: one caller binds a same-named call elsewhere (x = LRU(); x.head()) and has a second one DECLINED (q.head()): +# the unbound scan treats that caller as bound, so only the declined evidence can floor Selection.head +mkdir -p "$W/py2/pkg/css" "$W/py2/pkg/other" +printf 'class Selection:\n def head(self):\n raise ValueError("first item marker")\n' > "$W/py2/pkg/css/query.py" +printf 'class LRU:\n def head(self):\n return "lru head"\n' > "$W/py2/pkg/other/cache.py" +printf 'def mixed(q):\n x = LRU()\n x.head()\n return q.head()\n' > "$W/py2/pkg/mix.py" +cat > "$W/ts/router.ts" <<'EOF' +export interface Router { add(m: string): void; match(p: string): T | null } +EOF +for n in Alpha Beta Gamma Delta Epsilon Zeta; do +cat > "$W/ts/$n.ts" < implements Router { + add(m: string): void {} + match(p: string): T | null { return null } +} +EOF +done +cat > "$W/ts/lonely.ts" <<'EOF' +export class Lonely { lonelyMatch(p: string): string { return "lonely marker " + p } } +export function onlyName() { return lonelyMatch('y') } +EOF +cat > "$W/md/notes.md" <<'EOF' +# Notes + +## Added + +- Added HEADING_MARKER environment variable +EOF + +run(){ ( cd "$W/$1" && shift && "$BIN" . --no-cache "$@" 2>/dev/null ); } +root_tag(){ nocomment "$1" | grep -oE "<$2 [^>]*>" | head -1; } +attr(){ printf '%s' "$1" | grep -oE " $2=\"[^\"]*\"" | head -1 | sed -e "s/^ $2=\"//" -e 's/"$//'; } +nocomment(){ printf '%s' "$1" | perl -0pe 's///gs'; } +comments(){ printf '%s' "$1" | perl -0ne 'print "$&\n" while //gs'; } +first_iface(){ nocomment "$1" | grep -oE ']*>' | head -1; } +enc_row(){ printf '%s' "$1" | grep -oE "]*/>" | head -1; } + +echo "countfloorcheck: BIN=$BIN" + +# ── A: a C struct ────────────────────────────────────────────────────────────────────────────────── +echo "=== A: --safe-delete on a C struct: the zero is a floor, risk is no reading, next= finds its uses ===" +OUT_A="$( run c --safe-delete=cell )"; R_A="$( root_tag "$OUT_A" safe-delete )" +[ -n "$R_A" ] || no "(A) premise: no root for cell" +[ "$( attr "$R_A" callers )" = 0 ] && [ "$( attr "$R_A" uses )" = 0 ] || no "(A) premise: callers/uses are not 0: $R_A" +if [ "$( attr "$R_A" callers_floor )" = 1 ]; then ok "(A) callers_floor=\"1\" beside callers=\"0\""; else no "(A) callers=\"0\" printed as a total: $R_A"; fi +if [ "$( attr "$R_A" uses_floor )" = 1 ]; then ok "(A) uses_floor=\"1\" beside uses=\"0\""; else no "(A) uses=\"0\" printed as a total: $R_A"; fi +if [ "$( attr "$R_A" risk )" = unmodelled ]; then ok "(A) risk=\"unmodelled\", never none-found"; else no "(A) risk=\"$( attr "$R_A" risk )\", expected unmodelled"; fi +NEXT_A="$( attr "$R_A" next )" +if [ "$NEXT_A" = "--grep=cell" ]; then ok "(A) next=\"--grep=cell\" (C reads are never indexed: the literal scan)"; else no "(A) next=\"$NEXT_A\", expected --grep=cell"; fi +FOLLOW_A="$( run c "$NEXT_A" )" +printf '%s' "$FOLLOW_A" | grep -q '' && ok "(A) the clue leads to the use: --grep=cell names a.c (struct cell c;)" \ + || no "(A) following next= did not reach a.c" + +# ── B: a provably complete zero ────────────────────────────────────────────────────────────────────────── +echo "=== B: a static C function with no call anywhere keeps a plain zero ===" +R_B="$( root_tag "$( run c --safe-delete=helper_dead )" safe-delete )" +[ "$( attr "$R_B" callers )" = 0 ] && [ "$( attr "$R_B" risk )" = none-found ] && [ "$( attr "$R_B" dead_code_candidate )" = 1 ] \ + && ok "(B) callers=\"0\" risk=\"none-found\" dead_code_candidate=\"1\" (unchanged)" || no "(B) the complete zero changed: $R_B" +case "$R_B" in *callers_floor=*|*uses_floor=*|*' next='*) no "(B) a provably complete zero carries a floor marker: $R_B" ;; *) ok "(B) no floor marker, no next= on a complete zero" ;; esac + +# ── R: recursion — a self-call bound to the definition itself is not evidence of a miss ───────────────── +# graph.h drops self-loops (CallDisposition::Self), so a recursive call commits no edge: the unbound scan must still +# count it as bound (it bound to this very definition). Negatives: a static recursive C function with no other caller, +# a Python method recursing through self.m() and a recursive module function, mutual recursion (both edges bound). +# Near miss: a recursive TS method that ALSO has a same-named call bound nowhere still floors. +echo "=== R: a recursive function's own call keeps a plain count; an unbound outside call still floors ===" +mkdir -p "$W/crec" "$W/pyrec" "$W/tsrec" +printf 'static int fact(int n) { return n ? n * fact(n - 1) : 1; }\nstatic int pong(int n);\nstatic int ping(int n) { return n ? pong(n - 1) : 0; }\nstatic int pong(int n) { return n ? ping(n - 1) : 1; }\nint entry(void) { return ping(3); }\n' > "$W/crec/r.c" +printf 'class Walker:\n def walk(self, n):\n return self.walk(n - 1) if n else 0\n\ndef walk_tree(n):\n return walk_tree(n - 1) if n else 0\n' > "$W/pyrec/w.py" +printf 'export class Tree { walkDown(n: number): number { return n ? this.walkDown(n - 1) : 0 } }\nexport function outside() { return walkDown(3) }\n' > "$W/tsrec/t.ts" +R_R1="$( root_tag "$( run crec --safe-delete=fact )" safe-delete )" +[ "$( attr "$R_R1" callers )" = 0 ] && [ "$( attr "$R_R1" dead_code_candidate )" = 1 ] || no "(R) premise: fact: $R_R1" +case "$R_R1" in *callers_floor=*|*' next='*) no "(R) a static recursive C function with no caller is floored: $R_R1" ;; *) ok "(R) --safe-delete=fact (self-call only): no callers_floor, no next=" ;; esac +ROW_R1="$( enc_row "$( run crec --grep='n - 1) : 1' )" fact )" +[ -n "$ROW_R1" ] || no "(R) premise: no row for fact" +case "$ROW_R1" in *callers_floor=*) no "(R) grep fact (self-call only) is floored: $ROW_R1" ;; *) ok "(R) grep stays a plain zero" ;; esac +OUT_R2="$( run pyrec --grep='if n else 0' )" +ROW_R2="$( enc_row "$OUT_R2" 'Walker::walk' )"; ROW_R3="$( enc_row "$OUT_R2" walk_tree )" +[ -n "$ROW_R2" ] && [ -n "$ROW_R3" ] || no "(R) premise: Python rows: [$ROW_R2] [$ROW_R3]" +case "$ROW_R2" in *callers_floor=*) no "(R) Python self.walk() recursion is floored: $ROW_R2" ;; *) ok "(R) Python method recursing via self.walk(): no callers_floor" ;; esac +case "$ROW_R3" in *callers_floor=*) no "(R) Python recursive module function is floored: $ROW_R3" ;; *) ok "(R) Python recursive module function walk_tree: no callers_floor" ;; esac +R_R3="$( root_tag "$( run pyrec --safe-delete=walk_tree )" safe-delete )" +case "$R_R3" in *callers_floor=*|*' next='*) no "(R) --safe-delete=walk_tree is floored: $R_R3" ;; *) ok "(R) --safe-delete=walk_tree: no callers_floor, no next=" ;; esac +OUT_RM="$( run crec --grep='n ? p' )"; ROW_PING="$( enc_row "$OUT_RM" pong )"; ROW_PONG="$( enc_row "$OUT_RM" ping )" +[ "$( attr "$ROW_PING" callers )" = 1 ] && [ "$( attr "$ROW_PONG" callers )" = 2 ] || no "(R) premise: mutual recursion counts: [$ROW_PING] [$ROW_PONG]" +case "$ROW_PING$ROW_PONG" in *callers_floor=*) no "(R) bound mutual recursion is floored: $ROW_PING $ROW_PONG" ;; *) ok "(R) mutual recursion ping<->pong (both bound): no callers_floor" ;; esac +ROW_R4="$( enc_row "$( run tsrec --grep='this.walkDown' )" walkDown )" +if [ "$( attr "$ROW_R4" callers_floor )" = 1 ]; then ok "(R) near miss: recursive walkDown with an outside call bound nowhere still floors"; else no "(R) near miss lost its floor: $ROW_R4"; fi + +# ── C/D: the call-site line ────────────────────────────────────────────────────────────────────────────── +echo "=== C/D: --safe-delete caller rows carry the CALL lines beside the definition line ===" +OUT_C="$( run c --safe-delete=width_of )" +ROW_PAD="$( printf '%s' "$OUT_C" | grep -oE ']*/>' | head -1 )" +ROW_USER="$( printf '%s' "$OUT_C" | grep -oE ']*/>' | head -1 )" +if [ "$( attr "$ROW_PAD" p )" = "a.c:9" ]; then ok "(C) p= still names the caller's definition line (a.c:9)"; else no "(C) p= changed on the pad row: $ROW_PAD"; fi +if [ "$( attr "$ROW_PAD" sites_l )" = "11,12,13" ]; then ok "(C) three calls on three lines: sites_l=\"11,12,13\""; else no "(C) pad row: $ROW_PAD"; fi +if [ "$( attr "$ROW_USER" sites_l )" = "22" ]; then ok "(C) a call continued onto its own line: sites_l=\"22\""; else no "(C) user row: $ROW_USER"; fi +ROW_TW="$( printf '%s' "$( run c --safe-delete=twice )" | grep -oE ']*/>' | head -1 )" +if [ "$( attr "$ROW_TW" sites_l )" = "20" ]; then ok "(D) a single-definition callee called once still prints its call site (20)"; else no "(D) twice row: $ROW_TW"; fi +R_C="$( root_tag "$OUT_C" safe-delete )" +case "$R_C" in *callers_floor=*) no "(C) bound direct calls only, yet callers is floored: $R_C" ;; *) ok "(C) callers=\"2\" with no floor (every call bound)" ;; esac + +# ── E: a Python variable ───────────────────────────────────────────────────────────────────────────────── +echo "=== E: grep on a module variable ===" +OUT_E="$( run py --grep=APP_BACKEND_ENV )"; ROW_E="$( enc_row "$OUT_E" BACKEND )" +[ "$( attr "$ROW_E" callers )" = 0 ] || no "(E) premise: BACKEND row: $ROW_E" +if [ "$( attr "$ROW_E" callers_floor )" = 1 ]; then ok "(E) callers_floor=\"1\" on a variable's call count"; else no "(E) printed callers=\"0\" as a total: $ROW_E"; fi +NEXT_E="$( attr "$ROW_E" floor_next )" +if [ "$NEXT_E" = "--uses=BACKEND" ]; then ok "(E) floor_next=\"--uses=BACKEND\""; else no "(E) floor_next=\"$NEXT_E\""; fi +if run py "$NEXT_E" | grep -q 'pkg/app.py:4'; then ok "(E) the clue leads to the reader: --uses=BACKEND lists pkg/app.py:4"; else no "(E) --uses=BACKEND did not list the reading line"; fi +R_ED="$( root_tag "$( run py --safe-delete=BACKEND )" safe-delete )" +[ "$( attr "$R_ED" uses_floor )" = 1 ] && [ "$( attr "$R_ED" risk )" = unmodelled ] && [ "$( attr "$R_ED" next )" = "--uses=BACKEND" ] \ + && ok "(E) --safe-delete=BACKEND: uses_floor=\"1\" risk=\"unmodelled\" next=\"--uses=BACKEND\" (Python reads ARE indexed by the uses verb)" \ + || no "(E) --safe-delete=BACKEND: $R_ED" + +# ── F: a declined call ─────────────────────────────────────────────────────────────────────────────────── +echo "=== F: grep on a method whose one call was declined ===" +OUT_F="$( run py --grep='no items match' )"; ROW_F="$( enc_row "$OUT_F" 'Selection::tail' )" +CALLERS_F="$( root_tag "$( run py --callers=Selection.tail )" callers )" +[ -n "$( attr "$CALLERS_F" declined_calls )" ] || no "(F) premise: the q.tail() call is not declined here: $CALLERS_F" +[ "$( attr "$ROW_F" callers )" = 0 ] && [ "$( attr "$ROW_F" callers_floor )" = 1 ] && [ "$( attr "$ROW_F" floor_next )" = "--uses=tail" ] \ + && ok "(F) callers=\"0\" callers_floor=\"1\" floor_next=\"--uses=tail\"" || no "(F) declined row: $ROW_F" + +echo "=== F2: a declined call from a caller that ALSO binds the same name elsewhere — only the decline can floor it ===" +CALLERS_F2="$( root_tag "$( run py2 --callers=Selection.head )" callers )" +CALLERS_F2B="$( root_tag "$( run py2 --callers=LRU.head )" callers )" +[ -n "$( attr "$CALLERS_F2" declined_calls )" ] && [ "$( attr "$CALLERS_F2B" count )" = 1 ] || no "(F2) premise: want q.head() declined and x.head() bound: $CALLERS_F2 / $CALLERS_F2B" +ROW_F2="$( enc_row "$( run py2 --grep='first item marker' )" 'Selection::head' )" +if [ "$( attr "$ROW_F2" callers )" = 0 ] && [ "$( attr "$ROW_F2" callers_floor )" = 1 ]; then ok "(F2) declined-only evidence: callers_floor=\"1\""; else no "(F2) row: $ROW_F2"; fi + +# ── G: an unbound call, no decline ─────────────────────────────────────────────────────────────────────── +echo "=== G: grep on a TS method whose one call bound nowhere ===" +ROW_G="$( enc_row "$( run ts --grep='lonely marker' )" lonelyMatch )" +CALLERS_G="$( root_tag "$( run ts --callers=lonelyMatch )" callers )" +[ -z "$( attr "$CALLERS_G" declined_calls )" ] || no "(G) premise: this arm wants NO decline: $CALLERS_G" +if [ "$( attr "$ROW_G" callers )" = 0 ] && [ "$( attr "$ROW_G" callers_floor )" = 1 ]; then ok "(G) unbound same-name call: callers_floor=\"1\""; else no "(G) row: $ROW_G"; fi + +# ── H: a value use ─────────────────────────────────────────────────────────────────────────────────────── +echo "=== H: grep on a C function only a table holds ===" +ROW_H="$( enc_row "$( run c --grep='v + 41' )" run_fn )" +if [ "$( attr "$ROW_H" callers )" = 0 ] && [ "$( attr "$ROW_H" callers_floor )" = 1 ]; then ok "(H) value use: callers_floor=\"1\""; else no "(H) row: $ROW_H"; fi + +# ── I/J: complete counts stay plain ────────────────────────────────────────────────────────────────────── +echo "=== I/J: counts the index can vouch for stay plain totals ===" +ROW_I="$( enc_row "$( run c --grep='n++' )" width_of )" +[ "$( attr "$ROW_I" callers )" = 2 ] || no "(I) premise: width_of row: $ROW_I" +case "$ROW_I" in *callers_floor=*|*floor_next=*) no "(I) a fully bound count carries a floor: $ROW_I" ;; *) ok "(I) callers=\"2\" stays a plain total" ;; esac +ROW_J="$( printf '%s' "$( run md --grep=HEADING_MARKER )" | grep -oE ']*/>' | head -1 )" +[ -n "$ROW_J" ] || no "(J) premise: no row for the heading" +case "$ROW_J" in *callers_floor=*) no "(J) a markdown heading's zero is floored: $ROW_J" ;; *) ok "(J) a heading's callers=\"0\" stays plain" ;; esac + +# ── K: the --for lego count ────────────────────────────────────────────────────────────────────────────── +echo "=== K: the --for bundle's implementors= is the lego verb's count, a short list says so ===" +TOTAL_K="$( attr "$( first_iface "$( run ts --lego=Router )" )" implementors )" +[ "$TOTAL_K" = 6 ] || no "(K) premise: --lego=Router implementors=\"$TOTAL_K\", expected 6" +LEGO_ALL="$( run ts '--for=which router classes implement the Router interface' | grep -oE ']*>' | head -1 )" +if [ "$( attr "$LEGO_ALL" implementors )" = 6 ]; then ok "(K) all six listed: implementors=\"6\""; else no "(K) $LEGO_ALL"; fi +case "$LEGO_ALL" in *implementors_shown=*|*implementors_next=*) no "(K) every row listed, yet a short-list marker: $LEGO_ALL" ;; *) ok "(K) no implementors_shown= when every row is listed" ;; esac +# the narrowed shape, built by construction: a task that reaches ONE implementor's file and the interface's +# file, so the bundle's rows are narrowed to the files its own sigs render (legobundlecheck rule 2) — one row of six +mkdir -p "$W/tsn"; cp "$W/ts/"[A-Z]*.ts "$W/ts/router.ts" "$W/tsn/" +printf 'export function routerDeltaDispatch(): number { return 1 }\n' >> "$W/tsn/router.ts" +cat > "$W/tsn/Delta.ts" <<'EOF' +import { Router } from './router' +export class DeltaRouter implements Router { + add(m: string): void {} + match(p: string): T | null { return null } + deltaDispatch(p: string): number { return p.length } +} +EOF +LEGO_N="$( run tsn '--for=delta dispatch' | grep -oE ']*>' | head -1 )" +if [ -z "$LEGO_N" ]; then + no "(K) premise: no Router iface row in the narrowed bundle" +else + IMPL_N="$( attr "$LEGO_N" implementors )"; SHOWN_N="$( attr "$LEGO_N" implementors_shown )" + if [ "$IMPL_N" = 6 ]; then ok "(K) narrowed bundle still says implementors=\"6\""; else no "(K) narrowed bundle: $LEGO_N"; fi + if [ -n "$SHOWN_N" ]; then + [ "$SHOWN_N" -lt 6 ] && [ "$( attr "$LEGO_N" implementors_next )" = "--lego=router.ts:Router" ] \ + && ok "(K) implementors_shown=\"$SHOWN_N\" implementors_next=\"--lego=router.ts:Router\"" || no "(K) short-list marker: $LEGO_N" + if run tsn --lego=router.ts:Router | grep -q 'implementors="6"'; then ok "(K) the clue lists all six"; else no "(K) implementors_next did not list six"; fi + else + no "(K) premise: the bundle was not narrowed (no implementors_shown=): $LEGO_N" + fi +fi + +# ── L: implementors floor ──────────────────────────────────────────────────────────────────────────────── +echo "=== L: an inherit clause naming the interface that bound nowhere floors implementors= ===" +mkdir -p "$W/tsl"; cp "$W/ts/"*.ts "$W/tsl/" +printf 'class PyRouter(Router):\n def add(self, m):\n return m\n' > "$W/tsl/py_router.py" +IF_L="$( first_iface "$( run tsl --lego=Router )" )" +[ "$( attr "$IF_L" implementors )" = 6 ] || no "(L) premise: the Python class must not be counted: $IF_L" +[ "$( attr "$IF_L" implementors_floor )" = 1 ] && [ "$( attr "$IF_L" floor_next )" = "--uses=Router" ] \ + && ok "(L) --lego: implementors_floor=\"1\" floor_next=\"--uses=Router\"" || no "(L) --lego: $IF_L" +IF_LB="$( run tsl '--for=which router classes implement the Router interface' | grep -oE ']*>' | head -1 )" +if [ "$( attr "$IF_LB" implementors_floor )" = 1 ]; then ok "(L) the --for bundle carries the same floor"; else no "(L) bundle: $IF_LB"; fi +case "$( first_iface "$( run ts --lego=Router )" )" in *implementors_floor=*) no "(L) a fully bound interface carries implementors_floor" ;; *) ok "(L) no floor when every inherit clause bound" ;; esac + +# ── L2: the derived type is the one the GRAPH bound, not the reference's enclosing symbol ───────────────── +# graph.h's inheritance pass re-keys two languages' derived type: a Rust `impl Trait for T` header sits outside T's +# span (the ref carries T's NAME in `qualifier`; fromSymbol is whatever encloses the impl), and a reopened Ruby class +# is ONE implementor (rubyBases.canonicalClass), so the clause in the second `class Foo < Base` comes from a symbol id +# the implementor list does not hold. Both are BOUND clauses and must not floor; a Rust impl for a type the tree +# never defines is the near miss that still must. +echo "=== L2: a bound Rust impl / a reopened Ruby class is not evidence of a miss; an impl for an undefined type is ===" +mkdir -p "$W/rs" "$W/rsg" "$W/rb" +printf 'pub trait Shape { fn area(&self) -> u32; }\npub struct Widget { n: u32 }\nimpl Shape for Widget { fn area(&self) -> u32 { self.n } }\n' > "$W/rs/lib.rs" +cp "$W/rs/lib.rs" "$W/rsg/lib.rs"; printf 'impl Shape for Ghost { fn area(&self) -> u32 { 0 } }\n' >> "$W/rsg/lib.rs" +printf 'class Base\nend\nclass Foo < Base\n def a; end\nend\nclass Foo < Base\n def b; end\nend\n' > "$W/rb/a.rb" +IF_RS="$( first_iface "$( run rs --lego=Shape )" )" +[ "$( attr "$IF_RS" implementors )" = 1 ] || no "(L2) premise: Rust Shape must have its one implementor: $IF_RS" +case "$IF_RS" in *implementors_floor=*) no "(L2) a bound Rust impl floors implementors=: $IF_RS" ;; *) ok "(L2) Rust impl Shape for Widget (bound): no floor" ;; esac +IF_RB="$( first_iface "$( run rb --lego=Base )" )" +[ "$( attr "$IF_RB" implementors )" = 1 ] || no "(L2) premise: the reopened Ruby class must be one implementor: $IF_RB" +case "$IF_RB" in *implementors_floor=*) no "(L2) a reopened Ruby class floors implementors=: $IF_RB" ;; *) ok "(L2) Ruby class Foo < Base opened twice (bound): no floor" ;; esac +IF_RSG="$( first_iface "$( run rsg --lego=Shape )" )" +[ "$( attr "$IF_RSG" implementors )" = 1 ] && [ "$( attr "$IF_RSG" implementors_floor )" = 1 ] \ + && ok "(L2) Rust impl Shape for an undefined Ghost (unbound) still floors" || no "(L2) near miss: $IF_RSG" + +# ── M: MCP twins ───────────────────────────────────────────────────────────────────────────────────────── +echo "=== M: the MCP grep and for twins say the same thing ===" +cat > "$W/mcptext.py" <<'PY' +import sys, json +for line in sys.stdin: + line = line.strip() + if not line: continue + try: d = json.loads( line ) + except Exception: continue + c = d.get( "result", {} ).get( "content" ) + if c: print( c[0].get( "text", "" ) ) +PY +mcp_call(){ printf '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"%s","arguments":%s}}\n' "$1" "$2" \ + | "$BIN" --mcp 2>/dev/null | python3 "$W/mcptext.py"; } +MCP_G="$( mcp_call grep "{\"path\":\"$W/py\",\"pattern\":\"APP_BACKEND_ENV\"}" )" +printf '%s' "$MCP_G" | python3 -c ' +import json, sys +d = json.loads( sys.stdin.read() ) +rows = { r["n"]: r for r in d.get( "enclosing", [] ) } +r = rows.get( "BACKEND", {} ) +sys.exit( 0 if r.get( "callers" ) == 0 and r.get( "callers_floor" ) == 1 and r.get( "floor_next" ) == "--uses=BACKEND" else 1 )' \ + && ok "(M) MCP grep enclosing BACKEND: callers 0, callers_floor 1, floor_next --uses=BACKEND" || no "(M) MCP grep: $MCP_G" +MCP_GI="$( mcp_call grep "{\"path\":\"$W/c\",\"pattern\":\"n++\"}" )" +printf '%s' "$MCP_GI" | python3 -c ' +import json, sys +d = json.loads( sys.stdin.read() ) +r = { x["n"]: x for x in d.get( "enclosing", [] ) }.get( "width_of", {} ) +sys.exit( 0 if r.get( "callers" ) == 2 and "callers_floor" not in r and "floor_next" not in r else 1 )' \ + && ok "(M) MCP grep: a complete count carries no floor key (as on the CLI)" || no "(M) MCP grep negative: $MCP_GI" +MCP_F="$( mcp_call for "{\"path\":\"$W/tsl\",\"task\":\"which router classes implement the Router interface\",\"sections\":\"lego\"}" | grep -oE '.*' | head -1 )" +CLI_F="$( run tsl '--for=which router classes implement the Router interface' --sections=lego | grep -oE '.*' | head -1 )" +if [ -n "$CLI_F" ] && [ "$MCP_F" = "$CLI_F" ]; then ok "(M) MCP for == CLI --for (implementors_floor included)"; else no "(M) lego twin differs: cli='$CLI_F' mcp='$MCP_F'"; fi + +# ── GO: the Go named-type kinds floor as a struct does ────────────────────────────────────────────────────── +echo "=== GO: grep on Go t=\"type\" / t=\"functype\" floors like the Go struct ===" +mkdir -p "$W/go" +printf 'package m\n\ntype TestName string\n\ntype Pair struct{ a int }\n\ntype Handler func(int) int\n\nfunc Use(s string) int {\n\tn := TestName(s)\n\tvar p Pair\n\t_ = p\n\treturn len(n)\n}\n\nfunc Wrap(h Handler) int { return h(1) }\n' > "$W/go/m.go" +MAP_GO="$( run go )" +for kn in "type:TestName" "struct:Pair" "functype:Handler"; do + printf '%s' "$MAP_GO" | grep -q " row"; continue; } + if [ "$( attr "$E_GO" callers_floor )" = 1 ] && [ "$( attr "$E_GO" floor_next )" = "--grep=$n" ]; then + ok "(GO) callers=\"$( attr "$E_GO" callers )\" carries callers_floor=\"1\" floor_next=\"--grep=$n\"" + else + no "(GO) printed its count as a total: $E_GO" + fi +done + +# ── N: legend ──────────────────────────────────────────────────────────────────────────────────────────── +echo "=== N: every new attribute is defined where it rides ===" +for dialect in compact full; do + L_A="$( run c --safe-delete=cell --legend=$dialect )"; L_C="$( run c --safe-delete=width_of --legend=$dialect )" + L_E="$( run py --grep=APP_BACKEND_ENV --legend=$dialect )" + L_K="$( run tsl '--for=which router classes implement the Router interface' --legend=$dialect )" + for pair in "L_A:callers_floor=" "L_A:uses_floor=" "L_A:unmodelled" "L_C:sites_l=" "L_E:callers_floor=" "L_E:floor_next=" \ + "L_K:implementors_floor=" "L_K:implementors_shown=" "L_K:floor_next="; do + var="${pair%%:*}"; term="${pair#*:}" + comments "${!var}" | grep -qF -- "$term" \ + && ok "(N/$dialect) $term defined in the legend of the $var answer" || no "(N/$dialect) $term is not defined in the $var legend" + done +done +case "$( run c --safe-delete=helper_dead --legend=compact )" in *callers_floor*|*uses_floor*|*sites_l*) no "(N) a legend defines a floor/sites term its answer does not carry" ;; *) ok "(N) present-only: no floor/sites term on an answer without them" ;; esac + +# ── O: determinism + xmllint ───────────────────────────────────────────────────────────────────────────── +echo "=== O: determinism and well-formed XML ===" +for spec in "c --safe-delete=cell" "c --safe-delete=width_of" "py --grep=APP_BACKEND_ENV" "tsl --lego=Router"; do + set -- $spec; a="$( run "$@" )"; b="$( run "$@" )" + if [ -n "$a" ] && [ "$a" = "$b" ]; then ok "(O) deterministic: $spec"; else no "(O) not deterministic (or empty): $spec"; fi + if command -v xmllint >/dev/null 2>&1; then + if printf '%s' "$a" | xmllint --noout - 2>/dev/null; then ok "(O) well-formed: $spec"; else no "(O) xmllint rejects: $spec"; fi + fi +done + +if [ "$fail" -ne 0 ]; then echo "countfloorcheck: FAIL"; exit 1; fi +echo "countfloorcheck: PASS" +exit 0 diff --git a/test/cppqualcheck.sh b/test/cppqualcheck.sh index 714e3e3c1..b4f369f88 100755 --- a/test/cppqualcheck.sh +++ b/test/cppqualcheck.sh @@ -256,9 +256,11 @@ US="$( run . --uses=selectBaseline --no-cache )" # to find an annotation naming it — one new call site, same helper. # 30 -> 31 (lane/fe-a-false-edges): FE-A's Go module census (graph.h collectGoModules) reads each go.mod above a Go file # through the canonical helper — one new call site, same helper. -[ "$( cnt "$( run . --uses=readWholeFile --no-cache )" )" = 31 ] \ - && ok "repo: --uses=readWholeFile count=31 (docparse::detail:: — a seam the audit's rw::-anchored grep missed)" \ - || no "repo: --uses=readWholeFile expected 31" +# 31 -> 32 (lane/cr-qd-kinds-068): quality.h defectSitesOf reads each C++ / Python / Bash test-script file for the +# defect-shape scanners through the canonical helper — one new call site, same helper. +[ "$( cnt "$( run . --uses=readWholeFile --no-cache )" )" = 32 ] \ + && ok "repo: --uses=readWholeFile count=32 (docparse::detail:: — a seam the audit's rw::-anchored grep missed)" \ + || no "repo: --uses=readWholeFile expected 32" [ "$( cnt "$( run . --callers=writeTally --no-cache )" )" = 1 ] \ && ok "repo: --callers=writeTally count=1 (was 0 — both template call sites are in writeDocDriftPage)" \ || no "repo: --callers=writeTally expected 1" diff --git a/test/crossrefcheck.sh b/test/crossrefcheck.sh index 044f7734d..f210cde53 100755 --- a/test/crossrefcheck.sh +++ b/test/crossrefcheck.sh @@ -21,6 +21,9 @@ ROOT="$( cd "$( dirname "$0" )/.." && pwd )" BIN="${1:-${RIPWIRE_BIN:-$ROOT/build/ripwire}}" # BOTH seams: positional and RIPWIRE_BIN [ "${BIN#/}" = "$BIN" ] && BIN="$ROOT/$BIN" TMP="$( mktemp -d )"; trap 'rm -rf "$TMP"' EXIT +# Every fixture repo below is built in a hermetic git environment (gitenvhermeticcheck (D): a gate that initialises a +# repository sources the shared helper, so an inherited GIT_DIR/GIT_WORK_TREE or agent home cannot leak into it). +. "$ROOT/test/lib/clean-env.sh" fail=0 ok(){ printf ' PASS %s\n' "$*" || { fail=1; printf ' FAIL could not write the PASS line for: %s\n' "$*"; }; return 0; } no(){ printf ' FAIL %s\n' "$*"; fail=1; } @@ -161,7 +164,10 @@ grep -q 'ref="feat-unmerged"' "$TMP/w1" \ grep -q 'kind="def"' "$TMP/w1" \ && ok "whereis: classifies the definition site as def" || { no "whereis: no def-kind hit"; head -c 600 "$TMP/w1"; } -"$BIN" "$R" --whereis=computeBudget >"$TMP/w2" 2>/dev/null +# lean-answers lane: the ordering arms below read the doc-quoting row, a kind="ref" row the DEFAULT listing counts +# rather than prints — so they read the whole list (--whereis-listing=all, the pre-listing answer), and the twin +# after them holds the same facts on the default listing. +"$BIN" "$R" --whereis=computeBudget --whereis-listing=all >"$TMP/w2" 2>/dev/null grep -q 'on-head="1"' "$TMP/w2" \ && ok 'whereis: a live-line symbol reports on-head="1"' || { no 'whereis: expected on-head="1"'; head -c 600 "$TMP/w2"; } grep -q 'ref="HEAD"' "$TMP/w2" \ @@ -223,6 +229,17 @@ srcRow="$( grep -n 'engine\.' "$TMP/w2rows" | tail -1 | cut -d: -f1 )" && ok "whereis: every source row (last at $srcRow) precedes the doc row (at $docRow)" \ || no "whereis: docs interleave with source (doc $docRow, last source $srcRow)" +# TWIN on the default listing (lean-answers lane): the same def rows in the same order (source before the doc), the +# doc-quoting kind="ref" row not dropped but COUNTED — refs count= equals the whole list's kind="ref" rows. +"$BIN" "$R" --whereis=computeBudget >"$TMP/w2d" 2>/dev/null +tr '<' '\n' <"$TMP/w2d" | sed -n 's/^hit ref="\([^"]*\)".* p="\([^"]*\)" l="\([0-9]*\)" kind="\([a-z]*\)".*/\1 \4 \2:\3/p' >"$TMP/w2dall" +W2REFS="$( grep -c ' ref ' "$TMP/w2all" )" +{ [ "$( grep ' def ' "$TMP/w2dall" )" = "$( grep ' def ' "$TMP/w2all" )" ] && ! grep -q ' ref ' "$TMP/w2dall" \ + && [ "$( sed -n 1p "$TMP/w2dall" )" = "HEAD def engine.cpp:3" ] && [ "$W2REFS" -ge 1 ] \ + && grep -q "" \ + || { no "whereis (default listing twin): the default lost a def row or miscounted the refs"; cat "$TMP/w2dall"; grep -o ']*>' "$TMP/w2d"; } + # The finding's own repro, on this repo, when it is a git tree deep enough to answer. Skipped rather # than failed on a shallow/absent checkout: this arm is a bonus over the fixture arms above, which # already pin the ordering deterministically. @@ -316,7 +333,9 @@ legend_of(){ printf '%s' "$1" | grep -oE '' | head -1; } # L1 (2026-09-19): the CLI default legend is compact; §B8.2/§B12.2/§B11.2 read the FULL legend prose and count real rows # (the compact legend spells row shapes inside its comment), so these documents ask for the full legend. -W1="$( "$BIN" "$R" --whereis=computeBudget --limit=1 --legend=full 2>/dev/null )" +# lean-answers lane: the arithmetic below is over the WHOLE list (hits=), so it pages listing=all; the twin after it +# holds the default listing's own sum (shown + more + refs count = hits). +W1="$( "$BIN" "$R" --whereis=computeBudget --limit=1 --legend=full --whereis-listing=all 2>/dev/null )" WLEG="$( legend_of "$W1" )" { printf '%s' "$WLEG" | grep -q 'TRUNCATION' && printf '%s' "$WLEG" | grep -q 'more hits=N'; } \ && ok "§B8.2 whereis: the legend DEFINES its own remainder" \ @@ -335,13 +354,36 @@ if [ -n "$W_MORE" ]; then && ok "§B8.2 whereis: shown($W_SHOWN) + more($W_MORE) == hits($W_HITS), and $W_ROWS rows were really emitted" \ || no "§B8.2 whereis: shown=$W_SHOWN more=$W_MORE hits=$W_HITS rows=$W_ROWS — the remainder does not add up" # past-the-end page: the element must VANISH exactly when nothing is left, never print more="0". - WEND="$( "$BIN" "$R" --whereis=computeBudget --limit=1 --offset="$W_HITS" 2>/dev/null )" - printf '%s' "$WEND" | grep -q ' remainder" \ - || ok "§B8.2 whereis: the remainder is absent on a page with nothing left" + WEND="$( "$BIN" "$R" --whereis=computeBudget --limit=1 --offset="$W_HITS" --whereis-listing=all 2>/dev/null )" + # the absence is read only off a run that produced its root (a crash prints nothing and has no either) + if ! printf '%s' "$WEND" | grep -q ' root" + elif printf '%s' "$WEND" | grep -q ' remainder" + else + ok "§B8.2 whereis: the remainder is absent on a page with nothing left" + fi else no "§B8.2 whereis: --limit=1 produced no to check (fixture has too few hits)" fi +# TWIN on the default listing: the same arithmetic, the counted refs included — shown + more + refs count == hits. +W1D="$( "$BIN" "$R" --whereis=computeBudget --limit=1 2>/dev/null )" +WD_SHOWN="$( printf '%s' "$W1D" | grep -oE ']*' | grep -oE 'shown="[0-9]+"' | grep -oE '[0-9]+' )" +WD_HITS="$( printf '%s' "$W1D" | grep -oE ']*' | grep -oE ' hits="[0-9]+"' | grep -oE '[0-9]+' )" +WD_MORE="$( printf '%s' "$W1D" | grep -oE '//' | grep -oE '/dev/null )" +if ! printf '%s' "$WDEND" | grep -q ' root" +elif printf '%s' "$WDEND" | grep -q ' remainder" +else + ok "§B8.2 whereis (default listing twin): no past the end (the count is not a page remainder)" +fi # the stray-content sibling: force the per-ref file listing (capped at 12) past its cap on its own branch. g checkout -q main @@ -451,6 +493,258 @@ else printf ' SKIP §B11.2 MCP parity (no python3)\n' fi +# ── LEAN: the default listing and the tip/date hoist (lean-answers lane, src/crossref.h WhereisListing) ───────── +# The default lists every kind="def" row and COUNTS the kind="ref" rows in one element; the +# next= (--whereis-listing=refs) lists exactly those rows; --whereis-listing=all is the whole hit list, the uncapped +# variant the lean answer is graded against. A row on HEAD's commit omits tip=/date= (at= and head_date= carry them). +# Every assertion below compares the lean answer against the SAME binary's listing=all answer, so the arms read +# "nothing was lost" directly, not against a remembered byte shape. +LR="$TMP/lean"; mkdir -p "$LR" +lg(){ git -C "$LR" "$@" >/dev/null 2>&1; } +lg init -q -b main; lg config commit.gpgsign false +printf '# lean fixture\n' >"$LR/README.md"; lg add -A; lg commit -qm root +# the branch forks from the bare root, so its tree holds ONLY its own definition (the HEAD symbols stay single-tree) +lg checkout -qb feat-lean +printf 'int zqLean( int x, int y )\n{\n return x + y;\n}\n' >"$LR/branch_only.c" +lg add -A +GIT_COMMITTER_DATE="2026-02-02T00:00:00Z" GIT_AUTHOR_DATE="2026-02-02T00:00:00Z" lg commit -qm "branch def" +lg checkout -q main +printf 'int zqLean( int x );\nint zqDefOnly( void );\n' >"$LR/lean.h" +printf '#include "lean.h"\nint zqLean( int x )\n{\n return x + 1;\n}\nint zqDefOnly( void )\n{\n return 3;\n}\n' >"$LR/lean.c" +printf '#include "lean.h"\nint useA( void ) { return zqLean( 1 ); }\nint useB( void ) { return zqLean( 2 ); }\n' >"$LR/use.c" +printf '# notes\n\nzqLean is the entry point; zqMentionOnly is a word no code defines.\n' >"$LR/NOTES.md" +{ printf '#include "lean.h"\n'; i=0; while [ $i -lt 70 ]; do printf 'int many%d( void ) { return zqLean( %d ); }\n' $i $i; i=$((i+1)); done; } >"$LR/many.c" +# fix round 1 (review B1): a symbol with ONE short ref row, where the defs page pays more than it elides +printf 'int zqOne( void ) { return 1; }\n' >"$LR/one.c" +printf 'int oneUse( void ) { return zqOne(); }\n' >"$LR/one_use.c" +lg add -A; lg commit -qm base +LHEAD="$( git -C "$LR" rev-parse --short=9 HEAD )"; LFEAT="$( git -C "$LR" rev-parse --short=9 feat-lean )" +lw(){ "$BIN" "$LR" --whereis="$1" --no-cache "${@:2}" 2>/dev/null; } +hits_of(){ tr '<' '\n' | grep "^hit " | grep "kind=\"$1\"" | sed 's|/>$||; s|/>.*||'; } +lroot(){ grep -o ']*>' | head -1; } +LDEF="$( lw zqLean )"; LALL="$( lw zqLean --whereis-listing=all --limit=1000 )" +# (L1) the def rows: every kind="def" row of the whole list, byte for byte, in order — and no kind="ref" row +[ -n "$( printf '%s' "$LALL" | hits_of def )" ] && [ "$( printf '%s' "$LDEF" | hits_of def )" = "$( printf '%s' "$LALL" | hits_of def )" ] \ + && [ -z "$( printf '%s' "$LDEF" | hits_of ref )" ] \ + && ok "LEAN (L1): the default lists every kind=\"def\" row of listing=all, byte for byte, and no kind=\"ref\" row" \ + || { no "LEAN (L1): the default's def rows differ from listing=all's (or a ref row is listed)"; printf '%s\n' "$LDEF"; } +# (L2) the count is exact: refs count= == the kind="ref" rows of the whole list +NREF="$( printf '%s' "$LALL" | hits_of ref | wc -l | tr -d ' ' )" +CNT="$( printf '%s' "$LDEF" | grep -oE ' equals the $NREF kind=\"ref\" rows listing=all prints" \ + || no "LEAN (L2): refs count='${CNT:-none}' vs $NREF ref rows in listing=all" +# (L3) next= round-trips: the pasted next= (all its pages) prints exactly listing=all's ref rows, byte for byte +NXT="$( printf '%s' "$LDEF" | grep -oE ']*next="[^"]*"' | sed 's/.*next="//; s/"$//' )" +case "$NXT" in "--whereis=zqLean --whereis-listing=refs") ok "LEAN (L3a): next= names the refs listing of the same symbol" ;; + *) no "LEAN (L3a): next='$NXT'" ;; esac +P1="$( lw zqLean --whereis-listing=refs )"; P2="$( lw zqLean --whereis-listing=refs --offset=60 )" +{ printf '%s' "$P1" | hits_of ref; printf '%s' "$P2" | hits_of ref; } >"$TMP/lean_next" +printf '%s' "$LALL" | hits_of ref >"$TMP/lean_allref" +[ -s "$TMP/lean_allref" ] && cmp -s "$TMP/lean_next" "$TMP/lean_allref" && [ -z "$( printf '%s' "$P1" | hits_of def )" ] \ + && printf '%s' "$P1" | lroot | grep -q ' listing="refs"' && printf '%s' "$P1" | grep -q "" \ + && ok "LEAN (L3b): next= (two pages, the first ending in ) reproduces listing=all's ref rows byte for byte" \ + || { no "LEAN (L3b): the refs listing differs from listing=all's ref rows"; diff "$TMP/lean_next" "$TMP/lean_allref" | head -5; } +# (L4) zero refs: no , no listing=, byte-identical to listing=all +D0="$( lw zqDefOnly )"; A0="$( lw zqDefOnly --whereis-listing=all )" +[ -n "$( printf '%s' "$D0" | hits_of def )" ] && [ "$D0" = "$A0" ] && ! printf '%s' "$D0" | grep -q ', identical to listing=all +M0="$( lw zqMentionOnly )"; MA="$( lw zqMentionOnly --whereis-listing=all )" +[ -n "$( printf '%s' "$M0" | hits_of ref )" ] && [ "$M0" = "$MA" ] && ! printf '%s' "$M0" | grep -q '), drops complete= +L1R="$( lw zqLean --limit=1 )" +printf '%s' "$L1R" | lroot | grep -q ' shown="1" capped="1" total="3"' && printf '%s' "$L1R" | grep -q '' \ + && ! printf '%s' "$L1R" | lroot | grep -q 'complete=' && printf '%s' "$LDEF" | lroot | grep -q ' complete="1"' \ + && ok "LEAN (L7): shown=/capped=/ window the def rows; complete= rides the uncut defs listing, not the cut one" \ + || { no "LEAN (L7): the window does not read the listed rows"; printf '%s\n' "$L1R" | lroot; } +# (L8) refusals: an unknown value, and the modifier without --whereis +"$BIN" "$LR" --whereis=zqLean --whereis-listing=bogus >/dev/null 2>&1; rcb=$? +"$BIN" "$LR" --whereis-listing=all >/dev/null 2>&1; rcn=$? +[ $rcb -eq 1 ] && [ $rcn -eq 1 ] && ok "LEAN (L8): --whereis-listing=bogus and a bare --whereis-listing refuse (exit 1)" \ + || no "LEAN (L8): refusal exits $rcb / $rcn (want 1 / 1)" +# (L9) both legends define what the lean answer carries +printf '%s' "$LDEF" | grep -oE '' | head -1 | grep -q 'listing=defs' \ + && printf '%s' "$LDEF" | grep -oE '' | head -1 | grep -q 'head_date=' && printf '%s' "$LDEF" | grep -oE '' | head -1 | grep -q 'refs count=' \ + && lw zqLean --legend=full | grep -oE '' | head -1 | grep -q 'LISTING:.*TIP AND DATE:' \ + && ok "LEAN (L9): compact and full legends define listing=, head_date=, refs count=" \ + || no "LEAN (L9): a lean attribute rides undefined" +# (L10) MCP twin: the same answers over MCP (default, listing all, refs) and the same refusal; one call per session +mcpw(){ printf '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"whereis","arguments":{"path":"%s","symbol":"zqLean"%s}}}\n' "$LR" "$1" \ + | "$BIN" --mcp 2>/dev/null | python3 -c 'import sys,json +for line in sys.stdin: + line=line.strip() + if not line: continue + d=json.loads(line); c=d.get("result",{}).get("content") + if c: print(c[0].get("text",""),end="") + elif "error" in d: print("ERROR",d["error"].get("code"),end="")'; } +if command -v python3 >/dev/null 2>&1; then + [ "$( mcpw '' )" = "$( lw zqLean )" ] && [ "$( mcpw ',"listing":"all"' )" = "$( lw zqLean --whereis-listing=all )" ] \ + && [ "$( mcpw ',"listing":"refs"' )" = "$( lw zqLean --whereis-listing=refs )" ] && [ "$( mcpw ',"listing":"bogus"' )" = "ERROR -32602" ] \ + && ok "LEAN (L10): MCP whereis default / listing:all / listing:refs are byte-identical to the CLI; listing:bogus refuses -32602" \ + || no "LEAN (L10): the MCP twin differs from the CLI" + # (L10b) CodeRabbit 5468003465: a NON-STRING listing refuses through the shared shape gate and echoes the value SENT. + # `listing` used to be read inside the whereis arm, after that gate, so listing:5 decoded to "" and the closed-set + # refusal said "got ''". Near-miss twins: a string outside the set still echoes itself; a string inside it answers (L10). + mcpwmsg(){ printf '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"whereis","arguments":{"path":"%s","symbol":"zqLean"%s}}}\n' "$LR" "$1" \ + | "$BIN" --mcp 2>/dev/null | python3 -c 'import sys,json +for line in sys.stdin: + line=line.strip() + if not line: continue + d=json.loads(line) + if "error" in d: print("ERROR",d["error"].get("code"),d["error"].get("message",""),end="") + elif d.get("result",{}).get("content"): print("ANSWER",end="")'; } + for arm in '5|5' '["all"]|["all"]' '{"v":1}|{"v":1}' 'true|true' '"bogus"|bogus'; do + val="${arm%%|*}"; echo_="${arm#*|}" + m="$( mcpwmsg ",\"listing\":$val" )" + case "$m" in + "ERROR -32602 invalid value for field: listing"*"got '$echo_'"*) ok "LEAN (L10b): MCP whereis listing:$val refuses -32602 and echoes '$echo_'" ;; + *) no "LEAN (L10b): MCP whereis listing:$val — want a -32602 refusal echoing '$echo_', got: ${m:-nothing}" ;; + esac + done +else + printf ' SKIP LEAN (L10) MCP twin (no python3)\n' +fi + +# ── THE DEFAULT IS THE SHORTER PAGE (fix round 1, review B1; crossref.h whereisServedListing) ──────────────────── +# When the defs page and the listing=all page show the SAME definitions, the default serves the defs page only when it is +# STRICTLY shorter, in bytes, than the all page (as written AND in the compact dialect); otherwise it serves the all page. +# (Fix round 2, review D1: when the defs page shows MORE definitions under the row cap, the default serves it whatever its +# bytes — the L15/L16 arms below.) An explicit --whereis-listing=defs is served as asked, +# so each arm reads the two explicit pages of the same binary and checks which one the default is, byte for byte. +nbytes(){ printf '%s' "$1" | wc -c | tr -d ' '; } +# (L11) a 1-ref symbol: the defs page is LONGER than the all page, so the default IS the all page (no listing=, no ) +O_D="$( lw zqOne )"; O_A="$( lw zqOne --whereis-listing=all )"; O_F="$( lw zqOne --whereis-listing=defs )" +{ [ -n "$( printf '%s' "$O_A" | hits_of ref )" ] && printf '%s' "$O_F" | grep -q ' all page $( nbytes "$O_A" ) B, so the default is byte-identical to listing=all" \ + || { no "LEAN (L11): the 1-ref default is not the (shorter) all page: default $( nbytes "$O_D" ) B, all $( nbytes "$O_A" ) B, defs $( nbytes "$O_F" ) B"; printf '%s\n' "$O_D" | lroot; } +# (L12) a many-ref symbol (73 refs) stays lean: the default IS the explicit defs page, strictly shorter than all +M_F="$( lw zqLean --whereis-listing=defs )"; M_A="$( lw zqLean --whereis-listing=all )" +{ [ "$LDEF" = "$M_F" ] && printf '%s' "$LDEF" | lroot | grep -q ' listing="defs"' && [ "$( nbytes "$M_F" )" -lt "$( nbytes "$M_A" )" ]; } \ + && ok "LEAN (L12): 73-ref symbol — the default is the defs page ($( nbytes "$M_F" ) B < all $( nbytes "$M_A" ) B)" \ + || no "LEAN (L12): the many-ref default is not the defs page (default $( nbytes "$LDEF" ) B, defs $( nbytes "$M_F" ) B, all $( nbytes "$M_A" ) B)" +# (L13) the TIE: three repos identical but for the length of the one ref row's text. The probe repo measures how much +# longer the defs page is (D); padding the ref row by D more bytes makes the two pages EQUAL. A tie serves all (equal +# bytes, more rows); one byte more and defs is strictly shorter, one byte less and all is. +mk_tie(){ local d="$TMP/tie$1"; mkdir -p "$d"; git -C "$d" init -q -b main >/dev/null 2>&1; git -C "$d" config commit.gpgsign false + printf 'int zqTie( int x ) { return x; }\n' >"$d/tie.c" + printf 'int useT( void ) { return zqTie( 1 ); } // %s\n' "$( head -c "$1" "$d/use.c" + git -C "$d" add -A >/dev/null 2>&1; git -C "$d" commit -qm tie >/dev/null 2>&1; printf '%s' "$d"; } +tw(){ "$BIN" "$1" --whereis=zqTie --no-cache "${@:2}" 2>/dev/null; } +TP="$( mk_tie 1 )"; TD=$(( $( nbytes "$( tw "$TP" --whereis-listing=defs )" ) - $( nbytes "$( tw "$TP" --whereis-listing=all )" ) )) +if [ "$TD" -gt 1 ] 2>/dev/null; then + for k in -1 0 1; do + TR="$( mk_tie $(( 1 + TD + k )) )" + T_D="$( tw "$TR" )"; T_F="$( tw "$TR" --whereis-listing=defs )"; T_A="$( tw "$TR" --whereis-listing=all )" + TF="$( nbytes "$T_F" )"; TA="$( nbytes "$T_A" )" + case $k in + -1) { [ $(( TF - TA )) -eq 1 ] && [ "$T_D" = "$T_A" ]; } \ + && ok "LEAN (L13a): defs page 1 B LONGER ($TF vs $TA) — the default is the all page" \ + || no "LEAN (L13a): defs $TF / all $TA, default $( nbytes "$T_D" ) B — want the all page" ;; + 0) { [ "$TF" -eq "$TA" ] && [ "$T_D" = "$T_A" ] && [ "$T_D" != "$T_F" ]; } \ + && ok "LEAN (L13b): defs page and all page EQUAL ($TF B) — the tie serves the all page (more rows)" \ + || no "LEAN (L13b): tie defs $TF / all $TA, default $( nbytes "$T_D" ) B — want the all page on a tie" ;; + 1) { [ $(( TA - TF )) -eq 1 ] && [ "$T_D" = "$T_F" ]; } \ + && ok "LEAN (L13c): defs page 1 B SHORTER ($TF vs $TA) — the default is the defs page" \ + || no "LEAN (L13c): defs $TF / all $TA, default $( nbytes "$T_D" ) B — want the defs page" ;; + esac + done +else + no "LEAN (L13): the tie probe measured no defs-page overhead (D='$TD'): the probe did not run" +fi +# (L14) MCP twin: the same served page over MCP — the 1-ref default is the all page, the tie serves all, the +# many-ref default is the defs page; each byte-identical to the CLI. +mcpq(){ printf '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"whereis","arguments":{"path":"%s","symbol":"%s"%s}}}\n' "$1" "$2" "$3" \ + | "$BIN" --mcp 2>/dev/null | python3 -c 'import sys,json +for line in sys.stdin: + line=line.strip() + if not line: continue + d=json.loads(line); c=d.get("result",{}).get("content") + if c: print(c[0].get("text",""),end="") + elif "error" in d: print("ERROR",d["error"].get("code"),end="")'; } +if command -v python3 >/dev/null 2>&1; then + TT="$TMP/tie$(( 1 + TD ))" + { [ "$( mcpq "$LR" zqOne '' )" = "$O_A" ] && [ "$( mcpq "$LR" zqOne ',"listing":"defs"' )" = "$O_F" ] \ + && [ "$( mcpq "$LR" zqLean '' )" = "$M_F" ] && [ -d "$TT" ] && [ "$( mcpq "$TT" zqTie '' )" = "$( tw "$TT" --whereis-listing=all )" ]; } \ + && ok "LEAN (L14): MCP whereis serves the same page as the CLI default — all on the 1-ref symbol and the tie, defs on the 73-ref symbol" \ + || no "LEAN (L14): the MCP default serves a different page than the CLI" +else + printf ' SKIP LEAN (L14) MCP twin (no python3)\n' +fi + +# ── COMPLETE ANSWERS FIRST (fix round 2, review D1; crossref.h whereisServedListing) ─────────────────────────────── +# Under the shared row cap the all page can list FEWER definitions than the defs page (HEAD's references fill the cap +# before the branch definitions arrive). The default serves the page that SHOWS MORE definitions whatever its bytes; only +# when both show the same definitions do the bytes decide. The arms are fixture-built and compare the DEFAULT with the two +# explicit pages of the same binary, byte for byte. +defs_in(){ hits_of def | wc -l | tr -d ' '; } +dw(){ "$BIN" "$1" --whereis="$2" --no-cache "${@:3}" 2>/dev/null; } +# (L15) the cap bites: HEAD defines zqCap once and calls it 70 times; 64 branches sit at the previous commit (same files) +# and HEAD adds one unrelated commit, so every branch row carries tip=/date=. The all page lists HEAD's definition and 59 +# references (60 rows), the defs page 60 definitions, and the defs page is the LONGER one in bytes — the default is defs. +D15="$TMP/cap15"; mkdir -p "$D15"; git -C "$D15" init -q -b main >/dev/null 2>&1; git -C "$D15" config commit.gpgsign false +printf 'int zqCap( int x ) { return x; }\n' >"$D15/def.c" +{ printf 'int useAll( void )\n{\n int s = 0;\n'; i=0; while [ $i -lt 70 ]; do printf ' s += zqCap( %d );\n' $i; i=$((i+1)); done; printf ' return s;\n}\n'; } >"$D15/use.c" +printf '// head\n' >"$D15/other.c" +git -C "$D15" add -A >/dev/null 2>&1; git -C "$D15" commit -qm cap1 >/dev/null 2>&1 +i=0; while [ $i -lt 64 ]; do git -C "$D15" branch "b$i" >/dev/null 2>&1; i=$((i+1)); done +printf '// head, later\n' >"$D15/other.c"; git -C "$D15" add -A >/dev/null 2>&1 +GIT_COMMITTER_DATE="2026-02-02T00:00:00Z" GIT_AUTHOR_DATE="2026-02-02T00:00:00Z" git -C "$D15" commit -qm cap2 >/dev/null 2>&1 +C_D="$( dw "$D15" zqCap )"; C_A="$( dw "$D15" zqCap --whereis-listing=all )"; C_F="$( dw "$D15" zqCap --whereis-listing=defs )" +CD_A="$( printf '%s' "$C_A" | defs_in )"; CD_F="$( printf '%s' "$C_F" | defs_in )" +if [ "$CD_F" -gt "$CD_A" ] 2>/dev/null && [ "$( nbytes "$C_F" )" -gt "$( nbytes "$C_A" )" ] \ + && printf '%s' "$C_A" | lroot | grep -q ' capped="1"'; then + { [ "$C_D" = "$C_F" ] && printf '%s' "$C_D" | lroot | grep -q ' listing="defs"'; } \ + && ok "LEAN (L15): capped, the defs page is LONGER ($( nbytes "$C_F" ) B vs $( nbytes "$C_A" ) B) but lists more definitions ($CD_F vs $CD_A) — the default is the defs page" \ + || { no "LEAN (L15): the default ($( nbytes "$C_D" ) B) is not the defs page ($( nbytes "$C_F" ) B, $CD_F defs) over the all page ($( nbytes "$C_A" ) B, $CD_A defs)"; printf '%s\n' "$C_D" | lroot; } +else + no "LEAN (L15): premises not met: defs shown $CD_F vs all $CD_A, bytes $( nbytes "$C_F" ) vs $( nbytes "$C_A" ), all capped?" +fi +# (L15-MCP) the same symbol over MCP (no `listing`): byte-identical to the CLI defs page. +if command -v python3 >/dev/null 2>&1; then + { [ -n "$C_F" ] && [ "$( mcpq "$D15" zqCap '' )" = "$C_F" ] && [ "$( mcpq "$D15" zqCap ',"listing":"all"' )" = "$C_A" ]; } \ + && ok "LEAN (L15-MCP): MCP whereis serves the same defs page as the CLI default on the capped answer (listing:\"all\" still the all page)" \ + || no "LEAN (L15-MCP): the MCP default differs from the CLI's on the capped answer" +else + printf ' SKIP LEAN (L15-MCP) MCP twin (no python3)\n' +fi +# (L16) the negative for the bytes rule: 61 definitions on HEAD, one reference, a single branch. Both pages are capped at +# 60 rows and both show the SAME 60 definitions (definitions sort before references), and the all page is shorter (no +# element). The default is the all page: a fix that serves defs whenever the all page is capped, or that counts +# the TOTAL definitions instead of the SHOWN ones, goes red here. +D16="$TMP/cap16"; mkdir -p "$D16"; git -C "$D16" init -q -b main >/dev/null 2>&1; git -C "$D16" config commit.gpgsign false +i=0; while [ $i -lt 61 ]; do printf 'int zqMany( int );\n' >"$D16/h$i.h"; i=$((i+1)); done +printf 'int useMany( void ) { return zqMany( 1 ); }\n' >"$D16/use.c" +git -C "$D16" add -A >/dev/null 2>&1; git -C "$D16" commit -qm many >/dev/null 2>&1 +M16_D="$( dw "$D16" zqMany )"; M16_A="$( dw "$D16" zqMany --whereis-listing=all )"; M16_F="$( dw "$D16" zqMany --whereis-listing=defs )" +M16_DA="$( printf '%s' "$M16_A" | defs_in )"; M16_DF="$( printf '%s' "$M16_F" | defs_in )" +if [ "$M16_DA" = "$M16_DF" ] && [ "$M16_DA" -ge 60 ] 2>/dev/null && printf '%s' "$M16_A" | lroot | grep -q ' capped="1"' \ + && printf '%s' "$M16_F" | lroot | grep -q ' capped="1"' && [ "$( nbytes "$M16_A" )" -lt "$( nbytes "$M16_F" )" ]; then + { [ "$M16_D" = "$M16_A" ] && ! printf '%s' "$M16_D" | lroot | grep -q ' listing='; } \ + && ok "LEAN (L16): capped on both pages, the SAME $M16_DA definitions shown — the shorter all page ($( nbytes "$M16_A" ) B < $( nbytes "$M16_F" ) B) is the default" \ + || { no "LEAN (L16): the default ($( nbytes "$M16_D" ) B) is not the shorter all page ($( nbytes "$M16_A" ) B) when both show the same definitions"; printf '%s\n' "$M16_D" | lroot; } +else + no "LEAN (L16): premises not met: defs shown $M16_DA (all) vs $M16_DF (defs), bytes $( nbytes "$M16_A" ) vs $( nbytes "$M16_F" )" +fi +if command -v python3 >/dev/null 2>&1; then + [ "$( mcpq "$D16" zqMany '' )" = "$M16_A" ] \ + && ok "LEAN (L16-MCP): MCP whereis serves the same all page as the CLI when both pages show the same definitions" \ + || no "LEAN (L16-MCP): the MCP default differs from the CLI's on the capped, same-definitions answer" +else + printf ' SKIP LEAN (L16-MCP) MCP twin (no python3)\n' +fi + if command -v xmllint >/dev/null 2>&1; then if printf '%s' "$SW" | xmllint --noout - 2>/dev/null; then ok "capped stray-content still G4 clean"; else no "capped stray-content XML malformed"; fi if printf '%s' "$W1" | xmllint --noout - 2>/dev/null; then ok "paged whereis still G4 clean"; else no "paged whereis XML malformed"; fi diff --git a/test/defectshapecheck.sh b/test/defectshapecheck.sh new file mode 100644 index 000000000..346479eca --- /dev/null +++ b/test/defectshapecheck.sh @@ -0,0 +1,1724 @@ +#!/usr/bin/env bash +# defectshapecheck.sh — gate for the twelfth --quality-delta kind, defect-shape: four shapes of a REAL defect +# that review keeps finding by hand and that a deterministic syntactic check finds at write time. Each row is +# , one kind with four facets so the kind list grows once and +# --ack-only=FACET selects one shape: +# format-arity — a literal std::format-family / rw::emitTo / rw::formatTo / fmt:: format string, or a Python +# "literal".format(...), whose replacement fields do not match its arguments. GATES (a dropped +# argument is a defect, not debt — so it gates whatever the origin; see §1 freshbad). +# std::format_string rejects too FEW arguments at compile time and accepts too MANY silently; +# Python raises on too few at run time and ignores too many. +# utf8-cut — C++: a byte cap on display text (a cut plus an appended ellipsis) with no UTF-8 boundary +# back-off, or an ellipsis appended at a byte cap reached by pushing bytes. REPORT-ONLY. +# dedup-first — C++: std::unique / std::ranges::unique with a predicate over a type that carries a +# severity-like field, where neither the predicate nor the sort before it reads that field, +# so the FIRST row of a run survives, not the most severe. REPORT-ONLY. +# vacuous-assert — Bash, test-script paths only: an ABSENCE assertion (grep -q PAT whose match is the failure +# branch) read off a command whose failure is never checked, so a crash reads as PASS. +# REPORT-ONLY. +# Like every kind, a row fires only on what THIS change introduced, never on pre-existing debt: a site is new +# when its normalized text occurs more often than among the baseline's sites of that facet (repo-wide), so an untouched, +# MOVED or RENAMED mismatch gives no row and a swap (one fixed, one added in the same function) still does. +# was/now count the anchor's offending sites. Each facet has near-miss negatives the new code could plausibly +# mis-handle. +# +# format-arity is the ONE exception to "new-symbol rows never gate": new-symbol rows never gate, except +# defect-shape format-arity (§1 freshbad, §5 legend/help/MCP/skill/docs arms). Every surface that applies the +# exit predicate (XML gating=, --json and MCP "gating", --ack-only=gating) must agree on it (§1 ackgating, §5). +# +# Stated floors (no row is not a verdict on these): Python %-formatting and f-strings; a format held in a +# named constant, a macro, a pack expansion or a runtime/vformat wrapper; an argument that may be a macro (this file's +# #defines, a FUNCTION-LIKE macro the tree defines, an ALL_CAPS name called — an object-like macro defined only in another +# file is not seen), a call with a directive inside it or under a literal #if 0 (#if false / #if (0) / #if 0 && … / the +# #else of #if 1 read as live), an escape that spells a brace (§1d); an unqualified format()/print(); +# the std::print ostream overload; a dedup whose sort reads severity in the MILD-first direction, or whose +# predicate/comparator is a named function (skipped, never guessed); a utf8 cut with no ellipsis; a +# vacuous-assert absence read off a file a run redirected into (§4 n_fileredirect). And the +# utf8/dedup fixtures cannot see an exit-only bug on their own (their clone near-misses gate duplication) — +# §4 vac and §5 both carry the exit judgment for the report-only facets. +# +# Every absence assertion in this gate is read off a delta run that SUCCEEDED (rc 0 or 2 and a +# /dev/null 2>&1 || { echo " SKIP defectshapecheck (git not available)"; exit 0; } + +WORK="$( mktemp -d )" || { echo " FAIL defectshapecheck: mktemp -d failed"; exit 1; } +[ -n "$WORK" ] && [ -d "$WORK" ] || { echo " FAIL defectshapecheck: no temp dir"; exit 1; } +trap 'rm -rf "$WORK"' EXIT +echo "defectshapecheck: BIN=$BIN (temp corpora)" + +# ── harness ───────────────────────────────────────────────────────────────────────────────────────────── +fx_init(){ # NAME → a fresh git repo at $WORK/NAME + local d="$WORK/$1" + mkdir -p "$d" && ( cd "$d" && git init -q && git config user.email t@t && git config user.name t ) \ + || { no "$1: fixture repo could not be created"; return 1; } +} +fx_commit(){ ( cd "$WORK/$1" && git add -A >/dev/null 2>&1 && git commit -qm base >/dev/null 2>&1 ) || no "$1: baseline commit failed"; } +QD_OUT=""; QD_RC=99; QD_OK=0 +fx_run(){ # NAME [extra args] → QD_OUT, QD_RC, QD_OK (1 only when the run succeeded and produced its root) + local d="$WORK/$1"; shift + QD_OUT="$( cd "$d" && "$BIN" . --quality-delta --no-cache "$@" 2>/dev/null )" + QD_RC=$? + QD_OK=0 + if { [ "$QD_RC" = 0 ] || [ "$QD_RC" = 2 ]; } && printf '%s' "$QD_OUT" | grep -q '/dev/null )" + QD_RC=$? + QD_OK=0 + if { [ "$QD_RC" = 0 ] || [ "$QD_RC" = 2 ]; } && printf '%s' "$QD_OUT" | grep -q '' '\n' | grep '}" +} +hdr_attr(){ printf '%s' "$QD_OUT" | tr '>' '\n' | grep '= MIN, + # and the exit is 2 exactly when some row gates. + [ "$QD_OK" = 1 ] || { no "$1: cannot judge gating off a failed run"; return; } + local hg all fa other + hg="$( hdr_attr gating )" + case "$hg" in ''|*[!0-9]*) no "$1: header gating= is missing or not numeric ('$hg')"; return ;; esac + all="$( printf '%s' "$QD_OUT" | tr '>' '\n' | grep '= $2)"; else no "$1: $fa format-arity rows gate, want >= $2"; fi + if [ "$all" -gt 0 ]; then + if [ "$QD_RC" = 2 ]; then ok "$1: exit 2 with $all gating rows"; else no "$1: $all gating rows but exit $QD_RC"; fi + else + if [ "$QD_RC" = 0 ]; then ok "$1: exit 0 with no gating row"; else no "$1: no gating row but exit $QD_RC"; fi + fi +} + +# ── 1) FORMAT-ARITY, C++ ──────────────────────────────────────────────────────────────────────────────── +# Every function exists at the baseline with a CORRECT call. The edit turns some into a mismatch (the +# positives) and the rest into correct-but-tricky spellings the counter must get right (the near-misses): +# {{ }} escapes, format specs, positional {0}, nested dynamic width {:>{}} (it consumes an argument), a +# non-literal or macro format (skipped, never guessed), a "{}" inside an ARGUMENT, a raw string, a wide +# literal, a member function that happens to be called format, a commented-out call, a char argument '{'. +fx_init fmt_cpp +mkdir -p "$WORK/fmt_cpp/src" +FMT_HDR='#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace rw +{ +template void emitTo( std::FILE* stream, std::format_string f, A&&... a ); +template std::size_t formatTo( char* buf, std::size_t cap, std::format_string f, A&&... a ); +template void emitRaw( std::FILE* stream, const S& text ); +} + +#define FMT_PAIR "{} {}" +#define PAIR a, b +constexpr const char* kFmt = "{} and {}"; +std::string format( const char* f, int a ); + +struct Log +{ + std::string format( const char* f, int a ) const; +}; +' +{ printf '%s' "$FMT_HDR"; cat <<'EOF' +std::string extra( int a, int b ) { return std::format( "a={} b={}", a, b ); } +std::string fewer( int a, int b ) { return std::format( "a={} b={}", a, b ); } +void concat( int a, int b ) +{ + rw::emitTo( stdout, "\n", + a, b ); +} +std::size_t formatto( int a, int b ) { char buf[64]; return rw::formatTo( buf, sizeof( buf ), "", a, b ); } +std::string formattostd( int a, int b ) { std::string out; std::format_to( std::back_inserter( out ), "{}-{}", a, b ); return out; } +std::string positional( int a, int b ) { return std::format( "{0} {1}", a, b ); } +std::string dynwidthbad( const std::string& s, int w ) { return std::format( "{:>{}}", s, w ); } +void printstream( int a, int b ) { std::print( stderr, "{} {}\n", a, b ); } +std::string fmtns( int a, int b ) { return fmt::format( "{} {}", a, b ); } +std::string escaped( int a ) { return std::format( "{}", a ); } +std::string spec( int a, double d, int b, std::chrono::sys_days t, const std::string& s ) { return std::format( "{} {} {} {} {}", a, d, b, t, s ); } +std::string posok( int a, int b ) { return std::format( "{} {}", a, b ); } +std::string dynwidth( const std::string& s, int w, double d, int p ) { return std::format( "{} {} {} {}", s, w, d, p ); } +std::string nonliteral( int a, int b ) { return std::format( "{} {}", a, b ); } +std::string fmtconstarg( int a ) { return std::format( "{}", a ); } +std::string macro( int a, int b ) { return std::format( "{} {}", a, b ); } +std::string inarg( int a ) { return std::format( "{}", a ); } +std::string rawstr( int a ) { return std::format( "{}", a ); } +std::wstring wide( int a, int b ) { return std::format( L"{}", a + b ); } +std::string member( const Log& log, int a ) { return log.format( "{}", a ); } +std::string commented( int a ) { return std::format( "{}", a ); } +std::string charlit( int a ) { return std::format( "{}", a ); } +void printok( int a ) { std::println( "{}", a ); } +void raw() { rw::emitRaw( stdout, "plain" ); } +std::string localebad( int a, int b ) { return std::format( std::locale( "C" ), "{} {}", a, b ); } +std::string unrefpos( int a, int b ) { return std::format( "{0} {1}", a, b ); } +template std::string packexp( A&&... a ) { return std::format( "{}", std::forward( a )... ); } +std::string runtimefmt( int a ) { return std::format( std::runtime_format( "{}" ), a ) + fmt::format( fmt::runtime( "{}" ), a ); } +std::string pairmacro( int a, int b ) { return std::format( "{}", a + b ); } +std::string pricat( std::uint64_t a ) { return std::format( "{}", a ); } +std::string unqualified( int a ) { return format( "{}", a ); } +void ostreamprint( int a ) { std::print( std::cout, "{}", a ); } +std::string formattonok( int a, int b ) { char out[16]; std::format_to_n( out, 10, "{}", a + b ); return out; } +std::string posnested( const std::string& s, int w ) { return std::format( "{}", s ); } +std::string swapped( int a, int b ) { return std::format( "{} {}", a, b, b ) + std::format( "{}", a ); } +std::string moved( int a ) { return std::format( "m={}", a, a ); } +std::string cmpinarg( int a, int b ) { return std::format( "{}", a > b ); } +std::string tmplarg( int a, int b ) { return std::format( "{}", b ); } +std::string legacy( int a ) { return std::format( "{} {}", a, a, a ); } +int drive() { return 0; } +EOF +} >"$WORK/fmt_cpp/src/fmt.cpp" +printf '#include \n#define LOGF( ... ) std::format( "{}", __VA_ARGS__ )\n' >"$WORK/fmt_cpp/src/vamacro.h" +fx_commit fmt_cpp +{ printf '%s' "$FMT_HDR"; cat <<'EOF' +std::string extra( int a, int b ) { return std::format( "a={} b={}", a, b, a + b ); } +std::string fewer( int a, int b ) { return std::format( "a={} b={} c={}", a, b ); } +void concat( int a, int b ) +{ + rw::emitTo( stdout, "\n", + a, b, a, b ); +} +std::size_t formatto( int a, int b ) { char buf[64]; return rw::formatTo( buf, sizeof( buf ), "", a, b ); } +std::string formattostd( int a, int b ) { std::string out; std::format_to( std::back_inserter( out ), "{}-{}", a, b, a ); return out; } +std::string positional( int a, int b ) { return std::format( "{0} {1}", a, b, a ); } +std::string dynwidthbad( const std::string& s, int w ) { return std::format( "{:>{}}", s ); } +void printstream( int a, int b ) { std::print( stderr, "{} {}\n", a ); } +std::string fmtns( int a, int b ) { return fmt::format( "{} {}", a ); } +std::string escaped( int a ) { return std::format( "{{literal}} {} }}{{", a ); } +std::string spec( int a, double d, int b, std::chrono::sys_days t, const std::string& s ) { return std::format( "{:>8} {:.2f} {:#x} {:%Y-%m-%d} {:*^10}", a, d, b, t, s ); } +std::string posok( int a, int b ) { return std::format( "{0} and {0} and {1}", a, b ); } +std::string dynwidth( const std::string& s, int w, double d, int p ) { return std::format( "{:>{}} {:{}.{}f}", s, w, d, w, p ); } +std::string nonliteral( int a, int b ) { return std::format( kFmt, a, b, a ) + std::vformat( kFmt, std::make_format_args( a, b, a ) ); } +std::string fmtconstarg( int a ) { return std::format( kFmt, "{}" ); } +std::string macro( int a, int b ) { return std::format( FMT_PAIR, a, b, a ); } +std::string inarg( int a ) { return std::format( "{}", "{} {}" ); } +std::string rawstr( int a ) { return std::format( R"x()x", a ); } +std::wstring wide( int a, int b ) { return std::format( L"{} {}", a, b ); } +std::string member( const Log& log, int a ) { return log.format( "{} {} {}", a ); } +std::string commented( int a ) +{ + // return std::format( "{} {}", a ); + return std::format( "{}", a ); +} +std::string charlit( int a ) { return std::format( "{}{}", '{', a ); } +void printok( int a ) { std::println( "{} {}", a, a ); } +void raw() { rw::emitRaw( stdout, "{} {} {}" ); } +std::string localebad( int a, int b ) { return std::format( std::locale( "C" ), "{} {}", a, b, a ); } +std::string unrefpos( int a, int b ) { return std::format( "{1}", a, b ); } +template std::string packexp( A&&... a ) { return std::format( "{} {}", std::forward( a )... ); } +std::string runtimefmt( int a ) { return std::format( std::runtime_format( "{} {}" ), a ) + fmt::format( fmt::runtime( "{} {}" ), a ); } +std::string pairmacro( int a, int b ) { return std::format( "{} {}", PAIR ); } +std::string pricat( std::uint64_t a ) { return std::format( "{} " PRIu64, a ); } +std::string unqualified( int a ) { return format( "{} {}", a ); } +void ostreamprint( int a ) { std::print( std::cout, "{} {}", a ); } +std::string formattonok( int a, int b ) { char out[16]; std::format_to_n( out, 10, "{} {}", a, b ); return out; } +std::string posnested( const std::string& s, int w ) { return std::format( "{0:>{1}}", s, w ); } +std::string swapped( int a, int b ) { return std::format( "{} {}", a, b ) + std::format( "{}", a, b ); } +std::string cmpinarg( int a, int b ) { return std::format( "{}", a > b, a ); } +std::string tmplarg( int a, int b ) { return std::format( "{} {}", std::pair{ a, b }.first, b ); } +std::string legacy( int a ) { return std::format( "{} {}", a, a, a ); } +std::string freshbad( int a ) { return std::format( "{}", a, a ); } +std::string freshok( int a ) { return std::format( "{} {}", a, a ); } +int drive() { return 1; } +EOF +} >"$WORK/fmt_cpp/src/fmt.cpp" +printf '#include \n#define LOGF( ... ) std::format( "{} {}", __VA_ARGS__ )\n' >"$WORK/fmt_cpp/src/vamacro.h" +printf '#include \n#include \nstd::string movedRenamed( int a ) { return std::format( "m={}", a, a ); }\n' >"$WORK/fmt_cpp/src/moved.cpp" +fx_run fmt_cpp +L="format-arity (c++)" +ds_has "$L" format-arity extra gating +ds_has "$L" format-arity fewer gating +ds_has "$L" format-arity concat gating +ds_has "$L" format-arity formatto gating +ds_has "$L" format-arity formattostd gating +ds_has "$L" format-arity positional gating +ds_has "$L" format-arity dynwidthbad gating +ds_has "$L" format-arity printstream gating +ds_has "$L" format-arity fmtns gating +ds_has "$L" format-arity localebad gating +ds_has "$L" format-arity unrefpos gating +ds_has "$L" format-arity swapped gating +ds_has "$L" format-arity cmpinarg gating +ds_has "$L" format-arity freshbad gating-new +ds_wasnow "$L" format-arity extra 0 1 +ds_wasnow "$L" format-arity concat 0 1 +ds_wasnow "$L" format-arity swapped 1 1 +ds_none "$L" escaped "{{ and }} are escapes, not fields" +ds_none "$L" spec "a format spec is part of its field" +ds_none "$L" posok "{0} twice and {1} need two arguments" +ds_none "$L" dynwidth "a nested {} width/precision consumes an argument" +ds_none "$L" nonliteral "a named format constant or vformat is skipped, never guessed" +ds_none "$L" fmtconstarg "the literal is an ARGUMENT, not the format" +ds_none "$L" macro "a macro format is skipped, never guessed" +ds_none "$L" inarg "a {} inside an argument string is not a field" +ds_none "$L" rawstr "a raw string with a custom delimiter and a )\" inside, one field" +ds_none "$L" wide "an L-prefixed literal counts like any other" +ds_none "$L" member "Log::format is not the format family" +ds_none "$L" commented "a commented-out call is not code" +ds_none "$L" charlit "a '{' char argument is an argument" +ds_none "$L" printok "std::println with matching arguments" +ds_none "$L" raw "emitRaw prints bytes, it does not format" +ds_none "$L" legacy "a mismatch the change did not introduce (untouched function)" +ds_none "$L" freshok "a new function with a matching call" +ds_none "$L" packexp "a pack expansion makes the argument count unknowable (skipped)" +ds_none "$L" runtimefmt "std::runtime_format / fmt::runtime wrap a non-literal format (skipped)" +ds_none "$L" pairmacro "an object-like macro argument may expand to a comma list (skipped)" +ds_none "$L" pricat "an adjacent-literal concat with a macro piece is not a whole literal (skipped)" +ds_none "$L" unqualified "an unqualified in-repo format() is not the format family" +ds_none "$L" ostreamprint "the std::print ostream overload is skipped, never guessed (stated floor)" +ds_none "$L" formattonok "std::format_to_n puts the format at index 2: two fields, two arguments" +ds_none "$L" posnested "a positional nested width {0:>{1}} references both arguments" +ds_none "$L" tmplarg "a template argument list (std::pair) the comma split would cut makes the count unknowable (skipped)" +ds_none_p "$L" src/vamacro.h "a format inside a macro body (__VA_ARGS__) is skipped" +ds_none "$L" moved "a mismatch moved out of this file is not new" +ds_none "$L" movedRenamed "a mismatch moved to a new file and renamed is not new (its call text was already in the baseline)" +ds_none_p "$L" src/moved.cpp "the moved mismatch's new file carries no row" +gating_split "$L" 14 +OFMT="$QD_OUT" +fx_run fmt_cpp +if [ "$QD_OK" = 1 ] && [ "$OFMT" = "$QD_OUT" ]; then ok "$L: delta byte-identical run-to-run"; else no "$L: non-deterministic delta"; fi +if command -v xmllint >/dev/null 2>&1; then + if printf '%s' "$OFMT" | xmllint --noout - 2>/dev/null; then ok "$L: xml well-formed"; else no "$L: xml malformed"; fi +else + echo " SKIP $L: xml well-formedness (xmllint not installed)" +fi +# The exit predicate's own ack token: --ack-only=gating selects whatever would exit 2, and a new-symbol +# format-arity row (freshbad) DOES exit 2, so the ack must take it too — the ack token and the XML gating= +# must read one predicate. On a fresh copy, so the later arms see the unacked fixture. +L="format-arity ack gating" +if printf '%s' "$OFMT" | tr '>' '\n' | grep '/dev/null 2>&1 ) + ARC=$? + if [ "$ARC" = 0 ]; then ok "$L: --ack-only=gating wrote its acks (exit 0)"; else no "$L: --ack-only=gating exited $ARC"; fi + fx_run fmt_ackg + if [ "$QD_OK" = 1 ]; then + left="$( ds_rows | grep -c 'defect="format-arity"' )" + if [ "$left" = 0 ]; then ok "$L: no format-arity row survives --ack-only=gating (freshbad included)" + else no "$L: $left format-arity rows survived --ack-only=gating: $( ds_rows | grep 'defect="format-arity"' | head -3 )"; fi + gating_split "$L" 0 + fi +else + no "$L: premise: the unacked fixture has no format-arity row on freshbad, so there is nothing for the ack to select" +fi + +# ── 1c) FORMAT-ARITY across a CLEAN MERGE (ref-pair mode) — the train-25 incident ─────────────────────── +# Each side adds the same field literal and one argument; git merges the two cleanly into 5 fields and 6 +# arguments, which compiles. Each side alone matches. --quality-delta=BASE..HEAD takes the ref-pair snapshot +# path, which no other arm here exercises. +fx_init merge +mkdir -p "$WORK/merge/src" +MERGE_BASE_SRC='#include +namespace rw { template void emitTo( std::FILE* stream, const char* f, A&&... a ); } +void legend( int a, int b, int c, int d, int e, int f ) +{ + rw::emitTo( stdout, "\n", + a, + e, + f, + d ); +} +int drive() { return 0; } +' +printf '%s' "$MERGE_BASE_SRC" >"$WORK/merge/src/legend.cpp" +fx_commit merge +MB="$( cd "$WORK/merge" && git rev-parse HEAD )" +( cd "$WORK/merge" && git checkout -q -b side_x \ + && sed -i.bak -e 's|^ "x=\\"\\" "$|&\ + "n=\\"{}\\" "|' -e 's|^ a,$|&\ + b,|' src/legend.cpp && rm -f src/legend.cpp.bak && git commit -qam x \ + && git checkout -q "$MB" && git checkout -q -b side_y \ + && sed -i.bak -e 's|^ "x=\\"\\" "$|&\ + "n=\\"{}\\" "|' -e 's|^ f,$|&\ + c,|' src/legend.cpp && rm -f src/legend.cpp.bak && git commit -qam y \ + && git checkout -q -b merged side_x && git merge -q --no-edit side_y >/dev/null 2>&1 ) \ + || no "merge: building the two sides and their clean merge failed" +MF="$( grep -c '{}' "$WORK/merge/src/legend.cpp" )" +MA="$( grep -cE '^ [a-f],$|^ d \);$' "$WORK/merge/src/legend.cpp" )" +if [ "$MF" = 3 ] && [ "$MA" = 6 ]; then ok "merge: the clean merge holds the duplicated literal once and both arguments (premise)" +else no "merge: premise: expected 3 field lines and 6 argument lines after the merge, got $MF / $MA"; cat "$WORK/merge/src/legend.cpp"; fi +L="format-arity (merge, ref-pair)" +fx_run_ref merge "$MB..HEAD" +ds_has "$L" format-arity legend gating +gating_split "$L" 1 +fx_run_ref merge "$MB..side_x" +ds_none "$L side x" legend "one side alone: five fields, five arguments" +fx_run_ref merge "$MB..side_y" +ds_none "$L side y" legend "the other side alone: five fields, five arguments" + +# ── 1b) FORMAT-ARITY, Python "literal".format(...) ────────────────────────────────────────────────────── +# Too few raises IndexError/KeyError at run time; too many is ignored silently. Named fields count against +# keyword arguments, auto/positional fields against positional ones; *args / **kwargs make the count +# unknowable (skipped). f-strings, %-formatting and a non-literal receiver are out of the shape. An explicit +# keyword the format never names is ignored silently (a row); a raw r"" literal and an unparenthesised +# implicit concat ("a" "b".format) are one literal like any other. +fx_init fmt_py +printf 'VERSION = "1"\nBANNER = "v{}".format(VERSION)\n' >"$WORK/fmt_py/banner.py" +cat >"$WORK/fmt_py/fmt.py" <<'EOF' + + +def extra(a, b): + return "a={} b={}".format(a, b) + + +def fewer(a, b): + return "a={} b={}".format(a, b) + + +def namedmissing(x, y): + return "{x} {y}".format(x=x, y=y) + + +def concat(a, b): + return ("a={} " "b={}").format(a, b) + + +def escaped(a): + return "{}".format(a) + + +def star(pair): + return "{} {}".format(pair[0], pair[1]) + + +def kwstar(d): + return "{a} {b}".format(a=d["a"], b=d["b"]) + + +def attr(f): + return "{}".format(f.name) + + +def index(d): + return "{}".format(d["k"]) + + +def nested(a): + return "{}".format(a) + + +def mixed(a, b): + return "{} {}".format(a, b) + + +def fstring(a, b): + return "{} {}".format(a, b) + + +def nonliteral(tmpl, a, b): + return tmpl.format(a, b) + + +def percent(a): + return "%s" % (a,) + + +def triple(a, b): + return """{} {}""".format(a, b) + + +def kwunused(x, y): + return "{x}".format(x=x) + + +def rawbad(a): + return r"{}".format(a) + + +def concatbare(a, b): + return "a={} " "b={}".format(a, b) + + +def nestedauto(s, w): + return "{}".format(s) + + +def fmtmap(d): + return "{a}".format_map(d) + + +def legacy(a): + return "{} {}".format(a, a, a) +EOF +fx_commit fmt_py +printf 'VERSION = "1"\nBANNER = "v{} build {}".format(VERSION)\n' >"$WORK/fmt_py/banner.py" +cat >"$WORK/fmt_py/fmt.py" <<'EOF' + + +def extra(a, b): + return "a={} b={}".format(a, b, a + b) + + +def fewer(a, b): + return "a={} b={} c={}".format(a, b) + + +def namedmissing(x, y): + return "{x} {y}".format(x=x) + + +def concat(a, b): + return ("a={} " "b={} " "c={}").format(a, b) + + +def escaped(a): + return "{{}} {} {{x}}".format(a) + + +def star(pair): + return "{} {} {}".format(*pair) + + +def kwstar(d): + return "{a} {b} {c}".format(**d) + + +def attr(f): + return "{0.name} {0.size}".format(f) + + +def index(d): + return "{0[k]} {0[j]}".format(d) + + +def nested(a): + return "{!r:>{w}}".format(a, w=3) + + +def mixed(a, b): + return "{0} {name}".format(a, name=b) + + +def fstring(a, b): + return f"{a} {b} {{}}" + + +def nonliteral(tmpl, a, b): + return tmpl.format(a, b, a) + + +def percent(a): + return "%s %s" % (a,) + + +def triple(a, b): + return """{} +{}""".format(a, b) + + +def kwunused(x, y): + return "{x}".format(x=x, y=y) + + +def rawbad(a): + return r"{} {}".format(a) + + +def concatbare(a, b): + return "a={} " "b={}".format(a) + + +def nestedauto(s, w): + return "{:>{}}".format(s, w) + + +def fmtmap(d): + return "{a} {b}".format_map(d) + + +def legacy(a): + return "{} {}".format(a, a, a) + + +def freshbad(a): + return "{}".format(a, a) +EOF +fx_run fmt_py +L="format-arity (python)" +ds_has "$L" format-arity extra gating +ds_has "$L" format-arity fewer gating +ds_has "$L" format-arity namedmissing gating +ds_has "$L" format-arity concat gating +ds_has "$L" format-arity freshbad gating-new +ds_has_p "$L" format-arity banner.py gating +ds_has "$L" format-arity kwunused gating +ds_has "$L" format-arity rawbad gating +ds_has "$L" format-arity concatbare gating +ds_none "$L" escaped "{{ }} are escapes" +ds_none "$L" star "*args makes the count unknowable" +ds_none "$L" kwstar "**kwargs makes the names unknowable" +ds_none "$L" attr "{0.name} {0.size} need one positional argument" +ds_none "$L" index "{0[k]} {0[j]} need one positional argument" +ds_none "$L" nested "a nested named width {w} is a keyword argument" +ds_none "$L" mixed "one positional, one named, both given" +ds_none "$L" fstring "an f-string has no argument list" +ds_none "$L" nonliteral "a non-literal receiver is skipped, never guessed" +ds_none "$L" percent "%-formatting is not this shape" +ds_none "$L" triple "a triple-quoted literal with two fields and two arguments" +ds_none "$L" legacy "a mismatch the change did not introduce" +ds_none "$L" nestedauto "an auto nested width {:>{}} consumes a positional argument" +ds_none "$L" fmtmap ".format_map takes a mapping, not an argument list (skipped)" +gating_split "$L" 9 + +# ── 1d) FORMAT-ARITY precision: macros, directives, dead code (rv-cr-qd-kinds-068 §1b F1/F2/F3) ───────── +# The rule that lets format-arity gate: when the argument count cannot be known, SKIP, never guess. The +# reviewer's adversarial shapes (fixtures p2/fp/repo, repo2) as negatives, each beside a near-miss POSITIVE the +# skip must not swallow: +# F1 an argument that may be a macro expanding to an argument list: an identifier this file or the tree +# #defines (SRC_LOC(), a lower-case pq( p ) here, xy( p ) defined in ANOTHER file), or an ALL_CAPS name +# followed by '(' whose definition is not visible (PAIR_OF( a ), and OUT_IT( s ) BEFORE the format, which +# may shift its index). A real function call (twice( a )) and a qualified Foo::BAR( 1 ) are still counted. +# F2 a preprocessor directive inside the call's parentheses (#ifdef arms in the argument list, or around the +# format itself): every arm's tokens would be concatenated. A directive OUTSIDE the call does not skip it. +# F3 a call inside #if 0 … #endif (nested conditionals inside it too) is dead code; the #else arm is live. +# And the low-realism edges: other::std::format is not the family (::std::format is); "\x7b\x7d" spells "{}" +# by escapes (skipped), "\x1b[1m{}" does not (counted). +fx_init fmt_pp +mkdir -p "$WORK/fmt_pp/src" +printf '#pragma once\n#define xy( p ) p.x, p.y\n#define INNER2() a, b\n' >"$WORK/fmt_pp/src/macros.h" +printf '#include "macros.h"\nint drive() { return 0; }\n' >"$WORK/fmt_pp/src/pp.cpp" +fx_commit fmt_pp +cat >"$WORK/fmt_pp/src/pp.cpp" <<'EOF' +#include +#include +#include +#include "macros.h" +#define SRC_LOC() __FILE__, __LINE__ +#define VEC3( v ) ( v ).x, ( v ).y, ( v ).z +#define pq( p ) p.first, p.second +#define ARGS( ... ) __VA_ARGS__ +#define LOGV( fmt, ... ) std::print( fmt, __VA_ARGS__ ) +#define FMTOF( x ) "{} " x +#define outer2() INNER2() +struct Foo { static int BAR( int x ); }; +int twice( int a ); +std::string n_srcloc( const char* m ) { return std::format( "{}:{} {}", SRC_LOC(), m ); } +std::string n_vec3( auto v ) { return std::format( "({}, {}, {})", VEC3( v ) ); } +std::string n_lowersame( auto p ) { return std::format( "{} {}", pq( p ) ); } +std::string n_lowertree( auto p ) { return std::format( "{} {}", xy( p ) ); } +std::string n_capsundef( int a ) { return std::format( "{} {}", PAIR_OF( a ) ); } +std::string n_beforefmt( std::string& s, int a, int b ) { std::format_to( OUT_IT( s ), "{}", a, b ); return s; } +std::string n_variadic( int a, int b ) { return std::format( "{} {}", ARGS( a, b ) ); } +void n_vawrapper( int a ) { LOGV( "{} {}\n", a ); } +std::string n_fmtmacro( int a ) { return std::format( FMTOF( "{}" ), a ); } +std::string n_nested( int a, int b ) { return std::format( "{} {}", outer2() ); } +std::string n_ifdefargs( int a, int b, int c ) +{ + return std::format( "{} {}", a +#ifdef _WIN32 + , b +#else + , c +#endif + ); +} +void n_ifdeffmt( int a, int b, int c ) +{ + std::print( +#ifdef VERBOSE + "{} {} {}\n", a, b, c +#else + "{}\n", a +#endif + ); +} +std::string n_ifzero( int a ) +{ +#if 0 + return std::format( "{} {}", a ); +#ifdef NESTED + return std::format( "{}", a, a ); +#endif + return std::format( "{} {} {}", a ); +#endif + return {}; +} +std::string n_qualified( int a, int b ) { return other::std::format( "{}", a, b ); } +std::string n_hexesc( int a ) { return std::format( "\x7b\x7d", a ); } +std::string p_lowerfn( int a ) { return std::format( "{} {}", twice( a ) ); } +std::string p_qualcaps( int a ) { return std::format( "{}", a, Foo::BAR( 1 ) ); } +#ifdef OUTSIDE +int outsideDirective = 1; +#endif +std::string p_afterdirective( int a ) { return std::format( "{}", a, a ); } +std::string p_ifzeroelse( int a ) +{ +#if 0 + return {}; +#else + return std::format( "{}", a, a ); +#endif +} +std::string p_globalns( int a ) { return ::std::format( "{}", a, a ); } +std::string p_hexother( int a ) { return std::format( "\x1b[1m{}\x1b[0m", a, a ); } +int drive() { return 1; } +EOF +fx_run fmt_pp +L="format-arity precision (c++)" +for f in n_srcloc n_vec3 n_lowersame n_lowertree n_capsundef n_beforefmt n_variadic n_vawrapper n_fmtmacro n_nested; do + ds_none "$L" "$f" "F1: a macro that may expand to an argument list makes the count unknowable (skipped)" +done +ds_none "$L" n_ifdefargs "F2: #ifdef arms inside the argument list (skipped)" +ds_none "$L" n_ifdeffmt "F2: #ifdef arms around the format itself (skipped)" +ds_none "$L" n_ifzero "F3: calls inside #if 0 … #endif, nested conditionals included, are dead code" +ds_none "$L" n_qualified "other::std::format is not the format family" +ds_none "$L" n_hexesc "a literal whose escapes spell a brace is skipped, never guessed" +ds_none_p "$L" src/macros.h "a macro definition is not a call" +ds_has "$L" format-arity p_lowerfn gating-new +ds_has "$L" format-arity p_qualcaps gating-new +ds_has "$L" format-arity p_afterdirective gating-new +ds_has "$L" format-arity p_ifzeroelse gating-new +ds_has "$L" format-arity p_globalns gating-new +ds_has "$L" format-arity p_hexother gating-new +FA="$( ds_rows | grep -c 'defect="format-arity"' )" +if [ "$QD_OK" = 1 ] && [ "$FA" = 6 ]; then ok "$L: exactly the 6 near-miss positives carry a format-arity row" +else no "$L: $FA format-arity rows, want exactly the 6 positives: $( ds_rows | grep 'defect="format-arity"' | head -10 )"; fi + +# Python: a raw literal has no escapes (r"\N{a}" holds the field {a}); "\x7b\x7d" spells "{}" (skipped); a +# newline outside brackets ends a statement, so X = "a {}" and a next-line "{}".format( 1 ) are not one concat. +fx_init fmt_py2 +printf 'def drive():\n return 0\n' >"$WORK/fmt_py2/p.py" +fx_commit fmt_py2 +cat >"$WORK/fmt_py2/p.py" <<'EOF' +def n_rawN(a): + return r"\N{a}".format(a=a) + + +def n_hexesc(a): + return "\x7b\x7d".format(a) + + +def n_namedesc(a): + return "\N{LEFT CURLY BRACKET}\N{RIGHT CURLY BRACKET}".format(a) + + +def p_rawextra(a, b): + return r"\d {}".format(a, b) + + +def p_escother(a, b): + return "\x1b[1m{}".format(a, b) + + +def drive(): + return 1 +EOF +printf 'X = "a {}"\n"{}".format(1)\n' >"$WORK/fmt_py2/m.py" +printf 'Y = 1\n"{} {}".format(1)\n' >"$WORK/fmt_py2/q.py" +fx_run fmt_py2 +L="format-arity precision (python)" +ds_none "$L" n_rawN "a raw literal's \\N is a backslash and an N; {a} is a field and a=a gives it" +ds_none "$L" n_hexesc "a literal whose escapes spell a brace is skipped, never guessed" +ds_none "$L" n_namedesc "\\N{LEFT CURLY BRACKET} spells a brace: skipped" +ds_none_p "$L" m.py "a module-level statement's literal is not concatenated with the next line's receiver" +ds_has "$L" format-arity p_rawextra gating-new +ds_has "$L" format-arity p_escother gating-new +ds_has_p "$L" format-arity q.py gating-new +# next= hands a gating row's body to open; a FILE-anchored row has no symbol to expand (rv N2), a function's row does +if [ "$QD_OK" = 1 ]; then + if ds_row_p format-arity q.py | grep -q ' next='; then no "$L: the file-anchored q.py row carries a next= that matches no symbol: $( ds_row_p format-arity q.py )" + else ok "$L: the file-anchored q.py row carries no next="; fi + if ds_row format-arity p_rawextra | grep -q ' next="--expand=p.py:p_rawextra"'; then ok "$L: a function-anchored row keeps next=--expand=FILE:NAME" + else no "$L: the p_rawextra row lost its next=: $( ds_row format-arity p_rawextra )"; fi +fi + +# ── 2) UTF8-CUT, C++ (report-only) ────────────────────────────────────────────────────────────────────── +# The corpus shapes: a cut (resize / substr(0,N) / erase(N)) plus an appended ellipsis on the SAME string +# with no UTF-8 back-off in the function, and an ellipsis appended once a byte-pushing loop reaches the +# cap (the cut lands mid-sequence and the ellipsis can fire with nothing dropped). Near-misses: a +# continuation-byte back-off (0xC0/0x80), a utf8-named helper, a cut with no ellipsis (a buffer, not text — +# a stated floor), an ellipsis on a different variable, an ellipsis that replaces the value, a u32string, +# a cut at a parsed delimiter (the cut length must be the bound of a size comparison on the same string, or +# a cap-named constant/parameter), and a loop that appends whole words. A back-off guard anywhere in the +# function exempts it: a precision choice, stated in the legend. +fx_init utf8 +mkdir -p "$WORK/utf8/src" +U8_HDR='#include +#include +#include + +constexpr std::size_t kCap = 80; +std::string truncateUtf8WithEllipsis( std::string_view s, std::size_t cap ); +' +{ printf '%s' "$U8_HDR"; cat <<'EOF' +std::string excerpt( std::string s ) { return s; } +std::string clip( const std::string& s, std::size_t n ) { return s; } +std::string build( std::string_view s ) +{ + std::string out; + for( char c : s ) + { + out.push_back( c ); + } + return out; +} +std::string erasecut( std::string s, std::size_t n ) { return s; } +std::string guarded( std::string s ) { return s; } +std::string helper( const std::string& s ) { return s; } +std::string noellipsis( std::string s, std::size_t n ) { return s; } +std::string othervar( std::string buf, std::string label, std::size_t n ) { return buf + label; } +std::string replaced( const std::string& s, std::size_t n ) { return s; } +std::string packname( const std::string& text, bool pack ) { return text; } +std::string wordloop( const std::vector& words ) { std::string out; return out; } +std::string capminus( std::string s ) { return s; } +std::string guardanywhere( std::string s ) { return s; } +std::u32string wide( std::u32string s, std::size_t n ) { return s; } +std::string legacy( std::string s ) { if( s.size() > 40 ) { s.resize( 40 ); s += "..."; } return s; } +int drive() { return 0; } +EOF +} >"$WORK/utf8/src/text.cpp" +fx_commit utf8 +{ printf '%s' "$U8_HDR"; cat <<'EOF' +std::string excerpt( std::string s ) +{ + if( s.size() > 117 ) + { + s.resize( 117 ); + s += "..."; + } + return s; +} +std::string clip( const std::string& s, std::size_t n ) { return s.size() > n ? s.substr( 0, n ) + "\xE2\x80\xA6" : s; } +std::string build( std::string_view s ) +{ + std::string out; + for( char c : s ) + { + out.push_back( c ); + if( out.size() >= kCap ) + { + out += "\xE2\x80\xA6"; + break; + } + } + return out; +} +std::string erasecut( std::string s, std::size_t n ) +{ + if( s.size() > n ) + { + s.erase( n ); + s.append( "..." ); + } + return s; +} +std::string guarded( std::string s ) +{ + if( s.size() > 117 ) + { + std::size_t n = 117; + while( n > 0 && ( static_cast( s[n] ) & 0xC0 ) == 0x80 ) + { + --n; + } + s.resize( n ); + s += "..."; + } + return s; +} +std::string helper( const std::string& s ) { return truncateUtf8WithEllipsis( s, 117 ); } +std::string noellipsis( std::string s, std::size_t n ) +{ + if( s.size() > n ) + { + s.resize( n ); + } + return s; +} +std::string othervar( std::string buf, std::string label, std::size_t n ) +{ + buf.resize( n ); + label += "..."; + return buf + label; +} +std::string replaced( const std::string& s, std::size_t n ) { return s.size() > n ? std::string( "..." ) : s; } +std::string packname( const std::string& text, bool pack ) +{ + std::string n = text.substr( 0, text.find( '=' ) ); + if( pack ) + { + n += "..."; + } + return n; +} +std::string wordloop( const std::vector& words ) +{ + std::string out; + for( const std::string& w : words ) + { + out += w; + if( out.size() >= kCap ) + { + out += "\xE2\x80\xA6"; + break; + } + } + return out; +} +std::u32string wide( std::u32string s, std::size_t n ) +{ + if( s.size() > n ) + { + s.resize( n ); + s += U"…"; + } + return s; +} +std::string guardanywhere( std::string s ) +{ + if( s.size() > 117 ) + { + std::size_t n = 117; + while( n > 0 && ( static_cast( s[n] ) & 0xC0 ) == 0x80 ) + { + --n; + } + s.resize( 117 ); + s += "..."; + } + return s; +} +std::string capminus( std::string s ) +{ + if( s.size() > 120 ) + { + s.resize( 117 ); + s += "..."; + } + return s; +} +std::string legacy( std::string s ) { if( s.size() > 40 ) { s.resize( 40 ); s += "..."; } return s; } +std::string freshcut( std::string s ) { if( s.size() > 60 ) { s = s.substr( 0, 60 ) + "..."; } return s; } +int drive() { return 1; } +EOF +} >"$WORK/utf8/src/text.cpp" +fx_run utf8 +L="utf8-cut (c++)" +ds_has "$L" utf8-cut excerpt minor +ds_has "$L" utf8-cut clip minor +ds_has "$L" utf8-cut build minor +ds_has "$L" utf8-cut erasecut minor +ds_has "$L" utf8-cut capminus minor +ds_has "$L" utf8-cut freshcut new-symbol +ds_wasnow "$L" utf8-cut excerpt 0 1 +ds_none "$L" guarded "the cut backs off continuation bytes (0xC0/0x80)" +ds_none "$L" helper "a utf8 truncation helper does the cut" +ds_none "$L" guardanywhere "a back-off anywhere in the function exempts it, even one that does not cover the cut (a precision choice, stated in the legend)" +ds_none "$L" noellipsis "a cut with no ellipsis is not judged display text (stated floor)" +ds_none "$L" othervar "the ellipsis goes on a different string than the one cut" +ds_none "$L" replaced "the ellipsis replaces the value, nothing is cut" +ds_none "$L" packname "the cut is at a parsed ASCII delimiter, not a size cap, and ... is pack syntax (src/ingest_names.h cppTemplateParameterName)" +ds_none "$L" wordloop "whole strings are appended, so no byte sequence is split (the byte-push rule needs a single-char push)" +ds_none "$L" wide "a u32string holds code points, a cut cannot split one" +ds_none "$L" legacy "a cut the change did not introduce" +gating_split "$L" 0 + +# ── 3) DEDUP-FIRST, C++ (report-only) ─────────────────────────────────────────────────────────────────── +# The corpus shape (a two-pass merge, sorted on (line, rule), uniqued keeping the first) plus its ranges and +# unsorted siblings. Near-misses: the sort orders on severity (worst first), the predicate compares severity, +# a type with no severity-like field, a field whose name only CONTAINS a severity word (indentLevel — a +# bounded token match), a predicate-less unique, a unique over ints, a heading level (bare level is not a +# severity token: severity-like = sev / severity / priority / prio), and a NAMED predicate (skipped). +# Stated floor: a sort that reads severity in the mild-first direction is not caught. +fx_init dedup +mkdir -p "$WORK/dedup/src" +DD_HDR='#include +#include +#include + +enum class Severity { Info, Warn, Critical }; +struct Finding +{ + int line = 0; + std::string rule; + Severity sev = Severity::Info; +}; +struct Row +{ + int line = 0; + std::string rule; + std::string text; +}; +struct Node +{ + int line = 0; + std::string rule; + int indentLevel = 0; +}; +struct Heading +{ + int line = 0; + int level = 0; +}; +bool sameLineSev( const Finding& a, const Finding& b ); +' +{ printf '%s' "$DD_HDR"; cat <<'EOF' +void mergeFirst( std::vector& v ) { (void)v; } +void rangesFirst( std::vector& v ) { (void)v; } +void unsortedFirst( std::vector& v ) { (void)v; } +void worstFirst( std::vector& v ) { (void)v; } +void predSev( std::vector& v ) { (void)v; } +void noSev( std::vector& v ) { (void)v; } +void indent( std::vector& v ) { (void)v; } +void defaultEq( std::vector& v ) { (void)v; } +void ints( std::vector& v ) { (void)v; } +void headings( std::vector& v ) { (void)v; } +void namedPred( std::vector& v ) { (void)v; } +void legacy( std::vector& v ) +{ + v.erase( std::unique( v.begin(), v.end(), []( const Finding& a, const Finding& b ) { return a.line == b.line; } ), v.end() ); +} +int drive() { return 0; } +EOF +} >"$WORK/dedup/src/merge.cpp" +fx_commit dedup +{ printf '%s' "$DD_HDR"; cat <<'EOF' +void mergeFirst( std::vector& v ) +{ + std::stable_sort( v.begin(), v.end(), []( const Finding& a, const Finding& b ) + { return a.line != b.line ? a.line < b.line : a.rule < b.rule; } ); + v.erase( std::unique( v.begin(), v.end(), []( const Finding& a, const Finding& b ) + { return a.line == b.line && a.rule == b.rule; } ), + v.end() ); +} +void rangesFirst( std::vector& v ) +{ + std::ranges::sort( v, []( const Finding& a, const Finding& b ) { return a.line < b.line; } ); + v.erase( std::ranges::unique( v, []( const Finding& a, const Finding& b ) { return a.line == b.line; } ).begin(), v.end() ); +} +void unsortedFirst( std::vector& v ) +{ + v.erase( std::unique( v.begin(), v.end(), []( const Finding& a, const Finding& b ) { return a.rule == b.rule; } ), v.end() ); +} +void worstFirst( std::vector& v ) +{ + std::stable_sort( v.begin(), v.end(), []( const Finding& a, const Finding& b ) + { return a.line != b.line ? a.line < b.line : a.sev > b.sev; } ); + v.erase( std::unique( v.begin(), v.end(), []( const Finding& a, const Finding& b ) { return a.line == b.line; } ), v.end() ); +} +void predSev( std::vector& v ) +{ + v.erase( std::unique( v.begin(), v.end(), []( const Finding& a, const Finding& b ) + { return a.line == b.line && a.sev == b.sev; } ), + v.end() ); +} +void noSev( std::vector& v ) +{ + v.erase( std::unique( v.begin(), v.end(), []( const Row& a, const Row& b ) { return a.line == b.line; } ), v.end() ); +} +void indent( std::vector& v ) +{ + v.erase( std::unique( v.begin(), v.end(), []( const Node& a, const Node& b ) { return a.line == b.line; } ), v.end() ); +} +void defaultEq( std::vector& v ) +{ + std::sort( v.begin(), v.end() ); + v.erase( std::unique( v.begin(), v.end() ), v.end() ); +} +void ints( std::vector& v ) +{ + v.erase( std::unique( v.begin(), v.end(), []( int a, int b ) { return a / 10 == b / 10; } ), v.end() ); +} +void headings( std::vector& v ) +{ + v.erase( std::unique( v.begin(), v.end(), []( const Heading& a, const Heading& b ) { return a.line == b.line; } ), v.end() ); +} +bool sameLineSev( const Finding& a, const Finding& b ) { return a.line == b.line && a.sev == b.sev; } +void namedPred( std::vector& v ) +{ + v.erase( std::unique( v.begin(), v.end(), sameLineSev ), v.end() ); +} +void legacy( std::vector& v ) +{ + v.erase( std::unique( v.begin(), v.end(), []( const Finding& a, const Finding& b ) { return a.line == b.line; } ), v.end() ); +} +void freshFirst( std::vector& v ) +{ + v.erase( std::unique( v.begin(), v.end(), []( const Finding& a, const Finding& b ) { return a.line == b.line; } ), v.end() ); +} +int drive() { return 1; } +EOF +} >"$WORK/dedup/src/merge.cpp" +fx_run dedup +L="dedup-first (c++)" +ds_has "$L" dedup-first mergeFirst minor +ds_has "$L" dedup-first rangesFirst minor +ds_has "$L" dedup-first unsortedFirst minor +ds_has "$L" dedup-first freshFirst new-symbol +ds_none "$L" worstFirst "the sort before the unique orders on severity, worst first" +ds_none "$L" predSev "the predicate keeps rows of different severity apart" +ds_none "$L" noSev "the element type has no severity-like field" +ds_none "$L" indent "indentLevel only contains a severity word (bounded token match)" +ds_none "$L" defaultEq "a predicate-less unique compares whole values" +ds_none "$L" ints "a unique over ints carries no severity" +ds_none "$L" headings "a heading/nesting level is not a severity (bare level, risk and grade are not severity tokens)" +ds_none "$L" namedPred "a named predicate function is skipped, never guessed" +ds_none "$L" legacy "a keep-first the change did not introduce" +gating_split "$L" 0 + +# ── 4) VACUOUS-ASSERT, Bash gate scripts (report-only) ────────────────────────────────────────────────── +# One script per arm (top-level code has no enclosing symbol, so its row anchors on the file and a file +# per arm keeps the arms apart). Baseline: each script has its reporters and one sound assertion; the edit +# appends the arm. Positives: an absence assertion read off an unchecked $( ) capture, off a direct pipe +# (with or without pipefail — under pipefail a crash makes the pipeline false, which takes the same PASS +# branch), the if/else spelling, backslash continuations, the !-negated pass spelling, other reporter +# names, inside a function, and a brand-new script; under set -e a `local x="$( )"` capture (local masks the +# rc) and a direct pipe (left of &&, where errexit is suspended) stay positive. Near-misses: the capture's rc +# checked, `|| no` on the capture, a presence check, a positive assertion on the same output before OR after +# the absence, an enclosing if/case that requires content of the same capture, a plain capture under set -e +# (errexit aborts on its failure), the match-is-pass polarity, a literal producer, a commented-out shape, a +# pre-existing vacuous line, a sound if, a new function with a handled capture, the same shape in a script +# outside a test path, a text utility over a file, a script-defined wrapper (stated floor), a presence test +# on a variable derived from the capture, a requirement inside a { …; } group, and an elif chain. A capture +# derived from another capture (TAG="$( printf '%s' "$OUT" | grep -o … )") is followed back to its run. +fx_init vac +mkdir -p "$WORK/vac/test" "$WORK/vac/scripts" +VHDR="$( cat <<'EOF' +#!/usr/bin/env bash +BIN="${1:?}" +fail=0 +ok(){ printf ' PASS %s\n' "$*"; } +no(){ printf ' FAIL %s\n' "$*"; fail=1; } +V0="$( "$BIN" --version 2>&1 )"; RC0=$? +[ "$RC0" = 0 ] && ok "version runs" || no "version failed: $V0" +EOF +)" +VHDR="$VHDR +" +for f in t_var t_pipe t_pipefail t_if t_cont t_neg t_names t_fn t_setelocal t_setepipe t_derived n_rc n_orno n_presence n_positive n_polarity n_literal n_comment n_legacy n_ifok n_posafter n_nestif n_case n_casealone n_sete n_seteuo n_filepipe n_wrapper n_derivedguard n_bracegroup n_elif n_ifand n_elifpos n_helper n_fileredirect t_orcond t_undefhelper t_reassign; do + printf '%s' "$VHDR" >"$WORK/vac/test/$f.sh" +done +for f in t_setepipe n_sete; do printf 'set -e\n' >>"$WORK/vac/test/$f.sh"; done +printf 'set -euo pipefail\n' >>"$WORK/vac/test/n_seteuo.sh" +cat >>"$WORK/vac/test/t_setelocal.sh" <<'EOF' +set -e +chk_local(){ + ok "placeholder" +} +chk_local +EOF +printf '%s' "$VHDR" >"$WORK/vac/scripts/build.sh" +cat >>"$WORK/vac/test/n_legacy.sh" <<'EOF' +OLD="$( "$BIN" --old 2>/dev/null )" +printf '%s' "$OLD" | grep -q 'stale' && no "stale present" || ok "stale absent" +EOF +cat >"$WORK/vac/test/t_names.sh" <<'EOF' +#!/usr/bin/env bash +BIN="${1:?}" +status=0 +report_pass(){ echo "PASS $*"; } +report_fail(){ echo "FAIL $*"; status=1; } +"$BIN" --version >/dev/null 2>&1 && report_pass "runs" || report_fail "does not run" +EOF +cat >>"$WORK/vac/test/t_fn.sh" <<'EOF' +absent(){ + local out + out="$( "$BIN" --version 2>&1 )" || { no "version failed"; return; } + printf '%s' "$out" | grep -q "$1" && no "$1 present" || ok "$1 absent" +} +absent zzz +EOF +fx_commit vac +cat >>"$WORK/vac/test/t_var.sh" <<'EOF' +OUT="$( "$BIN" --list 2>/dev/null )" +printf '%s' "$OUT" | grep -q 'bad' && no "bad present" || ok "bad absent" +EOF +cat >>"$WORK/vac/test/t_pipe.sh" <<'EOF' +"$BIN" --list 2>/dev/null | grep -q 'bad' && no "bad present" || ok "bad absent" +EOF +{ printf 'set -o pipefail\n'; cat <<'EOF' +"$BIN" --list 2>/dev/null | grep -q 'bad' && no "bad present" || ok "bad absent" +EOF +} >>"$WORK/vac/test/t_pipefail.sh" +cat >>"$WORK/vac/test/t_if.sh" <<'EOF' +OUT2="$( "$BIN" --list 2>/dev/null )" +if printf '%s' "$OUT2" | grep -q 'bad'; then + no "bad present" +else + ok "bad absent" +fi +EOF +cat >>"$WORK/vac/test/t_cont.sh" <<'EOF' +MAP="$( "$BIN" --map 2>/dev/null )" +printf '%s' "$MAP" | grep -q 'suspect=' \ + && no "(f) a clean file carries suspect=" \ + || ok "(f) no suspect= on the clean file" +EOF +cat >>"$WORK/vac/test/t_neg.sh" <<'EOF' +NOUT="$( "$BIN" --list 2>/dev/null )" +! printf '%s' "$NOUT" | grep -q 'bad' && ok "bad absent" || no "bad present" +EOF +cat >>"$WORK/vac/test/t_names.sh" <<'EOF' +R="$( "$BIN" --list 2>/dev/null )" +echo "$R" | grep -q 'bad' && report_fail "bad present" || report_pass "bad absent" +EOF +cat >>"$WORK/vac/test/t_fn.sh" <<'EOF' +absent_unchecked(){ + local out + out="$( "$BIN" --list 2>/dev/null )" + printf '%s' "$out" | grep -q "$1" && no "$1 present" || ok "$1 absent" +} +absent_unchecked yyy +EOF +{ printf '%s' "$VHDR"; cat <<'EOF' +FRESH="$( "$BIN" --list 2>/dev/null )" +printf '%s' "$FRESH" | grep -q 'bad' && no "bad present" || ok "bad absent" +EOF +} >"$WORK/vac/test/t_new.sh" +cat >>"$WORK/vac/test/n_rc.sh" <<'EOF' +OUT="$( "$BIN" --list 2>/dev/null )"; RC=$? +[ "$RC" = 0 ] || no "the list run failed (rc=$RC)" +printf '%s' "$OUT" | grep -q 'bad' && no "bad present" || ok "bad absent" +EOF +cat >>"$WORK/vac/test/n_orno.sh" <<'EOF' +OUT="$( "$BIN" --list 2>/dev/null )" || no "the list run failed" +printf '%s' "$OUT" | grep -q 'bad' && no "bad present" || ok "bad absent" +EOF +cat >>"$WORK/vac/test/n_presence.sh" <<'EOF' +OUT="$( "$BIN" --list 2>/dev/null )" +[ -n "$OUT" ] || no "the list run printed nothing" +printf '%s' "$OUT" | grep -q 'bad' && no "bad present" || ok "bad absent" +EOF +cat >>"$WORK/vac/test/n_positive.sh" <<'EOF' +OUT="$( "$BIN" --list 2>/dev/null )" +printf '%s' "$OUT" | grep -q '>"$WORK/vac/test/n_polarity.sh" <<'EOF' +OUT="$( "$BIN" --list 2>/dev/null )" +printf '%s' "$OUT" | grep -q 'good' && ok "good present" || no "good missing" +EOF +cat >>"$WORK/vac/test/n_literal.sh" <<'EOF' +X="fixed text" +printf '%s' "$X" | grep -q 'bad' && no "bad present" || ok "bad absent" +EOF +cat >>"$WORK/vac/test/n_comment.sh" <<'EOF' +# OUT="$( "$BIN" --list 2>/dev/null )" +# printf '%s' "$OUT" | grep -q 'bad' && no "bad present" || ok "bad absent" +echo done +EOF +cat >>"$WORK/vac/test/n_legacy.sh" <<'EOF' +echo "an unrelated line after the pre-existing assertion" +EOF +cat >>"$WORK/vac/test/n_ifok.sh" <<'EOF' +OUT="$( "$BIN" --list 2>/dev/null )"; RC=$? +if [ "$RC" = 0 ] && ! printf '%s' "$OUT" | grep -q 'bad'; then ok "bad absent"; else no "bad present or the run failed (rc=$RC)"; fi +EOF +cat >>"$WORK/vac/scripts/build.sh" <<'EOF' +OUT="$( "$BIN" --list 2>/dev/null )" +printf '%s' "$OUT" | grep -q 'bad' && no "bad present" || ok "bad absent" +EOF +cat >>"$WORK/vac/test/t_fn.sh" <<'EOF' +absent_checked(){ + local out + out="$( "$BIN" --list 2>/dev/null )" || { no "the list run failed"; return; } + printf '%s' "$out" | grep -q "$1" && no "$1 present" || ok "$1 absent" +} +absent_checked xxx +EOF +{ printf '%s' "$VHDR"; cat <<'EOF' +set -e +chk_local(){ + local x="$( "$BIN" --list 2>/dev/null )" + printf '%s' "$x" | grep -q 'bad' && no "bad present" || ok "bad absent" +} +chk_local +EOF +} >"$WORK/vac/test/t_setelocal.sh" +cat >>"$WORK/vac/test/t_setepipe.sh" <<'EOF' +"$BIN" --list 2>/dev/null | grep -q 'bad' && no "bad present" || ok "bad absent" +EOF +cat >>"$WORK/vac/test/n_posafter.sh" <<'EOF' +OUT="$( "$BIN" --list 2>/dev/null )" +printf '%s' "$OUT" | grep -q 'bad' && no "bad present" || ok "bad absent" +printf '%s' "$OUT" | grep -q '>"$WORK/vac/test/n_nestif.sh" <<'EOF' +OUT="$( "$BIN" --list 2>/dev/null )" +if printf '%s' "$OUT" | grep -q '>"$WORK/vac/test/n_case.sh" <<'EOF' +OUT="$( "$BIN" --list 2>/dev/null )" +case "$OUT" in + '>"$WORK/vac/test/n_casealone.sh" <<'EOF' +OUT="$( "$BIN" --list 2>/dev/null )" +case "$OUT" in + '>"$WORK/vac/test/$f.sh" <<'EOF' +OUT="$( "$BIN" --list 2>/dev/null )" +printf '%s' "$OUT" | grep -q 'bad' && no "bad present" || ok "bad absent" +EOF +done +cat >>"$WORK/vac/test/t_derived.sh" <<'EOF' +OUT="$( "$BIN" --list 2>/dev/null )" +TAG="$( printf '%s' "$OUT" | grep -o ']*>' )" +printf '%s' "$TAG" | grep -q 'bad=' && no "bad= present" || ok "bad= absent" +EOF +cat >>"$WORK/vac/test/n_filepipe.sh" <<'EOF' +"$BIN" --list >"$TMPDIR/out.txt" 2>/dev/null +grep -v '^#' "$TMPDIR/out.txt" | grep -q 'bad' && no "bad present" || ok "bad absent" +EOF +cat >>"$WORK/vac/test/n_wrapper.sh" <<'EOF' +runit(){ "$BIN" "$@" 2>/dev/null; } +W="$( runit --list )" +printf '%s' "$W" | grep -q 'bad' && no "bad present" || ok "bad absent" +EOF +cat >>"$WORK/vac/test/n_derivedguard.sh" <<'EOF' +OUT="$( "$BIN" --list 2>/dev/null )" +ROWS="$( printf '%s' "$OUT" | grep -o ']*>' )" +[ -n "$ROWS" ] || no "the list has no rows" +printf '%s' "$OUT" | grep -q 'bad' && no "bad present" || ok "bad absent" +EOF +cat >>"$WORK/vac/test/n_bracegroup.sh" <<'EOF' +OUT="$( "$BIN" --list 2>/dev/null )" +{ printf '%s' "$OUT" | grep -q ''; } && ok "the list is whole" || no "the list is cut" +printf '%s' "$OUT" | grep -q 'bad' && no "bad present" || ok "bad absent" +EOF +cat >>"$WORK/vac/test/n_elif.sh" <<'EOF' +OUT="$( "$BIN" --list 2>/dev/null )" +if printf '%s' "$OUT" | grep -q 'bad'; then + no "bad present" +elif printf '%s' "$OUT" | grep -q '>"$WORK/vac/test/n_ifand.sh" <<'EOF' +C_OUT="$( "$BIN" --list 2>/dev/null )" +if echo "$C_OUT" | grep -q ''; then + ok "the list is whole" +else + no "the list is cut" +fi +if echo "$C_OUT" | grep -q 'bad'; then + no "bad present" +else + ok "bad absent" +fi +EOF +cat >>"$WORK/vac/test/n_elifpos.sh" <<'EOF' +page="$( "$BIN" --list 2>/dev/null | head -1 )" +rows="$( "$BIN" --list 2>/dev/null | grep -c '' '\n' | grep '/dev/null )" +rows "$ODP2" | grep 'kind="dup"' | grep -q 'alpha' \ + && ok "the unrelated group survives" \ + || no "the unrelated group is gone" +printf '%s' "$ODP2" | grep -q 'config-warnings=' && no "a config warning" || ok "no config warning" +EOF +cat >>"$WORK/vac/test/t_orcond.sh" <<'EOF' +OR_OUT="$( "$BIN" --list 2>/dev/null )" +if echo "$OR_OUT" | grep -q '>"$WORK/vac/test/t_undefhelper.sh" <<'EOF' +UH="$( "$BIN" --list 2>/dev/null )" +rowsx "$UH" | grep -q 'alpha' && ok "alpha" || no "no alpha" +printf '%s' "$UH" | grep -q 'bad' && no "bad present" || ok "bad absent" +EOF +cat >>"$WORK/vac/test/t_reassign.sh" <<'EOF' +RA="$( "$BIN" --list 2>/dev/null )" +if echo "$RA" | grep -q ''; then + ok "the first list is whole" +fi +RA="$( "$BIN" --list --other 2>/dev/null )" +printf '%s' "$RA" | grep -q 'bad' && no "bad present" || ok "bad absent" +EOF +cat >>"$WORK/vac/test/n_fileredirect.sh" <<'EOF' +"$BIN" --list >"$TMPDIR/exp.xml" 2>/dev/null +grep -q 'secret' "$TMPDIR/exp.xml" && no "the secret leaked" || ok "no secret" +EOF +fx_run vac +L="vacuous-assert (bash)" +for f in t_var t_pipe t_pipefail t_if t_cont t_neg t_names t_setepipe t_derived; do + ds_has_p "$L" vacuous-assert "test/$f.sh" minor +done +ds_has "$L" vacuous-assert chk_local minor +ds_has "$L" vacuous-assert absent_unchecked new-symbol +ds_has_p "$L" vacuous-assert test/t_new.sh new-symbol +ds_none "$L" absent "the capture's failure is handled (|| { no; return; }) — and the function is untouched" +ds_none_p "$L" test/n_rc.sh "the capture's rc is checked before the absence" +ds_none_p "$L" test/n_orno.sh "|| no on the capture" +ds_none_p "$L" test/n_presence.sh "a presence check before the absence" +ds_none_p "$L" test/n_positive.sh "a positive assertion on the same output first" +ds_none_p "$L" test/n_polarity.sh "a match is the PASS branch: a crash fails" +ds_none_p "$L" test/n_literal.sh "the producer is a literal, not a command" +ds_none_p "$L" test/n_comment.sh "a commented-out shape is not code" +ds_none_p "$L" test/n_legacy.sh "a vacuous line the change did not introduce" +ds_none_p "$L" test/n_ifok.sh "the if checks the rc in its condition" +ds_none_p "$L" scripts/build.sh "not a test-script path: the kind is scoped to gate scripts" +ds_none "$L" absent_checked "a NEW function whose capture failure is handled (|| { no; return; })" +ds_none_p "$L" test/n_posafter.sh "a positive assertion on the same capture AFTER the absence fails on a crash" +ds_none_p "$L" test/n_nestif.sh "the absence is nested in an if that requires content of the same capture" +ds_none_p "$L" test/n_case.sh "the absence is nested in a case arm that requires content of the same capture" +ds_none_p "$L" test/n_casealone.sh "a case pattern alone on its line (the scanner must not read past it), the absence on the next line" +ds_none_p "$L" test/n_sete.sh "set -e: a failed plain capture aborts the script" +ds_none_p "$L" test/n_seteuo.sh "set -euo pipefail: a failed plain capture aborts the script" +ds_none_p "$L" test/n_filepipe.sh "a text utility over a file is not a run under test (its input's provenance is not on the line)" +ds_none_p "$L" test/n_wrapper.sh "a capture through a script-defined wrapper is not judged (stated floor)" +ds_none_p "$L" test/n_derivedguard.sh "a presence test on a variable DERIVED from the capture requires the capture's text" +ds_none_p "$L" test/n_bracegroup.sh "a requirement inside a { …; } group on the same capture" +ds_none_p "$L" test/n_elif.sh "an elif chain: a crash takes the final else (a failure), not a pass" +ds_none_p "$L" test/n_ifand.sh "a positive on the same capture in an if condition of &&-joined greps guards it (rv §1c, wrapverbscheck:271)" +ds_none_p "$L" test/n_elifpos.sh "a positive on the same capture in an elif condition guards it (rv §1c, ensemblecheck:371)" +ds_none_p "$L" test/n_helper.sh "a positive on the same capture through a script-defined function guards it (rv §1c, qddialscheck:323)" +ds_none_p "$L" test/n_fileredirect.sh "an absence read off a FILE a run redirected into is not judged (stated floor, redactcheck:81)" +for f in t_orcond t_undefhelper t_reassign; do + ds_has_p "$L" vacuous-assert "test/$f.sh" minor +done +gating_split "$L" 0 + +# ── 5) SURFACES: legend, help, MCP, skill and docs; JSON twin; MCP; the ack identity of a facet ─────────── +# D2: format-arity is the one exception to "new-symbol rows never gate", and every surface that states the +# rule states the exception in ONE fixed phrase (PHRASE below), so no surface still reads unqualified. +L="surfaces" +PHRASE='new-symbol rows never gate, except defect-shape format-arity' +phrase_ok(){ # LABEL TEXT — the text states the exception, and every "new-symbol rows never gate" in it carries it + local all qual flat # whitespace-normalized first: a sentence may wrap anywhere in a document + flat="$( printf '%s' "$2" | tr '\n' ' ' | tr -s ' ' )" + all="$( printf '%s' "$flat" | grep -o 'new-symbol rows never gate' | wc -l | tr -d ' ' )" + qual="$( printf '%s' "$flat" | grep -o "$PHRASE" | wc -l | tr -d ' ' )" + if [ "$qual" -ge 1 ] && [ "$all" = "$qual" ]; then ok "$1: states '$PHRASE' ($qual), no unqualified 'new-symbol rows never gate'" + else no "$1: '$PHRASE' x$qual, 'new-symbol rows never gate' x$all — the exception is missing or a sentence still reads unqualified"; fi +} +LEG="$( cd "$WORK/fmt_cpp" && "$BIN" . --quality-delta --no-cache --legend=full 2>/dev/null )" +if printf '%s' "$LEG" | grep -q '}"; fi + if printf '%s' "$LEG" | grep -q 'report-only by facet, not by size'; then ok "$L: the legend says the report-only facets are sev=minor by facet, not by size (D3)" + else no "$L: the legend does not say 'report-only by facet, not by size' — sev=minor is otherwise defined as a small numeric delta"; fi + if printf '%s' "$LEG" | grep -q 'f-strings are not checked'; then ok "$L: the legend names the Python formats it does not check" + else no "$L: the legend does not say '%-formatting and f-strings are not checked' (no row is not a verdict on them)"; fi +else + no "$L: the --legend=full delta run produced no /dev/null )" +if printf '%s' "$CLEG" | grep -q '&1 )"; HRC=$? +if [ "$HRC" = 0 ] && printf '%s' "$HELP" | grep -q -- '--quality-delta'; then + if printf '%s' "$HELP" | grep -qE '(across )?11[ -]kind'; then no "$L: --help still says 11 kinds: $( printf '%s' "$HELP" | grep -E '11[ -]kind' | head -2 )"; else ok "$L: --help no longer says 11 kinds"; fi +else + no "$L: --help failed (rc=$HRC) or does not list --quality-delta" +fi +HELPQ="$( "$BIN" --help=quality-delta 2>&1 )"; HQRC=$? +if [ "$HQRC" = 0 ] && printf '%s' "$HELPQ" | grep -q -- '--quality-delta'; then + if printf '%s' "$HELPQ" | grep -q 'defect-shape'; then ok "$L: --help=quality-delta names defect-shape"; else no "$L: --help=quality-delta does not name defect-shape"; fi + phrase_ok "$L --help=quality-delta" "$HELPQ" +else + no "$L: --help=quality-delta failed (rc=$HQRC) or does not print the flag" +fi +if command -v python3 >/dev/null 2>&1; then + TL="$( printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize"}' '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \ + | "$BIN" --mcp 2>/dev/null | tail -1 )" + QDD="$( printf '%s' "$TL" | python3 -c ' +import json, sys +try: r = json.load(sys.stdin) +except Exception as e: print("BROKEN %s" % e); raise SystemExit +d = [t.get("description", "") for t in r.get("result", {}).get("tools", []) if t.get("name") == "quality_delta"] +print(d[0] if d else "MISSING")' )" + case "$QDD" in + BROKEN*|MISSING) no "$L: MCP tools/list has no quality_delta description: $QDD" ;; + *) if printf '%s' "$QDD" | grep -q '11 measured failure modes'; then no "$L: the MCP quality_delta description still says '11 measured failure modes'" + else ok "$L: the MCP quality_delta description has no stale kind count"; fi + if printf '%s' "$QDD" | grep -q 'defect-shape'; then ok "$L: the MCP quality_delta description names defect-shape"; else no "$L: the MCP quality_delta description does not name defect-shape"; fi + phrase_ok "$L MCP description" "$QDD" ;; + esac +else + echo " SKIP $L: MCP tools/list description (python3 not installed)" +fi +# docs/COMMANDS.md is generated from --help plus a DATED showcase capture: its ``` sample blocks are the recorded +# output of the binary that made the capture and change only when the capture is re-recorded (a train step), +# so the arms read the generated prose and skip the sample blocks. +SKILL="$ROOT/skills/ripwire-quality-bar/SKILL.md" +for doc in "$SKILL" "$ROOT/docs/COMMANDS.md" "$ROOT/README.md"; do + rel="${doc#"$ROOT"/}" + if [ ! -f "$doc" ]; then no "$L: $rel is missing"; continue; fi + if [ "$rel" = docs/COMMANDS.md ]; then DTEXT="$( awk '/^```/ { f = !f; next } !f' "$doc" )"; else DTEXT="$( cat "$doc" )"; fi + if [ -z "$DTEXT" ]; then no "$L: $rel read empty"; continue; fi + if printf '%s' "$DTEXT" | grep -qiE '(^|[^0-9])11 kinds|eleven kinds|eleven quality kinds|ELEVEN KINDS'; then + no "$L: $rel still says 11/eleven kinds: $( printf '%s' "$DTEXT" | grep -iE '(^|[^0-9])11 kinds|eleven kinds|eleven quality kinds' | head -2 | cut -c1-160 )" + else ok "$L: $rel has no stale 11/eleven-kinds count"; fi + phrase_ok "$L $rel" "$DTEXT" +done +if [ -f "$SKILL" ]; then + FM="$( awk 'NR==1 && /^---$/ {f=1; next} f && /^---$/ {exit} f' "$SKILL" )" + if printf '%s' "$FM" | tr '\n' ' ' | tr -s ' ' | grep -q "$PHRASE"; then ok "$L: the quality-bar skill's trigger text states the format-arity exception" + else no "$L: the quality-bar skill's trigger text (frontmatter description) does not state '$PHRASE'"; fi +fi +if command -v python3 >/dev/null 2>&1; then + # D2 at every exit-predicate site: the new-symbol freshbad row carries "gating": true, and the top-level + # "gating" equals the rows that carry it (the same predicate as the XML gating= and the exit code). + JPROBE=' +import json, sys +try: + raw = sys.stdin.read() + d = json.loads(raw) + if "result" in d: d = json.loads(d["result"]["content"][0]["text"]) +except Exception as e: print("BROKEN %s" % e); raise SystemExit +rs = d.get("r", []) +rows = [r for r in rs if r.get("kind") == "defect-shape" and r.get("defect") == "format-arity"] +fresh = [r for r in rows if r.get("sym") == "freshbad"] +ng = sum(1 for r in rs if r.get("gating") is True) +print("ROWS %d FRESH %d FRESHGATE %d TOP %s NG %d" % (len(rows), len(fresh), sum(1 for r in fresh if r.get("gating") is True), d.get("gating"), ng))' + JS="$( cd "$WORK/fmt_cpp" && "$BIN" . --quality-delta --no-cache --json 2>/dev/null )" + MC="$( printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize"}' \ + '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"quality_delta","arguments":{"path":"'"$WORK/fmt_cpp"'"}}}' \ + | "$BIN" --mcp 2>/dev/null | tail -1 )" + for surf in json mcp; do + if [ "$surf" = json ]; then V="$( printf '%s' "$JS" | python3 -c "$JPROBE" )"; else V="$( printf '%s' "$MC" | python3 -c "$JPROBE" )"; fi + set -- $V + if [ "${1:-}" != ROWS ]; then no "$L $surf: probe: $V"; continue; fi + n="$2"; fr="$4"; frg="$6"; top="$8"; ng="${10}" + case "$n$fr$frg$ng" in *[!0-9]*) no "$L $surf: probe printed non-numbers: $V"; continue ;; esac + if [ "$n" -ge 14 ]; then ok "$L $surf: carries the $n format-arity rows (kind + defect keys)"; else no "$L $surf: carries $n format-arity rows, want >= 14"; fi + if [ "$fr" = 1 ] && [ "$frg" = 1 ]; then ok "$L $surf: the new-symbol freshbad format-arity row carries \"gating\": true" + else no "$L $surf: freshbad rows=$fr gating=$frg (want 1 and 1)"; fi + if [ "$top" = "$ng" ]; then ok "$L $surf: top-level \"gating\" ($top) equals the rows that carry \"gating\": true" + else no "$L $surf: top-level \"gating\" is $top but $ng rows carry \"gating\": true"; fi + done +else + echo " SKIP $L: --json and MCP row checks (python3 not installed)" +fi + +# Ack identity (checklist 16): one symbol with BOTH a format-arity and a utf8-cut row. Acking only the +# format-arity facet must leave the utf8-cut row on the same symbol standing — a facet is part of the +# finding's identity, not a label on one shared per-symbol key. --ack-only=defect-shape (the kind) takes +# both facets; each on a fresh copy. +fx_init both +mkdir -p "$WORK/both/src" +cat >"$WORK/both/src/b.cpp" <<'EOF' +#include +#include +std::string label( std::string s, int a ) { return std::format( "{}", a ) + s; } +int drive() { return 0; } +EOF +fx_commit both +cat >"$WORK/both/src/b.cpp" <<'EOF' +#include +#include +std::string label( std::string s, int a ) +{ + if( s.size() > 50 ) + { + s.resize( 50 ); + s += "..."; + } + return std::format( "{}", a, a ) + s; +} +int drive() { return 1; } +EOF +fx_run both +ds_has "$L ack" format-arity label gating +ds_has "$L ack" utf8-cut label minor +fx_copy both both_kind +( cd "$WORK/both" && "$BIN" . --quality-delta --no-cache --ack-only=format-arity --quality-ack="deliberate in this fixture" >/dev/null 2>&1 ) +ARC=$? +if [ "$ARC" = 0 ]; then ok "$L ack: --ack-only=format-arity selects the facet (exit 0)"; else no "$L ack: --ack-only=format-arity exited $ARC"; fi +fx_run both +if [ "$QD_OK" = 1 ]; then + if [ -z "$( ds_row format-arity label )" ]; then ok "$L ack: the acked format-arity row is suppressed"; else no "$L ack: the format-arity row survived its ack: $( ds_row format-arity label )"; fi + if [ -z "$( ds_rows | grep 'gating=' )" ]; then ok "$L ack: no defect-shape row carries gating= after the ack"; else no "$L ack: a defect-shape row still gates: $( ds_rows | grep 'gating=' | head -2 )"; fi +fi +ds_has "$L ack" utf8-cut label minor +gating_split "$L ack" 0 +( cd "$WORK/both_kind" && "$BIN" . --quality-delta --no-cache --ack-only=defect-shape --quality-ack="deliberate in this fixture" >/dev/null 2>&1 ) +ARC=$? +if [ "$ARC" = 0 ]; then ok "$L ack kind: --ack-only=defect-shape selects the kind (exit 0)"; else no "$L ack kind: --ack-only=defect-shape exited $ARC"; fi +fx_run both_kind +if [ "$QD_OK" = 1 ]; then + left="$( ds_rows | wc -l | tr -d ' ' )" + if [ "$left" = 0 ]; then ok "$L ack kind: both facets are suppressed by the kind token"; else no "$L ack kind: $left defect-shape rows survived --ack-only=defect-shape: $( ds_rows | head -2 )"; fi +fi +gating_split "$L ack kind" 0 + +# Ack identity, the next layer (checklist 16; rv-cr-qd-kinds-068 §3, fixture p2/ack): the ack key is the anchor, +# the facet AND the anchor's site set of that facet, so an ack covers exactly the defect it named. (a) swap: defect +# A is acked and committed with the ledger, then A is fixed and a DIFFERENT defect B lands in the same function → +# B's row stands and gates, and A's ack is listed stale (finding-gone). (b) beside: A is acked, then B is added +# NEXT to the untouched A → the row re-reports (now=2). (c) unchanged: A acked and left alone stays suppressed, +# and its ack is not stale. +L="ack swap" +fx_init swap +cat >"$WORK/swap/a.cpp" <<'EOF' +#include +#include +std::string legend( int a, int b, int c ) +{ + std::string s = std::format( "a={} b={}", a, b ); + return s + std::format( "c={}", c ); +} +EOF +fx_commit swap +sed -i.bak 's|"a={} b={}", a, b )|"a={} b={}", a, b, c )|' "$WORK/swap/a.cpp" && rm -f "$WORK/swap/a.cpp.bak" +fx_run swap +ds_has "$L premise" format-arity legend gating +( cd "$WORK/swap" && "$BIN" . --quality-delta --no-cache --ack-only=format-arity --quality-ack="defect A, accepted for this fixture" >/dev/null 2>&1 ) +ARC=$? +if [ "$ARC" = 0 ]; then ok "$L: --ack-only=format-arity acked defect A (exit 0)"; else no "$L: --ack-only=format-arity exited $ARC"; fi +fx_copy swap swap_beside +fx_run swap +if [ "$QD_OK" = 1 ]; then + if [ -z "$( ds_row format-arity legend )" ]; then ok "$L unchanged: the acked, unchanged defect A stays suppressed"; else no "$L unchanged: acked A re-reported: $( ds_row format-arity legend )"; fi + st="$( hdr_attr stale )" + if [ "$st" = 0 ]; then ok "$L unchanged: the ack of A is not stale while A stands"; else no "$L unchanged: stale=${st:-} while the acked A stands"; fi +fi +( cd "$WORK/swap" && git add -A >/dev/null 2>&1 && git commit -qm "defect A + its ack" >/dev/null 2>&1 ) || no "$L: committing A and its ack failed" +cat >"$WORK/swap/a.cpp" <<'EOF' +#include +#include +std::string legend( int a, int b, int c ) +{ + std::string s = std::format( "a={} b={}", a, b ); + return s + std::format( "c={}", c, a ); +} +EOF +fx_run swap +ds_has "$L swapped" format-arity legend gating +if [ "$QD_OK" = 1 ]; then + if printf '%s' "$QD_OUT" | tr '>' '\n' | grep '' "$1" | wc -l | tr -d ' '; } +# knob-honesty-068: a capped tag also carries its continuation, next="…" (the call that serves it uncut) — the block is the +# same block, so the shape admits the attribute; the count it asserts is unchanged. +sigblocks(){ grep -oE '' "$1" | wc -l | tr -d ' '; } # ── #1: --detail=0 == no --detail (byte-identical, golden-neutral) ────────────────────────────────────── "$BIN" src --for="$TASK" --no-cache >"$TMP/plain" 2>/dev/null diff --git a/test/docdemotecheck.sh b/test/docdemotecheck.sh index 31bc84c78..a4fbdabc7 100755 --- a/test/docdemotecheck.sh +++ b/test/docdemotecheck.sh @@ -99,6 +99,36 @@ PY "$BIN" docdemotefix --for="$BUGQ" --no-route --format=candidates --no-cache >"$TMP/noroute.xml" 2>/dev/null "$BIN" docdemotefix --for="$BUGQ" --no-cache >"$TMP/bugfor.xml" 2>/dev/null "$BIN" docdemotefix --for="$TRACEQ" --no-cache >"$TMP/tracefor.xml" 2>/dev/null +# RE-PIN 2026-10-08 (train 26b: lean-answers merged): docdemotegolden_for.xml 6167 -> 5851 B (est_tokens "2467" -> "2340") +# and docdemotegolden_noroute.xml 9695 -> 9364 B ("3441" -> "3309"). Only the zero elision and its legend reading moved: +# undoing it (zeros back, reading removed, est_tokens=/at= masked) gives the train-side goldens exactly, on both fixtures, +# and no r= row spells a zero (arm (f) one row spelling). The routed golden was first re-pinned at 5902 B with the two +# rows the docs reorder moved still spelling cx="0" ccx="0" in="0"; the reorder now keeps the spelling (serialize.h). +# RE-PIN 2026-10-08 (train 26b: FE-B merged beside for-spine-span's hop-slot rule): docdemotegolden_for.xml 6268 -> 6167 B +# (est_tokens "2507" -> "2467"). FE-B binds request_page's only callee `evict_one( self )` by name alone, so the hop-slot +# rule gives request_page no slot: with its one row becomes . That and est_tokens= are the whole diff against the train-side golden (checked mechanically); the header +# carries no via clause, since no row carries via="name" (arm (f) present-only via clause). The noroute golden is unchanged. +# RE-PIN 2026-10-04 (lane for-spine-span-068, by design): docdemotegolden_for.xml 5821 -> 6268 B and +# docdemotegolden_noroute.xml 9423 -> 9695 B. Every row with a known extent gains e="N" right after l= (5 and 6 +# rows), the full legend gains the e= clause, est_tokens moves with the bytes. The routed golden also shows the +# code-above-docs REORDER: the same 14 rows, flush_cache (r=7 -> r=5) now above the two notes/paging_design.md rows, +# and its reading. --no-route has no reorder (the rule is routed-path only). Checked +# mechanically: removing e=, est_tokens=, the e= clause and the docs_after_code attribute/reading leaves the no-route +# golden byte-identical and the routed one differing only in that row order. Gate: test/forsigspancheck.sh. +# RE-PIN 2026-10-04 (lane FE-B, receiver evidence): docdemotegolden_for.xml 5821 -> 6010 B (est_tokens "2328" -> "2404"). +# CAUSE: pagecache.py's `evict_one( self )` names a function its file never imports — Python resolves it nowhere — so +# FE-B binds it by NAME ALONE: the hop's row carries via="name", and --for's header defines it (one +# present-only clause, graphlegend.h kForViaNameClause, on the hop ids the compact route serves). Verified before +# re-pinning, against the pre-change golden: the root's est_tokens=, the header clause and the row's attribute are the whole +# diff — no ranking, demotion or route byte moved, and arm (f)'s own assertions still hold. The noroute golden is +# UNCHANGED: its carry no row through a name-only edge, so the clause does not ride (present-only). +# RE-PIN 2026-10-07 (lane/lean-answers-068, --for zero elision): docdemotegolden_for.xml 5821 -> 5505 B +# (est_tokens "2328" -> "2202") and docdemotegolden_noroute.xml 9423 -> 9092 B (est_tokens "3333" -> "3200"). +# ONE identified change: a lens row omits cx=/ccx=/in= when 0 and the legend says an absent one is 0. +# Verified before re-pinning: putting cx="0"/ccx="0"/in="0" back on every r= row, deleting the zero reading +# from the legend comment and masking est_tokens=/at=, live and previous goldens are byte-identical on BOTH +# fixtures — no ranking, demotion, route or row moved. (f)/(h)'s own assertions are unchanged. # RE-PIN 2026-09-13 (merge of lane/sc-legend and lane/for-widen): docdemotegolden_for.xml RE-MEASURED on the # MERGED tree at 5,809 B (est_tokens "2328"), from 5,887 on for-widen's tree and 5,425 on sc-legend's. Neither # lane's own number is the merged one, so this is measured, not summed. Three identified changes, and the golden @@ -234,6 +264,29 @@ grep -q 'doc_tier="' "$TMP/conceptc.xml" \ diff -q "$TMP/concept.xml" "$ROOT/test/docdemotegolden_for.xml" >/dev/null \ && ok "(f) conceptual --for byte-identical to the pre-change golden" \ || no "(f) conceptual --for drifted from test/docdemotegolden_for.xml" +# (f) one row spelling: every r= row of a --for answer omits a zero cx=/ccx=/in= (the lens legend: absent = 0), the rows +# the code-above-docs reorder moved and re-headed (serialize.h reorderDocsAfterCode) included. This routed question +# moves the two notes/paging_design.md rows below the code. +if [ -s "$TMP/concept.xml" ] && grep -q ']*>' "$TMP/concept.xml" | grep -cE ' (cx|ccx|in)="0"' )" + if [ "$zrows" = "0" ]; then ok "(f) no --for row spells a zero cx=/ccx=/in= (moved rows included)" + else no "(f) $zrows --for row(s) still spell a zero cx=/ccx=/in= (a re-headed row lost the lens spelling)"; fi +else + no "(f) conceptual --for produced no root, so the row-spelling arm proves nothing" +fi +# (f) present-only via clause: the header's via="name" reading rides only when a row carries via="name". On this +# fixture the one hop whose only callee is name-only (request_page -> evict_one) takes no slot (the hop-slot rule, +# test/forsigspancheck.sh (H)), so no row is left to carry the attribute and the clause must not ride either. +if [ -s "$TMP/concept.xml" ] && grep -q ' row`) + if grep -q 'matched by name alone' "$TMP/concept.xml" && ! grep -Eq '<[a-z]+ [^<>]*via="name"' "$TMP/concept.xml"; then + no "(f) the via=\"name\" legend clause rides with no via=\"name\" row in the answer (a hop dropped by the hop-slot rule still counted)" + else + ok "(f) the via=\"name\" legend clause rides only beside a via=\"name\" row" + fi +else + no "(f) conceptual --for produced no root, so the present-only via arm proves nothing" +fi # ── (g) --recall untouched ────────────────────────────────────────────────────────────────────────────── diff -q "$TMP/recall.xml" "$ROOT/test/docdemotegolden_recall.golden" >/dev/null \ diff --git a/test/docdemotegolden_for.xml b/test/docdemotegolden_for.xml index 06e898765..132e16a1f 100644 --- a/test/docdemotegolden_for.xml +++ b/test/docdemotegolden_for.xml @@ -1 +1 @@ -def pin_page( self, page_id ):def evict_one( cache ):class PageCache:def request_page( self, page_id ):# Paging design# Paging design The page cache holds a fixed number of pages. A pinned page is one the reader still holdsdef flush_cache( self ):# Bug report# Bug report **Describe the bug** A clear and concise description of what the bug is. **Steps to reproduce** Steps to reproduce the behavior: 1. Go to the page and open it 2. Pin a page and flush the cache 3. See the error **Expected behavi…## Unreleased# Changelog ## Unreleased - Fixed the RuntimeError raised during eviction of a pinned page in the page cache during flush. The traceback pointed at runpy and at the exec of run_globals in _run_module_as_main, so the failure read as a startu…# Changelog# Contributing Thanks for reporting a bug. Please describe the bug, list the steps to reproduce it, and state the expected behavior before opening a pull request about the page cache, its eviction policy, or a pinned page during a flush.# Contributing# Tier probe# Tier probe A pinned page, an eviction, and a flush of the page cache described in exactly the same words twice, so two documents differing only in where they live can be compared directly. Describe the bug, the steps to reproduce, and the…# Tier probe# Tier probe A pinned page, an eviction, and a flush of the page cache described in exactly the same words twice, so two documents differing only in where they live can be compared directly. Describe the bug, the steps to reproduce, and the… \ No newline at end of file +def pin_page( self, page_id ):def evict_one( cache ):class PageCache:def request_page( self, page_id ):def flush_cache( self ):# Paging design# Paging design The page cache holds a fixed number of pages. A pinned page is one the reader still holds# Bug report# Bug report **Describe the bug** A clear and concise description of what the bug is. **Steps to reproduce** Steps to reproduce the behavior: 1. Go to the page and open it 2. Pin a page and flush the cache 3. See the error **Expected behavi…## Unreleased# Changelog ## Unreleased - Fixed the RuntimeError raised during eviction of a pinned page in the page cache during flush. The traceback pointed at runpy and at the exec of run_globals in _run_module_as_main, so the failure read as a startu…# Changelog# Contributing Thanks for reporting a bug. Please describe the bug, list the steps to reproduce it, and state the expected behavior before opening a pull request about the page cache, its eviction policy, or a pinned page during a flush.# Contributing# Tier probe# Tier probe A pinned page, an eviction, and a flush of the page cache described in exactly the same words twice, so two documents differing only in where they live can be compared directly. Describe the bug, the steps to reproduce, and the…# Tier probe# Tier probe A pinned page, an eviction, and a flush of the page cache described in exactly the same words twice, so two documents differing only in where they live can be compared directly. Describe the bug, the steps to reproduce, and the… \ No newline at end of file diff --git a/test/docdemotegolden_noroute.xml b/test/docdemotegolden_noroute.xml index 9a699e69c..e2216ec48 100644 --- a/test/docdemotegolden_noroute.xml +++ b/test/docdemotegolden_noroute.xml @@ -1,4 +1,4 @@ -# Bug report# Bug report **Describe the bug** A clear and concise description of what the bug is. **Steps to reproduce** Steps to reproduce the behavior: 1. Go to the page and open it 2. Pin a page and flush the cache 3. See the error **Expected behavi…class PageCache:# Contributing Thanks for reporting a bug. Please describe the bug, list the steps to reproduce it, and state the expected behavior before opening a pull request about the page cache, its eviction policy, or a pinned page during a flush.# Contributingdef pin_page( self, page_id ):# Tier probe# Tier probe A pinned page, an eviction, and a flush of the page cache described in exactly the same words twice, so two documents differing only in where they live can be compared directly. Describe the bug, the steps to reproduce, and the…# Tier probe# Tier probe A pinned page, an eviction, and a flush of the page cache described in exactly the same words twice, so two documents differing only in where they live can be compared directly. Describe the bug, the steps to reproduce, and the…def evict_one( cache ):def flush_cache( self ):def request_page( self, page_id ):# Paging design# Paging design The page cache holds a fixed number of pages. A pinned page is one the reader still holds## Unreleased# Changelog ## Unreleased - Fixed the RuntimeError raised during eviction of a pinned page in the page cache during flush. The traceback pointed at runpy and at the exec of run_globals in _run_module_as_main, so the failure read as a startu…# Changelogdef __init__( self, capacity ):# Bug report# Bug report **Describe the bug** A clear and concise description of what the bug is. **Steps to reproduce** Steps to reproduce the behavior: 1. Go to the page and open it 2. Pin a page and flush the cache 3. See the error **Expected behavi…class PageCache:# Contributing Thanks for reporting a bug. Please describe the bug, list the steps to reproduce it, and state the expected behavior before opening a pull request about the page cache, its eviction policy, or a pinned page during a flush.# Contributingdef pin_page( self, page_id ):# Tier probe# Tier probe A pinned page, an eviction, and a flush of the page cache described in exactly the same words twice, so two documents differing only in where they live can be compared directly. Describe the bug, the steps to reproduce, and the…# Tier probe# Tier probe A pinned page, an eviction, and a flush of the page cache described in exactly the same words twice, so two documents differing only in where they live can be compared directly. Describe the bug, the steps to reproduce, and the…def evict_one( cache ):def flush_cache( self ):def request_page( self, page_id ):# Paging design# Paging design The page cache holds a fixed number of pages. A pinned page is one the reader still holds## Unreleased# Changelog ## Unreleased - Fixed the RuntimeError raised during eviction of a pinned page in the page cache during flush. The traceback pointed at runpy and at the exec of run_globals in _run_module_as_main, so the failure read as a startu…# Changelogdef __init__( self, capacity ): 2542, and the other three forms do not move (the # attribute rides only the auto-HEAD basis, and dirty/scope/refpair here do not take it). The pin is the next # multiple of 100 over the measured total, as every anchor in this file is. -run_budget qd_clean 2600 8574 "$FX" --quality-delta +# qd 2600/3900/5200/4300 -> 2700/4000/5300/4400 (2026-10-07, lane/cr-qd-kinds-068, the twelfth kind defect-shape): +# the ALWAYS-printed core gained the kind's name in the kind list and the one exception to "new-symbol rows never +# gate" (defect-shape format-arity gates on any origin), which every form must state because the exit code it +# explains is in every form. Measured on this fixture against the main binary (60dd3b3f): clean 2576 -> 2663, +# dirty 3889 -> 3976, scope 5163 -> 5250, refpair 4293 -> 4380 (+87 B each, the same sentence). Each pin is the +# next multiple of 100 over the measured total, as every anchor in this file is. +run_budget qd_clean 2700 8574 "$FX" --quality-delta printf '%s' "$DIRT" >> "$FX/src/base.cpp" # qd_dirty 3800 -> 3900 (2026-09-10, the string/perf round): the --quality-delta legend gained two facts a reader # needs to act on a row — the api-new-surface= count (one sentence, +105 B in every form, "printed even at zero" @@ -173,10 +179,48 @@ printf '%s' "$DIRT" >> "$FX/src/base.cpp" # against the pre-round binary: clean 2177 -> 2282, dirty 3642 -> 3855, scope 4916 -> 5129, refpair 4058 -> 4271. # Three forms still clear their ceilings; the dirty form's 3800 had 158 B of headroom and the two sentences cost # 213 there, so the ceiling moves by less than the growth (45 B of headroom left) — a ratchet, not an allowance. -run_budget qd_dirty 3900 8574 "$FX" --quality-delta -run_budget qd_dirty_scope 5200 10512 "$FX" --quality-delta "--scope=src/*" -run_budget sd_uses 3800 4112 "$FX" --safe-delete=classifyWidth +run_budget qd_dirty 4000 8574 "$FX" --quality-delta +run_budget qd_dirty_scope 5300 10512 "$FX" --quality-delta "--scope=src/*" +# sd_uses 3800 -> 4100 (2026-10-07, lane count-floor-068, CALLSITE-LINE): every safe-delete row now carries +# sites_l=, the caller's call-site LINES beside p= (the caller's definition line) — graded answers that named every +# caller were scored wrong for printing only definition lines — and the law this gate pins (no emitted name is +# undefined) puts its one defining sentence in the full legend exactly when a row carries it (verbs_navigate.h, +# flags.hasSitesL). Measured on this fixture: 3662 -> 4086 B (+424, the sentence alone); sd_none (no caller row, no +# sites_l=) does not move (3659). The pin is the next multiple of 100 over the measured total. The OLD ceiling is +# not dropped: the twin arm below holds everything EXCEPT that sentence at 3800, so the rest of the essay cannot +# re-inflate under the new headroom. +run_budget sd_uses 4100 4112 "$FX" --safe-delete=classifyWidth run_budget sd_none 3800 4112 "$FX" --safe-delete=tangle +# twin of the pre-2026-10-07 sd_uses ceiling: the sd_uses legend with the sites_l= sentence cut out still fits 3800 B. +# The cut is only taken when the row really carries sites_l= AND the sentence is found once, start to end; +# any other premise FAILs (a cut that silently removes nothing, or removes the wrong text, would prove nothing). +sdRest="$( python3 - "$WORK/sd_uses.xml" <<'PY' +import re, sys +doc = open( sys.argv[1], 'rb' ).read().decode( 'utf-8', 'replace' ) +m = re.match( r'\A(?:\s*)+', doc, re.S ) +leg = m.group( 0 ) if m else '' +body = doc[ len( leg ): ] +if not leg or 'sites_l="' not in body: + print( 'NOPREMISE row-without-sites_l' ); sys.exit( 0 ) +a, z = 'sites_l= on a c row', 'Two calls on one line are one site. ' +i = leg.find( a ); j = leg.find( z, i + 1 ) if i >= 0 else -1 +if i < 0 or j < 0 or leg.count( a ) != 1: + print( 'NOPREMISE sentence-not-found-once' ); sys.exit( 0 ) +rest = leg[ :i ] + leg[ j + len( z ): ] +print( len( rest.encode() ), len( leg.encode() ) - len( rest.encode() ) ) +PY +)" +case "$sdRest" in + NOPREMISE*) no "(a) sd_uses twin (old 3800 ceiling minus the sites_l= sentence) cannot measure: ${sdRest#NOPREMISE }" ;; + *) read -r restB cutB </dev/null && [ "$restB" -le 3800 ]; then + ok "(a) sd_uses twin: legend minus the sites_l= sentence ($cutB B) is $restB B <= 3800 B (the pre-CALLSITE-LINE ceiling)" + else + no "(a) sd_uses twin: legend minus the sites_l= sentence ($cutB B) is ${restB:-?} B > 3800 B — the rest of the essay re-inflated" + fi ;; +esac # tg_empty 1200 -> 1400 (2026-09-04, capture-audit wave-1 close, lane L4 M15): the zero-row root now carries # graph_ambiguous=/graph_unresolved= (the resolver gauge every graph-floored root spells, floormarkcheck arm 11), # and two emitted attributes must be DEFINED in this document (arm (d) below) — the shortest honest sentence is @@ -196,7 +240,7 @@ run_budget tg_empty 1590 1332 "$FXC" --test-gate # own HEAD~1..HEAD: that range means a different diff after every landing, so a budget on it would be a # ratchet whose value depends on whoever committed last. ( cd "$FX" && git add -A >/dev/null 2>&1 && git commit -qm dirt >/dev/null 2>&1 ) -run_budget qd_refpair 4300 8574 "$FX" --quality-delta=HEAD~1..HEAD +run_budget qd_refpair 4400 8574 "$FX" --quality-delta=HEAD~1..HEAD echo echo "=== (b) the fraction, reported not asserted (see the header for why) ===============================" @@ -243,6 +287,9 @@ present sd_uses 'untested-radius: callers or uses exist' absent sd_uses 'none-found: zero callers AND zero uses' present sd_none 'none-found: zero callers AND zero uses' absent sd_none 'untested-radius: callers or uses exist' +# sites_l= (CALLSITE-LINE, 2026-10-07) is defined exactly where a caller row carries it: sd_uses has one caller row, sd_none none. +present sd_uses 'sites_l= on a c row' +absent sd_none 'sites_l=' echo echo "=== (d) COVERAGE — every attribute the root emits is DEFINED in that document's own legend ==========" diff --git a/test/dynmapsimdcheck.sh b/test/dynmapsimdcheck.sh index 6010b8d24..1bd4a2362 100755 --- a/test/dynmapsimdcheck.sh +++ b/test/dynmapsimdcheck.sh @@ -22,17 +22,27 @@ set -u ROOT="$( cd "$( dirname "$0" )/.." && pwd )" -CXX="${CXX:-c++}" +# The whole gate is an ASan harness: an explicit CXX is respected, else Homebrew llvm@22 when installed, else c++ +# (scripts/asanprobe.sh: Apple clang 17 / Command Line Tools 26.3 on macOS 26.7 deadlocks every ASan binary before main). +. "$ROOT/scripts/asanprobe.sh" +CXX="$( ripwire_asan_pick_cxx "${CXX:-}" )" # ask THIS front end how it spells C++23 (see scripts/cxxstd.sh — AppleClang 15 rejects -std=c++23) . "$ROOT/scripts/cxxstd.sh" CXXSTD="$( ripwire_cxx_std_flag "$CXX" )" HARNESS="$ROOT/test/dynmapsimd_harness.cpp" -WORK="$( mktemp -d )"; trap 'rm -rf "$WORK"' EXIT +WORK="$( mktemp -d )"; trap 'gate_bounded_reap; rm -rf "$WORK"' EXIT; gate_bounded_arm ARCH="$( uname -m )" echo "dynmapsimdcheck: CXX=$CXX arch=$ARCH" +# A named, expected missing premise (checklist 14/17): a toolchain whose ASan runtime cannot start a program. The harness +# and every assertion below are unchanged; on such a host they cannot run, so the gate says so and stops. +if ! ripwire_asan_probe "$CXX" "$WORK"; then + echo "dynmapsimdcheck: SKIP — $RIPWIRE_ASAN_PROBE_WHY. Use a toolchain whose ASan runtime starts: set CXX to Homebrew llvm@22's clang++, or update the Command Line Tools/Xcode (CONTRIBUTING.md, Sanitizer build)." + exit 0 +fi + # G1's 'integer' / float-cast groups are Clang spellings; GCC only has the address,undefined core. # Probe THIS front end rather than guessing from its name (same posture as scripts/cxxstd.sh). SAN="-fsanitize=address,undefined,integer,float-divide-by-zero,float-cast-overflow" diff --git a/test/estcalib.manifest b/test/estcalib.manifest index a745e590f..36099e7d0 100644 --- a/test/estcalib.manifest +++ b/test/estcalib.manifest @@ -1,5 +1,5 @@ # est_calib pins — REAL tokenizer counts of ripwire's output on test/estcalibfix. -# label o200k cl100k est_at_pin_time argv... (space-separated; no argv may contain a space) +# label o200k cl100k est_at_pin_time argv... (space-separated; no argv may contain a space; a leading NAME=VALUE is env) # Regenerate: python3 bench/tokenaudit/pin.py --bin build/ripwire (needs tiktoken) # Read by: test/tokenbudgetcheck.sh #18 — the calibration band. See bench/tokenaudit/README.md. map 1009 999 1123 @@ -7,6 +7,10 @@ map-topk5 538 535 653 --top-k=5 metrics 2381 2370 2509 --metrics pack-signatures 1801 1784 2116 --pack-signatures expand 541 542 455 --expand=billableTotal -for-named 789 785 1050 --for=billableTotal -for-budgeted 419 415 598 --for=dedupe --token-budget=400 +# for-named re-pinned 2026-10-07: its --for rows now carry e= (+237 B, +64 o200k over its twin); +# for-named-noe is its e=-free twin (RIPWIRE_FOR_ENDLINES=never, byte-identical to the pre-e= answer). Other rows: 2026-09-12 pins. +for-named 962 958 1290 --for=billableTotal +for-named-noe 898 893 1195 RIPWIRE_FOR_ENDLINES=never --for=billableTotal +# for-budgeted re-pinned 2026-10-08 on the train 26b tree (knob-honesty's next= + floor clause, measured with pin.py; other rows kept). +for-budgeted 491 486 698 --for=dedupe --token-budget=400 pack-task 882 862 1104 --pack-task=dedupe diff --git a/test/estchargecheck.sh b/test/estchargecheck.sh index 82d1100da..05117459d 100755 --- a/test/estchargecheck.sh +++ b/test/estchargecheck.sh @@ -31,6 +31,7 @@ ROOT="$( cd "$( dirname "$0" )/.." && pwd )" . "$ROOT/test/lib/clean-env.sh" BIN="${1:-${RIPWIRE_BIN:-$ROOT/build/ripwire}}" [ "${BIN#/}" = "$BIN" ] && BIN="$ROOT/$BIN" +echo "estchargecheck: BIN=$BIN" fail=0 ok(){ printf ' PASS %s\n' "$*" || { fail=1; printf ' FAIL could not write the PASS line for: %s\n' "$*"; }; return 0; } no(){ printf ' FAIL %s\n' "$*"; fail=1; } @@ -38,7 +39,6 @@ skip(){ printf ' SKIP %s\n' "$*"; } [ -x "$BIN" ] || { echo "no ripwire binary at $BIN — build first"; exit 2; } cd "$ROOT" -echo "estchargecheck: BIN=$BIN" TMP="$( mktemp -d )"; trap 'rm -rf "$TMP"' EXIT @@ -585,9 +585,45 @@ ALLOW="$( awk "BEGIN{printf \"%d\", $TBF*2.36*1.15}" )" "$BIN" src --for="$FOR_TASK" --token-budget=$TBF --detail=20 --with-graph --no-cache >"$TMP/f_tb.out" 2>"$TMP/f_tb.err" rc_ftb=$? FTB="$( bytes_of "$TMP/f_tb.out" )" -{ [ "$rc_ftb" -eq 0 ] && [ "$FTB" -le "$ALLOW" ]; } 2>/dev/null \ - && ok "#11 A7 --for --token-budget=$TBF --detail=20 --with-graph: $FTB B within the $ALLOW B allowance (exit 0, SHAPED)" \ - || no "#11 A7 --for --token-budget=$TBF --detail=20 --with-graph: $FTB B vs the $ALLOW B allowance, exit $rc_ftb — the budget does not bound the appended sections" +# THE UNPAID TWIN (knob-honesty-068; orchestrator rulings 2026-10-07, PROCESS rules 4/5). A capped under a +# hard ceiling carries next= — the call that recovers its cut — and pays for it from its rows ONLY where that makes the +# answer fit (ruling C3). Where it cannot (the rank 1..4 floor has nothing left to shed, or the answer lands past its ceiling +# paid or not) no row is dropped for it: the recovery handle ALWAYS ships, unpaid, and the document may then land past the +# allowance, and only if it says so. This is not a wider allowance: every condition of the old bound still holds for +# everything except the disclosed handle. sigs_unpaid_twin FILE ALLOW → exit 0 iff ALL of: the root carries +# over_ceiling="1"; the legend carries the unpaid clause defining it; is capped and carries next="--for=…"; +# est_tokens is positive and prices the delivered bytes inside the 2.00-4.20 B/tok band (honest: it moved with the handle); +# and the document WITHOUT the handle and its label (the next= attribute, over_ceiling="1", the clause) is within ALLOW — +# the old bound, intact — or carries the ladder's last-rung disclosure (the header floor alone exceeds the budget: the one +# overshoot the old bound already admitted). The rows are the ones the cut alone leaves (the C3 arm below pins them). +sigs_unpaid_twin(){ python3 - "$1" "$2" <<'PYF' +import sys, re +d = open( sys.argv[ 1 ], 'rb' ).read().decode( 'utf-8', 'replace' ); allow = int( sys.argv[ 2 ] ) +clause = ' [over_ceiling=1 also when the sigs next= rides unpaid: no row is dropped to pay for it on an answer that would not fit either way]' +root = re.search( r']*>', d ); sigs = re.search( r']*>', d ) +why = [] +if not root or ' over_ceiling="1"' not in root.group( 0 ): why.append( 'no root over_ceiling="1"' ) +if clause not in d: why.append( 'no unpaid clause' ) +nxt = re.search( r' next="--for=[^"]*"', sigs.group( 0 ) ) if sigs else None +if not sigs or ' capped="1"' not in sigs.group( 0 ): why.append( ' not capped' ) +if not nxt: why.append( ' carries no next=' ) +est = re.search( r' est_tokens="(\d+)"', root.group( 0 ) ) if root else None +b = len( d.encode( 'utf-8' ) ) +if not est or int( est.group( 1 ) ) <= 0 or not ( 200 <= b * 100 // int( est.group( 1 ) ) <= 420 ): why.append( 'est_tokens absent or out of band' ) +rest = b - ( len( nxt.group( 0 ) ) if nxt else 0 ) - len( ' over_ceiling="1"' ) - len( clause ) +lastRung = '[over_ceiling= is 1 on the root: the header floor' in d +if rest > allow and not lastRung: why.append( f'{rest} B without the handle > {allow} B (and no last-rung disclosure)' ) +print( '; '.join( why ) if why else f'{b} B, {rest} B without the handle <= {allow} B, est_tokens={est.group( 1 )}' ) +sys.exit( 1 if why else 0 ) +PYF +} +if { [ "$rc_ftb" -eq 0 ] && [ "$FTB" -le "$ALLOW" ]; } 2>/dev/null; then + ok "#11 A7 --for --token-budget=$TBF --detail=20 --with-graph: $FTB B within the $ALLOW B allowance (exit 0, SHAPED)" +elif [ "$rc_ftb" -eq 0 ] && a7why="$( sigs_unpaid_twin "$TMP/f_tb.out" "$ALLOW" )"; then + ok "#11 A7 --for --token-budget=$TBF --detail=20 --with-graph: the unpaid twin — $a7why, labelled over_ceiling=\"1\" with next= (exit 0, SHAPED)" +else + no "#11 A7 --for --token-budget=$TBF --detail=20 --with-graph: $FTB B vs the $ALLOW B allowance, exit $rc_ftb — the budget does not bound the appended sections${a7why:+ (unpaid twin: $a7why)}" +fi # and without an explicit --token-budget the bodies keep their own budget: the bundle must NOT have shrunk { [ "$( bytes_of "$TMP/f_for_detail.out" )" -gt "$ALLOW" ]; } 2>/dev/null \ && ok "#11 A7 no --token-budget: --detail keeps its --pack-budget-bytes budget (unbudgeted bundle unshrunk)" \ @@ -624,23 +660,36 @@ for i in range( 4 ): with open( os.path.join( out, f"mod{i}.cpp" ), "w" ) as fh: fh.write( "\n".join( lines ) ) PYG -a7s_bad=""; a7s_badn=0; a7s_runs=0; a7s_inside_labelled=0 +a7s_bad=""; a7s_badn=0; a7s_runs=0; a7s_inside_labelled=0; a7s_floor=0 # RE-ANCHORED 2026-09-13 (PR #215): the default sweep starts at 760, not 1200. --for's rung zero now triggers on the # EXACT ceiling (verbs_for.h), so a document 1..15% over its budget drops its three explanatory clauses before the # allowance is consulted; on this corpus the late-label band (over_ceiling="1" INSIDE the allowance — the residual # after that drop) therefore sits at 780..810 instead of inside 1200..1500, and the control below would otherwise be # inert. Swept 700..3300 step 10 on the new binary: default hits at 780 790 800 810, none on the --detail=20 arm. -for spec in "default:760:1500:" "detail_graph:2880:3080:--detail=20 --with-graph"; do - s_label="${spec%%:*}"; s_rest="${spec#*:}"; s_from="${s_rest%%:*}"; s_rest="${s_rest#*:}"; s_to="${s_rest%%:*}"; s_args="${s_rest#*:}" - for (( N = s_from; N <= s_to; N += 10 )); do +# RE-ANCHORED 2026-10-07 (knob-honesty-068, orchestrator ruling C3): a capped, CHARGED whose answer is past its ceiling +# paid or not is now served with the rows the cut alone leaves and its next= unpaid — bigger than the paid answer, so the +# capped documents that sat in the late-label band (dd6e4c8e: the --detail=20 arm's 2880..3080, est_tokens > N inside the +# allowance) now cross the allowance on the ladder's disclosed last rung, and no capped document on this corpus is left in +# the band (swept 700..1500 and 2880..3080 step 10). The band itself is untouched by the ruling: an UNCAPPED block owes no +# continuation, so the third spec — a name-exact task serving ONE row — keeps a document whose header floor lands in it +# (480..488 at the time of writing; step 4 so a band 9 budgets wide is always crossed). Fields: label:from:to:step:task:args. +for spec in "default:760:1500:10:serialize the map:" "detail_graph:2880:3080:10:serialize the map:--detail=20 --with-graph" \ + "named_one_row:400:600:4:serializeRow0_3:"; do + s_label="${spec%%:*}"; s_rest="${spec#*:}"; s_from="${s_rest%%:*}"; s_rest="${s_rest#*:}"; s_to="${s_rest%%:*}"; s_rest="${s_rest#*:}" + s_step="${s_rest%%:*}"; s_rest="${s_rest#*:}"; s_task="${s_rest%%:*}"; s_args="${s_rest#*:}" + for (( N = s_from; N <= s_to; N += s_step )); do # shellcheck disable=SC2086 - ( cd "$A7S" && "$BIN" corpus --for="serialize the map" --token-budget=$N $s_args --no-cache ) >"$A7S/o.xml" 2>/dev/null + ( cd "$A7S" && "$BIN" corpus --for="$s_task" --token-budget=$N $s_args --no-cache ) >"$A7S/o.xml" 2>/dev/null s_rc=$? a7s_runs=$(( a7s_runs + 1 )) s_b="$( bytes_of "$A7S/o.xml" )" s_a="$( awk "BEGIN{printf \"%d\", $N*2.36*1.15}" )" s_root="$( grep -aoE '^]*>' "$A7S/o.xml" | head -1 )" - if [ "$s_rc" -ne 0 ] || { [ "$s_b" -gt "$s_a" ] && ! grep -aqF '[over_ceiling= is 1 on the root: the header floor' "$A7S/o.xml"; }; then + # past the allowance: the ladder's last rung says so, or (knob-honesty-068) the unpaid twin above holds in full + if [ "$s_rc" -eq 0 ] && [ "$s_b" -gt "$s_a" ] && ! grep -aqF '[over_ceiling= is 1 on the root: the header floor' "$A7S/o.xml" \ + && sigs_unpaid_twin "$A7S/o.xml" "$s_a" >/dev/null; then + a7s_floor=$(( a7s_floor + 1 )) + elif [ "$s_rc" -ne 0 ] || { [ "$s_b" -gt "$s_a" ] && ! grep -aqF '[over_ceiling= is 1 on the root: the header floor' "$A7S/o.xml"; }; then a7s_badn=$(( a7s_badn + 1 )) [ "$a7s_badn" -le 6 ] && a7s_bad="$a7s_bad $s_label@$N=${s_b}/${s_a}B(exit $s_rc)" elif [ "$s_b" -le "$s_a" ] && [ "${s_root#* over_ceiling=\"1\"}" != "$s_root" ]; then @@ -649,13 +698,55 @@ for spec in "default:760:1500:" "detail_graph:2880:3080:--detail=20 --with-graph done done [ "$a7s_badn" -eq 0 ] \ - && ok "#11 A7 sweep: $a7s_runs budgets over a git-less corpus (default 760..1500, --detail=20 --with-graph 2880..3080, step 10) — every document within N x 2.36 x 1.15 at exit 0, or on the ladder's disclosed last rung" \ + && ok "#11 A7 sweep: $a7s_runs budgets over a git-less corpus (default 760..1500, --detail=20 --with-graph 2880..3080, step 10; one-row named task 400..600, step 4) — every document within N x 2.36 x 1.15 at exit 0, or on the ladder's disclosed last rung, or the disclosed unpaid twin ($a7s_floor)" \ || no "#11 A7 sweep: $a7s_badn of $a7s_runs budgets deliver past the allowance with no ladder rung fired (first:$a7s_bad) — a byte spliced in after the ladder priced the document" # control: the sweep must cross the band the defect lives in — a root that says over_ceiling="1" while the document # still fits the allowance (est_tokens > N at 2.50 B/tok, bytes <= 2.714 B/tok). No such budget = inert, re-anchor. [ "$a7s_inside_labelled" -gt 0 ] \ && ok "#11 A7 sweep control: $a7s_inside_labelled budget(s) carry a root over_ceiling=\"1\" INSIDE the allowance — the sweep crosses the late-label band" \ || no "#11 A7 sweep control: no budget carried over_ceiling=\"1\" inside the allowance — the sweep no longer reaches the est_tokens > N band, re-anchor its ranges" +# A7 UNPAID TWIN, EXERCISED EVERY RUN (knob-honesty-068; rulings 2026-10-07). The A7 arm and the sweep accept the twin only +# as an alternative, so a fixture that stopped reaching it would leave it untested. On this git-less corpus at +# --token-budget=780 the default bundle's sits at its rank 1..4 floor: the answer the cut alone leaves FITS (the +# pre-continuation binary, 255dc199, serves it at est_tokens=774 with no label), its next= cannot be paid in rows, and the +# document lands past the allowance only by the handle — the twin must hold, with no last-rung disclosure to lean on. +( cd "$A7S" && "$BIN" corpus --for="serialize the map" --token-budget=780 --no-cache ) >"$A7S/floor.xml" 2>/dev/null; fl_rc=$? +fl_a="$( awk 'BEGIN{printf "%d", 780*2.36*1.15}' )" +if [ "$fl_rc" -eq 0 ] && ! grep -aqF '[over_ceiling= is 1 on the root: the header floor' "$A7S/floor.xml" \ + && fl_why="$( sigs_unpaid_twin "$A7S/floor.xml" "$fl_a" )"; then + ok "#11 A7 unpaid twin @780: $fl_why — next= ships at the floor and the overshoot is labelled over_ceiling=\"1\" with its clause" +else + no "#11 A7 unpaid twin @780 (exit $fl_rc): ${fl_why:-the ladder last rung fired, so the twin is not the only disclosure} — the floor did not ship its next= with a labelled, honestly priced overshoot" +fi +# C3 (orchestrator ruling 2026-10-07): NEVER DROP ROWS TO PAY FOR next= WHEN THE ANSWER IS OVER ITS CEILING ANYWAY. At 790 the +# answer the cut alone leaves is past its ceiling already (255dc199 serves 5 rows there at est_tokens=952, last rung fired); +# paying from rows (dd6e4c8e: 4 rows, est_tokens=885) still lands over, so it bought nothing. The answer must keep the cut's +# own rows — at least 255dc199's 5 (re-pin from a pre-continuation binary if row bytes change; smaller rows only raise it) — +# and MORE than the paid near miss at 850 serves (4 rows: there paying is what makes the answer fit), with next= riding +# unpaid and over_ceiling="1" + the unpaid clause on the root. RED on dd6e4c8e (4 rows). +( cd "$A7S" && "$BIN" corpus --for="serialize the map" --token-budget=790 --no-cache ) >"$A7S/c3.xml" 2>/dev/null; c3_rc=$? +( cd "$A7S" && "$BIN" corpus --for="serialize the map" --token-budget=850 --no-cache ) >"$A7S/paid.xml" 2>/dev/null; pd_rc=$? +sigs_shown(){ grep -aoE ']*>' "$1" | tail -1 | grep -oE ' shown="[0-9]+"' | tr -dc '0-9'; } +c3_sh="$( sigs_shown "$A7S/c3.xml" )"; pd_sh="$( sigs_shown "$A7S/paid.xml" )" +c3_root="$( grep -aoE '^]*>' "$A7S/c3.xml" | head -1 )" +if [ "$c3_rc" -eq 0 ] && [ -n "$c3_sh" ] && [ -n "$pd_sh" ] && [ "$c3_sh" -ge 5 ] && [ "$c3_sh" -gt "$pd_sh" ] \ + && grep -aqE ']* capped="1"[^>]* next="--for=' "$A7S/c3.xml" && [ "${c3_root#* over_ceiling=\"1\"}" != "$c3_root" ] \ + && grep -aqF 'next= rides unpaid: no row is dropped to pay for it' "$A7S/c3.xml"; then + ok "#11 A7 C3 @790: over its ceiling either way, the answer keeps the cut's own $c3_sh rows (>= 255dc199's 5, > the paid 850 answer's $pd_sh) — next= unpaid, over_ceiling=\"1\" + the unpaid clause" +else + no "#11 A7 C3 @790 (exit $c3_rc): shown=${c3_sh:-?} (want >= 5 and > the paid 850 answer's ${pd_sh:-?}), next= + over_ceiling=\"1\" + the unpaid clause — rows were dropped to pay for a handle that cannot make the answer fit" +fi +# Its near miss, 850 — the PAYABLE case, unchanged: paying is what makes the answer fit (255dc199: 5 rows, est_tokens=911, +# over), so the handle is paid (the block sheds its bytes above the floor) — next= rides inside the allowance, and neither +# over_ceiling="1" nor the unpaid clause appears (a label that rode on every capped answer would pass the arms above alone). +pd_b="$( bytes_of "$A7S/paid.xml" )"; pd_a="$( awk 'BEGIN{printf "%d", 850*2.36*1.15}' )" +pd_root="$( grep -aoE '^]*>' "$A7S/paid.xml" | head -1 )" +if [ "$pd_rc" -eq 0 ] && [ "$pd_b" -le "$pd_a" ] && grep -aqE ']* capped="1"[^>]* next="--for=' "$A7S/paid.xml" \ + && [ "${pd_root#* over_ceiling=\"1\"}" = "$pd_root" ] && ! grep -aqF 'next= rides unpaid' "$A7S/paid.xml"; then + ok "#11 A7 unpaid twin near miss @850: the capped pays for its next= — $pd_b B <= $pd_a B, no over_ceiling=, no unpaid clause" +else + no "#11 A7 unpaid twin near miss @850 (exit $pd_rc): $pd_b B vs $pd_a B — expected a paid next= inside the allowance with no label" +fi # A9/A10 — the header's own spliced attributes are inside the number. IDENTITY, not a band: for a bundle with # no --detail bodies, est_tokens is markup-only, so it must equal round(delivered bytes / 2.50) EXACTLY @@ -1670,7 +1761,11 @@ PYRZ rz_run(){ ( cd "$RZ" && "$BIN" corpus --for="widget ping box router" --detail=1 --token-budget="$1" --no-cache --legend=full ) >"$RZ/o.xml" 2>/dev/null; } rz_est(){ grep -aoE 'est_tokens="[0-9]+"' "$RZ/o.xml" | head -1 | tr -dc '0-9'; } rz_note(){ grep -acF '[legend clauses:' "$RZ/o.xml"; } -RZ_WIDE=1200 +# FE-B (test/receiverevidencecheck.sh): 1200 -> 1400. The fixture's bare widgetPingStepN() calls name a function another +# file defines with no import, so their rows are via="name" and --for's header now defines it (~190 B, never +# dropped). The full document prices at est_tokens=1269 (it was 1162, 38 under the old 1200), so 1200 no longer carried +# its legend; 1400 is the wide control again with ~130 tokens of headroom. The probe and the control still read E off it. +RZ_WIDE=1400 rz_run "$RZ_WIDE"; RZ_E="$( rz_est )"; RZ_WIDE_NOTE="$( rz_note )" # THE CLAUSES THIS ARM COUNTS — all THREE of the droppable trio (CodeRabbit, PR #215). It counted two: the # confidence reading and the tail reading, but not the route= reading the arm's own paragraph above names. diff --git a/test/falseedgecheck.sh b/test/falseedgecheck.sh index 7a123d41c..05a672d61 100755 --- a/test/falseedgecheck.sh +++ b/test/falseedgecheck.sh @@ -35,7 +35,9 @@ # (`lib.Shape()` via example.com/vendored); a function passed as a value. Root gonomod/ has NO go.mod: its # full-path import of an in-tree package keeps its edge (an unknown module path proves nothing outside). # Root gomodcomment/: a `module x // c` line and a nested `module "y" // c` are x and y (near miss: cmx is -# not under cm); root goreplace/: a module only a local `replace` names (its directory has no go.mod). +# not under cm); root goreplace/: a module only a local `replace` names (its directory has no go.mod), also +# spelled quoted (goreplace/quoted/: `"x" => "./x"`, a raw-string path; near miss: a quoted module replaced by +# another quoted MODULE stays outside). # (B) JS: JSON.stringify, `const { stringify } = JSON`, `new URL()`, Buffer.from, `globalThis.fetch`, console/Math/ # Object/Array/Promise members, require('destroy'), require('supertest'), a receiver from require('qs') # and a name destructured from require('cookie') never reach an in-repo function, getter, method or object @@ -70,7 +72,8 @@ # (F) propagation: --callers and --impact of the in-repo decoys no longer list the false callers. # (G) disclosure: every call the arms above unbind is a `C external` census row (empty targets) — except a Python bare # name nothing binds and no builtin table holds (run_all's `process`, siblings.py's `helper`), which has no -# in-repo target and no proof of an outside one: it is unresolved=, no census row at all; each root's map +# in-repo target and no proof of an outside one: it is unresolved=, no census row at all, and the py and c +# roots pin their census unresolved= to exactly the sites named (an omitted call cannot pass); each root's map # header external= equals its census `# dispositions external=` and is at least the number of expected # external rows; every root's dispositions still sum to calls= with unaccounted=0. # (H) MCP twins (fresh TMPDIR cache): find_referencing_symbols / find_symbol name exactly the rows the CLI's @@ -251,6 +254,21 @@ lacks gomodcomment callees app/near.go:Near "Shape lib/lib.go" # goreplace/ keeps the replace-only shape the go/ root had before it became buildable Go: legacy/ has no go.mod, so # the replace line is the only source of example.com/legacy (twin of the old UseVendored arm) exactly goreplace callees app/app.go:UseLegacy "fn Shape legacy/lib/lib.go" +# goreplace/quoted/: the go.mod grammar lets either side of a replace be quoted — an interpreted string or a raw +# (backquoted) one, single-line or grouped. Each is the same local replace as the bare spelling. Near miss: a quoted +# module replaced by another quoted MODULE (not a directory) stays outside the tree — reading the quote itself as the +# start of a local path would put it in. +exactly goreplace callees quoted/app/app.go:UseQuoted "fn Quote quoted/qlegacy/lib/lib.go" +exactly goreplace callees quoted/app/raw.go:UseRaw "fn Raw quoted/qraw/lib/lib.go" +lacks goreplace callees quoted/app/remote.go:UseRemote "Quote quoted/qlegacy/lib/lib.go" +# goworkspace/: two roots indexed together; a's go.mod replaces example.com/wb by the SIBLING root ../b, quoted. The +# cross-root alias (resolve.h parseGoModReplaces) reads the path with the same goModToken, so the call keeps its edge. +wsf="$TMP/goworkspace.callees.xml" +( cd "$CORPUS/goworkspace" && "$BIN" a b --no-cache --callees=app/app.go:Use >"$wsf" 2>/dev/null ); wsrc=$? +wsgot="$( rows "$wsf" | tr '\n' ';' | sed 's/;$//' )" +if [ "$wsrc" -ne 0 ]; then no "(goworkspace) --callees=app/app.go:Use over roots a b exited rc=$wsrc" +elif [ "$wsgot" = "fn Far b/lib/lib.go" ]; then ok "(goworkspace) a quoted replace into a sibling root keeps the cross-root edge [fn Far b/lib/lib.go]" +else no "(goworkspace) --callees=app/app.go:Use over roots a b: rows [${wsgot:-none}], want [fn Far b/lib/lib.go]"; fi echo "=== (B) JS: globals, required packages, accessors ===" lacks js callees lib/response.js:length "stringify lib/query.js" @@ -372,11 +390,13 @@ externals tsimport src/remote.ts pull fetch externals js lib/selfalias.js onMessage process externals ts src/selfalias.ts onTick process externals gomodcomment app/near.go Near Shape +externals goreplace quoted/app/remote.go UseRemote Quote # a Python name no import, local or module def binds and that is no builtin: no in-repo target, but nothing proves it is # outside the tree either (a closure variable, a star import of an unresolved module) — so it is no census row at all # (counted unresolved=), never a C external row and never a bound one unresolved_site(){ local r="$1" file="$2" caller="$3" callee="$4" + printf '%s %s %s\n' "$file" "$caller" "$callee" >>"$TMP/$r.nunres" # the pin below counts the sites named per root if python3 - "$TMP/$r.tsv" "$file" "$caller" "$callee" <<'PY' import sys census, path, caller, callee = sys.argv[ 1: ] @@ -395,6 +415,20 @@ unresolved_site py src/ui/widget.py run_all process unresolved_site py src/ui/siblings.py run helper externals py src/ui/widget.py read_config open unresolved_site c copy.c classify find_type +# unresolved_site reads an ABSENT census row, so a call extraction dropped would pass it too (CodeRabbit on #372). Each +# root that names unresolved sites therefore pins its census `unresolved=` to exactly the number it names: a dropped call +# lowers the count (red), and a new unresolved call in the fixture raises it (red until an arm names that site too). +unresolved_pinned(){ + local r="$1" want got + want="$( sort -u "$TMP/$r.nunres" 2>/dev/null | wc -l | tr -d ' ' )" + got="$( grep -m1 '^# dispositions ' "$TMP/$r.tsv" 2>/dev/null | grep -oE ' unresolved=[0-9]+' | grep -oE '[0-9]+$' )" + if [ -z "$got" ]; then no "(G) ($r) the census dispositions carry no unresolved= count — the pin cannot be read" + elif [ "$want" -lt 1 ]; then no "(G) ($r) no unresolved site is named for this root — the pin would be vacuous" + elif [ "$got" -ne "$want" ]; then no "(G) ($r) census unresolved=$got, but this gate names $want unresolved site(s): $( tr '\n' ';' <"$TMP/$r.nunres" )" + else ok "(G) ($r) census unresolved=$got == the $want site(s) named above: each was extracted and left unresolved"; fi +} +unresolved_pinned py +unresolved_pinned c externals c window.c elapsed clock externals rs src/lib.rs draw render for r in $ROOTS; do diff --git a/test/falseedgefix/goreplace/quoted/app/app.go b/test/falseedgefix/goreplace/quoted/app/app.go new file mode 100644 index 000000000..62a12b338 --- /dev/null +++ b/test/falseedgefix/goreplace/quoted/app/app.go @@ -0,0 +1,6 @@ +package app + +import "example.com/qlegacy/lib" + +// UseQuoted calls through an import a quoted local replace puts in this tree: a true edge. +func UseQuoted(n int) int { return lib.Quote(n) } diff --git a/test/falseedgefix/goreplace/quoted/app/raw.go b/test/falseedgefix/goreplace/quoted/app/raw.go new file mode 100644 index 000000000..87391737b --- /dev/null +++ b/test/falseedgefix/goreplace/quoted/app/raw.go @@ -0,0 +1,6 @@ +package app + +import "example.com/qraw/lib" + +// UseRaw calls through an import a backquoted local replace puts in this tree: a true edge. +func UseRaw(n int) int { return lib.Raw(n) } diff --git a/test/falseedgefix/goreplace/quoted/app/remote.go b/test/falseedgefix/goreplace/quoted/app/remote.go new file mode 100644 index 000000000..74f22db55 --- /dev/null +++ b/test/falseedgefix/goreplace/quoted/app/remote.go @@ -0,0 +1,6 @@ +package app + +import lib "example.com/qremote/lib" + +// UseRemote calls a module replaced by another MODULE, not a directory: outside the tree, no edge to the in-tree Quote. +func UseRemote(n int) int { return lib.Quote(n) } diff --git a/test/falseedgefix/goreplace/quoted/go.mod b/test/falseedgefix/goreplace/quoted/go.mod new file mode 100644 index 000000000..23055db7e --- /dev/null +++ b/test/falseedgefix/goreplace/quoted/go.mod @@ -0,0 +1,13 @@ +module example.com/rq + +go 1.22 + +// Not buildable Go on purpose, as ../go.mod: the quoted spellings Go's go.mod grammar allows for a local replace. +replace "example.com/qlegacy" => "./qlegacy" + +replace ( + `example.com/qraw` => ./qraw +) + +// A quoted module replaced by another MODULE (not a directory) is not this tree's code. +replace "example.com/qremote" => "example.com/qfork" v1.0.0 diff --git a/test/falseedgefix/goreplace/quoted/qlegacy/lib/lib.go b/test/falseedgefix/goreplace/quoted/qlegacy/lib/lib.go new file mode 100644 index 000000000..56af67342 --- /dev/null +++ b/test/falseedgefix/goreplace/quoted/qlegacy/lib/lib.go @@ -0,0 +1,4 @@ +package lib + +// Quote is in this tree under a module path only a QUOTED local replace maps here. +func Quote(n int) int { return n * 5 } diff --git a/test/falseedgefix/goreplace/quoted/qraw/lib/lib.go b/test/falseedgefix/goreplace/quoted/qraw/lib/lib.go new file mode 100644 index 000000000..c933adf49 --- /dev/null +++ b/test/falseedgefix/goreplace/quoted/qraw/lib/lib.go @@ -0,0 +1,4 @@ +package lib + +// Raw is in this tree under a module path only a raw-string (backquoted) local replace maps here. +func Raw(n int) int { return n * 7 } diff --git a/test/falseedgefix/goworkspace/a/app/app.go b/test/falseedgefix/goworkspace/a/app/app.go new file mode 100644 index 000000000..92aea7b5e --- /dev/null +++ b/test/falseedgefix/goworkspace/a/app/app.go @@ -0,0 +1,6 @@ +package app + +import "example.com/wb/lib" + +// Use calls into the sibling root a quoted replace maps: a true edge. +func Use(n int) int { return lib.Far(n) } diff --git a/test/falseedgefix/goworkspace/a/go.mod b/test/falseedgefix/goworkspace/a/go.mod new file mode 100644 index 000000000..5b70cc33e --- /dev/null +++ b/test/falseedgefix/goworkspace/a/go.mod @@ -0,0 +1,6 @@ +module example.com/wa + +go 1.22 + +// A quoted replace of a module by a SIBLING root (indexed beside this one): the cross-root alias. +replace "example.com/wb" => "../b" diff --git a/test/falseedgefix/goworkspace/b/go.mod b/test/falseedgefix/goworkspace/b/go.mod new file mode 100644 index 000000000..49301ae03 --- /dev/null +++ b/test/falseedgefix/goworkspace/b/go.mod @@ -0,0 +1,3 @@ +module example.com/wb + +go 1.22 diff --git a/test/falseedgefix/goworkspace/b/lib/lib.go b/test/falseedgefix/goworkspace/b/lib/lib.go new file mode 100644 index 000000000..989b90698 --- /dev/null +++ b/test/falseedgefix/goworkspace/b/lib/lib.go @@ -0,0 +1,4 @@ +package lib + +// Far lives in the sibling root. +func Far(n int) int { return n } diff --git a/test/fieldnarrowcheck.sh b/test/fieldnarrowcheck.sh index 8a5ef8cf7..60d53551a 100755 --- a/test/fieldnarrowcheck.sh +++ b/test/fieldnarrowcheck.sh @@ -210,18 +210,21 @@ PY="$( callees po_go )" && ok "(e-py) po_go() self.member.compute() stays honestly split (annotated attr NOT narrowed — disclosed limit)" \ || no "(e-py) po_go() lost its honest split — Python receiver behavior must be unchanged this round" TS="$( callees to_go )" -( printf '%s\n' "$TS" | grep -q 't.ts:1"' ) && ( printf '%s\n' "$TS" | grep -q 't.ts:2"' ) \ - && ok "(e-ts) to_go() this.member.compute() stays honestly split (TS receivers uncaptured — disclosed limit)" \ - || no "(e-ts) to_go() lost its honest split — TS receiver behavior must be unchanged this round" +# FE-B (test/receiverevidencecheck.sh) reads TS field annotations: `member: THelper;` types `this.member`, so to_go() +# RESOLVES to THelper.compute and TDecoy.compute is no candidate (it was the honest split while TS receivers were +# uncaptured). An UNtyped TS chain stays name-only (receiverevidencecheck's `ctx.req.raw.headers.get()` arm). +( printf '%s\n' "$TS" | grep -q 't.ts:1"' ) && ! ( printf '%s\n' "$TS" | grep -q 't.ts:2"\|via="name"' ) \ + && ok "(e-ts) to_go() this.member.compute() resolves to THelper.compute alone (the annotated field types it — FE-B)" \ + || no "(e-ts) to_go() should resolve to THelper.compute alone, plain: $( printf '%s' "$TS" | tr '\n' ' ' )" -# ── (h) the header gauge agrees with the arms above: exactly the 6 honest splits remain ambiguous -# (expl, unk, freeuse, multi, po_go, to_go — run/ptr/inh_go narrowed, shadowLocal and shadowParam are +# ── (h) the header gauge agrees with the arms above: exactly the 5 honest splits remain ambiguous +# (expl, unk, freeuse, multi, po_go — to_go resolves since FE-B; run/ptr/inh_go narrowed, shadowLocal and shadowParam are # Rule 2; shadowParam was a split until Rule 2 read parameter types, 2026-09-16, which moved this from 7). # Counted from the fixture, not guessed: flip arms above before touching this number. ── AMB="$( printf '%s\n' "$MAP" | grep -o 'ambiguous=[0-9]*' | head -1 )" -[ "$AMB" = "ambiguous=6" ] \ - && ok "(h) header gauge ambiguous=6 — only the honest splits remain" \ - || no "(h) header gauge is '$AMB', expected ambiguous=6 (3 field-typed calls narrowed, 6 honest splits kept)" +[ "$AMB" = "ambiguous=5" ] \ + && ok "(h) header gauge ambiguous=5 — only the honest splits remain" \ + || no "(h) header gauge is '$AMB', expected ambiguous=5 (3 field-typed calls narrowed, to_go resolved, 5 honest splits kept)" # ── (n) same-NAMED class collision (FIX2): conflicting same-named fields tombstone — NEITHER Dup::go narrows ── MAP2="$( "$BIN" "$FIX2" --no-cache --legend=full 2>/dev/null | tr '>' '\n' )" diff --git a/test/fixedbufsweep.sh b/test/fixedbufsweep.sh index ddf94a920..3e760a6bd 100755 --- a/test/fixedbufsweep.sh +++ b/test/fixedbufsweep.sh @@ -189,8 +189,8 @@ TABLE = { ( "src/serialize.h", "bodylessAttr" ): ( 1, "safe", "bodylessAttr[40] in packBodies (issue #60, integration/train-12): the exact twin of the packtask.h row above — same format, same 12 B of literal, one std::size_t (bodylessCount), 32 B against 39 usable + NUL, 7 B of margin, no string interpoland. Same [32]-on-arrival correction, widened in the same commit." ), ( "src/serialize.h", "fitAttr" ): ( 1, "safe", "fitAttr[96]: two %zu plus the literal ' over_ceiling=1'." ), ( "src/serialize.h", "attr" ): ( 2, "safe", "attr[352] x2: the per-symbol metric attrs. Widest 26 lit + 4x10 digits + 11 role + qbuf(<=95) + ambs(<=35: amb= + lpin=) + kbuf(<=23) = 230 B." ), - ( "src/serialize.h", "tail" ): ( 2, "safe", "tail[192] x2: the row tail. Widest 34 lit+digits + inAttr(<=23) + lens(qbuf, <=79) + pure(9) = 145 B." ), - ( "src/serialize.h", "hdr" ): ( 15, "safe", "hdr[64] x2 (packBodies/packOutline): ' row tail. Widest 34 lit+digits + inAttr(<=23) + lens(qbuf, <=79) + pure(9) = 145 B. The third (lane/lean-answers-068, sigRowTail's zero-elision branch) prints cxAttr/ccxAttr/inAttr pre-formatted, each omitted at 0 — never wider than the second." ), + ( "src/serialize.h", "hdr" ): ( 13, "safe", "hdr[64] x2 (packBodies/packOutline) (13: train 26b — count-floor-068 replaced the implementors= header writes, two hdr sites, with string appends): ' plus pageDisclosure's own capped buffer, sized against that cap by construction." ), ( "src/serialize.h", "hb" ): ( 3, "latent", "hb[176]: ; shape= is a fixed vocabulary but acd/nccd are %.1f/%.2f on DOUBLES, formally unbounded. Realistic worst case 66 lit + 60 digits + 24 float + 10 shape = 160 B, ~16 B of margin. Truncation drops the '/>' and orphans the element." ), ( "src/serialize.h", "fit" ): ( 1, "safe", "fit[160]: the JSON max_tokens/fit_bytes twin; the %s is the literal ',\"over_ceiling\":true'." ), @@ -260,12 +260,18 @@ NUMERIC_ONLY = { ( "src/serialize.h", "...)" ): 1, ( "src/serialize.h", "callsHdr" ): 2, ( "src/serialize.h", "cb" ): 1, + ( "src/serialize.h", "ccxAttr" ): 1, # lane/lean-answers-068: sigRowTail's zero elision, " ccx=\"{}\"" of one count (<= 17 B of 23) ( "src/serialize.h", "changedAttr" ): 1, + ( "src/serialize.h", "eAttr" ): 1, # lane/for-spine-span-068 (2026-10-04): writeSigHeadWithEnd's ' e="{}"' — 5 B literal + one + # std::uint32_t (10 digits) = 15 B against 23 usable + NUL. A count, no %s, nothing escaped. + ( "src/serialize.h", "cxAttr" ): 1, # lane/lean-answers-068: the same, " cx=\"{}\"" (<= 16 B of 23) ( "src/serialize.h", "eb" ): 1, + ( "src/serialize.h", "eKey" ): 1, # lane/for-spine-span-068: spliceJsonEndLine's JSON twin ',"e":{}' — 5 B + 10 digits = 15 B + # against 23 usable + NUL. A count, no %s, nothing escaped. ( "src/serialize.h", "estAttr" ): 1, ( "src/serialize.h", "gb" ): 1, ( "src/serialize.h", "gfb" ): 1, - ( "src/serialize.h", "inAttr" ): 1, + ( "src/serialize.h", "inAttr" ): 2, # +1 lane/lean-answers-068: the zero-elision branch's own " in=\"{}\"" (<= 16 B of 23) ( "src/serialize.h", "kbuf" ): 1, ( "src/serialize.h", "lb" ): 4, # row 6 (2026-09-12): collectCalleeNameRow's line buffer (the merged row) joined the two # …and a FOURTH (2026-09-13, PR #215 item 8): appendMergedCalleeNameRows joins the row's @@ -274,10 +280,14 @@ NUMERIC_ONLY = { # "{}" of one std::uint32_t, ten digits worst case against 15 usable + NUL, no %s and # nothing escaped, so it does not join the string-interpolating population ( "src/serialize.h", "lineAttr" ): 1, - ( "src/serialize.h", "nb" ): 3, # row 6 (2026-09-12): appendCalleeNameRow's `"\" l=\"{}\"/>"` buffer went with the merge. + ( "src/serialize.h", "nb" ): 6, # row 6 (2026-09-12): appendCalleeNameRow's `"\" l=\"{}\"/>"` buffer went with the merge. # cut-fix lane A (2026-09-23): +2, sigsOpenTag's nb[96] — ' shown="{}" total="{}" capped="1"' # (31 B literal + two size_t at 20 digits = 71 B worst case, against 95 usable + NUL) and # ' docs_dropped="{}"' (16 B + 20 digits = 36 B). Counts only, no %s, nothing escaped. + # lane/for-spine-span-068 (2026-10-04): +1, the same nb[96]'s ' docs_after_code="{}"' + # (19 B + 20 digits = 39 B). A count, no %s, nothing escaped. + # knob-honesty-068 (2026-10-07): +2 in the same nb[96] — ' next_offset="{}"' (15 B + 20 + # digits = 35 B) and ' next_budget_tokens="{}"' (22 B + 20 digits = 42 B). Counts only. ( "src/serialize.h", "precAttr" ): 1, ( "src/serialize.h", "rankAttr" ): 1, ( "src/serialize.h", "rc" ): 1, @@ -594,7 +604,20 @@ if not bad: # 238 -> 239 calls/sites, 104 -> 105 rows). noteCacheReject's new `char detail[192]` names both version # numbers on a refused cache blob; one formatTo of a literal and two std::uint32_t, rowed 'not-markup' # (a stderr notice). Re-derived on the train 20 merged tree; no other train lane moves the population. -EXPECTED = { "mentions": 351, "calls": 241, "sites": 241, "rows": 105, "widthforms": 0 } +# 2026-10-07 (lane/lean-answers-068, --for zero elision in serialize.h sigRowTail): +4 calls/+4 mentions/+4 +# sites/+2 rows (351 -> 355 mentions, 241 -> 245 calls/sites, 105 -> 107 rows): new cxAttr[24] and ccxAttr[24] +# rows, a second inAttr[24] site and a third tail[192] site, all in the branch that omits a zero cx=/ccx=/in=. +# Each formats one integer or pre-formatted numeric attributes; classified above. Re-derived, not summed. +# train 26b (lean-answers merged onto the train's 352/242/242/107): re-derived on the merged tree. +# 2026-10-07 (lane/knob-honesty-068): +2 calls/+2 mentions/+2 sites, rows unchanged — sigsOpenTag's existing nb[96] +# formats the cut's resume index (' next_offset="{}"') and the MCP machine continuation (' next_budget_tokens="{}"'): +# one size_t each, no string argument (35 B / 42 B worst case against 95 usable + NUL). +# train 26b (knob-honesty merged onto the train's 356/246/246/109): re-derived on the merged tree. +# 2026-10-08 (lane/cr-qd-kinds-068, the defect-shape kind): +5 mentions, no call/site/row (351 -> 356). Five +# lines NAME rw::formatTo without calling it: the format-arity family table and two comments in +# src/defectshape.h, the defect-shape legend sentence (verbs_quality.h) and the --help paragraph (cli.h). +# train 26b (cr-qd-kinds merged onto the train's 358/248/248/109): re-derived on the merged tree. +EXPECTED = { "mentions": 363, "calls": 248, "sites": 248, "rows": 109, "widthforms": 0 } # 2026-09-30 (#325 ruby_bases_unscoped=): +2 calls/+2 mentions/+2 sites, rows unchanged — graphGaugeAttrXml/Json (graphlegend.h) each format the absent-at-zero Ruby gauge into the SAME local buf[160]: one size_t, no string argument (21 + 20 digits worst case). # 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 diff --git a/test/forcompletefix/README.md b/test/forcompletefix/README.md new file mode 100644 index 000000000..acfd608f1 --- /dev/null +++ b/test/forcompletefix/README.md @@ -0,0 +1,3 @@ +Fixture roots for test/forsigspancheck.sh (the e= end line on --for rows, hop-slot choice, code ranked above +docs). Each subdirectory (c/, js/, py/, ts/) is indexed on its own. The code is minimal, written for the gate, and never +built. Some shapes (callers, value tables) are kept as ranking context for the questions the gate asks. diff --git a/test/forcompletefix/c/src/board.c b/test/forcompletefix/c/src/board.c new file mode 100644 index 000000000..c82102cd4 --- /dev/null +++ b/test/forcompletefix/c/src/board.c @@ -0,0 +1,18 @@ +#include "monitor.h" + +void Board_onKey( Board* b, int key ) +{ + Widget* w = b->focus; + if( w && w->klass->onKey ) + { + w->klass->onKey( w, key ); + } +} + +void Board_paint( Board* b ) +{ + for( int i = 0; i < b->gaugeCount; i++ ) + { + Gauge_render( &b->gauges[ i ] ); + } +} diff --git a/test/forcompletefix/c/src/gauge.c b/test/forcompletefix/c/src/gauge.c new file mode 100644 index 000000000..dc6f973d2 --- /dev/null +++ b/test/forcompletefix/c/src/gauge.c @@ -0,0 +1,40 @@ +#include +#include "monitor.h" + +static void Gauge_drawBar( Gauge* g ) +{ + for( int i = 0; i < g->value; i++ ) + { + putchar( '|' ); + } +} + +static void Gauge_drawText( Gauge* g ) +{ + printf( "%d", g->value ); +} + +static void Gauge_drawDots( Gauge* g ) +{ + for( int i = 0; i < g->value; i += 10 ) + { + putchar( '.' ); + } +} + +const GaugeStyle Gauge_modes[] = { + { .label = "bar", .draw = Gauge_drawBar }, + { .label = "text", .draw = Gauge_drawText }, + { .label = "dots", .draw = Gauge_drawDots }, +}; + +void Gauge_setStyle( Gauge* g, int style ) +{ + g->style = style; + g->paint = Gauge_modes[ style ].draw; +} + +void Gauge_render( Gauge* g ) +{ + g->paint( g ); +} diff --git a/test/forcompletefix/c/src/keys.c b/test/forcompletefix/c/src/keys.c new file mode 100644 index 000000000..1b61604e7 --- /dev/null +++ b/test/forcompletefix/c/src/keys.c @@ -0,0 +1,35 @@ +#include "monitor.h" + +typedef void ( *KeyAction )( Board* b ); + +static KeyAction keyTable[ 128 ]; + +static void actionCycleStyle( Board* b ) +{ + for( int i = 0; i < b->gaugeCount; i++ ) + { + Gauge_setStyle( &b->gauges[ i ], ( b->gauges[ i ].style + 1 ) % 3 ); + } +} + +static void actionFreeze( Board* b ) +{ + b->gaugeCount = 0; +} + +void Keys_bind( void ) +{ + keyTable[ 's' ] = actionCycleStyle; + keyTable[ 'f' ] = actionFreeze; +} + +static void BoardWidget_onKey( Widget* w, int key ) +{ + KeyAction fn = keyTable[ key & 127 ]; + if( fn ) + { + fn( ( Board* )w->state ); + } +} + +const WidgetClass BoardWidget_class = { .onKey = BoardWidget_onKey }; diff --git a/test/forcompletefix/c/src/main.c b/test/forcompletefix/c/src/main.c new file mode 100644 index 000000000..ef1398cb5 --- /dev/null +++ b/test/forcompletefix/c/src/main.c @@ -0,0 +1,10 @@ +#include "monitor.h" + +int main( int argc, char** argv ) +{ + ( void )argc; + ( void )argv; + Monitor* m = Monitor_new(); + Monitor_loop( m ); + return 0; +} diff --git a/test/forcompletefix/c/src/monitor.c b/test/forcompletefix/c/src/monitor.c new file mode 100644 index 000000000..3dac55a95 --- /dev/null +++ b/test/forcompletefix/c/src/monitor.c @@ -0,0 +1,41 @@ +#include +#include "monitor.h" + +static void Monitor_refresh( Monitor* m ) +{ + if( m->stale ) + { + Sampler_collectAll( m->sampler ); + m->stale = 0; + } + Board_paint( m->board ); +} + +void Monitor_loop( Monitor* m ) +{ + while( !m->quit ) + { + Monitor_refresh( m ); + int key = Input_read(); + if( key == 'q' ) + { + m->quit = 1; + } + else if( key >= 0 ) + { + Board_onKey( m->board, key ); + } + } +} + +Monitor* Monitor_new( void ) +{ + Monitor* m = calloc( 1, sizeof( Monitor ) ); + m->stale = 1; + return m; +} + +int Input_read( void ) +{ + return -1; +} diff --git a/test/forcompletefix/c/src/monitor.h b/test/forcompletefix/c/src/monitor.h new file mode 100644 index 000000000..6634c46b9 --- /dev/null +++ b/test/forcompletefix/c/src/monitor.h @@ -0,0 +1,66 @@ +#ifndef MONITOR_H +#define MONITOR_H + +typedef struct Probe { int id; int raw; } Probe; + +typedef struct Sampler +{ + Probe* probes; + int values[ 16 ]; + int count; +} Sampler; + +typedef struct Gauge Gauge; +typedef void ( *GaugeDrawFn )( Gauge* g ); + +typedef struct GaugeStyle +{ + const char* label; + GaugeDrawFn draw; +} GaugeStyle; + +struct Gauge +{ + int value; + int style; + GaugeDrawFn paint; +}; + +typedef struct Widget Widget; +typedef struct WidgetClass +{ + void ( *onKey )( Widget* w, int key ); +} WidgetClass; + +struct Widget +{ + const WidgetClass* klass; + void* state; +}; + +typedef struct Board +{ + Widget* focus; + Gauge* gauges; + int gaugeCount; +} Board; + +typedef struct Monitor +{ + Sampler* sampler; + Board* board; + int stale; + int quit; +} Monitor; + +int Probe_read( Probe* p ); +void Sampler_collectAll( Sampler* s ); +void Board_onKey( Board* b, int key ); +void Board_paint( Board* b ); +void Gauge_setStyle( Gauge* g, int style ); +void Gauge_render( Gauge* g ); +int Input_read( void ); +void Monitor_loop( Monitor* m ); +Monitor* Monitor_new( void ); + +#endif diff --git a/test/forcompletefix/c/src/sampler.c b/test/forcompletefix/c/src/sampler.c new file mode 100644 index 000000000..8fb847d4a --- /dev/null +++ b/test/forcompletefix/c/src/sampler.c @@ -0,0 +1,29 @@ +#include "monitor.h" + +int Probe_read( Probe* p ) +{ + return p->raw * 2 + p->id; +} + +static void Sampler_collectOne( Sampler* s, int i ) +{ + s->values[ i ] = Probe_read( &s->probes[ i ] ); +} + +static void Sampler_prune( Sampler* s ) +{ + while( s->count > 0 && s->values[ s->count - 1 ] < 0 ) + { + s->count--; + } +} + +/* Collect one reading from every probe, then drop trailing invalid readings. */ +void Sampler_collectAll( Sampler* s ) +{ + for( int i = 0; i < s->count; i++ ) + { + Sampler_collectOne( s, i ); + } + Sampler_prune( s ); +} diff --git a/test/forcompletefix/c/src/sink.c b/test/forcompletefix/c/src/sink.c new file mode 100644 index 000000000..e13047a25 --- /dev/null +++ b/test/forcompletefix/c/src/sink.c @@ -0,0 +1,23 @@ +#include "monitor.h" + +typedef struct Sink +{ + void ( *flush )( Sampler* s ); +} Sink; + +/* Write every buffered sample out and reset the buffer. */ +void flush( Sampler* s ) +{ + s->count = 0; +} + +void Sink_drainSamples( Sink* sink, Sampler* s ) +{ + /* a call through the field: nothing proves sink->flush is the free function flush */ + sink->flush( s ); +} + +void Monitor_flushSamples( Sampler* s ) +{ + flush( s ); +} diff --git a/test/forcompletefix/c/src/window.c b/test/forcompletefix/c/src/window.c new file mode 100644 index 000000000..a773814de --- /dev/null +++ b/test/forcompletefix/c/src/window.c @@ -0,0 +1,27 @@ +#include "monitor.h" + +static int Sampler_scale( int v ) { return v * 2; } + +static int +Sampler_average( Sampler* s ) +{ + int sum = 0; + for( int i = 0; i < s->count; i++ ) + { + sum += Sampler_scale( s->values[ i ] ); + } + return s->count > 0 ? sum / s->count : 0; +} + +/* Legal C with the struct defined in the return type: both definitions are extent-suspect (head). */ +struct SampleWindow +{ + int lo; + int hi; +} Sampler_window( Sampler* s ) +{ + struct SampleWindow w; + w.lo = 0; + w.hi = Sampler_average( s ); + return w; +} diff --git a/test/forcompletefix/c/test/sampler_test.c b/test/forcompletefix/c/test/sampler_test.c new file mode 100644 index 000000000..5a71336bb --- /dev/null +++ b/test/forcompletefix/c/test/sampler_test.c @@ -0,0 +1,9 @@ +#include "../src/monitor.h" + +int test_collect_all( void ) +{ + Probe probes[ 2 ] = { { 1, 2 }, { 2, 3 } }; + Sampler s = { probes, { 0 }, 2 }; + Sampler_collectAll( &s ); + return s.values[ 0 ] == 5 ? 0 : 1; +} diff --git a/test/forcompletefix/js/lib/queue.js b/test/forcompletefix/js/lib/queue.js new file mode 100644 index 000000000..41c64a355 --- /dev/null +++ b/test/forcompletefix/js/lib/queue.js @@ -0,0 +1,45 @@ +'use strict'; + +function resizeImage( payload ) { + const w = payload.width / 2; + const h = payload.height / 2; + return { width: w, height: h }; +} + +function logUnknown( payload ) { + return { skipped: true, payload }; +} + +const handlers = { + resize: resizeImage, + fallback: logUnknown, +}; + +function pickHandler( type ) { + return handlers[ type ] || handlers.fallback; +} + +function runJob( job ) { + const handler = pickHandler( job.type ); + return handler( job.payload ); +} + +class JobQueue { + constructor() { + this.jobs = []; + this.stopped = false; + } + + push( job ) { + this.jobs.push( job ); + } + + drain() { + while( this.jobs.length > 0 && !this.stopped ) { + const job = this.jobs.shift(); + runJob( job ); + } + } +} + +module.exports = { JobQueue, runJob }; diff --git a/test/forcompletefix/js/lib/worker.js b/test/forcompletefix/js/lib/worker.js new file mode 100644 index 000000000..46a88b72c --- /dev/null +++ b/test/forcompletefix/js/lib/worker.js @@ -0,0 +1,26 @@ +'use strict'; + +const { JobQueue } = require( './queue' ); + +class Worker { + constructor() { + this.queue = new JobQueue(); + } + + tick() { + this.queue.drain(); + } + + start() { + for( let i = 0; i < 3; i++ ) { + this.tick(); + } + } +} + +// A relay forwards to whatever it was handed: nothing proves `target` is a JobQueue. +function relay( target ) { + target.drain(); +} + +module.exports = { Worker, relay }; diff --git a/test/forcompletefix/js/package.json b/test/forcompletefix/js/package.json new file mode 100644 index 000000000..bb0878868 --- /dev/null +++ b/test/forcompletefix/js/package.json @@ -0,0 +1 @@ +{ "name": "jobs-fixture", "version": "0.0.0", "private": true } diff --git a/test/forcompletefix/py/src/pump/__init__.py b/test/forcompletefix/py/src/pump/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/test/forcompletefix/py/src/pump/app.py b/test/forcompletefix/py/src/pump/app.py new file mode 100644 index 000000000..c9e32aa93 --- /dev/null +++ b/test/forcompletefix/py/src/pump/app.py @@ -0,0 +1,16 @@ +from pump.loop import MessagePump +from pump.resolver import Resolver + + +class App(MessagePump): + def __init__(self): + super().__init__() + self.resolver = Resolver() + + def on_lookup(self, message): + return self.resolver.resolve(message.name) + + +def main(): + app = App() + app.run_forever() diff --git a/test/forcompletefix/py/src/pump/cache.py b/test/forcompletefix/py/src/pump/cache.py new file mode 100644 index 000000000..0bf39eada --- /dev/null +++ b/test/forcompletefix/py/src/pump/cache.py @@ -0,0 +1,20 @@ +class LookupCache: + def __init__(self): + self._slots = {} + + def fetch_entry(self, key): + """Return the cached entry for key, computing and storing it on a miss.""" + if key not in self._slots: + self._slots[key] = self._compute(key) + return self._slots[key] + + @staticmethod + def _compute(key): + return key.upper() + + def evict_entries( + self, + keys, + ): + for key in keys: + self._slots.pop(key, None) diff --git a/test/forcompletefix/py/src/pump/loop.py b/test/forcompletefix/py/src/pump/loop.py new file mode 100644 index 000000000..161554cc3 --- /dev/null +++ b/test/forcompletefix/py/src/pump/loop.py @@ -0,0 +1,21 @@ +class MessagePump: + def __init__(self): + self._pending = [] + self._running = True + + def post(self, message): + self._pending.append(message) + + def run_forever(self): + while self._running: + self._drain() + + def _drain(self): + while self._pending: + message = self._pending.pop(0) + self.deliver(message) + + def deliver(self, message): + handler = getattr(self, "on_" + message.kind, None) + if handler is not None: + handler(message) diff --git a/test/forcompletefix/py/src/pump/resolver.py b/test/forcompletefix/py/src/pump/resolver.py new file mode 100644 index 000000000..4a3191548 --- /dev/null +++ b/test/forcompletefix/py/src/pump/resolver.py @@ -0,0 +1,15 @@ +from pump.cache import LookupCache + + +class Resolver: + def __init__(self): + self.cache = LookupCache() + + def resolve(self, name): + return self.cache.fetch_entry(name) + + +class Downloader: + def pull(self, source, url): + # `source` is an untyped parameter: nothing proves it is a LookupCache. + return source.fetch_entry(url) diff --git a/test/forcompletefix/pydocs/docs/cache.md b/test/forcompletefix/pydocs/docs/cache.md new file mode 100644 index 000000000..4fdef4767 --- /dev/null +++ b/test/forcompletefix/pydocs/docs/cache.md @@ -0,0 +1,5 @@ +# Cache + +## How the message cache resolves entries + +The resolver delivers cached message entries; a miss posts a message to the pump. diff --git a/test/forcompletefix/pydocs/docs/pump.md b/test/forcompletefix/pydocs/docs/pump.md new file mode 100644 index 000000000..7ab09d293 --- /dev/null +++ b/test/forcompletefix/pydocs/docs/pump.md @@ -0,0 +1,15 @@ +# Message pump + +## How a message is delivered + +The message pump drains pending messages and delivers each message to its handler. +`MessagePump.post` queues a message; `run_forever` drains the pending queue. + +## Delivering a message to a handler + +`deliver` looks up the `on_` handler for each message and calls it. +A message without a handler is dropped. + +## Draining pending messages + +`_drain` pops messages in arrival order until the pending queue is empty. diff --git a/test/forcompletefix/pydocs/src/pump/__init__.py b/test/forcompletefix/pydocs/src/pump/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/test/forcompletefix/pydocs/src/pump/app.py b/test/forcompletefix/pydocs/src/pump/app.py new file mode 100644 index 000000000..c9e32aa93 --- /dev/null +++ b/test/forcompletefix/pydocs/src/pump/app.py @@ -0,0 +1,16 @@ +from pump.loop import MessagePump +from pump.resolver import Resolver + + +class App(MessagePump): + def __init__(self): + super().__init__() + self.resolver = Resolver() + + def on_lookup(self, message): + return self.resolver.resolve(message.name) + + +def main(): + app = App() + app.run_forever() diff --git a/test/forcompletefix/pydocs/src/pump/cache.py b/test/forcompletefix/pydocs/src/pump/cache.py new file mode 100644 index 000000000..0bf39eada --- /dev/null +++ b/test/forcompletefix/pydocs/src/pump/cache.py @@ -0,0 +1,20 @@ +class LookupCache: + def __init__(self): + self._slots = {} + + def fetch_entry(self, key): + """Return the cached entry for key, computing and storing it on a miss.""" + if key not in self._slots: + self._slots[key] = self._compute(key) + return self._slots[key] + + @staticmethod + def _compute(key): + return key.upper() + + def evict_entries( + self, + keys, + ): + for key in keys: + self._slots.pop(key, None) diff --git a/test/forcompletefix/pydocs/src/pump/loop.py b/test/forcompletefix/pydocs/src/pump/loop.py new file mode 100644 index 000000000..161554cc3 --- /dev/null +++ b/test/forcompletefix/pydocs/src/pump/loop.py @@ -0,0 +1,21 @@ +class MessagePump: + def __init__(self): + self._pending = [] + self._running = True + + def post(self, message): + self._pending.append(message) + + def run_forever(self): + while self._running: + self._drain() + + def _drain(self): + while self._pending: + message = self._pending.pop(0) + self.deliver(message) + + def deliver(self, message): + handler = getattr(self, "on_" + message.kind, None) + if handler is not None: + handler(message) diff --git a/test/forcompletefix/pydocs/src/pump/resolver.py b/test/forcompletefix/pydocs/src/pump/resolver.py new file mode 100644 index 000000000..4a3191548 --- /dev/null +++ b/test/forcompletefix/pydocs/src/pump/resolver.py @@ -0,0 +1,15 @@ +from pump.cache import LookupCache + + +class Resolver: + def __init__(self): + self.cache = LookupCache() + + def resolve(self, name): + return self.cache.fetch_entry(name) + + +class Downloader: + def pull(self, source, url): + # `source` is an untyped parameter: nothing proves it is a LookupCache. + return source.fetch_entry(url) diff --git a/test/forcompletefix/ts/docs/context.md b/test/forcompletefix/ts/docs/context.md new file mode 100644 index 000000000..813943f6c --- /dev/null +++ b/test/forcompletefix/ts/docs/context.md @@ -0,0 +1,23 @@ +# Context + +The per-request context object. + +## Creating a context + +Each request creates a context. + +## Per-request context state + +The context state is fresh per request. + +## Context and request + +The context wraps the request. + +## Context lifecycle + +A context is created per request and dropped after the response. + +## Request context helpers + +Helpers read the request through the context. diff --git a/test/forcompletefix/ts/package.json b/test/forcompletefix/ts/package.json new file mode 100644 index 000000000..d2d1f2fb0 --- /dev/null +++ b/test/forcompletefix/ts/package.json @@ -0,0 +1 @@ +{ "name": "ctx-fixture", "version": "0.0.0", "private": true } diff --git a/test/forcompletefix/ts/src/application.ts b/test/forcompletefix/ts/src/application.ts new file mode 100644 index 000000000..b58897fac --- /dev/null +++ b/test/forcompletefix/ts/src/application.ts @@ -0,0 +1,23 @@ +import { Context, IncomingRequest } from './context'; + +function freshState(): Record { + return { startedAt: Date.now() }; +} + +export class Application { + private contexts = 0; + + // Build the per-request context object every middleware receives. + createContext( + req: IncomingRequest, + ): Context { + const ctx = new Context( req ); + ctx.state = freshState(); + this.contexts += 1; + return ctx; + } + + handle( req: IncomingRequest ): Context { + return this.createContext( req ); + } +} diff --git a/test/forcompletefix/ts/src/context.ts b/test/forcompletefix/ts/src/context.ts new file mode 100644 index 000000000..d98906ac1 --- /dev/null +++ b/test/forcompletefix/ts/src/context.ts @@ -0,0 +1,15 @@ +import { RequestView } from './request'; + +export interface IncomingRequest { + url: string; + headers: Record; +} + +export class Context { + request: RequestView; + state: Record = {}; + + constructor( req: IncomingRequest ) { + this.request = new RequestView( req ); + } +} diff --git a/test/forcompletefix/ts/src/registry.ts b/test/forcompletefix/ts/src/registry.ts new file mode 100644 index 000000000..626227bb6 --- /dev/null +++ b/test/forcompletefix/ts/src/registry.ts @@ -0,0 +1,7 @@ +export class Registry { + private entries: Record = {}; + + lookup( key: string ): string | undefined { + return this.entries[ key ]; + } +} diff --git a/test/forcompletefix/ts/src/request.ts b/test/forcompletefix/ts/src/request.ts new file mode 100644 index 000000000..883c12d74 --- /dev/null +++ b/test/forcompletefix/ts/src/request.ts @@ -0,0 +1,15 @@ +import type { IncomingRequest } from './context'; + +export class RequestView { + constructor( private raw: IncomingRequest ) {} + + // The host the request context was created for. + get contextHost(): string { + const bag: any = this.raw; + return bag.lookup( 'host' ) ?? ''; + } + + get path(): string { + return this.raw.url; + } +} diff --git a/test/fordisclosurecheck.sh b/test/fordisclosurecheck.sh index 90e3d0628..537b01889 100755 --- a/test/fordisclosurecheck.sh +++ b/test/fordisclosurecheck.sh @@ -125,6 +125,24 @@ grep -q ']*> block is intact" \ || no "#2 ceiling-exhausted: the block vanished" +# #2c (rv-knob-honesty-068 C2; CHECKLIST 17) the intact-block grep above CAN FAIL on exactly this document. It is a ceiling- +# exhausted one: its is capped, its next= rides unpaid and the root carries over_ceiling="1" with the clause defining +# that in the header legend — BEFORE the real tag. A clause that spelled the tag literally (dd6e4c8e's floor clause did) +# satisfied `grep -q ']* over_ceiling="1"' "$T" && grep -oE ']*>' "$T" | tail -1 | grep -q ' capped="1".* next="'; then + python3 -c 'import sys; s=open(sys.argv[1],"rb").read(); a=s.rfind(b"",a); sys.stdout.buffer.write(s[:a]+s[b+7:] if a>=0 and b>=0 else s)' "$T" >"$TMP/n400_nosigs" + grep -q ' block removed — a legend clause spells the tag" \ + || ok "#2c the intact-block grep FAILS once the block is removed (no legend clause spells the tag)" + sigsblock "$T" | grep -q -- '-->' \ + && no "#2c sigsblock() starts inside the header legend comment, not at the real tag" \ + || ok "#2c sigsblock() starts at the real tag" +else + no "#2c premise: the --token-budget=400 document is no longer a floor document (root over_ceiling=\"1\", a capped with next=) — re-anchor this arm on one that is" +fi "$BIN" src --for="$NAMETASK" --token-budget=400 --signatures-only --no-cache >"$TMP/n400so" 2>/dev/null sigsblock "$T" >"$TMP/s_a"; sigsblock "$TMP/n400so" >"$TMP/s_s" [ -s "$TMP/s_a" ] || no "#2 presence: could not extract a block from the disclosed run" diff --git a/test/forlenscheck.sh b/test/forlenscheck.sh index 7dd58ccce..f88a88113 100755 --- a/test/forlenscheck.sh +++ b/test/forlenscheck.sh @@ -36,7 +36,14 @@ dline(){ printf '%s' "$1" | sed 's/>\n]*>$2" | head -1; # ── 1) the --for bundle carries the quality lens on its ranked blocks (ccx + amp) ───────────────── FOR1="$( "$BIN" test/fixture --no-cache --for="compute perimeter distance" 2>/dev/null )" DL="$( dline "$FOR1" 'double distance' )" -if printf '%s' "$DL" | grep -q ' ccx='; then ok "--for carries ccx (cognitive complexity): $DL"; else no "--for missing ccx: $DL"; fi +# lean-answers lane (by design): a lens row omits a zero cx=/ccx=/in= and the legend reads an absent one as 0. The strict +# "carries ccx" arm reads a row whose ccx is NOT 0 (perimeter, ccx=1); the distance row (ccx 0) carries ccx= or omits it +# under the "absent = 0" reading — never omits it silently. +DLP="$( dline "$FOR1" 'double perimeter' )" +if printf '%s' "$DLP" | grep -q ' ccx="[1-9]'; then ok "--for carries ccx (cognitive complexity) on a nonzero row: $DLP"; else no "--for missing a nonzero ccx: $DLP"; fi +if printf '%s' "$DL" | grep -q ' ccx='; then ok "--for carries ccx (cognitive complexity): $DL" +elif [ -n "$DL" ] && printf '%s' "$FOR1" | grep -qE 'absent cx/ccx/in = 0|each absent when 0'; then ok "--for omits a zero ccx and the legend reads absent as 0: $DL" +else no "--for missing ccx with no absent = 0 reading: $DL"; fi if printf '%s' "$DL" | grep -q ' amp='; then ok "--for carries amp (change-amplification)"; else no "--for missing amp: $DL"; fi # ── 2) GOLDEN NEUTRALITY — plain --pack-signatures must NOT carry any lens attr; default map unchanged ─ diff --git a/test/fornotesbudgetcheck.sh b/test/fornotesbudgetcheck.sh index 54d0fe262..d0697b819 100755 --- a/test/fornotesbudgetcheck.sh +++ b/test/fornotesbudgetcheck.sh @@ -176,6 +176,7 @@ ROOT="$( cd "$( dirname "$0" )/.." && pwd )" . "$ROOT/test/lib/clean-env.sh" BIN="${1:-${RIPWIRE_BIN:-$ROOT/build/ripwire}}" [ "${BIN#/}" = "$BIN" ] && BIN="$ROOT/$BIN" +echo "fornotesbudgetcheck: BIN=$BIN" TMP="$( mktemp -d )"; trap 'rm -rf "$TMP"' EXIT CORPUS="$TMP/corpus" fail=0 @@ -187,7 +188,6 @@ no(){ printf ' FAIL %s\n' "$*"; fail=1; } command -v python3 >/dev/null 2>&1 || { echo "fornotesbudgetcheck: python3 is required (JSON arms)"; exit 2; } command -v git >/dev/null 2>&1 || { echo "fornotesbudgetcheck: git is required (notes carry a sha/branch stamp)"; exit 2; } -echo "fornotesbudgetcheck: BIN=$BIN" # ── the sandbox corpus: 72 small symbols across 12 files, one long note on every one ─────────────── mkdir -p "$CORPUS/src" || { echo "fornotesbudgetcheck: cannot create corpus under $TMP"; exit 2; } diff --git a/test/forrankordercheck.sh b/test/forrankordercheck.sh index 783df6376..f260451e1 100755 --- a/test/forrankordercheck.sh +++ b/test/forrankordercheck.sh @@ -29,14 +29,16 @@ # (6) shown= on a capped equals the number of rows printed; two runs are byte-identical; # full and compact bundles are xmllint-clean. # -# Usage: bash test/forrankordercheck.sh | RIPWIRE_BIN=asan/ripwire bash test/forrankordercheck.sh +# Usage: bash test/forrankordercheck.sh [BIN] | RIPWIRE_BIN=asan/ripwire bash test/forrankordercheck.sh +# BIN is $1, else RIPWIRE_BIN, else build/ripwire; the first output line names the binary used (knob-honesty-068 N7). # Exits non-zero on any failure. set -u ROOT="$( cd "$( dirname "$0" )/.." && pwd )" . "$ROOT/test/lib/clean-env.sh" -BIN="${RIPWIRE_BIN:-$ROOT/build/ripwire}" +BIN="${1:-${RIPWIRE_BIN:-$ROOT/build/ripwire}}" [ "${BIN#/}" = "$BIN" ] && BIN="$ROOT/$BIN" +echo "forrankordercheck: BIN=$BIN" fail=0 ok(){ printf ' PASS %s\n' "$*" || { fail=1; printf ' FAIL could not write the PASS line for: %s\n' "$*"; }; return 0; } no(){ printf ' FAIL %s\n' "$*"; fail=1; } @@ -45,7 +47,6 @@ no(){ printf ' FAIL %s\n' "$*"; fail=1; } command -v python3 >/dev/null 2>&1 || { echo "python3 required"; exit 2; } command -v git >/dev/null 2>&1 || { echo "git required"; exit 2; } cd "$ROOT" -echo "forrankordercheck: BIN=$BIN" TMP="$( mktemp -d )"; trap 'rm -rf "$TMP"' EXIT @@ -413,5 +414,209 @@ case "$first_capped" in *) no "(8) first capped state is not shrunk-not-dropped: $first_capped" ;; esac +# ── (9) A CAPPED NAMES THE CALL THAT SERVES IT UNCUT (knob-honesty-068; PROCESS rule 5) ─────────────────── +# The ladder disclosed its cut (shown= total= capped="1") and named no call that serves the cut rows: the bundle's only +# next= was the r=1 row's (a body, or the widening page). Fixture: 40 matching C functions with long docs and long +# parameter lists, so the DEFAULT ceiling drops rows. The tag must carry next= (+ its legend clause), pasting it must +# serve all total= rows uncut in the same rank order, the shown rows must be its prefix. Under a tight explicit (hard) +# ceiling it is CHARGED and TERSE: next= alone, and est_tokens stays within budget_tokens. At an explicit ceiling wide enough that +# the sig side is frozen at the default's it is EXEMPT: the block is byte-identical to the default's. JSON + MCP. +# Negatives: an uncapped tag (arm 8's fixture) carries none; --no-route is echoed. RED on 255dc199 (no next= on ). +mkdir -p "$TMP/fxcut" +python3 - "$TMP/fxcut" <<'PY' +import sys +for f in range( 2 ): + with open( "%s/cut%d.c" % ( sys.argv[1], f ), "w" ) as o: + for i in range( 20 ): + n = f * 20 + i + 1 + o.write( "/* gadget assembler %d builds one gadget from its parts, validating every part against the catalogue before it is placed and logged */\n" % n ) + o.write( "int gadgetAssembler%d( int partCount, const char* catalogueName, double toleranceMillimetres, unsigned long serialNumber ) { return partCount + %d; }\n\n" % ( n, n ) ) +PY +cut_arm(){ python3 -c ' +import json, re, shlex, sys +s = sys.stdin.read() +m = re.search( r"]*)>(.*?)", s, re.S ) +if not m: print( "FAIL no " ); sys.exit( 1 ) +tag = m.group( 1 ) +nx = re.search( r"\snext=\"([^\"]*)\"", tag ) +sh = re.search( r"shown=\"([0-9]+)\"", tag ); tt = re.search( r"total=\"([0-9]+)\"", tag ) +names = re.findall( r"]*\bn=\"([^\"]*)\"", m.group( 2 ) ) +ranks = [ int( r ) for r in re.findall( r"]*\br=\"([0-9]+)\"", m.group( 2 ) ) ] +no_ = re.search( r"\snext_offset=\"([0-9]+)\"", tag ); nb_ = re.search( r"\snext_budget_tokens=\"([0-9]+)\"", tag ) +out = { "capped": "capped=\"1\"" in tag, "shown": int( sh.group( 1 ) ) if sh else len( names ), "total": int( tt.group( 1 ) ) if tt else len( names ), + "next": nx.group( 1 ).replace( "'", chr( 39 ) ).replace( """, chr( 34 ) ).replace( "&", "&" ) if nx else "", + "clause": "[sigs next=:" in s[ : s.find( "]*>", s ); r = root.group( 0 ) if root else "" +for k in ( "est_tokens", "budget_tokens" ): + v = re.search( r"\b%s=\"([0-9]+)\"" % k, r ); out[ k ] = int( v.group( 1 ) ) if v else None +out[ "over" ] = "over_ceiling=\"1\"" in r +print( json.dumps( out ) )'; } +cfield(){ python3 -c 'import json,sys; d=json.loads(sys.argv[1]); v=d.get(sys.argv[2]); print(json.dumps(v) if isinstance(v,(list,dict,bool)) or v is None else v)' "$1" "$2"; } +C1="$( cd "$TMP" && "$BIN" fxcut --for="gadget assembler" --no-cache 2>/dev/null | cut_arm )" +if [ "$( cfield "$C1" capped )" = true ] && [ "$( cfield "$C1" shown )" -lt "$( cfield "$C1" total )" ]; then + ok "(9) presence guard: the default ceiling drops rows ($( cfield "$C1" shown ) of $( cfield "$C1" total ))" +else + no "(9) presence guard: the fixture is not cut at the default ceiling: $C1" +fi +NX1="$( cfield "$C1" next )" +case "$NX1" in + "--for='gadget assembler' --signatures-only --token-budget="[0-9]*) ok "(9) the capped names its call: next=\"$NX1\"" ;; + *) no "(9) the capped carries next='$NX1' (want --for='gadget assembler' --signatures-only --token-budget=N)" ;; +esac +if [ "$( cfield "$C1" clause )" = true ]; then ok "(9) the [sigs next=: …] clause defines it ahead of "; else no "(9) rides with no legend clause"; fi +# the resume index (the #362 candidate page's next_offset): the last printed row's candidate rank, defined by its own clause +if [ "$( cfield "$C1" next_offset )" = "$( cfield "$C1" max_r )" ] && [ "$( cfield "$C1" next_offset )" = "$( cfield "$C1" shown )" ] \ + && [ "$( cfield "$C1" offset_clause )" = true ]; then + ok "(9) next_offset=\"$( cfield "$C1" next_offset )\" is the resume index: the last printed row's rank (= shown, no pseudo slot here), defined" +else + no "(9) next_offset='$( cfield "$C1" next_offset )' vs last printed r=$( cfield "$C1" max_r ), shown=$( cfield "$C1" shown ), clause=$( cfield "$C1" offset_clause )" +fi +# paste it (shlex-split, as an agent would) +pasted(){ python3 -c 'import shlex,sys; print( "\0".join( shlex.split( sys.argv[1] ) ), end = "" )' "$1" > "$TMP/nx.argv"; ( cd "$TMP" && xargs -0 "$BIN" "$2" --no-cache < "$TMP/nx.argv" 2>/dev/null ); } +C2="$( [ -n "$NX1" ] && pasted "$NX1" fxcut | cut_arm )" +if [ -n "$C2" ] && [ "$( cfield "$C2" capped )" = false ] && [ "$( cfield "$C2" shown )" = "$( cfield "$C1" total )" ] && [ -z "$( cfield "$C2" next )" ]; then + ok "(9) pasting it serves all $( cfield "$C2" shown ) rows uncut, and that answer carries no " +else + no "(9) the pasted next= did not serve the block uncut: ${C2:-}" +fi +python3 -c 'import json,sys; a=json.loads(sys.argv[1])["names"]; b=json.loads(sys.argv[2])["names"]; sys.exit(0 if a and b[:len(a)]==a else 1)' "$C1" "${C2:-{\"names\":[]\}}" 2>/dev/null \ + && ok "(9) the cut block's rows are a prefix of the continuation's (same ranked list, same order)" \ + || no "(9) the continuation does not extend the cut block's rows in order" +# tight explicit ceiling: CHARGED — still named, and est_tokens stays within the hard ceiling the root names +C3="$( cd "$TMP" && "$BIN" fxcut --for="gadget assembler" --token-budget=1500 --no-cache 2>/dev/null | cut_arm )" +NX3="$( cfield "$C3" next )" +{ [ "$( cfield "$C3" capped )" = true ] && [ -n "$NX3" ] && [ "$( cfield "$C3" over )" = false ] && [ "$( cfield "$C3" next_offset )" = null ] \ + && [ "$( cfield "$C3" clause )" = false ] && [ "$( cfield "$C3" est_tokens )" -le "$( cfield "$C3" budget_tokens )" ]; } \ + && ok "(9) --token-budget=1500: charged and terse — next= alone (no next_offset=, no clause), est_tokens $( cfield "$C3" est_tokens ) <= budget_tokens 1500" \ + || no "(9) --token-budget=1500: $C3" +# wide explicit ceiling (sig side frozen at the default's): EXEMPT — the block, next= included, is the default's byte for byte +sigsblk(){ python3 -c 'import re,sys; m=re.search(r"]*>.*?",sys.stdin.read(),re.S); print(m.group(0) if m else "")'; } +if [ "$( cd "$TMP" && "$BIN" fxcut --for="gadget assembler" --no-cache 2>/dev/null | sigsblk )" = "$( cd "$TMP" && "$BIN" fxcut --for="gadget assembler" --token-budget=8000 --no-cache 2>/dev/null | sigsblk )" ]; then + ok "(9) --token-budget=8000: exempt — the block (its next= included) is the default's byte for byte" +else + no "(9) --token-budget=8000: the block differs from the default's (the exempt regime moved rows or its next=)" +fi +C4="$( [ -n "$NX3" ] && pasted "$NX3" fxcut | cut_arm )" +[ -n "$C4" ] && [ "$( cfield "$C4" capped )" = false ] && [ "$( cfield "$C4" shown )" = "$( cfield "$C3" total )" ] \ + && ok "(9) --token-budget=1500: pasting its next= serves the block uncut" || no "(9) --token-budget=1500: the pasted next= left it cut: ${C4:-}" +# negatives: an uncapped tag carries no next=/next_offset=; a ranking flag is echoed (the re-run must rank the same list) +U1="$( cd "$TMP" && "$BIN" fxdocs --for="widget helper" --no-cache 2>/dev/null | cut_arm )" +[ "$( cfield "$U1" capped )" = false ] && [ -z "$( cfield "$U1" next )" ] && [ "$( cfield "$U1" clause )" = false ] && [ "$( cfield "$U1" next_offset )" = null ] \ + && ok "(9) negative: an uncapped carries no next= and no clause" || no "(9) negative: uncapped fixture: $U1" +case "$( cd "$TMP" && "$BIN" fxcut --for="gadget assembler" --no-route --no-cache 2>/dev/null | cut_arm | python3 -c 'import json,sys; print(json.load(sys.stdin)["next"])' )" in + *' --no-route'*) ok "(9) --no-route is echoed in the continuation" ;; + *) no "(9) --no-route was not echoed in the continuation" ;; +esac +# JSON twin: "sigs_next" on a capped array, and pasting it serves the array uncut +J1="$( cd "$TMP" && "$BIN" fxcut --for="gadget assembler" --json --no-cache 2>/dev/null )" +JNX="$( printf '%s' "$J1" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("sigs_next",""))' 2>/dev/null )" +case "$JNX" in + *' --signatures-only '*' --json') ok "(9) JSON twin: \"sigs_next\" names the --json call" ;; + *) no "(9) JSON twin: sigs_next='$JNX'" ;; +esac +JNO="$( printf '%s' "$J1" | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("sigs_next_offset"), d.get("sigs_shown"))' 2>/dev/null )" +case "$JNO" in "None"*|"") no "(9) JSON twin: no sigs_next_offset ('$JNO')" ;; *) set -- $JNO; if [ "$1" = "$2" ]; then ok "(9) JSON twin: sigs_next_offset=$1 (the resume index)"; else no "(9) JSON twin: sigs_next_offset=$1 vs sigs_shown=$2"; fi ;; esac +J2="$( [ -n "$JNX" ] && pasted "$JNX" fxcut | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("capped"), len(d["sigs"]), "sigs_next" in d)' 2>/dev/null )" +[ "$J2" = "False $( printf '%s' "$J1" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("sigs_total"))' 2>/dev/null ) False" ] \ + && ok "(9) JSON twin: pasting sigs_next serves every row uncut" || no "(9) JSON twin: pasted sigs_next gave '$J2'" +# MCP twin (signatures only, its own ranking pipeline): NO pasteable CLI argv — the machine form next_budget_tokens=T (and +# next_offset=); re-calling `for` with budget_tokens=T serves every row uncut +mcpfor(){ printf '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"for","arguments":{"path":"%s","task":"gadget assembler"%s}}}\n' "$TMP/fxcut" "$1" \ + | "$BIN" --mcp 2>/dev/null | python3 -c 'import json,sys +for line in sys.stdin: + line=line.strip() + if not line: continue + d=json.loads(line) + if d.get("id")==1: print(d["result"]["content"][0]["text"])' 2>/dev/null | cut_arm; } +M1="$( mcpfor '' )" +MNB="$( cfield "${M1:-{\}}" next_budget_tokens 2>/dev/null )" +if [ -n "$M1" ] && [ "$( cfield "$M1" capped )" = true ] && [ -z "$( cfield "$M1" next )" ] && [ "$MNB" != null ] && [ "$( cfield "$M1" budget_clause )" = true ] \ + && [ "$( cfield "$M1" next_offset )" = "$( cfield "$M1" max_r )" ]; then + ok "(9) MCP twin: no CLI argv on ; next_budget_tokens=\"$MNB\" and next_offset=\"$( cfield "$M1" next_offset )\", both defined" +else + no "(9) MCP twin: ${M1:-}" +fi +M2="$( [ "$MNB" != null ] && mcpfor ",\"budget_tokens\":$MNB" )" +if [ -n "$M2" ] && [ "$( cfield "$M2" capped )" = false ] && [ "$( cfield "$M2" shown )" = "$( cfield "$M1" total )" ]; then + ok "(9) MCP twin: re-calling for with budget_tokens=$MNB serves all $( cfield "$M2" shown ) rows uncut" +else + no "(9) MCP twin: the re-call with budget_tokens=$MNB did not serve the block uncut: ${M2:-}" +fi + +# ── (10) C3: NO ROW IS DROPPED TO PAY FOR A HANDLE THAT CANNOT MAKE THE ANSWER FIT (orchestrator ruling 2026-10-07) ────────── +# The MCP `for` answer overshoots budget_tokens on its own (its sig ledger exempts header bytes), so under a budget that caps +# it is often past its ceiling paid or not; paying next_budget_tokens= from the rows (dd6e4c8e: ~245 B, 1-2 rows) +# bought nothing there. Over its ceiling either way → the rows the cut alone leaves, the handle unpaid, over_ceiling="1". +# Where paying IS what makes it fit, it pays (unchanged). Fixture: (9)'s 40 gadget functions, called by a RELATIVE path from +# $TMP so no checkout or temp-dir path rides in the header bytes the rows are budgeted against (root="fxcut" everywhere). +# over-anyway @2000 / @3000: 255dc199 serves 14 / 25 of 40 (est 2270 / 3125, over); dd6e4c8e paid 2 rows (12 / 23) and was +# still over (est 2206 / 3071) — RED there. +# train 26b re-pin (2026-10-08): lean-answers' zero elision made every row smaller, so @3000 is no longer over either way +# on the merged tree — the pre-continuation train binary (d82e4cf1) serves 25 rows at est 2945, FITTING; the unpaid +# answer + handle lands over and the paid one (23 rows, est 2918) fits, i.e. @3000 moved into the payable regime and is +# served paid, as ruled. The over-either-way budget is now @2600: d82e4cf1 serves 21 rows at est 2684 (over before any +# handle); the merged binary serves those 21 + the unpaid handle, est 2793, over_ceiling="1" (paying ~2 rows, ~90 tokens, +# cannot reach 2600). @2000 is unchanged (d82e4cf1 14 rows est 2178 over). Rows >= 255dc199's (re-pin from a pre-continuation binary if row bytes or the +# header change; smaller rows only raise it), the handle rides, over_ceiling="1". (Budgets with a margin on both sides of +# the ceiling: near one, a few bytes of legend decide whether paying fits, and the arm would pin that, not the rule.) +# payable @3800: 255dc199 serves 35 rows at est 3824 — OVER; paying the handle from rows (33, est 3773) is what makes it fit: +# the answer carries next_budget_tokens= and NO over_ceiling= (a binary that never pays serves 35 rows at est 3934, over). +mcprel(){ ( cd "$TMP" && printf '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"for","arguments":{"path":"fxcut","task":"gadget assembler","budget_tokens":%s}}}\n' "$1" \ + | "$BIN" --mcp 2>/dev/null ) | python3 -c 'import json,sys +for line in sys.stdin: + line=line.strip() + if not line: continue + d=json.loads(line) + if d.get("id")==1: print(d["result"]["content"][0]["text"])' 2>/dev/null | cut_arm; } +c3row(){ printf '%s' "$1" | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d["shown"], d["capped"], d["next_budget_tokens"] is not None, d["over"], d["est_tokens"])' 2>/dev/null; } +for spec in 2000:14 2600:21; do + bt="${spec%%:*}"; want="${spec##*:}" + set -- $( c3row "$( mcprel "$bt" )" ) + if [ "$#" -eq 5 ] && [ "$1" -ge "$want" ] && [ "$2" = True ] && [ "$3" = True ] && [ "$4" = True ]; then + ok "(10) MCP budget_tokens=$bt, over its ceiling either way: $1 rows (>= the cut's own $want, none dropped for the handle), next_budget_tokens= unpaid, over_ceiling=\"1\" (est $5)" + else + no "(10) MCP budget_tokens=$bt: shown/capped/handle/over/est = ${*:-} — want >= $want rows (the cut's own), the handle, over_ceiling=\"1\"" + fi +done +set -- $( c3row "$( mcprel 3800 )" ) +if [ "$#" -eq 5 ] && [ "$2" = True ] && [ "$3" = True ] && [ "$4" = False ] && [ "$5" -le 3800 ]; then + ok "(10) MCP budget_tokens=3800, payable: the handle is paid from rows ($1 shown) and that is what makes the answer fit (est $5 <= 3800, no over_ceiling=)" +else + no "(10) MCP budget_tokens=3800: shown/capped/handle/over/est = ${*:-} — want a paid handle inside the budget" +fi + +# ── (11) C3 COMPLETED: PAY FOR next= ONLY WHEN PAYING IS WHAT MAKES THE ANSWER FIT (orchestrator ruling 2026-10-08) ───────────── +# The answer with every row the cut leaves PLUS the unpaid handle is the first candidate; it is served whenever it fits, and only +# when it lands past its ceiling are rows dropped to pay (and served only if THAT fits — (10)'s @3800). 45a2eeba paid first and +# kept the paid answer whenever it fit, so it dropped rows the unpaid answer had room for: the served answer had fewer rows than +# an alternative that also fit. Same fixture and relative path as (10). Pins are 255dc199's own rows at each budget (the cut's +# rows; re-pin from a pre-continuation binary if row bytes or the header change), budgets chosen with room on both sides: +# CLI XML @2160: 255dc199 17 rows (est 2046); 45a2eeba 16 (est 1998) — RED there; head 17 + next= (est 2078 <= 2160). +# --json @2000: 255dc199 16 rows (est 1657); 45a2eeba 15 — RED there; head 16 + "sigs_next" (est 1691). +# MCP @4340: 255dc199 40 rows (est 4257); 45a2eeba 39 — RED there; head 40 + next_budget_tokens= (est 4308 <= 4340). +# Each: rows >= the cut's own, the handle rides, est_tokens <= the budget, no over_ceiling. A binary that always pays when it +# can fit (the 45a2eeba order) is red on all three. +c11row(){ printf '%s' "$1" | python3 -c 'import json,sys; d=json.load(sys.stdin); h=d["next"] or d["next_budget_tokens"]; print(d["shown"], d["capped"], bool(h), d["over"], d["est_tokens"])' 2>/dev/null; } +c11check(){ # LABEL BUDGET WANT ROWS CAPPED HANDLE OVER EST + local label="$1" bt="$2" want="$3"; shift 3 + if [ "$#" -eq 5 ] && [ "$1" -ge "$want" ] && [ "$2" = True ] && [ "$3" = True ] && [ "$4" = False ] && [ "$5" -le "$bt" ]; then + ok "(11) $label @$bt fits with every row the cut leaves: $1 rows (>= $want, none dropped to pay), the handle unpaid, est $5 <= $bt, no over_ceiling=" + else + no "(11) $label @$bt: shown/capped/handle/over/est = ${*:-} — want >= $want rows, the handle, est <= $bt, no over_ceiling= (paid where it already fit?)" + fi +} +c11check "CLI --for" 2160 17 $( c11row "$( cd "$TMP" && "$BIN" fxcut --for="gadget assembler" --token-budget=2160 --no-cache 2>/dev/null | cut_arm )" ) +c11check "MCP for" 4340 40 $( c11row "$( mcprel 4340 )" ) +set -- $( cd "$TMP" && "$BIN" fxcut --for="gadget assembler" --token-budget=2000 --json --no-cache 2>/dev/null \ + | python3 -c 'import json,sys; d=json.load(sys.stdin); print(len(d["sigs"]), "sigs_next" in d, bool(d.get("over_ceiling")), d["est_tokens"])' 2>/dev/null ) +if [ "$#" -eq 4 ] && [ "$1" -ge 16 ] && [ "$2" = True ] && [ "$3" = False ] && [ "$4" -le 2000 ]; then + ok "(11) --for --json @2000 fits with every row the cut leaves: $1 rows (>= 16), \"sigs_next\" unpaid, est $4 <= 2000, no over_ceiling" +else + no "(11) --for --json @2000: rows/sigs_next/over/est = ${*:-} — want >= 16 rows, \"sigs_next\", est <= 2000, no over_ceiling" +fi + [ "$fail" = 0 ] && echo "ALL PASS" || echo "FAILURES ABOVE" exit "$fail" diff --git a/test/forsigspancheck.sh b/test/forsigspancheck.sh new file mode 100755 index 000000000..7fe51c441 --- /dev/null +++ b/test/forsigspancheck.sh @@ -0,0 +1,700 @@ +#!/usr/bin/env bash +# forsigspancheck.sh — a --for row says where its definition ENDS (e=), the one-hop slots go to top rows whose +# edges are proven, and the defining code outranks the docs that describe it. +# +# test/forsigspancheck.sh # uses build/ripwire on test/forcompletefix +# RIPWIRE_BIN=asan/ripwire test/forsigspancheck.sh +# test/forsigspancheck.sh build_base/ripwire # the RED run (a pre-change binary) +# +# THE GAPS (each seen in graded answers; the fixture paraphrases them as minimal code): +# (1) SPAN. A row carried only l= (the line of the definition's name). "Which body holds line N" — the question a +# reader asks of every gold item that lives INSIDE a function — was not answerable from the row; a peer that +# printed start-end lines got the credit. +# (2) HOP SLOT. The compact answer hop-expands its ranked head in rank order and the first row with ANY resolved +# callee took the slot: an off-topic getter whose one callee row was a call bound by name alone (`bag.lookup()` +# on an untyped local) spent the slot and printed a false edge. +# (3) DOCS FIRST. Markdown headings that repeat the question's words filled the ranked rows above the code that +# defines the answer (on one graded answer about half of the shown rows were headings). +# +# THE CONTRACT. +# (1) Every row of a --for answer whose definition extent is KNOWN carries e="N" right after l=: the 1-based line +# of the definition's LAST line (body-inclusive; a declaration's own last line; e == l for a one-line definition). +# l= keeps its meaning (the NAME's line), so a definition may start above l= (a return type on the line before, +# a decorator). e= is ABSENT — never 0, never a guess — when the extent is not known: a row flagged +# extent_suspect=, a markdown/doc row (not a code body). Other verbs keep their bytes (no e= outside --for). +# (2) A hop slot ( row) goes only to a top-ranked row with at least ONE proven callee edge; a row whose every +# callee edge was bound by name alone gets no row. Rows that keep a slot keep it in rank order. +# (3) When the question does not name docs, no markdown row ranks above the code rows that define the answer, and +# markdown rows are at most a quarter of the shown rows. A question that names docs still gets a docs row +# in its top five (the twin that must hold). +# +# ARMS (one fixture root per language under test/forcompletefix/; each indexed on its own): +# (E) e= values: C Sampler_average l=6 e=14 (return type on the line above the name), Sampler_scale l=3 e=3 +# (a one-line definition), the header prototype Sampler_collectAll l=57 e=57; TS createContext l=11 e=18 (a +# multi-line signature), handle l=20 e=22; Python _compute l=12 e=13 (a decorator above), evict_entries l=15 +# e=20 (a multi-line signature). Oracle on EVERY row that carries e=: l <= e <= the file's line count, and +# the e= line closes the definition (brace languages, unless e == l: the line holds `}` or ends with `;`/`)`; Python: the next +# non-blank line is indented no deeper than the def, or EOF). e= sits right after l=. +# (U) unknown extent: Sampler_window and SampleWindow (extent_suspect="head") and every docs/*.md row carry NO +# e=; every other row carries one (a present e= is never dropped silently either). +# (B) an explicit --token-budget TIGHTER than the default signature budget (1200 tokens): no e= row and no e= clause, +# XML and JSON (the budget's rows and est_tokens promise stay what they were; charging e= there cost rows, exempting +# it broke the promise). Twin: --token-budget=8000 (above the default share) carries e= on its rows. +# (O) outside --for: --pack-signatures rows and the default map's rows carry no e=. +# (H) hop slots: TS — contextHost (only callee: a name-only `bag.lookup()`) has no row; createContext keeps +# one. Python — Downloader.pull (only callee: a name-only `source.fetch_entry()`) has no row; fetch_entry +# keeps one. C near miss — Sampler_collectAll keeps its row. rows follow the rows' r= order. +# The two "no row" arms need the receiver-evidence hedge (a name-only row marked via="name"); on a binary +# without it they SKIP by name ("expects FE-B"), detected on this fixture — never a silent PASS. +# (R) code above docs as a REORDER of the shown set (coordinator rule: it reorders, never shrinks the doc rows shown): +# the TS questions answered with the rule and with RIPWIRE_NO_DOCS_AFTER_CODE=1 (plain score order) show the +# same row SET and r= multiset; with the rule every code row precedes every doc row, rows stay in r= order, and +# counts the doc rows that moved (with its reading). Near misses: the same at a +# --token-budget that cuts (a demotion would evict docs there: red on the score-demotion build), and a +# question whose topic ("dropped after the response") only a docs row names keeps that row. Twin: "Where do the +# docs describe the context lifecycle?" keeps a docs row in its top five. +# (T) a TIGHT explicit ceiling (--token-budget / MCP budget_tokens below the default signature share) gets neither the +# reorder nor its uncharged note: the answer is byte-equal to the RIPWIRE_NO_DOCS_AFTER_CODE=1 twin (which is the +# pre-reorder answer), XML, JSON and MCP, at 700 and 1200 on the TS fixture; at 1200 the XML/JSON est_tokens stay +# within the budget with no over_ceiling the twin lacks (RED at 9d0ad7aa: est 1277 > 1200, over_ceiling="1"). +# MCP est_tokens is the twin's (already above 1200 on base; not this rule's to fix). Near misses: no ceiling and +# --token-budget=8000 still reorder and carry docs_after_code= with its reading, XML, JSON and MCP; a question that +# names docs keeps score order at 1200 too. +# A BODY ceiling (--max-tokens=N --detail=K, XML only: --json refuses the pair, MCP `for` has no max_tokens) is the third tight +# ceiling: the same twin equality, est_tokens <= N, on the TS fixture at 1600 and the Python+docs fixture (pydocs) at 1660 +# (RED at f4253d45: est 1634 > 1600 / 1725 > 1660, over_ceiling="1"); near miss --detail alone still reorders. +# (J) dialect parity: --for --json "sigs" entries carry "e" equal to the XML rows' e= (and none where XML has none). +# (M) the MCP `for` twin: the same e= on the C rows, none on the extent_suspect rows, and a legend clause for e=. +# (L) legend: --legend=full defines e= and says what it does NOT mean (absent = unknown, not 0; l= is the name's +# line, not the definition's first line). +# (N) the experimental RIPWIRE_FOR_ENDLINES switch (serialize.h forEndLinesMode; default auto = arm (B)'s rule): +# always — e= on the --token-budget=1200 rows, XML and JSON, and on the MCP `for` rows under budget_tokens=1200, +# with the SAME row set (n p l r) as auto there (e= never costs a row); est_tokens vs the budget is REPORTED +# (may exceed it — that is why auto is the default), not gated. never — no e= and no e= clause at the default +# ceiling (XML, JSON, MCP), same row set as auto. auto — byte-identical to unset (default and 1200). An unknown +# value ("sometimes", "Always", "") — byte-identical to unset (so e= stays at the default: never read as never) +# plus one stderr line naming the switch. Near miss: always does not put e= on --pack-signatures rows. +# (K) the predicates can fail: a row with e= < l=, a row with e= on an md row, a missing e= on a code row, and a +# name-only hop row are each caught; a document with no --for root is never a pass. Every run must exit 0. +# +# FLOORS — named, not gated here: +# * e= is the parser's span: a definition spliced by the preprocessor or a recovered parse is covered only by +# extent_suspect= (rows with an unflagged but wrong span are not detected). +# * the code-over-docs rule is checked on one fixture; the threshold for "names docs" is the question's wording. +# +# Exits non-zero on any failure. + +set -u +export PYTHONDONTWRITEBYTECODE=1 +ROOT="$( cd "$( dirname "$0" )/.." && pwd )" +. "$ROOT/test/lib/clean-env.sh" # a gate that indexes a repo must not inherit GIT_DIR/GIT_WORK_TREE (gitenvhermeticcheck D) +BIN="${1:-${RIPWIRE_BIN:-$ROOT/build/ripwire}}" +[ "${BIN#/}" = "$BIN" ] && BIN="$ROOT/$BIN" # allow a repo-relative binary +CORPUS="$ROOT/test/forcompletefix" +TMP="$( mktemp -d )"; trap 'rm -rf "$TMP"' EXIT +fail=0 +ok(){ printf ' PASS %s\n' "$*" || { fail=1; printf ' FAIL could not write the PASS line for: %s\n' "$*"; }; return 0; } +no(){ printf ' FAIL %s\n' "$*"; fail=1; } + +[ -x "$BIN" ] || { echo "no ripwire binary at $BIN — build first (cmake --build build -j)"; exit 2; } +[ -d "$CORPUS" ] || { echo "fixture missing: $CORPUS"; exit 2; } +command -v python3 >/dev/null 2>&1 || { echo "python3 required"; exit 2; } +echo "forsigspancheck: BIN=$BIN CORPUS=$CORPUS" + +run(){ local r="$1" f="$2"; shift 2; ( cd "$CORPUS/$r" && "$BIN" . --no-cache "$@" >"$f" 2>"$f.err" ); printf '%s' "$?" >"$f.rc"; } +ran_ok(){ local rc; rc="$( cat "$1.rc" 2>/dev/null )"; [ "$rc" = 0 ] && return 0; no "$2: the binary exited rc=${rc:-missing}"; return 1; } +ask(){ local f="$TMP/$1.$2.xml"; run "$1" "$f" "--for=$3"; printf '%s' "$f"; } + +cat >"$TMP/rows.py" <<'PY' +import re, sys +def attrs( tag ): + return dict( re.findall( r'\s([a-z_]+)="([^"]*)"', tag ) ) +def load( path ): + return open( path, encoding="utf-8", errors="replace" ).read() +def ctx_ok( doc ): + return re.search( r"]*>", doc ) is not None and " row, in document order.""" + return [ ( t, attrs( t ) ) for t in re.findall( r"]*>", strip_comments( doc ) ) ] +def hrows( doc ): + return [ attrs( t ) for t in re.findall( r"]*>", strip_comments( doc ) ) ] +def strip_comments( doc ): + # legend text inside spells row shapes (``); only real elements are rows + return re.sub( r"", "", doc, flags=re.S ) +def is_doc_row( a ): + return a.get( "p", "" ).endswith( ( ".md", ".markdown", ".rst", ".txt" ) ) +PY + +# echeck ANSWER LABEL ROOTDIR SPEC… — SPEC = name:path:l:e (a row n=name p=path l=l must carry e=e) +echeck(){ + local f="$1" label="$2" rootdir="$3"; shift 3 + python3 - "$TMP/rows.py" "$f" "$label" "$rootdir" "$@" <<'PY' +import os, re, sys +exec( open( sys.argv[ 1 ] ).read() ) +path, label, rootdir = sys.argv[ 2: 5 ] +specs = sys.argv[ 5: ] +doc = load( path ) +if not ctx_ok( doc ): + print( " FAIL %s: NOROOT" % label ); sys.exit( 1 ) +rows = drows( doc ) +bad = 0 +for spec in specs: + n, p, l, e = spec.rsplit( ":", 3 ) + hit = [ ( t, a ) for t, a in rows if a.get( "n" ) == n and a.get( "p" ) == p and a.get( "l" ) == l ] + if not hit: + print( " FAIL %s: no row (premise)" % ( label, n, p, l ) ); bad = 1; continue + t, a = hit[ 0 ] + if a.get( "e" ) != e: + print( " FAIL %s: %s l=%s carries e=%r, expected %s" % ( label, n, l, a.get( "e" ), e ) ); bad = 1; continue + if not re.match( r' row of one answer +oracle(){ + python3 - "$TMP/rows.py" "$1" "$2" "$3" <<'PY' +import os, re, sys +exec( open( sys.argv[ 1 ] ).read() ) +path, label, rootdir = sys.argv[ 2: 5 ] +doc = load( path ) +if not ctx_ok( doc ): + print( " FAIL %s: NOROOT" % label ); sys.exit( 1 ) +bad = [] +checked = 0 +for t, a in drows( doc ): + n, p = a.get( "n" ), a.get( "p", "" ) + unknown = is_doc_row( a ) or "extent_suspect" in a + if unknown: + if "e" in a: bad.append( "%s (%s) carries e= though its extent is unknown" % ( n, p ) ) + continue + if "e" not in a: + bad.append( "%s (%s:%s) has a known extent and no e=" % ( n, p, a.get( "l" ) ) ); continue + if not ( re.fullmatch( r"[0-9]+", a[ "e" ] ) and re.fullmatch( r"[0-9]+", a.get( "l", "" ) ) ): + bad.append( "%s: l=/e= not numeric" % n ); continue + l, e = int( a[ "l" ] ), int( a[ "e" ] ) + try: + lines = open( os.path.join( rootdir, p ), encoding="utf-8", errors="replace" ).read().split( "\n" ) + except OSError: + bad.append( "%s: cannot read %s (premise)" % ( n, p ) ); continue + if lines and lines[ -1 ] == "": lines = lines[ :-1 ] + if not ( 1 <= l <= e <= len( lines ) ): + bad.append( "%s: l=%d e=%d outside 1..%d or e < l" % ( n, l, e, len( lines ) ) ); continue + last = lines[ e - 1 ].rstrip() + if p.endswith( ".py" ): + ind = len( lines[ l - 1 ] ) - len( lines[ l - 1 ].lstrip() ) + nxt = next( ( x for x in lines[ e: ] if x.strip() ), None ) + if not last.strip() or ( nxt is not None and len( nxt ) - len( nxt.lstrip() ) > ind ): + bad.append( "%s: e=%d does not close the def (line %r)" % ( n, e, last ) ) + elif e != l and not ( "}" in last or last.endswith( ";" ) or last.endswith( ")" ) ): + bad.append( "%s: e=%d line %r does not close the definition" % ( n, e, last ) ) + checked += 1 +if bad: + for b in bad: print( " FAIL %s: %s" % ( label, b ) ) + sys.exit( 1 ) +if checked == 0: + print( " FAIL %s: no row carried e= (nothing checked)" % label ); sys.exit( 1 ) +print( " PASS %s: %d e= rows close their definitions; unknown extents carry none" % ( label, checked ) ) +PY + [ $? -eq 0 ] || { fail=1; return 1; } +} + +echo "(E)/(U) e= on rows" +CE="$( ask c avg 'How does the sampler compute the average reading window?' )" +if ran_ok "$CE" "E/C"; then + echeck "$CE" "E/C" "$CORPUS/c" Sampler_average:src/window.c:6:14 Sampler_scale:src/window.c:3:3 Sampler_collectAll:src/monitor.h:57:57 + oracle "$CE" "E+U/C" "$CORPUS/c" + python3 - "$TMP/rows.py" "$CE" <<'PY' +import sys +exec( open( sys.argv[ 1 ] ).read() ) +rows = [ a for _, a in drows( load( sys.argv[ 2 ] ) ) if a.get( "n" ) in ( "Sampler_window", "SampleWindow" ) ] +if len( rows ) < 2 or any( "extent_suspect" not in a for a in rows ): + print( " FAIL U/C: premise — Sampler_window and SampleWindow are not both shown with extent_suspect=" ); sys.exit( 1 ) +if any( "e" in a for a in rows ): + print( " FAIL U/C: an extent_suspect row carries e=" ); sys.exit( 1 ) +print( " PASS U/C: the two extent_suspect rows carry no e=" ) +PY + [ $? -eq 0 ] || fail=1 +fi +TE="$( ask ts ctx 'How is a per-request context created?' )" +if ran_ok "$TE" "E/TS"; then + echeck "$TE" "E/TS" "$CORPUS/ts" createContext:src/application.ts:11:18 handle:src/application.ts:20:22 + oracle "$TE" "E+U/TS" "$CORPUS/ts" +fi +PE="$( ask py compute 'What does LookupCache._compute return, and how are entries evicted?' )" +if ran_ok "$PE" "E/PY"; then + echeck "$PE" "E/PY" "$CORPUS/py" _compute:src/pump/cache.py:12:13 evict_entries:src/pump/cache.py:15:20 + oracle "$PE" "E+U/PY" "$CORPUS/py" +fi +JE="$( ask js run 'How does a queued job get run?' )" +if ran_ok "$JE" "E/JS"; then + oracle "$JE" "E+U/JS" "$CORPUS/js" +fi + +echo "(B) an explicit token budget: no e= at all (its rows and its est_tokens promise stay what they were)" +for spec in "xml|" "json|--json"; do + tag="${spec%%|*}"; extra="${spec#*|}"; f="$TMP/ts.budget.$tag" + if [ -n "$extra" ]; then run ts "$f" "--for=How is a per-request context created?" --token-budget=1200 "$extra"; else run ts "$f" "--for=How is a per-request context created?" --token-budget=1200; fi + if ran_ok "$f" "B/$tag"; then + if ! grep -q -e ']* e="[0-9]' "$f"; then + ok "O/$tag: rows present, none carries e=" + else + no "O/$tag: no rows to check, or a row carries e= outside --for" + fi + fi +done + +echo "(H) hop slots go to top rows with a proven callee edge" +hopcheck(){ + python3 - "$TMP/rows.py" "$@" <<'PY' +import sys +exec( open( sys.argv[ 1 ] ).read() ) +path, label, mode, name = sys.argv[ 2: 6 ] +doc = load( path ) +if not ctx_ok( doc ): + print( " FAIL %s: NOROOT" % label ); sys.exit( 1 ) +hs = hrows( doc ) +names = [ h.get( "n" ) for h in hs ] +if mode == "nohop": + good = name not in names; why = "no (every callee edge name-only)" % name +elif mode == "hop": + good = name in names; why = " kept (a proven callee edge)" % name +elif mode == "order": + rank = { ( a.get( "n" ), a.get( "p" ), a.get( "l" ) ): int( a[ "r" ] ) for _, a in drows( doc ) if a.get( "r", "" ).isdigit() } + rs = [ rank.get( ( h.get( "n" ), h.get( "p" ), h.get( "l" ) ) ) for h in hs ] + good = bool( rs ) and None not in rs and rs == sorted( rs ); why = " rows in r= order %s" % rs +else: + print( " FAIL %s: unknown mode" % label ); sys.exit( 1 ) +print( " %s %s: %s" % ( "PASS" if good else "FAIL", label, why ) ) +sys.exit( 0 if good else 1 ) +PY + [ $? -eq 0 ] || { fail=1; return 1; } +} +# CAPABILITY (checklist 17): the "only name-only edges" premise needs the receiver-evidence hedge — a binary that marks a +# call bound by name alone via="name". Detected here, on this fixture: Downloader.pull's `source.fetch_entry( url )` is +# such a call. A binary without the hedge cannot tell that edge from a proven one, so the two nohop arms SKIP BY NAME; +# every other arm (the near misses, rank order) runs either way. +FEB="$TMP/py.callers.fetch_entry.xml"; run py "$FEB" --callers=fetch_entry +FEB_ON=0 +if ran_ok "$FEB" "H premise"; then + if ! grep -q ']*n="pull"[^>]*via="name"|]*via="name"[^>]*n="pull"' "$FEB"; then + FEB_ON=1 + fi +fi +nohop_or_skip(){ + if [ "$FEB_ON" = 1 ]; then hopcheck "$@"; else printf ' SKIP %s: expects FE-B (this binary marks no name-only row via="name")\n' "$2"; fi +} +if ran_ok "$TE" "H/TS"; then + nohop_or_skip "$TE" "H/TS getter with a name-only edge" nohop contextHost + hopcheck "$TE" "H/TS near miss" hop createContext + hopcheck "$TE" "H/TS order" order - +fi +PH="$( ask py fetch 'How does the lookup cache fetch or evict an entry?' )" +if ran_ok "$PH" "H/PY"; then + nohop_or_skip "$PH" "H/PY untyped-parameter caller" nohop pull + hopcheck "$PH" "H/PY near miss" hop fetch_entry + hopcheck "$PH" "H/PY order" order - +fi +CH="$( ask c collect 'How does the sampler collect probe readings?' )" +if ran_ok "$CH" "H/C"; then + hopcheck "$CH" "H/C near miss" hop Sampler_collectAll +fi + +echo "(R) code above docs: the SHOWN set reordered, never shrunk" +# rset ANSWER — the shown rows as sorted "n|p|l" lines (the SET), and a line "DOCS k" with the doc-row count +# rcheck LABEL ON OFF [premise_doc_row] — ON (the rule) and OFF (RIPWIRE_NO_DOCS_AFTER_CODE=1, plain score order) show the +# same row SET and the same doc rows; in ON every code row precedes every doc row; the r= values are the same multiset; +# ON carries docs_after_code=N exactly when it moved N doc rows above which OFF had a code row below; the reading rides. +rcheck(){ + python3 - "$TMP/rows.py" "$@" <<'PY' +import re, sys +exec( open( sys.argv[ 1 ] ).read() ) +label, on, off = sys.argv[ 2: 5 ] +need = sys.argv[ 5 ] if len( sys.argv ) > 5 else None +don, doff = load( on ), load( off ) +if not ctx_ok( don ) or not ctx_ok( doff ): + print( " FAIL %s: NOROOT" % label ); sys.exit( 1 ) +ron = [ a for _, a in drows( don ) ] +roff = [ a for _, a in drows( doff ) ] +key = lambda a: ( a.get( "n" ), a.get( "p" ), a.get( "l" ) ) +bad = [] +if sorted( map( key, ron ) ) != sorted( map( key, roff ) ): + lost = sorted( set( map( key, roff ) ) - set( map( key, ron ) ) ) + bad.append( "the shown set changed (rows only in plain score order: %s)" % lost[ :4 ] ) +if sorted( a.get( "r" ) for a in ron ) != sorted( a.get( "r" ) for a in roff ): + bad.append( "the r= values are not the same multiset" ) +kinds = [ is_doc_row( a ) for a in ron ] +if True in kinds and False in kinds and kinds.index( True ) < len( kinds ) - 1 - kinds[ ::-1 ].index( False ): + bad.append( "a doc row precedes a code row in the reordered answer" ) +rs = [ int( a[ "r" ] ) for a in ron if a.get( "r", "" ).isdigit() ] +if rs != sorted( rs ): + bad.append( "rows are not in r= order" ) +koff = [ is_doc_row( a ) for a in roff ] +lastCode = max( ( i for i, d in enumerate( koff ) if not d ), default=-1 ) +moved = sum( 1 for i, d in enumerate( koff ) if d and i < lastCode ) +sig = re.search( r"]*>", strip_comments( don ) ) +attr = re.search( r' docs_after_code="([0-9]+)"', sig.group( 0 ) ) if sig else None +if moved and ( not attr or int( attr.group( 1 ) ) != moved ): + bad.append( "docs_after_code= is %s, expected %d" % ( attr.group( 1 ) if attr else "absent", moved ) ) +if not moved and attr: + bad.append( "docs_after_code= present though nothing moved" ) +if moved and "docs_after_code=N:" not in don: + bad.append( "the answer carries docs_after_code= without its reading" ) +if need and not [ a for a in ron if a.get( "n" ) == need and is_doc_row( a ) ]: + bad.append( "premise: the doc row %r is not shown" % need ) +if bad: + for b in bad: print( " FAIL %s: %s" % ( label, b ) ) + sys.exit( 1 ) +print( " PASS %s: same %d rows (%d docs), code first, docs_after_code=%s" % ( label, len( ron ), kinds.count( True ), attr.group( 1 ) if attr else "-" ) ) +PY + [ $? -eq 0 ] || { fail=1; return 1; } +} +# offr ROOT TAG QUESTION [ARGS…] — the same question in plain score order (the A/B handle) +offr(){ local r="$1" t="$2" q="$3"; shift 3; local f="$TMP/$r.$t.off.xml"; ( cd "$CORPUS/$r" && RIPWIRE_NO_DOCS_AFTER_CODE=1 "$BIN" . --no-cache "--for=$q" "$@" >"$f" 2>"$f.err" ); printf '%s' "$?" >"$f.rc"; printf '%s' "$f"; } +onr(){ local r="$1" t="$2" q="$3"; shift 3; local f="$TMP/$r.$t.on.xml"; run "$r" "$f" "--for=$q" "$@"; printf '%s' "$f"; } +Q1='How is a per-request context created?' +Q2='How is a per-request context created and dropped after the response?' # only the docs lifecycle section names "dropped after the response" +for spec in "r1|$Q1|" "r2|$Q2|"; do + tag="${spec%%|*}"; rest="${spec#*|}"; q="${rest%%|*}"; extra="${rest#*|}" + if [ -n "$extra" ]; then ON="$( onr ts "$tag" "$q" "$extra" )"; OFF="$( offr ts "$tag" "$q" "$extra" )"; else ON="$( onr ts "$tag" "$q" )"; OFF="$( offr ts "$tag" "$q" )"; fi + if ran_ok "$ON" "R/$tag" && ran_ok "$OFF" "R/$tag (plain order)"; then + case "$tag" in + r2) rcheck "R/$tag only a docs row names the topic: it stays" "$ON" "$OFF" "Context lifecycle" ;; + *) rcheck "R/$tag${extra:+ ($extra)}" "$ON" "$OFF" ;; + esac + fi +done +# (T) a tight explicit ceiling: neither the reorder nor its uncharged note (B1 of the review of 9d0ad7aa) +echo "(T) a tight explicit ceiling carries no reorder and no note; the near misses still reorder" +cat >"$TMP/tight.py" <<'PY' +import json, re, sys +exec( open( sys.argv[ 1 ] ).read() ) +# tight.py ROWS.PY LABEL MODE ON OFF [BUDGET] — MODE: tight | reorders +label, mode, on, off = sys.argv[ 2: 6 ] +budget = int( sys.argv[ 6 ] ) if len( sys.argv ) > 6 and sys.argv[ 6 ].isdigit() else 0 +def body( path ): + t = load( path ) + if path.endswith( ".mcp.json" ): + ls = [ l for l in t.splitlines() if l.strip() ] + t = json.loads( ls[ -1 ] )[ "result" ][ "content" ][ 0 ][ "text" ] + return t +A, B = body( on ), body( off ) +isjson = A.lstrip().startswith( "{" ) +if isjson: + try: + nrows = len( json.loads( A ).get( "sigs", [] ) ) if B.lstrip().startswith( "{" ) else 0 + except ValueError: + nrows = 0 +else: + nrows = len( drows( A ) ) if ctx_ok( A ) and ctx_ok( B ) else 0 +if nrows == 0: + print( " FAIL %s: no shown rows (premise)" % label ); sys.exit( 1 ) +bad = [] +moved = re.search( r'docs_after_code="[0-9]+"|"docs_after_code":[0-9]+', A ) is not None +note = "docs_after_code=N:" in A +if mode == "tight": + if open( on, "rb" ).read() != open( off, "rb" ).read(): + bad.append( "differs from the NO_DOCS_AFTER_CODE=1 twin (the pre-reorder answer)" ) + if moved or note: + bad.append( "carries docs_after_code= or its reading" ) + m = re.search( r']*\best_tokens="([0-9]+)"', A ) or re.search( r'"est_tokens":([0-9]+)', A ) + if budget and not on.endswith( ".mcp.json" ): + if not m: bad.append( "no est_tokens to compare with the budget (premise)" ) + elif int( m.group( 1 ) ) > budget: bad.append( "est_tokens %s > budget %d" % ( m.group( 1 ), budget ) ) + if "over_ceiling" in A and "over_ceiling" not in B: bad.append( "over_ceiling the twin lacks" ) +else: + if not moved: bad.append( "no docs_after_code= (the reorder did not apply)" ) + if not note and not isjson: bad.append( "no reading for docs_after_code=" ) # the JSON twin carries the count only + if open( on, "rb" ).read() == open( off, "rb" ).read(): bad.append( "equals the plain score-order twin (nothing moved)" ) +if bad: + for b in bad: print( " FAIL %s: %s" % ( label, b ) ) + sys.exit( 1 ) +print( " PASS %s" % label ) +PY +tcheck(){ python3 "$TMP/tight.py" "$TMP/rows.py" "$@"; [ $? -eq 0 ] || { fail=1; return 1; }; } +# mcpfor OUT TWIN(0|1) TASK [EXTRA_ARGS_JSON] — the MCP `for` answer; TWIN=1 sets RIPWIRE_NO_DOCS_AFTER_CODE=1 +mcpfor(){ local out="$1" twin="$2" task="$3" extra="${4:-}"; mkdir -p "$TMP/mcphome" + printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize"}' \ + '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"for","arguments":{"path":"'"$CORPUS/ts"'","task":"'"$task"'"'"$extra"'}}}' \ + | ( cd "$CORPUS/ts" && if [ "$twin" = 1 ]; then TMPDIR="$TMP/mcphome" RIPWIRE_NO_DOCS_AFTER_CODE=1 "$BIN" --mcp; else TMPDIR="$TMP/mcphome" env -u RIPWIRE_NO_DOCS_AFTER_CODE "$BIN" --mcp; fi >"$out" 2>/dev/null ); printf '%s' "$?" >"$out.rc"; } +for b in 700 1200; do + for spec in "xml|" "json|--json"; do + tag="${spec%%|*}"; extra="${spec#*|}" + if [ -n "$extra" ]; then ON="$( onr ts "t$b$tag" "$Q1" "--token-budget=$b" "$extra" )"; OFF="$( offr ts "t$b$tag" "$Q1" "--token-budget=$b" "$extra" )" + else ON="$( onr ts "t$b$tag" "$Q1" "--token-budget=$b" )"; OFF="$( offr ts "t$b$tag" "$Q1" "--token-budget=$b" )"; fi + if ran_ok "$ON" "T/$b/$tag" && ran_ok "$OFF" "T/$b/$tag (twin)"; then + tcheck "T/$tag --token-budget=$b: byte-equal to the plain-order twin, no docs_after_code=$([ "$b" = 1200 ] && echo ", est_tokens <= 1200")" tight "$ON" "$OFF" "$([ "$b" = 1200 ] && echo 1200 || echo 0)" + fi + done + mcpfor "$TMP/t$b.on.mcp.json" 0 "$Q1" ",\"budget_tokens\":$b"; mcpfor "$TMP/t$b.off.mcp.json" 1 "$Q1" ",\"budget_tokens\":$b" + if ran_ok "$TMP/t$b.on.mcp.json" "T/$b/mcp" && ran_ok "$TMP/t$b.off.mcp.json" "T/$b/mcp (twin)"; then + tcheck "T/mcp budget_tokens=$b: byte-equal to the plain-order twin, no docs_after_code= (est_tokens not gated: base MCP is already over)" tight "$TMP/t$b.on.mcp.json" "$TMP/t$b.off.mcp.json" + fi +done +# (T) body ceiling (--max-tokens=N --detail=K): the third tight explicit ceiling (B1' of the delta review of f4253d45). +# est_tokens must stay <= N exactly as it did before the reorder: the answer is byte-equal to the plain-order twin. Both fixtures +# sit in the window where the uncharged note alone pushed a within-ceiling answer over (RED at f4253d45: ts 1534 -> 1634 with +# over_ceiling="1" at 1600; pydocs 1626 -> 1725 at 1660). JSON refuses the pair and MCP `for` has no body-ceiling input. +QP='How is a message delivered to its handler?' +for spec in "ts|$Q1|1600" "pydocs|$QP|1660"; do + r="${spec%%|*}"; rest="${spec#*|}"; q="${rest%|*}"; mt="${rest##*|}" + ON="$( onr "$r" "bc$mt" "$q" "--max-tokens=$mt" --detail=1 )"; OFF="$( offr "$r" "bc$mt" "$q" "--max-tokens=$mt" --detail=1 )" + if ran_ok "$ON" "T/body/$r" && ran_ok "$OFF" "T/body/$r (twin)"; then + tcheck "T/body $r --max-tokens=$mt --detail=1: byte-equal to the plain-order twin, no docs_after_code=, est_tokens <= $mt" tight "$ON" "$OFF" "$mt" + fi + # near miss: --detail alone is not a ceiling (no --max-tokens), so the reorder and its note still apply + ON="$( onr "$r" "bd" "$q" --detail=1 )"; OFF="$( offr "$r" "bd" "$q" --detail=1 )" + if ran_ok "$ON" "T/body/near/$r" && ran_ok "$OFF" "T/body/near/$r (twin)"; then + tcheck "T/body/near $r --detail=1 without --max-tokens: the reorder and its note still apply" reorders "$ON" "$OFF" + fi +done +# --json refuses the pair (the arm above is XML-only), and MCP `for` takes no max_tokens: if either ever accepts it, this arm must be +# replaced by a byte-equal one. +( cd "$CORPUS/ts" && "$BIN" . --no-cache "--for=$Q1" --max-tokens=1600 --detail=1 --json >"$TMP/bcj.out" 2>"$TMP/bcj.err" ); jrc=$? +if [ "$jrc" -ne 0 ] && grep -q 'two output SHAPES' "$TMP/bcj.err"; then ok "T/body --json: refuses --max-tokens with --detail (no JSON body-ceiling answer to gate)"; else no "T/body --json: expected the two-shapes refusal, rc=$jrc"; fi +mcpfor "$TMP/bcm.mcp.json" 0 "$Q1" ',"max_tokens":1600,"detail":1' +if grep -q "unknown field: 'max_tokens'" "$TMP/bcm.mcp.json"; then ok "T/body mcp: for refuses max_tokens (no MCP body-ceiling input to gate)"; else no "T/body mcp: for no longer refuses max_tokens — add a byte-equal arm"; fi +# near misses: the SAME question with no explicit ceiling, and a ceiling at/above the default share, still reorder +for b in 0 8000; do + for spec in "xml|" "json|--json"; do + tag="${spec%%|*}"; extra="${spec#*|}"; set -- "$Q1"; [ "$b" != 0 ] && set -- "$@" "--token-budget=$b"; [ -n "$extra" ] && set -- "$@" "$extra" + ON="$( onr ts "n$b$tag" "$@" )"; OFF="$( offr ts "n$b$tag" "$@" )" + if ran_ok "$ON" "T/near/$b/$tag" && ran_ok "$OFF" "T/near/$b/$tag (twin)"; then + tcheck "T/near $tag $([ "$b" = 0 ] && echo "no ceiling" || echo "--token-budget=$b"): the reorder and its note still apply" reorders "$ON" "$OFF" + fi + done + if [ "$b" = 0 ]; then ex=""; else ex=",\"budget_tokens\":$b"; fi + mcpfor "$TMP/n$b.on.mcp.json" 0 "$Q1" "$ex"; mcpfor "$TMP/n$b.off.mcp.json" 1 "$Q1" "$ex" + if ran_ok "$TMP/n$b.on.mcp.json" "T/near/$b/mcp" && ran_ok "$TMP/n$b.off.mcp.json" "T/near/$b/mcp (twin)"; then + tcheck "T/near mcp $([ "$b" = 0 ] && echo "no ceiling" || echo "budget_tokens=$b"): the reorder and its note still apply" reorders "$TMP/n$b.on.mcp.json" "$TMP/n$b.off.mcp.json" + fi +done +# a question naming docs keeps score order at 1200 as well (the existing rule is untouched by the tight-ceiling one) +ON="$( onr ts d1200 "Where do the docs describe the context lifecycle?" --token-budget=1200 )"; OFF="$( offr ts d1200 "Where do the docs describe the context lifecycle?" --token-budget=1200 )" +if ran_ok "$ON" "T/docsq" && ran_ok "$OFF" "T/docsq (twin)"; then + if cmp -s "$ON" "$OFF"; then ok "T/docsq: a question naming docs is byte-equal to the plain-order twin at 1200"; else no "T/docsq: a docs question differs from the plain-order twin"; fi +fi +TD="$( ask ts docs 'Where do the docs describe the context lifecycle?' )" +if ran_ok "$TD" "R twin"; then + python3 - "$TMP/rows.py" "$TD" <<'PY' +import sys +exec( open( sys.argv[ 1 ] ).read() ) +rows = [ a for _, a in drows( load( sys.argv[ 2 ] ) ) if a.get( "r", "" ).isdigit() ] +top = [ a for a in rows if int( a[ "r" ] ) <= 5 and is_doc_row( a ) ] +if not rows or not top: + print( " FAIL R twin: a question that names docs has no docs row in its top five" ); sys.exit( 1 ) +print( " PASS R twin: docs row %s in the top five" % top[ 0 ].get( "n" ) ) +PY + [ $? -eq 0 ] || fail=1 +fi + +echo "(J) --for --json carries the same e= values" +TJ="$TMP/ts.ctx.json"; run ts "$TJ" "--for=How is a per-request context created?" --json +if ran_ok "$TJ" "J"; then + python3 - "$TMP/rows.py" "$TE" "$TJ" <<'PY' +import json, sys +exec( open( sys.argv[ 1 ] ).read() ) +x = { ( a.get( "n" ), a.get( "p" ), a.get( "l" ) ): a.get( "e" ) for _, a in drows( load( sys.argv[ 2 ] ) ) } +try: + j = json.loads( load( sys.argv[ 3 ] ) ) +except ValueError as err: + print( " FAIL J: not JSON (%s)" % err ); sys.exit( 1 ) +js = { ( r.get( "n" ), r.get( "p" ), str( r.get( "l" ) ) ): ( None if r.get( "e" ) is None else str( r.get( "e" ) ) ) for r in j.get( "sigs", [] ) } +common = set( x ) & set( js ) +if not common or not any( x[ k ] for k in common ): + print( " FAIL J: no shared row carries e= (premise)" ); sys.exit( 1 ) +diff = sorted( k for k in common if x[ k ] != js[ k ] ) +if diff: + print( " FAIL J: XML/JSON e= differ on %s" % diff[ :4 ] ); sys.exit( 1 ) +print( " PASS J: %d shared rows agree on e=" % len( common ) ) +PY + [ $? -eq 0 ] || fail=1 +fi + +echo "(M) the MCP for twin carries the same e= values, with the same exclusions" +MC="$TMP/mcp.c.json"; mkdir -p "$TMP/mcphome" +printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize"}' \ + '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"for","arguments":{"path":"'"$CORPUS/c"'","task":"How does the sampler compute the average reading window?"}}}' \ + | ( cd "$CORPUS/c" && TMPDIR="$TMP/mcphome" "$BIN" --mcp >"$MC" 2>/dev/null ); printf '%s' "$?" >"$MC.rc" +if ran_ok "$MC" "M"; then + python3 - "$TMP/rows.py" "$MC" "$CORPUS/c" <<'PY' +import json, os, sys +exec( open( sys.argv[ 1 ] ).read() ) +lines = [ l for l in open( sys.argv[ 2 ], encoding="utf-8", errors="replace" ) if l.strip() ] +try: + text = json.loads( lines[ -1 ] )[ "result" ][ "content" ][ 0 ][ "text" ] +except Exception as err: + print( " FAIL M: no readable MCP for answer (%s)" % err ); sys.exit( 1 ) +rows = { a.get( "n" ) + "@" + a.get( "p", "" ) + ":" + a.get( "l", "" ): a for _, a in drows( text ) } +want = { "Sampler_average@src/window.c:6": "14", "Sampler_scale@src/window.c:3": "3" } +bad = [ "%s e=%r, expected %s" % ( k, rows[ k ].get( "e" ) if k in rows else "(row absent)", v ) for k, v in want.items() if k not in rows or rows[ k ].get( "e" ) != v ] +bad += [ "%s is extent_suspect and carries e=" % k for k, a in rows.items() if "extent_suspect" in a and "e" in a ] +if "e=" not in " ".join( __import__( "re" ).findall( r"", text, __import__( "re" ).S ) ): + bad.append( "the MCP answer defines no e= in its legend" ) +if bad: + for b in bad: print( " FAIL M: %s" % b ) + sys.exit( 1 ) +print( " PASS M: MCP for rows carry the CLI's e= values; suspect rows none; legend defines e=" ) +PY + [ $? -eq 0 ] || fail=1 +fi + +echo "(L) legend defines e= and what it does not mean" +LG="$TMP/ts.legend.xml"; run ts "$LG" "--for=How is a per-request context created?" --legend=full +if ran_ok "$LG" "L"; then + python3 - "$LG" <<'PY' +import re, sys +legend = " ".join( re.findall( r"", open( sys.argv[ 1 ], encoding="utf-8", errors="replace" ).read(), re.S ) ) +need = [ ( r"\be=", "defines e=" ), ( r"(?i)absent[^.;]*(unknown|not known)", "says absent = unknown" ), + ( r"(?i)(never 0|not 0|never zero)", "says e= is never 0" ), ( r"(?i)l=[^.;]{0,60}(name's line|line of (the|its) (definition's )?name)", "says l= is the name's line" ) ] +bad = [ w for rx, w in need if not re.search( rx, legend ) ] +if bad: + print( " FAIL L: the legend does not %s" % "; ".join( bad ) ); sys.exit( 1 ) +print( " PASS L: e= defined with its NOT-meaning" ) +PY + [ $? -eq 0 ] || fail=1 +fi + +echo "(N) RIPWIRE_FOR_ENDLINES=always|auto|never (experimental; unset = auto)" +NQ="--for=How is a per-request context created?" +# runenv VALUE OUT ARGS… — VALUE "-" leaves the switch unset +runenv(){ local v="$1" f="$2"; shift 2 + if [ "$v" = "-" ]; then ( cd "$CORPUS/ts" && env -u RIPWIRE_FOR_ENDLINES "$BIN" . --no-cache "$@" >"$f" 2>"$f.err" ) + else ( cd "$CORPUS/ts" && RIPWIRE_FOR_ENDLINES="$v" "$BIN" . --no-cache "$@" >"$f" 2>"$f.err" ); fi + printf '%s' "$?" >"$f.rc"; } +# rowset FILE — the rows (XML) or "sigs" entries (JSON) as "n p l r" lines, e= ignored; "NOROWS" when none +cat >"$TMP/rowset.py" <<'PY' +import json, re, sys +exec( open( sys.argv[ 1 ] ).read() ) +text = load( sys.argv[ 2 ] ) +if text.lstrip().startswith( "{" ): + rows = [ ( r.get( "n" ), r.get( "p" ), str( r.get( "l" ) ), str( r.get( "r" ) ) ) for r in json.loads( text ).get( "sigs", [] ) ] + est = json.loads( text ).get( "est_tokens" ) +else: + rows = [ ( a.get( "n" ), a.get( "p" ), a.get( "l" ), a.get( "r" ) ) for _, a in drows( text ) ] + m = re.search( r']*\best_tokens="([0-9]+)"', text ); est = m.group( 1 ) if m else None +print( "EST %s" % est ) +print( "\n".join( " ".join( map( str, r ) ) for r in rows ) if rows else "NOROWS" ) +PY +rowset(){ python3 "$TMP/rowset.py" "$TMP/rows.py" "$1" | sed 1d; } +estof(){ python3 "$TMP/rowset.py" "$TMP/rows.py" "$1" | sed -n '1s/^EST //p'; } +has_e(){ grep -Eq ' e="[0-9]+"|"e":[0-9]+' "$1"; } +has_e_clause(){ grep -Eq 'e= on a d row|d e= its last line' "$1"; } +for spec in "xml|" "json|--json"; do + tag="${spec%%|*}"; extra="${spec#*|}" + set -- "$NQ" --token-budget=1200; [ -n "$extra" ] && set -- "$@" "$extra" + NA="$TMP/n.always.$tag"; NU="$TMP/n.unset1200.$tag"; NX="$TMP/n.auto1200.$tag" + runenv always "$NA" "$@"; runenv - "$NU" "$@"; runenv auto "$NX" "$@" + if ran_ok "$NA" "N/always/$tag" && ran_ok "$NU" "N/unset1200/$tag" && ran_ok "$NX" "N/auto1200/$tag"; then + if [ "$( rowset "$NU" )" = NOROWS ]; then no "N/always/$tag: the 1200-token answer has no rows (premise)" + elif ! has_e "$NA"; then no "N/always/$tag: RIPWIRE_FOR_ENDLINES=always carries no e= under --token-budget=1200" + elif has_e "$NU"; then no "N/always/$tag: premise — the unset 1200-token answer already carries e= (arm B's rule moved)" + elif [ "$( rowset "$NA" )" != "$( rowset "$NU" )" ]; then no "N/always/$tag: forcing e= changed the shown row set (e= cost or moved a row)" + else ok "N/always/$tag: e= present under --token-budget=1200, same $( rowset "$NU" | wc -l | tr -d ' ' ) rows as auto (est_tokens $( estof "$NU" ) -> $( estof "$NA" ) vs budget 1200, reported not gated)"; fi + if cmp -s "$NX" "$NU"; then ok "N/auto/$tag: RIPWIRE_FOR_ENDLINES=auto is byte-identical to unset at --token-budget=1200" + else no "N/auto/$tag: auto differs from unset at --token-budget=1200"; fi + fi + set -- "$NQ"; [ -n "$extra" ] && set -- "$@" "$extra" + NN="$TMP/n.never.$tag"; ND="$TMP/n.unset.$tag"; NXD="$TMP/n.auto.$tag" + runenv never "$NN" "$@"; runenv - "$ND" "$@"; runenv auto "$NXD" "$@" + if ran_ok "$NN" "N/never/$tag" && ran_ok "$ND" "N/unset/$tag" && ran_ok "$NXD" "N/auto/$tag"; then + if ! has_e "$ND"; then no "N/never/$tag: premise — the unset default answer carries no e=" + elif has_e "$NN" || has_e_clause "$NN"; then no "N/never/$tag: RIPWIRE_FOR_ENDLINES=never still carries e= or its clause at the default ceiling" + elif [ "$( rowset "$NN" )" != "$( rowset "$ND" )" ]; then no "N/never/$tag: dropping e= changed the shown row set" + else ok "N/never/$tag: no e= and no e= clause at the default ceiling, same rows as auto"; fi + if cmp -s "$NXD" "$ND"; then ok "N/auto/$tag: RIPWIRE_FOR_ENDLINES=auto is byte-identical to unset at the default" + else no "N/auto/$tag: auto differs from unset at the default"; fi + [ -s "$ND.err" ] && no "N/unset/$tag: the unset run wrote to stderr: $( head -c 200 "$ND.err" )" + fi +done +nu=0 +for bad in sometimes Always ""; do + NB="$TMP/n.unknown.$nu"; nu=$(( nu + 1 )) + runenv "$bad" "$NB" "$NQ" + if ran_ok "$NB" "N/unknown '$bad'"; then + if ! cmp -s "$NB" "$TMP/n.unset.xml"; then no "N/unknown '$bad': stdout differs from unset (an unknown value must fall back to auto)" + elif ! has_e "$NB"; then no "N/unknown '$bad': no e= at the default (read as never)" + elif [ "$( grep -c 'RIPWIRE_FOR_ENDLINES' "$NB.err" )" != 1 ]; then no "N/unknown '$bad': expected exactly one stderr line naming RIPWIRE_FOR_ENDLINES, got: $( head -c 300 "$NB.err" )" + else ok "N/unknown '$bad': falls back to auto (stdout == unset, e= kept) and says so once on stderr"; fi + fi +done +for v in always never; do + NM="$TMP/n.mcp.$v.json"; mkdir -p "$TMP/mcphome" + if [ "$v" = always ]; then bargs=',"budget_tokens":1200'; else bargs=''; fi + printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize"}' \ + '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"for","arguments":{"path":"'"$CORPUS/ts"'","task":"How is a per-request context created?"'"$bargs"'}}}' \ + | ( cd "$CORPUS/ts" && TMPDIR="$TMP/mcphome" RIPWIRE_FOR_ENDLINES="$v" "$BIN" --mcp >"$NM" 2>/dev/null ); printf '%s' "$?" >"$NM.rc" + if ran_ok "$NM" "N/mcp/$v"; then + python3 - "$TMP/rows.py" "$NM" "$v" <<'PY' +import json, re, sys +exec( open( sys.argv[ 1 ] ).read() ) +v = sys.argv[ 3 ] +lines = [ l for l in open( sys.argv[ 2 ], encoding="utf-8", errors="replace" ) if l.strip() ] +try: + text = json.loads( lines[ -1 ] )[ "result" ][ "content" ][ 0 ][ "text" ] +except Exception as err: + print( " FAIL N/mcp/%s: no readable MCP for answer (%s)" % ( v, err ) ); sys.exit( 1 ) +rows = drows( text ) +withE = [ a for _, a in rows if "e" in a ] +if not rows: + print( " FAIL N/mcp/%s: no rows (premise)" % v ); sys.exit( 1 ) +if v == "always" and not withE: + print( " FAIL N/mcp/always: budget_tokens=1200 rows carry no e=" ); sys.exit( 1 ) +if v == "never" and ( withE or "e= on a d row" in text or "d e= its last line" in text ): + print( " FAIL N/mcp/never: e= or an e= legend clause at the default ceiling" ); sys.exit( 1 ) +print( " PASS N/mcp/%s: MCP for %s (%d of %d rows with e=)" % ( v, "carries e= under budget_tokens=1200" if v == "always" else "carries no e= and no clause", len( withE ), len( rows ) ) ) +PY + [ $? -eq 0 ] || fail=1 + fi +done +NP="$TMP/n.packsig.xml"; ( cd "$CORPUS/c" && RIPWIRE_FOR_ENDLINES=always "$BIN" . --no-cache --pack-signatures >"$NP" 2>"$NP.err" ); printf '%s' "$?" >"$NP.rc" +if ran_ok "$NP" "N/near-miss"; then + if ! grep -q ' rows (premise)" + elif has_e "$NP"; then no "N/near-miss: RIPWIRE_FOR_ENDLINES=always put e= on --pack-signatures rows (the switch is --for only)" + else ok "N/near-miss: RIPWIRE_FOR_ENDLINES=always leaves --pack-signatures rows without e="; fi +fi + +echo "(K) the predicates can fail" +K="$TMP/k.xml" +printf 'x' >"$K" +if ( oracle "$K" "K1 (expected FAIL)" "$CORPUS/ts" ) >/dev/null 2>&1; then no "K1: e= < l= not caught"; else ok "K1: e= < l= is caught"; fi +printf 'xx' >"$K" +if ( oracle "$K" "K2 (expected FAIL)" "$CORPUS/ts" ) >/dev/null 2>&1; then no "K2: e= on a docs row not caught"; else ok "K2: e= on a docs row is caught"; fi +printf 'x' >"$K" +if ( oracle "$K" "K3 (expected FAIL)" "$CORPUS/ts" ) >/dev/null 2>&1; then no "K3: a code row without e= not caught"; else ok "K3: a code row without e= is caught"; fi +printf 'x' >"$K" +if ( hopcheck "$K" "K4 (expected FAIL)" nohop contextHost ) >/dev/null 2>&1; then no "K4: a name-only hop row not caught"; else ok "K4: a name-only hop row is caught"; fi +printf '' >"$K" +if ( oracle "$K" "K5 (expected FAIL)" "$CORPUS/ts" ) >/dev/null 2>&1; then no "K5: NOROOT passed"; else ok "K5: a document with no --for root is never a pass"; fi +K6ON="$TMP/k6on.xml"; K6OFF="$TMP/k6off.xml" +printf 'x' >"$K6ON" +printf 'xx' >"$K6OFF" +if ( rcheck "K6 (expected FAIL)" "$K6ON" "$K6OFF" ) >/dev/null 2>&1; then no "K6: an evicted doc row not caught"; else ok "K6: a doc row the reorder evicted is caught"; fi + +if [ "$fail" -ne 0 ]; then + echo "forsigspancheck: FAIL" + exit 1 +fi +echo "forsigspancheck: PASS" +exit 0 diff --git a/test/gokindcheck.sh b/test/gokindcheck.sh new file mode 100755 index 000000000..743fe785a --- /dev/null +++ b/test/gokindcheck.sh @@ -0,0 +1,236 @@ +#!/usr/bin/env bash +# gokindcheck.sh — a Go named type's t= says what its written form IS, never a blanket "struct". +# +# THE DEFECT. queries/go/tags.scm captured every `type_spec` as @definition.type, and ingest_crawl.h defKind maps that +# capture to SymKind::Struct (the typedef/alias/enum bucket). So `type TestName string` and +# `type eventFormatterFunc func(...)` were labelled t="struct": a false claim on every surface that prints t=. And +# `type A = B` parses as `type_alias`, a node the pattern never matched, so an alias was not indexed at all. +# +# THE CONTRACT (src/model.h SymKind::NamedType/Alias/FuncType): +# struct_type underlying -> t="struct"; interface_type -> t="iface"; function_type -> t="functype"; +# `type A = B` -> t="alias"; every other form (string, []T, map, chan, *T, [N]T, another named or qualified type, +# a generic over a non-struct form) -> t="type". Generic and grouped `type ( … )` specs follow the same rule. +# The three new kinds BEHAVE as Struct did (model.h isStructOrNamedType): a conversion `TestName( s )` keeps its +# caller edge. Only Go is split; another language's typedef keeps t="struct" (a disclosed floor, arm F). +# +# ARMS +# (A) positive: each non-struct form gets its own kind (type / functype / alias), standalone, generic and grouped. +# (B) near-miss negatives: struct, generic struct, grouped struct, embedded-field struct, interface, grouped +# interface keep t="struct"/t="iface"; a func declaration stays fn, a method on a defined type stays method, +# a package var of func type stays var. +# (C) behaviour kept: the conversion call is still an edge (--callers=TestName lists Convert). +# (D) the cache round-trips the new kind bytes: a warm run equals the --no-cache run byte for byte. +# (E) the legend defines the three kinds under --legend=full on a Go corpus; the graph query can select them. +# (F) scope floor: a C typedef keeps t="struct" and its map carries NO named-type clause (byte-identity rule). +# (H) the t=type legend clause is true of EVERY t=type row: `type Wrapped Pair` (a struct underneath) and +# `type HF http.HandlerFunc` (a func underneath) are t="type" because the kind follows the WRITTEN form, so the +# clause must say WRITTEN and must not claim the bare "not-a-struct" (red on the wording it replaced). +# (G) cross_kind= on --callers counts the new kinds (a name that is a func in one file and a defined type in another) +# without tripping callhierarchy.h's kind bound. +# +# Usage: test/gokindcheck.sh [BIN] (BIN defaults to RIPWIRE_BIN, then build/ripwire) +set -u +ROOT="$( cd "$( dirname "$0" )/.." && pwd )" +BIN="${1:-${RIPWIRE_BIN:-$ROOT/build/ripwire}}" +[ "${BIN#/}" = "$BIN" ] && BIN="$ROOT/$BIN" +TMP="$( mktemp -d )"; trap 'rm -rf "$TMP"' EXIT +fail=0 +ok(){ printf ' PASS %s\n' "$*" || { fail=1; printf ' FAIL could not write the PASS line for: %s\n' "$*"; }; return 0; } +no(){ printf ' FAIL %s\n' "$*"; fail=1; } + +[ -x "$BIN" ] || { echo "no ripwire binary at $BIN — build first"; exit 2; } + +mkdir -p "$TMP/g" "$TMP/g2" "$TMP/c" +cat > "$TMP/g/go.mod" <<'EOF' +module example.com/kinds + +go 1.22 +EOF +cat > "$TMP/g/kinds.go" <<'EOF' +package kinds + +type TestName string + +type eventFormatterFunc func(event string) error + +type Pair struct { + A int + B int +} + +type Reader interface { + Read(p []byte) (int, error) +} + +type Alias = Pair + +type List[T any] []T + +type Set[K comparable] map[K]struct{} + +type Box[T any] struct { + v T +} + +type ( + Count int + Handler func() + Lookup map[string]int + Events chan string + PairPtr *Pair + Names []string + Grid [4]int + Inner struct{ X int } + Closer interface{ Close() error } + OldName = Count + Wrapped Pair + Qualified ext.Thing + HF http.HandlerFunc +) + +type Outer struct { + Pair + *Inner + n TestName +} + +var OnEvent func(string) + +func (t TestName) Split() []string { return nil } + +func (f eventFormatterFunc) Format(e string) error { return f(e) } + +func Run() {} + +func Convert(s string) TestName { return TestName(s) } +EOF + +# The map rows, one "t n" pair per line, read off a run that must SUCCEED and produce rows (checklist 14). +MAP="$TMP/map.xml" +"$BIN" "$TMP/g" --no-cache > "$MAP" 2>"$TMP/map.err"; rc=$? +rows="$( grep -o ' "$TMP/callers.xml" 2>&1; rc=$? +if [ "$rc" -eq 0 ] && grep -q 'count="1"' "$TMP/callers.xml" && grep -q ' "$TMP/cold.xml" 2>/dev/null; c1=$? +"$BIN" "$TMP/g" --cache="$CACHE" > "$TMP/warm.xml" 2>/dev/null; c2=$? +if [ "$c1" -eq 0 ] && [ "$c2" -eq 0 ] && [ -s "$CACHE" ] && cmp -s "$MAP" "$TMP/warm.xml" && grep -q 't="functype"' "$TMP/warm.xml"; then + ok "(D) cold and warm runs equal the --no-cache map byte for byte (t=\"functype\" survives the cache)" +else + no "(D) cold rc=$c1 warm rc=$c2; warm differs from --no-cache: $( diff <( tr '>' '\n' < "$MAP" ) <( tr '>' '\n' < "$TMP/warm.xml" ) | head -5 )" +fi + +echo "(E) the legend defines them; the graph query selects them" +"$BIN" "$TMP/g" --no-cache --legend=full > "$TMP/full.xml" 2>&1 +if grep -q '' "$TMP/full.xml" )" +if [ -n "$tline" ] && printf '%s\n' "$tline" | grep -q 'type=a-defined-type-whose-WRITTEN-form-is-not-a-struct/iface/func-literal' \ + && printf '%s\n' "$tline" | grep -q 'type-W-Pair-too,whatever-Pair-is-underneath' \ + && ! printf '%s\n' "$tline" | grep -q 'type=a-defined-type-not-a-struct'; then + ok "(H) the clause says the form is WRITTEN, naming type W Pair; Wrapped and HF (struct/func underneath) are the t=type rows it must stay true of" +else + no "(H) the t=type clause is not qualified by the written form: [$tline]" +fi + +echo "(F) scope floor: only Go is split" +cat > "$TMP/c/t.c" <<'EOF' +typedef int handle_t; +struct point { int x; int y; }; +int use( handle_t h ) { return h; } +EOF +"$BIN" "$TMP/c" --no-cache --legend=full > "$TMP/c.xml" 2>&1; rc=$? +if [ "$rc" -eq 0 ] && grep -q ' "$TMP/g2/go.mod" <<'EOF' +module example.com/mixed + +go 1.22 +EOF +mkdir -p "$TMP/g2/a" "$TMP/g2/b" +cat > "$TMP/g2/a/a.go" <<'EOF' +package a + +type Mode string + +func Pick() Mode { return Mode("x") } +EOF +cat > "$TMP/g2/b/b.go" <<'EOF' +package b + +func Mode() int { return 1 } + +func Call() int { return Mode() } +EOF +"$BIN" "$TMP/g2" --no-cache --callers=Mode > "$TMP/ck.xml" 2>&1; rc=$? +if [ "$rc" -eq 0 ] && grep -q 'cross_kind="fn:1,type:1"' "$TMP/ck.xml"; then + ok "(G) --callers=Mode says cross_kind=\"fn:1,type:1\"" +else + no "(G) rc=$rc: $( grep -o 'cross_kind="[^"]*"' "$TMP/ck.xml" ) $( grep -m1 -i 'fail\|expects' "$TMP/ck.xml" )" +fi + +if [ "$fail" -eq 0 ]; then echo "ALL PASS"; else echo "FAIL"; fi +exit "$fail" diff --git a/test/hookcheck.sh b/test/hookcheck.sh index 4338b7ee1..91aa55778 100755 --- a/test/hookcheck.sh +++ b/test/hookcheck.sh @@ -623,7 +623,7 @@ LASTROW="$( meterrows "$DEFAULT_LOG" )" # ── M19-M20: the arm is on every row, and the PreToolUse path is now ARM-INDEPENDENT ─────────────── # §RETIRED (2026-09-02): with both tiers silent in both arms, the PreToolUse path can no longer differ # by arm — so these arms assert exactly that, plus the two things that did NOT change: the arm is still -# resolved, still recorded on the row, and still defaults to treatment. The one behaviour the arm does +# resolved, still recorded on the row, and now defaults to the auto split (issue #381). The one behaviour the arm does # separate is the SessionStart primer, gated by A3/A4. # OR-chain pattern (P4.2, 2026-08-29): a literal `grep -rn needle .` would demote category to "" before # the arm is consulted, and these arms would pass for the wrong reason (nudge=none). @@ -635,10 +635,59 @@ OUTM19="$( run_meter "$L19" "$( bashjson meter19 "grep -rn 'needle|other' ." )" || no "M19 meter: arm=[$( meterrowget "$L19" 1 arm )] nudged=[$( meterrowget "$L19" 1 nudged )] nudge=[$( meterrowget "$L19" 1 nudge )] out=[$OUTM19]" TM20="$TMP/tm20"; mkdir -p "$TM20"; L20="$TMP/m20.jsonl" OUTM20="$( run_meter "$L20" "$( bashjson meter20 "grep -rn 'needle|other' ." )" "$TM20" )" -[ "$( meterrowget "$L20" 1 arm )" = "treatment" ] && [ -z "$OUTM20" ] \ +# Issue #381: with no env override and no meter.conf the arm is now the `auto` split (it used to be +# `treatment` for every session, so no control arm could ever exist). Expected side computed here the way +# the meter hashes a session id. +M20H="$( printf '%s' meter20 | cksum | cut -d' ' -f1 )" +if [ "$(( M20H % 100 ))" -lt 50 ]; then M20WANT=control; else M20WANT=treatment; fi +[ "$( meterrowget "$L20" 1 arm )" = "$M20WANT" ] && [ -z "$OUTM20" ] \ && [ "$( meterrowget "$L20" 1 nudge )" = "retired" ] \ - && ok "M20 meter: DEFAULT arm is treatment, and treatment's PreToolUse path is silent too" \ - || no "M20 meter: default arm=[$( meterrowget "$L20" 1 arm )] nudge=[$( meterrowget "$L20" 1 nudge )] out=[${OUTM20:+set}]" + && ok "M20 meter: DEFAULT arm is the auto split ($M20WANT for this session), and the PreToolUse path is silent" \ + || no "M20 meter: default arm=[$( meterrowget "$L20" 1 arm )] want=[$M20WANT] nudge=[$( meterrowget "$L20" 1 nudge )] out=[${OUTM20:+set}]" +# Twin for the old default: an EXPLICIT treatment is still honoured, on a session the auto split would +# have put in control (so the arm cannot pass by coincidence). +M20CSID="" +for _m in meter20c1 meter20c2 meter20c3 meter20c4 meter20c5 meter20c6; do + _mh="$( printf '%s' "$_m" | cksum | cut -d' ' -f1 )"; [ "$(( _mh % 100 ))" -lt 50 ] && { M20CSID="$_m"; break; } +done +TM20C="$TMP/tm20c"; mkdir -p "$TM20C"; L20C="$TMP/m20c.jsonl" +run_meter "$L20C" "$( bashjson "$M20CSID" "grep -rn 'needle|other' ." )" "$TM20C" RIPWIRE_METER_ARM=treatment >/dev/null 2>&1 +[ -n "$M20CSID" ] && [ "$( meterrowget "$L20C" 1 arm )" = "treatment" ] \ + && ok "M20c meter: an explicit RIPWIRE_METER_ARM=treatment is honoured over the auto default" \ + || no "M20c meter: explicit treatment gave arm=[$( meterrowget "$L20C" 1 arm )] for control-side session [$M20CSID]" +TM20D="$TMP/tm20d"; mkdir -p "$TM20D"; L20D="$TMP/m20d.jsonl" +run_meter "$L20D" "$( bashjson "$M20CSID" "grep -rn 'needle|other' ." )" "$TM20D" >/dev/null 2>&1 +[ -n "$M20CSID" ] && [ "$( meterrowget "$L20D" 1 arm )" = "control" ] \ + && ok "M20d meter: the same control-side session with no override lands in control (the default really is auto)" \ + || no "M20d meter: unconfigured control-side session [$M20CSID] got arm=[$( meterrowget "$L20D" 1 arm )]" +# meter.conf PARSING (issue #381 fix round 1; the same arms as A10-A18 in routehookcheck, for meter_init): a +# final line without a newline is not lost, a CRLF value is not read as treatment, and near-miss values still +# read as treatment. The side the buggy reading would NOT give is used each time. meter_init reads every key +# through the same loop, so the CRLF arm also covers `enabled=` (M20f: `enabled=0\r` still turns counting off). +M20TRT="" +for _m in meter20t1 meter20t2 meter20t3 meter20t4 meter20t5 meter20t6; do + _mh="$( printf '%s' "$_m" | cksum | cut -d' ' -f1 )"; [ "$(( _mh % 100 ))" -ge 50 ] && { M20TRT="$_m"; break; } +done +meter_conf_arm() # meter_conf_arm LABEL SESSION CONFBYTES(printf format) WANT +{ + _cd="$TMP/mca_$1"; mkdir -p "$_cd"; printf "$3" >"$_cd/meter.conf"; _cl="$TMP/mca_$1.jsonl" + run_meter "$_cl" "$( bashjson "$2" "grep -rn 'needle|other' ." )" "$_cd" RIPWIRE_HOME="$_cd" >/dev/null 2>&1 + [ -n "$2" ] && [ "$( meterrowget "$_cl" 1 arm )" = "$4" ] && ok "$1 meter: meter.conf [$3] -> $4" \ + || no "$1 meter: meter.conf [$3] gave arm=[$( meterrowget "$_cl" 1 arm )], want $4 (session [$2])" +} +meter_conf_arm M20e1 "$M20CSID" 'arm=treatment' treatment +meter_conf_arm M20e2 "$M20TRT" 'arm=control' control +meter_conf_arm M20e3 "$M20TRT" 'arm=control\r\n' control +meter_conf_arm M20e4 "$M20CSID" 'arm=auto\r\n' control +meter_conf_arm M20e5 "$M20TRT" 'sweep=0\r\narm=control\r\nenabled=1\r\n' control +meter_conf_arm M20e6 "$M20TRT" 'sweep=0\narm=control' control +meter_conf_arm M20e7 "$M20CSID" 'arm=controlx\n' treatment +meter_conf_arm M20e8 "$M20CSID" 'arm=control\rx\n' treatment +meter_conf_arm M20e9 "$M20CSID" 'arm=treatment\r\n' treatment +_cd="$TMP/mca_en"; mkdir -p "$_cd"; printf 'enabled=0\r\n' >"$_cd/meter.conf"; _cl="$TMP/mca_en.jsonl"; : >"$_cl" +run_meter "$_cl" "$( bashjson "$M20TRT" "grep -rn 'needle|other' ." )" "$_cd" RIPWIRE_HOME="$_cd" >/dev/null 2>&1 +[ ! -s "$_cl" ] && ok "M20f meter: a CRLF meter.conf's enabled=0 turns counting off (the CR is not part of the value)" \ + || no "M20f meter: enabled=0 with a CRLF line still logged: $( cut -c1-120 "$_cl" )" [ "$OUTM19" = "$OUTM20" ] \ && ok "M20b meter: control and treatment produce BYTE-IDENTICAL PreToolUse output (both empty)" \ || no "M20b meter: the arms still differ on the PreToolUse path: control=[$OUTM19] treatment=[$OUTM20]" @@ -1461,9 +1510,11 @@ OUTA3="$( printf '%s' '{"session_id":"armss","cwd":"'"$REPO"'","source":"startup || no "A3b arm: control session-start row = [$( meterrowget "$LA3" 1 class )/$( meterrowget "$LA3" 1 arm )]" # A4: the positive control for A3. Suppressing the primer everywhere would pass A3 for the wrong reason. +# The arm is pinned to treatment explicitly: the unconfigured default is the auto split (issue #381), under +# which this session id could land in control; the default itself is asserted by M20/M20d. TA4="$TMP/ta4"; mkdir -p "$TA4"; LA4="$TMP/a4.jsonl" OUTA4="$( printf '%s' '{"session_id":"armsst","cwd":"'"$REPO"'","source":"startup"}' \ - | env HOME="$METERHOME" RIPWIRE_METER_LOG="$LA4" RIPWIRE_METER_FIXTURE=1 \ + | env HOME="$METERHOME" RIPWIRE_METER_LOG="$LA4" RIPWIRE_METER_FIXTURE=1 RIPWIRE_METER_ARM=treatment \ PATH="$WRAP_PATH" TMPDIR="$TA4" bash "$HOOK" --session-start )" [ -n "$OUTA4" ] && printf '%s' "$OUTA4" | is_valid_json \ && ok "A4 arm: the treatment arm still gets the SessionStart primer (A3's positive control)" \ diff --git a/test/impactimportcheck.sh b/test/impactimportcheck.sh index 74d0d73ac..ea9e08830 100755 --- a/test/impactimportcheck.sh +++ b/test/impactimportcheck.sh @@ -316,6 +316,49 @@ LCUT="$( attr lens "$( ri --format=columnar )" )"; LALL="$( attr lens "$( ri --l || no "next: --format=columnar lens='$LCUT' on the cut tier (want shown_importers,importers_capped,importers_next), lens='$LALL' uncut (want shown_importers,importers_capped)" rm -rf "$RS" +# ── #9d A LONG SELECTOR KEEPS ITS CALL (knob-honesty-068) ─────────────────────────────────────────────── +# sizeImportTier offered importers_next= only while the invocation was <= kNextAttrMaxBytes (120 B), so a cut tier +# whose symbol name is long lost its ONLY continuation with no marker: importers_capped="1" and nothing that serves +# the rest — the silent drop the 2026-09-25 ruling (138b383c: "emit the FULL next=") removed everywhere else. Same +# 48-importer shape as #9b with a 110-character function name: `--impact=NAME --limit=48` is 130 B. RED on 255dc199 +# (no importers_next= on the XML root, the --json dialect, or the MCP twin). +LS="$( mktemp -d )"; mkdir -p "$LS/pkg" +LONGN="importHubFn_$( printf 'x%.0s' $( seq 1 98 ) )" +python3 - "$LS" "$LONGN" <<'PY' +import os, sys +d, n = os.path.join( sys.argv[1], "pkg" ), sys.argv[2] +open( os.path.join( d, "__init__.py" ), "w" ).write( "" ) +open( os.path.join( d, "hub.py" ), "w" ).write( "def %s( x ):\n return x\n" % n ) +for i in range( 48 ): + open( os.path.join( d, "a_%02d.py" % i ), "w" ).write( "from pkg.hub import %s\n\ndef use_a_%02d( ):\n return %s( %d )\n" % ( n, i, n, i ) ) +PY +rl(){ perl -e 'alarm 30; exec @ARGV' "$BIN" "$LS" --impact="$LONGN" --no-cache "$@" 2>/dev/null; } +L_DEF="$( rl )" +WANT_L="--impact=$LONGN --limit=48" +{ [ "${#LONGN}" = 110 ] && [ "${#WANT_L}" -gt 120 ] && [ "$( attr importers_capped "$L_DEF" )" = 1 ]; } \ + && ok "long: presence guard — a ${#LONGN}-char selector, a ${#WANT_L} B call (> 120), importers_capped=1" \ + || no "long: fixture broken — name ${#LONGN} chars, call ${#WANT_L} B, importers_capped='$( attr importers_capped "$L_DEF" )'" +[ "$( attr importers_next "$L_DEF" )" = "$WANT_L" ] \ + && ok "long: the cut tier still names its call (importers_next= is ${#WANT_L} B, never dropped for length)" \ + || no "long: importers_next='$( attr importers_next "$L_DEF" )' on a cut tier (want the full ${#WANT_L} B call)" +L_NX="$( perl -e 'alarm 30; exec @ARGV' "$BIN" "$LS" $( attr importers_next "$L_DEF" ) --no-cache 2>/dev/null )" +{ [ "$( attr shown_importers "$L_NX" )" = 48 ] && [ "$( attr importers_capped "$L_NX" )" = 0 ]; } \ + && ok "long: pasting the long importers_next= serves all 48 importers" \ + || no "long: pasting importers_next= gave shown_importers='$( attr shown_importers "$L_NX" )'" +rl --json | grep -qF "\"importers_next\":\"$WANT_L\"" \ + && ok "long: the --json dialect carries the same full importers_next" \ + || no "long: --json lacks the full \"importers_next\" on the cut tier" +L_MCP="$( printf '{"jsonrpc":"2.0","id":0,"method":"initialize","params":{}}\n{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"impact","arguments":{"path":"%s","symbol":"%s"}}}\n' "$LS" "$LONGN" \ + | perl -e 'alarm 60; exec @ARGV' "$BIN" --mcp 2>/dev/null | tail -1 )" +printf '%s' "$L_MCP" | grep -qF "importers_next=\\\"$WANT_L\\\"" \ + && ok "long: the MCP impact twin carries the same full importers_next" \ + || no "long: MCP impact lacks importers_next on the cut tier: $( printf '%s' "$L_MCP" | grep -oE 'importers_[a-z]+=[^ ]+' | tr '\n' ' ' | cut -c1-200 )" +# negative twin: an UNCUT long tier carries no importers_next= (the follow-up rides a cut only) +[ -z "$( attr importers_next "$( rl --limit=100 )" )" ] \ + && ok "long: the uncut tier (--limit=100) carries no importers_next=" \ + || no "long: an uncut tier carries importers_next=" +rm -rf "$LS" + # ── #10 determinism + well-formedness ───────────────────────────────────────────────────────────────── A="$( i Widget )"; B="$( i Widget )" if [ "$A" = "$B" ]; then ok "determinism: --impact=Widget byte-identical run-to-run"; else no "non-deterministic --impact output"; fi diff --git a/test/impactpartitioncheck.sh b/test/impactpartitioncheck.sh index 0c9d0b9c4..5e2eb59f4 100755 --- a/test/impactpartitioncheck.sh +++ b/test/impactpartitioncheck.sh @@ -153,7 +153,9 @@ for name in seed_names: # scope included, so len(rows) == reaches stays the row-count invariance it always was. # 0.6.5 (depth-labelled --impact): a row may carry d= (its hop depth, run-length) between p= and tested=; the # row COUNT this invariance reads is unchanged by it (test/impactdepthcheck.sh gates the depths themselves). - rows = re.findall(r'', doc) + # FE-B: a row reached only through a by-name edge carries via="name" after tested= — it DISCLOSES how the row was + # reached, never whether it is one, so the row count this invariance reads must take it like d= above. + rows = re.findall(r'', doc) reaches = int(attrs.get("reaches", "-1")) if len(rows) != reaches: row_count_ok = False diff --git a/test/javamethodrefcheck.sh b/test/javamethodrefcheck.sh index 396d67f32..4da9e6997 100755 --- a/test/javamethodrefcheck.sh +++ b/test/javamethodrefcheck.sh @@ -216,7 +216,9 @@ expect_callers makeFn "genericTypeMethod lambdaForm nestedTypeMethod typeMethod" # set above is satisfied by an edge pointing anywhere — and before the receiver-resolution fix every # one of these references landed on the caller's own file's A.makeFn, via the bare-name ladder and # the locality tie-break. Each reference must resolve to the member of the type it named. -expect_callers Widget.java:makeFn "genericTypeMethod typeMethod" +# FE-B (test/receiverevidencecheck.sh): the lambda's INVOCATION `Widget.makeFn( item )` is a class-name receiver, so it +# resolves to Widget's member too — it used to land on A.makeFn by the same file-locality pin the references had. +expect_callers Widget.java:makeFn "genericTypeMethod lambdaForm typeMethod" expect_callers Outer.java:makeFn "nestedTypeMethod" # The r9 shadow pass is C++/ObjC evidence. A Java call never resolves to a local, so a parameter @@ -294,9 +296,9 @@ echo "callers_set(makeFn) after mutation = [${S1}]" # names no definition anywhere, so it produces no edge rather than one to a plausible neighbour. S2="$( callers_set "$MUT" Widget.java:makeFn )" echo "callers_set(Widget.java:makeFn) after mutation = [${S2}]" -[ "$S2" = "genericTypeMethod" ] \ - && ok "mutation leaves Widget.java:makeFn with only genericTypeMethod" \ - || no "mutation expected Widget.java:makeFn = [genericTypeMethod], got [$S2]" +[ "$S2" = "genericTypeMethod lambdaForm" ] \ + && ok "mutation leaves Widget.java:makeFn with genericTypeMethod and the untouched lambda invocation (FE-B: a class-name receiver)" \ + || no "mutation expected Widget.java:makeFn = [genericTypeMethod lambdaForm], got [$S2]" S3="$( callers_set "$MUT" Outer.java:makeFn )" [ "$S3" = "nestedTypeMethod" ] \ && ok "mutation leaves Outer.java:makeFn untouched (nestedTypeMethod)" \ diff --git a/test/jsonparitycheck.sh b/test/jsonparitycheck.sh index b23836aba..3b6abcde3 100755 --- a/test/jsonparitycheck.sh +++ b/test/jsonparitycheck.sh @@ -300,7 +300,9 @@ done && ok "§B1.4: an off-task surface with no interfaces reports lego_total:0 (genuinely absent)" \ || no "§B1.4: off-task lego_total is '$( printf '%s' "$LOFF" | jget lego_total )', expected 0" -LON="$( "$BIN" "$ROOT" --for="Circle Square shape area implementors" --json --no-cache 2>/dev/null | jsonok "--for --json (on-task, real lego surface)" )" +# --exclude=src/ (train 26b): the product's own src/ decides the whole-checkout shown set and pushed test/legofix out of it +# (legobundlecheck's on-task note); both dialects below read the same fixture-only corpus. +LON="$( "$BIN" "$ROOT" --for="Circle Square shape area implementors" --exclude=src/ --json --no-cache 2>/dev/null | jsonok "--for --json (on-task, real lego surface)" )" LON_LEGO="$( printf '%s' "$LON" | jget lego_total )" { [ -n "$LON_LEGO" ] && [ "$LON_LEGO" -gt 0 ]; } 2>/dev/null \ && ok "§B1.4: an on-task surface (Shape/Circle/Square) reports lego_total:$LON_LEGO (dropped, not absent)" \ @@ -311,7 +313,7 @@ LON_LEGO="$( printf '%s' "$LON" | jget lego_total )" # arm counts — --for collapses it to a counted stub by default, and the stub's own total= is a DIFFERENT # (post-dedup) count than lego_total by design (serialize.h sectionStubXml's own comment), so counting the # stub's total= here instead of the real rows would compare the wrong two numbers. -XML_IFACES="$( "$BIN" "$ROOT" --for="Circle Square shape area implementors" --sections=lego,compose --no-cache 2>/dev/null | grep -oE '/dev/null | grep -oE '0 guard on the XML side (2026-08-23 serving-shape sweep): without it this arm degrades to N>=0 the # moment the XML sibling stops emitting rows at all — an under-count is unobservable against an # empty roster, so the comparison must first prove the roster is non-empty (the routes arm below already diff --git a/test/kotlincheck.sh b/test/kotlincheck.sh index 459914ebf..4297bbe68 100755 --- a/test/kotlincheck.sh +++ b/test/kotlincheck.sh @@ -31,7 +31,7 @@ # fun greet(): String -> square(2) -- member function, CROSS-FILE call # } # fun Int.doubled(): Int -- extension function -# fun useJavaHelper(): Int -> JavaBridge.helper(5) -- Kotlin -> ???, QUALIFIED on a name both define (§5 trade-off) +# fun useJavaHelper(): Int -> JavaBridge.helper(5) -- Kotlin -> ???, QUALIFIED on a name both define (§5: Java's, by its class-name receiver, since FE-B) # fun useJavaOnly(): Int -> JavaBridge.javaOnly(3) -- Kotlin -> Java, qualified, a name only Java defines (§3) # fun ambiguousCall(): Int -> helper(5) -- Kotlin -> ???, BARE (the collision probe) # fun runAll(): Int -> of, greet, doubled, useJavaHelper, useJavaOnly, ambiguousCall @@ -53,13 +53,13 @@ # fixture and split across three directories alike (§14a). # - JVM BRIDGE, Kotlin -> Java: useJavaOnly's `JavaBridge.javaOnly(3)` reaches the Java method — a # name only Java defines, which is the case the bridge exists for (§3). -# - THE COLLISION, by rule: `helper` is defined in both languages, so both Kotlin sites — the BARE -# ambiguousCall() and the QUALIFIED useJavaHelper() — bind Kotlin's Extra.helper: the bridge admits -# the other JVM language only when the caller's own defines no candidate (graph.h -# keepOwnJvmLanguageCandidates). The qualified site is the disclosed trade-off: Kotlin receivers do -# not narrow candidates yet (ingest_binds.h). What this replaced reported both helpers at -# ambiguous=2 in this flat fixture — and silently bound NEITHER once the files sat in different -# directories, the same tier-3 drop that deleted Java edges on square/retrofit (§14). +# - THE COLLISION, by rule: `helper` is defined in both languages. The BARE ambiguousCall() binds Kotlin's Extra.helper +# (hedged via="name": nothing imports the object's member): the bridge admits the other JVM language only when the +# caller's own defines no candidate (graph.h keepOwnJvmLanguageCandidates). The QUALIFIED useJavaHelper() was the +# disclosed trade-off until FE-B (Kotlin receivers did not narrow); its class-name receiver now proves Java's +# JavaBridge.helper. What this replaced reported both helpers at ambiguous=2 in this flat fixture — and silently +# bound NEITHER once the files sat in different directories, the same tier-3 drop that deleted Java edges on +# square/retrofit (§14). # - A same-name Kotlin/Java pair of TYPES is kept as two definitions even when one side has no body # (model.h isDefinitionNotDeclaration, §11 and §13); the positional body fallback in ingest_sidecap.h is what # gives a bodied Kotlin definition its body span. @@ -262,9 +262,9 @@ echo "=== 5. THE COLLISION: a same-name Kotlin/Java pair binds the caller's OWN # ═══════════════════════════════════════════════════════════════════════════ # helper is defined in both languages (Util.kt's Extra.helper, JavaBridge.java's helper). The bridge admits the other JVM # language only when the caller's own defines no candidate of the name (graph.h keepOwnJvmLanguageCandidates), so BOTH -# Kotlin call sites — the bare ambiguousCall() and the QUALIFIED useJavaHelper()'s `JavaBridge.helper(5)` — bind Kotlin's -# Extra.helper. The qualified one is the disclosed trade-off, pinned so it cannot move silently: Kotlin receivers do not -# narrow candidates yet (ingest_binds.h), so `JavaBridge.` is evidence this resolver cannot read. What this replaced: both +# Kotlin call sites — the bare ambiguousCall() and the QUALIFIED useJavaHelper()'s `JavaBridge.helper(5)` — bound Kotlin's +# Extra.helper until FE-B: the qualified one was the disclosed trade-off (Kotlin receivers did not narrow). FE-B's class-name +# receiver closes it (see below). What this replaced: both # sites bound BOTH helpers at ambiguous=2 — but only because this fixture is one directory; split across three they # reached NEITHER, with no amb= and no unresolved= (§14a runs that split layout). USES_HELPER="$( "$BIN" "$FIX" --uses=helper --no-cache 2>/dev/null )" @@ -272,12 +272,17 @@ echo "$USES_HELPER" | grep -q 'defs="2"' && ok '--uses=helper: defs="2" — both || no "--uses=helper: expected defs=2: $( echo "$USES_HELPER" | grep -o ']*>' )" UTIL_HELPER_CALLERS="$( "$BIN" "$FIX" --callers="Util.kt:helper" --no-cache 2>/dev/null )" HELPER_CALLERS="$( "$BIN" "$FIX" --callers="JavaBridge.java:helper" --no-cache 2>/dev/null )" -echo "$UTIL_HELPER_CALLERS" | grep -q 'count="2"' && echo "$UTIL_HELPER_CALLERS" | grep -q 'n="useJavaHelper"' && echo "$UTIL_HELPER_CALLERS" | grep -q 'n="ambiguousCall"' \ - && ok "--callers=Util.kt:helper: count=2 — the bare ambiguousCall AND the qualified useJavaHelper both bind Kotlin's Extra.helper" \ - || no "--callers=Util.kt:helper: expected both Kotlin call sites: $( echo "$UTIL_HELPER_CALLERS" | grep -o ']*>' )" -echo "$HELPER_CALLERS" | grep -q 'count="0"' \ - && ok "--callers=JavaBridge.java:helper: count=0 — no Kotlin site reaches Java's helper while Kotlin defines one (the trade-off)" \ - || no "--callers=JavaBridge.java:helper: expected count=0 — a Kotlin site reached Java's helper although Kotlin defines one: $( echo "$HELPER_CALLERS" | grep -o ']*>' )" +# FE-B (test/receiverevidencecheck.sh) closed the trade-off: a Kotlin class-name receiver is evidence now, so the QUALIFIED +# `JavaBridge.helper(5)` reaches Java's JavaBridge.helper — proven, no via= — while the BARE ambiguousCall() keeps the +# own-language-first rule (it never reaches Java's helper) and binds Kotlin's Extra.helper only HEDGED: a top-level +# function's bare call reaches an object's member through an import, which this file does not have. +echo "$UTIL_HELPER_CALLERS" | grep -q 'count="1"' && echo "$UTIL_HELPER_CALLERS" | grep -q '' \ + && ! echo "$UTIL_HELPER_CALLERS" | grep -q 'n="useJavaHelper"' \ + && ok "--callers=Util.kt:helper: count=1 — the bare ambiguousCall, hedged via=\"name\"; the qualified useJavaHelper no longer lands here" \ + || no "--callers=Util.kt:helper: expected only the bare call, hedged: $( echo "$UTIL_HELPER_CALLERS" | grep -oE ']*>|]*/>' | tr '\n' ' ' )" +echo "$HELPER_CALLERS" | grep -q 'count="1"' && echo "$HELPER_CALLERS" | grep -q '' \ + && ok "--callers=JavaBridge.java:helper: count=1 — the qualified useJavaHelper, proven by its class-name receiver; the bare site never reaches Java (own language first)" \ + || no "--callers=JavaBridge.java:helper: expected exactly the qualified useJavaHelper, proven: $( echo "$HELPER_CALLERS" | grep -oE ']*>|]*/>' | tr '\n' ' ' )" # ═══════════════════════════════════════════════════════════════════════════ echo @@ -919,9 +924,10 @@ for L in flat split; do callerRows "$ROOTL" JavaBridge.java:javaOnly | grep -q 'n="useJavaOnly"' \ && ok "$L: Kotlin -> Java — useJavaOnly reaches JavaBridge.javaOnly, a name only Java defines" \ || no "$L: Kotlin -> Java — useJavaOnly does not reach JavaBridge.javaOnly" - [ "$( callersCount "$ROOTL" Util.kt:helper )" = 'count="2"' ] && [ "$( callersCount "$ROOTL" JavaBridge.java:helper )" = 'count="0"' ] \ - && ok "$L: both Kotlin helper(5) calls bind Kotlin's Extra.helper and none reaches Java's (own language first)" \ - || no "$L: helper — Util.kt:helper $( callersCount "$ROOTL" Util.kt:helper ), JavaBridge.java:helper $( callersCount "$ROOTL" JavaBridge.java:helper ); expected count=2 and count=0" + [ "$( callersCount "$ROOTL" Util.kt:helper )" = 'count="1"' ] && [ "$( callersCount "$ROOTL" JavaBridge.java:helper )" = 'count="1"' ] \ + && callerRows "$ROOTL" JavaBridge.java:helper | grep -q 'n="useJavaHelper"' \ + && ok "$L: the bare helper(5) binds Kotlin's Extra.helper (own language first) and the qualified JavaBridge.helper(5) reaches Java's (FE-B)" \ + || no "$L: helper — Util.kt:helper $( callersCount "$ROOTL" Util.kt:helper ), JavaBridge.java:helper $( callersCount "$ROOTL" JavaBridge.java:helper ); expected count=1 and count=1 (useJavaHelper)" done # 14b. Response.body beside an unrelated Kotlin body() in another directory. The Java callers must equal the same tree's diff --git a/test/legenddriftcheck.py b/test/legenddriftcheck.py index b27b3762d..8be1f8d3e 100755 --- a/test/legenddriftcheck.py +++ b/test/legenddriftcheck.py @@ -188,7 +188,10 @@ def extract_flag_tokens_from_legend(legend_text): # depth-labelled --impact (0.6.5): "d" joins for the same documented reason. src/graphlegend.h kImpactDepthLegend # defines the row's OUTPUT ATTRIBUTE as "d=N on : hop depth", and there is no --d flag (single-letter flags # do not exist in this CLI). Added by NAME, per this list's own rule. - placeholder_exclude = {"bodies", "bodyless", "overloads", "files", "hits", "toks", "noedge", "total", "shown", "d"} + # lane FE-B fix round 1: "x" joins for the same documented reason. The map's via legend (src/graphlegend.h + # kMapViaNameLegend) and the compact row define a merged map row's OUTPUT ATTRIBUTE as "x=N"; there is no --x + # flag. Added by NAME, per this list's own rule. + placeholder_exclude = {"bodies", "bodyless", "overloads", "files", "hits", "toks", "noedge", "total", "shown", "d", "x"} for match in re.finditer(r'\b([a-z][a-z0-9\-]*)=[NM]\b', legend_text): word = match.group(1) if word not in placeholder_exclude: diff --git a/test/legendrefcheck.sh b/test/legendrefcheck.sh index c9a5dd9e9..bb41db8c0 100755 --- a/test/legendrefcheck.sh +++ b/test/legendrefcheck.sh @@ -57,8 +57,13 @@ def ok(m): print(f" PASS {m}") def no(m): print(f" FAIL {m}"); fails.append(m) def check(c, m): (ok if c else no)(m) -def session(calls): +# lean-answers lane: the stdio default now OPENS the session on its first reducible answer. Arms (A)-(I) assert the +# pre-lane contract ("ref only after the read") and run it where it still lives, --mcp-legend=inline; arm (J) is their +# twin on the default posture. +OLD_POSTURE = ["--mcp-legend=inline"] +def session(calls, server=None): """One stdio MCP session. calls: [(tool, args)] or ('@method', params). Returns [(kind, text)].""" + server = OLD_POSTURE if server is None else server reqs = [{"jsonrpc": "2.0", "id": 0, "method": "initialize", "params": {"protocolVersion": "2025-06-18"}}] for i, (name, args) in enumerate(calls, 1): if name.startswith("@"): @@ -66,7 +71,7 @@ def session(calls): else: a = dict(args); a.setdefault("path", FX) reqs.append({"jsonrpc": "2.0", "id": i, "method": "tools/call", "params": {"name": name, "arguments": a}}) - p = subprocess.run([BIN, "--mcp"], input="".join(json.dumps(r) + "\n" for r in reqs).encode(), capture_output=True, cwd=FX) + p = subprocess.run([BIN, "--mcp"] + server, input="".join(json.dumps(r) + "\n" for r in reqs).encode(), capture_output=True, cwd=FX) out = [] for line in p.stdout.decode(errors="replace").splitlines(): if not line.strip(): continue @@ -314,6 +319,54 @@ for i, t in refs: illformed.append(f"{CALLS[i][0]}:{e}") check(NREF >= 12 and not illformed, f"(I) over {NREF} ref answers, every one parses and keeps its newlines inside CDATA ({illformed})") +# ── (J) THE DEFAULT SESSION (lean-answers lane): the twin of (A)-(F)/(I) on the stdio default ───────────────────────────── +# The session opens on its first answer the dictionary can reduce: that answer is the inline answer, byte for byte; +# every later reducible answer is ref (or, where ref would be longer, the inline answer); the core reaches the session +# once, in its first ref answer; no entry is sent twice; every roster attribute is defined where the reader holds it. +print("=== (J) the default session: first answer inline, later answers ref ===") +DEF = [] +D = session(CALLS + CALLS, server=DEF) +check(len(D) == 1 + 2 * N, f"(J) the default session answered every request ({len(D) - 1} of {2 * N})") +dinit = D[0][1] if D and D[0][0] == "init" else "" +check(len(dinit.encode()) <= 1000 and f"dictv={DICTV}" in dinit and "ripwire://legend-dict/full" in dinit, + f"(J) the default initialize names the posture, the same dictv and the full dictionary in <= 1,000 B ({len(dinit.encode())} B)") +dans = D[1:] +check(dans[0][1] == pre[0][1] and not is_ref(dans[0][1]), "(J) the session's first answer is the inline answer, byte-identical to the pre-read one") +drefs = [(i, t) for i, (_, t) in enumerate(dans) if i > 0 and t.lstrip().startswith("<")] +DNREF = sum(1 for _, t in drefs if is_ref(t)) +dinline = [CALLS[i % N][0] for i, t in drefs if not is_ref(t) and t != pre[i % N][1]] +check(DNREF >= 12 and not dinline, f"(J) every later XML answer is ref ({DNREF}) or the inline answer byte for byte (third shapes: {dinline})") +core0 = "" +firstref = next((t for _, t in drefs if is_ref(t)), "") +check(core0 in "".join(comments(firstref)), "(J) the first ref answer carries the dictionary core (the reading of )") +dpost = "".join("".join(comments(t)) for _, t in drefs if is_ref(t)) +dtwice = [e[:60] for e in entries if len(e) > 12 and dpost.count(e) > 1] +check(not dtwice and dpost.count(core0) == 1, f"(J) no dictionary entry reaches the session twice through its ref answers ({dtwice[:3]})") +ddiff = [CALLS[i % N][0] for i, t in drefs if is_ref(t) and pairs(t) != pairs(full_by.get(i % N) if CALLS[i % N][0] != "for" else pre[i % N][1])] +check(not ddiff, f"(J) each ref answer carries the same attribute name=value multiset as its inline twin ({ddiff})") +dheld, dgaps = "", [] +for i, (_, t) in enumerate(dans): + dheld += "".join(comments(t)) + if t.lstrip().startswith("<") and is_ref(t): + dgaps += [f"{CALLS[i % N][0]}@{a}" for a in sorted(roster_hits(t)) if not defined(a, dheld)] +check(not dgaps, f"(J) no roster attribute of a ref answer rides undefined by the bytes this session sent ({dgaps[:6]})") +dlong = [f"{CALLS[i % N][0]} {len(t)}>{len(pre[i % N][1])}" for i, t in drefs if is_ref(t) and len(t) > len(pre[i % N][1])] +check(not dlong, f"(J) every ref answer <= the inline answer it replaces ({dlong})") +dill = [] +for i, t in drefs: + if not is_ref(t): continue + try: + ET.fromstring(t) + if "\n" in strip_cdata(t).rstrip("\n"): dill.append(CALLS[i % N][0] + ":newline") + except ET.ParseError as e: + dill.append(f"{CALLS[i % N][0]}:{e}") +check(not dill, f"(J) every default-session ref answer is well-formed G4 XML ({dill})") +DI = session([CALLS[0]] + [(v, dict(a, legend="compact")) for v, a in CALLS if v != "for"], server=DEF) +check([t for _, t in DI[2:]] == pre_nf, "(J) legend:\"compact\" in an open default session keeps that answer inline, byte-identical") +r = subprocess.run([BIN, "--mcp-legend=inline"], capture_output=True, text=True) +r2 = subprocess.run([BIN, "--mcp", "--mcp-legend=bogus"], capture_output=True, text=True, input="") +check(r.returncode != 0 and r2.returncode != 0, f"(J) --mcp-legend without --mcp, and an unknown value, refuse (rc={r.returncode}/{r2.returncode})") + print(f"legendrefcheck: {'FAIL' if fails else 'PASS'} ({len(fails)} failing)") sys.exit(1 if fails else 0) PY diff --git a/test/legobundlecheck.sh b/test/legobundlecheck.sh index 0397ddd55..a4aa005bb 100755 --- a/test/legobundlecheck.sh +++ b/test/legobundlecheck.sh @@ -69,8 +69,16 @@ ONTASK="Circle Square shape area implementors" # without the flag every row below would see the counted stub instead (test/forsectioncollapsecheck.sh owns # THAT shape). Byte-identical to the pre-L2 default otherwise. "$BIN" . --no-cache --for="$OFFTASK" --sections=lego,compose >"$TMP/off.xml" 2>/dev/null -"$BIN" . --no-cache --for="$ONTASK" --sections=lego,compose >"$TMP/on.xml" 2>/dev/null -"$BIN" . --no-cache --for="$ONTASK" --sections=lego,compose >"$TMP/on2.xml" 2>/dev/null +# The on-task corpus excludes the product's own src/ (train 26b, 2026-10-08): over the whole checkout the answer's shown set +# is decided by this repo's own src — on the merged train-26b tree src/'s lego/compose/countfloor code outranks and out-budgets +# test/legofix (20 rows shown, legofix's Circle at rank 24; the d82e4cf1 binary gives the same 20 on that tree and 23 on its +# own, so the move is the tree's content, not the binary). The arm asserts the §P3 filter over a tree full of fixture +# interfaces, which test/ still is; the premise below fails by name if the fixture ever leaves the shown set again. +"$BIN" . --no-cache --for="$ONTASK" --sections=lego,compose --exclude=src/ >"$TMP/on.xml" 2>/dev/null +"$BIN" . --no-cache --for="$ONTASK" --sections=lego,compose --exclude=src/ >"$TMP/on2.xml" 2>/dev/null +grep -q ']*p="test/legofix/shapes.h"' "$TMP/on.xml" \ + && ok "--for=\"$ONTASK\" (premise): test/legofix/shapes.h is in the shown rows the lego block is narrowed to" \ + || no "--for=\"$ONTASK\" (premise): test/legofix/shapes.h is not among the shown rows — the on-task arm below would test another corpus" for f in off on; do [ -s "$TMP/$f.xml" ] || no "bundle $f.xml is empty — the rest of this gate is meaningless" diff --git a/test/lintbudgetcheck.sh b/test/lintbudgetcheck.sh index badc04e84..df4a1a4d0 100755 --- a/test/lintbudgetcheck.sh +++ b/test/lintbudgetcheck.sh @@ -123,5 +123,91 @@ case "$crow_user" in esac rm -rf "$COLLIDE" +# ── 5. THE FLOORED RULE NAMES ITS CALL (knob-honesty-068) ─────────────────────────────────────────────── +# A rule that spends its kLintMaxPerRule budget was disclosed (count_capped="1", findings_capped="1"), but the answer +# named no call that counts the rest: the budget had no flag, so the floor was a dead end on the CLI and in the SARIF +# a CI reads. Now --lint-max-per-rule=N raises the budget (the default stays kLintMaxPerRule), and a floored answer +# carries findings_next= (SARIF: run properties findingsNext) — the floored rules only, under a 10x budget. Fixture: +# one C file with 6000 gotos (raw captures past the 5000 default). RED on 255dc199 (no findings_next=, no flag). +GF="$TMP/gotofix"; mkdir -p "$GF" +python3 -c 'import sys; open( sys.argv[1], "w" ).write( "void f( void )\n{\nL: ;\n" + " goto L;\n" * 6000 + "}\n" )' "$GF/g.c" +python3 -c 'import sys; open( sys.argv[1], "w" ).write( "void f( void )\n{\nL: ;\n" + " goto L;\n" * 10 + "}\n" )' "$GF/../small.c" +mkdir -p "$TMP/smallfix"; mv "$GF/../small.c" "$TMP/smallfix/s.c" +lattr(){ printf '%s' "$2" | grep -oE "]*" | head -1 | grep -oE "(^| )$1=\"[^\"]*\"" | head -1 | sed -E 's/^ ?[a-z_]+="//; s/"$//'; } +grow(){ printf '%s' "$1" | grep -oE "" | head -1; } +G1="$( "$BIN" "$GF" --lint --no-cache 2>/dev/null )" +case "$( grow "$G1" goto )" in + *'count="5000"'*' count_capped="1"'* ) ok "goto fixture: presence guard — 6000 gotos, count=5000 count_capped=\"1\" (the default budget is unchanged)" ;; + * ) no "goto fixture: presence guard failed: $( grow "$G1" goto )" ;; +esac +GNEXT="$( lattr findings_next "$G1" )" +[ "$GNEXT" = "--lint --lint-select=goto --lint-max-per-rule=50000" ] \ + && ok "floored: the root names its call, findings_next=\"$GNEXT\" (the floored rule only, a 10x budget)" \ + || no "floored: findings_next='$GNEXT' (want --lint --lint-select=goto --lint-max-per-rule=50000)" +# paste it: the floor becomes the total, and that answer carries no findings_next (nothing left to recover) +G2="$( [ -n "$GNEXT" ] && "$BIN" "$GF" $GNEXT --no-cache 2>/dev/null )" +case "$( grow "$G2" goto )" in + *'count="6000"'* ) case "$( grow "$G2" goto )" in *count_capped*) no "pasted: goto still count_capped: $( grow "$G2" goto )";; *) ok "pasted: goto count=\"6000\" — the floor became the total";; esac ;; + * ) no "pasted findings_next did not count all 6000: $( grow "$G2" goto )" ;; +esac +[ -n "$G2" ] && [ -z "$( lattr findings_next "$G2" )" ] && [ -z "$( lattr findings_capped "$G2" )" ] \ + && ok "pasted: the uncapped answer carries neither findings_capped= nor findings_next=" \ + || no "pasted: findings_capped='$( lattr findings_capped "$G2" )' findings_next='$( lattr findings_next "$G2" )' on the re-run" +# negatives: nothing floored ⇒ no findings_next; the flag only raises (an explicit budget under the default floors sooner) +[ -z "$( lattr findings_next "$( "$BIN" "$TMP/smallfix" --lint --no-cache 2>/dev/null )" )" ] \ + && ok "negative: an unfloored answer carries no findings_next=" || no "negative: findings_next= on an answer nothing floored" +case "$( grow "$( "$BIN" "$TMP/smallfix" --lint --lint-max-per-rule=4 --no-cache 2>/dev/null )" goto )" in + *'count="4"'*' count_capped="1"'* ) ok "negative: --lint-max-per-rule=4 floors 10 gotos at 4 (the flag IS the budget, both ways)" ;; + * ) no "--lint-max-per-rule=4 not honoured on 10 gotos" ;; +esac +"$BIN" "$GF" --lint-max-per-rule=50000 --no-cache >/dev/null 2>&1; [ $? = 1 ] \ + && ok "refused: --lint-max-per-rule without --lint/--lint-rules exits 1 (never accepted and ignored)" \ + || no "--lint-max-per-rule without --lint was not refused" +"$BIN" "$GF" --lint --lint-max-per-rule=0 --no-cache >/dev/null 2>&1; [ $? = 1 ] \ + && ok "refused: --lint-max-per-rule=0 exits 1" || no "--lint-max-per-rule=0 was accepted" +# CodeRabbit 5468003465: the flag's ceiling (cli.h kLintMaxPerRuleMost = min(kIntFlagMax, SIZE_MAX/200)) keeps the 20x pack +# budget and the 10x findings_next= continuation inside size_t, and the continuation saturates AT it (lintNextMaxPerRule; both +# stated by static_asserts in verbs_lint.h lintPackQueryBudget). Where size_t is 64 bits the ceiling IS kIntFlagMax, so the +# accepted domain did not move: the twin below holds that (1e9 answers, 1e9+1 refuses). On a 32-bit size_t the ceiling is +# lower by design, so the twin's premise is the word size, checked by name. +if [ "$( getconf LONG_BIT 2>/dev/null )" = 64 ]; then + "$BIN" "$TMP/smallfix" --lint --lint-max-per-rule=1000000000 --no-cache >/dev/null 2>&1; [ $? = 0 ] \ + && ok "ceiling twin (64-bit): --lint-max-per-rule=1000000000 still answers" || no "--lint-max-per-rule=1000000000 refused on a 64-bit build" + "$BIN" "$TMP/smallfix" --lint --lint-max-per-rule=1000000001 --no-cache >/dev/null 2>&1; [ $? = 1 ] \ + && ok "ceiling twin (64-bit): --lint-max-per-rule=1000000001 refuses (exit 1)" || no "--lint-max-per-rule=1000000001 was accepted" +else + printf ' SKIP ceiling twin — getconf LONG_BIT is not 64 (the ceiling is SIZE_MAX/200 there by design)\n' +fi +# SARIF: the CI surface carries the same call (run properties), beside findingsCapped +GS="$( "$BIN" "$GF" --lint --sarif --no-cache 2>/dev/null )" +printf '%s' "$GS" | python3 -c ' +import json, sys +d = json.load( sys.stdin ); p = d["runs"][0]["properties"] +sys.exit( 0 if p.get( "findingsCapped" ) is True and p.get( "findingsNext" ) == "--lint --lint-select=goto --lint-max-per-rule=50000 --sarif" else 1 )' 2>/dev/null \ + && ok "SARIF: run properties carry findingsCapped=true and the same findingsNext, --sarif kept" \ + || no "SARIF: run properties lack findingsNext (with --sarif): $( printf '%s' "$GS" | grep -oE '"properties":\{"findingsCapped"[^}]*' | head -1 )" +# …and PASTING it (shlex-split, as a CI step would) re-runs in the dialect it was read from (rv-knob-honesty-068 N4): the +# answer parses as SARIF (a native-XML answer would not), the floor is lifted and no findingsNext rides. RED on dd6e4c8e. +GSN="$( printf '%s' "$GS" | python3 -c 'import json,sys; print(json.load(sys.stdin)["runs"][0]["properties"].get("findingsNext",""))' 2>/dev/null )" +python3 -c 'import shlex,sys; print( "\0".join( shlex.split( sys.argv[1] ) ), end = "" )' "$GSN" > "$TMP/gsn.argv" +( [ -n "$GSN" ] && xargs -0 "$BIN" "$GF" --no-cache < "$TMP/gsn.argv" 2>/dev/null ) | python3 -c ' +import json, sys +p = json.load( sys.stdin )["runs"][0]["properties"] +sys.exit( 0 if p.get( "findingsCapped" ) is False and "findingsNext" not in p else 1 )' 2>/dev/null \ + && ok "SARIF: pasting findingsNext answers in SARIF, findingsCapped=false and no findingsNext" \ + || no "SARIF: pasting findingsNext ('$GSN') did not answer as SARIF with the floor lifted" +# user rules: the call keeps --lint-rules=DIR and selects the floored rule id +mkdir -p "$TMP/urules"; printf -- '- id: many-goto\n language: c\n severity: warn\n message: goto\n query: (goto_statement) @hit\n' > "$TMP/urules/g.yml" +U1="$( "$BIN" "$GF" --lint-rules="$TMP/urules" --no-cache 2>/dev/null )" +UNEXT="$( lattr findings_next "$U1" )" +[ "$UNEXT" = "--lint-rules=$TMP/urules --lint-select=many-goto --lint-max-per-rule=50000" ] \ + && ok "user rules: findings_next keeps the rules directory and selects the floored id" \ + || no "user rules: findings_next='$UNEXT'" +U2="$( [ -n "$UNEXT" ] && "$BIN" "$GF" $UNEXT --no-cache 2>/dev/null )" +URow="$( printf '%s' "$U2" | grep -oE '' | head -1 )" +case "$URow" in *'count="6000"'*) ! printf '%s' "$URow" | grep -q 'count_capped' ;; *) false ;; esac \ + && ok "user rules: pasting it counts all 6000, and that row is no longer floored" || no "user rules: pasted run: $URow" +if printf '%s' "$G1" | xmllint --noout - 2>/dev/null; then ok "floored answer is well-formed XML"; else no "floored answer is not well-formed XML"; fi + [ "$fail" = 0 ] && echo "ALL PASS" || echo "FAILURES ABOVE" exit $fail diff --git a/test/localitycheck.sh b/test/localitycheck.sh index abf15765d..8525e3c11 100755 --- a/test/localitycheck.sh +++ b/test/localitycheck.sh @@ -30,8 +30,8 @@ # 6b control — a `std::unique_ptr` member, which Rule 2b reads since test/fieldnarrowcheck.sh arm p, narrows # `owned_->pick()` to Target::pick alone (receiver-rule). Arm 6 held that member until then, as the unreadable type. # 7 a TYPED receiver whose type defines no such method (`Shape& other`) splits (RED: Decoy::pick) -# 8 control — the FILE credit stays: {the caller itself, Target::peek} still pins Target::peek by locality, with -# the disclosure (census mech=locality). Skipping the tie-break for these receivers moved no target on seven +# 8 control — {the caller itself, Target::peek} answers Target::peek; since FE-B the call is name-only, so the +# disclosure is via="name" on the row (census mech=unique), no longer the locality pin. Skipping the tie-break for these receivers moved no target on seven # corpora either, but relabelled every such site `unique` and dropped its lpin= disclosure. # 9 control — a typed receiver Rule 2 narrows is untouched (receiver-rule, Target::pick alone) # Exits non-zero on any failure. @@ -163,9 +163,14 @@ expectSite "(6)" Wrapper::forward pick "pick@1 pick@15 pick@6" split expectSite "(6b)" Wrapper::forwardOwned pick "pick@1" receiver-rule # ── 7) a TYPED receiver whose type defines no `pick`: Rule 2 declines, CHA-lite's cone keeps nothing and degrades. expectSite "(7)" Decoy::typedNoMethod pick "pick@1 pick@15 pick@6" split -# ── 8) control — the FILE credit stays: the tier is {Decoy::peek itself, Target::peek}; the caller scores zero, so -# Target::peek is the locality pick and stays disclosed as one. -expectSite "(8)" Decoy::peek peek "peek@1" locality +# ── 8) control — the target stays: the tier is {Decoy::peek itself, Target::peek}; the caller is never its own answer, so +# Target::peek is the one row. Since FE-B (test/receiverevidencecheck.sh) an untyped receiver's call is NAME-ONLY and +# never reaches the tie-break, so the disclosure moved from the census's `locality` pin to the row itself: via="name" +# (the census labels a lone name-only target `unique`, as it labels a lone global). +expectSite "(8)" Decoy::peek peek "peek@1" unique +"$BIN" "$RFIX" --callees=Decoy::peek --no-cache 2>/dev/null | grep -q '' \ + && ok "(8) Decoy::peek(): the Target::peek row says via=\"name\" — the pick is disclosed on the row" \ + || no "(8) Decoy::peek(): the Target::peek row is not marked via=\"name\"" # ── 9) control — Rule 2 narrows a typed local before the tie-break: one precise edge, untouched. expectSite "(9)" Decoy::typedLocal pick "pick@1" receiver-rule diff --git a/test/loopconservationcheck.sh b/test/loopconservationcheck.sh index 1b78f260f..3b3858edb 100755 --- a/test/loopconservationcheck.sh +++ b/test/loopconservationcheck.sh @@ -23,7 +23,7 @@ # # THE CHECK (arm A). Walk EVERY commit in BASE..REF that touched test/regression.sh, and compare each # one against EACH of its OWN parents — one parent for an ordinary commit, two or more for a merge. -# The merge case is not lost, it is simply the len(parents)>1 case of the same loop; arm (C) proves it +# The merge case is not lost, it is simply the len(parents)>1 case of the same loop; arm (E) proves it # still fires. For a commit C with parent P: # # vanished = loop(P) - loop(C) - tombstones_new(C) @@ -31,11 +31,14 @@ # Any name present in a parent's loop, absent from the child's, and not declared retired IN THAT SAME # COMMIT is named and fails. # -# THE WINDOW. BASE is the merge-base of REF with its upstream tracking ref (falling back to -# origin/main, then origin/HEAD). If no upstream resolves, or the merge-base IS REF — the ordinary -# state on a freshly-pushed main, where the window would otherwise be empty and the gate would be -# inert all over again — BASE falls back to REF's first parent, so the newest commit is always -# examined. A root commit with no parents at all passes trivially and says so. +# THE WINDOW. BASE is the merge-base of REF with the first of: its upstream tracking ref, origin/main, +# origin/HEAD that is NOT already at or ahead of REF. A candidate whose merge-base IS REF is skipped, not +# final: a CI checkout of a branch tracks its own pushed copy, and stopping there judged only the tip +# (train 26b, 2026-10-08: workflow_dispatch green over 0 loop-touching commits while pull_request, detached +# at refs/pull/N/merge with no upstream, fell through to origin/main and judged 25 — arm I). Only when +# every candidate contains REF — the ordinary state on a freshly-pushed main, where the window would +# otherwise be empty and the gate inert all over again — does BASE fall back to REF's first parent, so +# the newest commit is always examined. A root commit with no parents at all passes trivially and says so. # # THE TOMBSTONE CONVENTION: a deliberate removal is not a violation, but it must be DECLARED, not merely # absent. In the SAME commit that drops NAME from the loop, add a standalone comment line anywhere in @@ -47,15 +50,24 @@ # vanish in that commit (either NAME is still in the loop, or NAME was never in the parent's loop) — a # tombstone nobody needed is itself worth noticing. Only NEWLY added tombstones are judged: a tombstone # is permanent once written, so judging the whole accumulated set at every commit would fire forever -# after the first legitimate retirement. +# after the first legitimate retirement. In a merge, a tombstone ANOTHER parent already carries is that +# side's retirement arriving, not a new declaration: against a parent whose loop never had NAME (and with +# NAME absent from the child's loop too) it is not judged stale (arms F/F2 green; the same merge with no +# parent carrying it, arm G, and a merge that keeps NAME in its loop under an inherited tombstone, arm H, +# stay red). The vanish side is unchanged: still per parent, against tombstones that parent lacks. # -# THE MUTATION CONTROLS (arms B, C, D) — this gate spent months green while inert, so it now proves on +# THE MUTATION CONTROLS (arms B..I) — this gate spent months green while inert, so it now proves on # every run that its walker can still go red. Each arm builds a throwaway git repository under a temp # directory (NOT this one: no object, ref, index or working-tree state of the repo under test is -# touched) with a known-bad history and requires arm A's own walker to name the violation: -# (B) single-parent silent drop → must be RED (the failure the old gate missed entirely) -# (C) two-parent merge drop → must be RED (the failure the old gate did catch — preserved) -# (D) the same drop WITH a tombstone → must be GREEN, and a tombstone matching no vanish → RED +# touched) with a known history and requires arm A's own walker to name the violation (or stay green): +# (B) single-parent silent drop → must be RED (the failure the old gate missed entirely) +# (C) the same drop WITH a tombstone → must be GREEN +# (D) a tombstone matching no vanish → must be RED +# (E) two-parent merge drop → must be RED (the failure the old gate did catch — preserved) +# (F, F2) merge inheriting a tombstone from either parent, other parent never had NAME → must be GREEN +# (G) the same merge, tombstone carried by NO parent → must be RED +# (H) merge keeping NAME in its loop under a tombstone one parent carries → must be RED +# (I) window derivation on a branch tracking its own pushed copy reaches origin/main → RED on a drop # # WHY docs/EVALS.md's gate-count pins do NOT get this same treatment (deliberately, not an oversight): # manifestcheck.sh already ties every "N gate scripts" / "loop ... names N" claim in EVALS.md to the @@ -151,20 +163,28 @@ def walk( repo, base, ref, path = "test/regression.sh" ): skipped.append( "%s (child has no `for _g in ...; do` loop)" % child[ :12 ] ) continue childTombs = tombstones( childText ) - for par in parents_of( repo, child ): + parents = parents_of( repo, child ) + tombsOf = { par: tombstones( loopAt( par ) ) for par in parents } + for par in parents: parText = loopAt( par ) parLoop = loop_names( parText ) if parLoop is None: skipped.append( "%s^%s (parent pre-dates the loop file)" % ( child[ :12 ], par[ :8 ] ) ) continue pairs += 1 - newTombs = childTombs - tombstones( parText ) + newTombs = childTombs - tombsOf[ par ] vanished = parLoop - childLoop silent = sorted( vanished - newTombs ) if silent: violations.append( "%s (parent %s): %d entr%s vanished from the loop with no `# retired:` tombstone: %s" % ( child[ :12 ], par[ :12 ], len( silent ), "y" if len( silent ) == 1 else "ies", ", ".join( silent ) ) ) - unneeded = sorted( newTombs - vanished ) + # A merge's tombstone that ANOTHER parent already carries is that side's retirement arriving, not + # a declaration made here — exempt it, but only when NAME is in neither this parent's loop nor + # the child's (it cannot be a vanish against this parent, so there is nothing for it to match). + # A NAME still in the child's loop stays red (arm H). For an ordinary commit `inherited` is + # empty, so the rule is exactly the single-parent one. + inherited = set().union( *( tombsOf[ q ] for q in parents if q != par ) ) + unneeded = sorted( t for t in newTombs - vanished if t in childLoop or t not in inherited ) if unneeded: stale.append( "%s (parent %s): tombstone(s) matching no vanish in that commit: %s" % ( child[ :12 ], par[ :12 ], ", ".join( unneeded ) ) ) @@ -184,8 +204,10 @@ def derive_base( repo, ref ): mb = mb.strip() if mb != refSha: return mb, "merge-base with %s" % upstream - # REF is already contained in its upstream: the window would be empty and this gate inert. - break + # REF is already contained in this candidate (a branch tracking its own pushed copy, as every CI + # checkout of a branch does): the window would be empty. Try the next candidate — origin/main + # gives the same window the pull_request event judges (arm I) — not the first-parent fallback. + continue if firstParent: return firstParent, "REF's first parent (no upstream ahead of REF — window would otherwise be empty)" return None, "REF is a root commit" @@ -290,6 +312,70 @@ def buildMerge( repo ): return a, m arm( "(E) mutation control, MERGE drop —", buildMerge, "rightcheck" ) +# ── (F) a merge that INHERITS a tombstone from one parent, the other parent never having had NAME ────── +# The train-26b shape (red on PR #383, 2026-10-08): a lane added spikecheck and retired it inside its own +# life, leaving `# retired: spikecheck`; every later merge brings that tombstone in from the parent that +# carries it. Judged against the OTHER parent (no tombstone, no spikecheck) it used to look like a +# tombstone newly declared for a vanish that never happened. It is the other side's retirement arriving, +# not a new declaration — GREEN, in both parent orders (the train merges lanes as the second parent; the +# GitHub PR merge commit puts main first and the train second). +def buildInheritedTomb( carrierFirst ): + def build( repo ): + a = commitLoop( repo, BASE_NAMES, [] ) + l1 = commitLoop( repo, BASE_NAMES + [ "spikecheck" ], [ a ] ) + l2 = commitLoop( repo, BASE_NAMES, [ l1 ], tombs = [ "spikecheck" ] ) # retired inside the lane + r = commitLoop( repo, BASE_NAMES + [ "othercheck" ], [ a ] ) # never had spikecheck + m = commitLoop( repo, BASE_NAMES + [ "othercheck" ], [ l2, r ] if carrierFirst else [ r, l2 ], + tombs = [ "spikecheck" ] ) + return a, m + return build +arm( "(F) merge inheriting a tombstone, carrier = 2nd parent —", buildInheritedTomb( False ), None ) +arm( "(F2) merge inheriting a tombstone, carrier = 1st parent —", buildInheritedTomb( True ), None ) + +# ── (G) the same merge where NO parent carried the tombstone — the merge itself declared it: RED ─────── +def buildMergeNewTomb( repo ): + a = commitLoop( repo, BASE_NAMES, [] ) + l = commitLoop( repo, BASE_NAMES + [ "leftcheck" ], [ a ] ) + r = commitLoop( repo, BASE_NAMES + [ "othercheck" ], [ a ] ) + m = commitLoop( repo, BASE_NAMES + [ "leftcheck", "othercheck" ], [ r, l ], tombs = [ "spikecheck" ] ) + return a, m +arm( "(G) mutation control, MERGE declares a tombstone no parent carried —", buildMergeNewTomb, "spikecheck" ) + +# ── (H) a merge that keeps NAME in the loop while one parent carries its tombstone: RED ─────────────── +# One side retired spikecheck, the other side still runs it, and the resolution kept it in the loop under +# the inherited tombstone. Judged against the side without the tombstone that is a tombstone whose NAME +# is still in the loop — the exception (F) makes is only for a NAME absent from both this parent's loop +# and the child's, so this stays red. +def buildMergeKeepsRetired( repo ): + a = commitLoop( repo, BASE_NAMES + [ "spikecheck" ], [] ) + l = commitLoop( repo, BASE_NAMES, [ a ], tombs = [ "spikecheck" ] ) + r = commitLoop( repo, BASE_NAMES + [ "spikecheck", "othercheck" ], [ a ] ) + m = commitLoop( repo, BASE_NAMES + [ "spikecheck", "othercheck" ], [ r, l ], tombs = [ "spikecheck" ] ) + return a, m +arm( "(H) mutation control, MERGE keeps a NAME one parent retired —", buildMergeKeepsRetired, "spikecheck" ) + +# ── (I) the WINDOW: a branch whose upstream is its own pushed copy still reaches back to origin/main ─── +# A CI checkout of a branch (workflow_dispatch, push) tracks origin/ == REF, so the merge-base with +# that upstream IS REF. Stopping there and falling back to REF's first parent judged ONE commit — the +# dispatch run on train 26b was green over a window of zero loop-touching commits while the pull_request +# run (detached at refs/pull/N/merge, no upstream → origin/main) judged all 25. The derived window must +# be the same under both events: an earlier silent drop on the branch is named. +def buildTrackedBranch( repo ): + a = commitLoop( repo, BASE_NAMES + [ "victimcheck" ], [] ) + b = commitLoop( repo, BASE_NAMES, [ a ] ) # silent drop, one commit below the tip + c = commitLoop( repo, BASE_NAMES + [ "newcheck" ], [ b ] ) # harmless tip that touches the loop + for args in ( [ "update-ref", "refs/remotes/origin/main", a ], + [ "update-ref", "refs/remotes/origin/feature", c ], + [ "update-ref", "refs/heads/feature", c ], + [ "config", "remote.origin.url", "file:///nonexistent.invalid" ], + [ "config", "remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*" ], + [ "config", "branch.feature.remote", "origin" ], + [ "config", "branch.feature.merge", "refs/heads/feature" ] ): + git( repo, args ) + b_, _why = derive_base( repo, "feature" ) + return ( b_ or c ), "feature" +arm( "(I) mutation control, WINDOW on a branch tracking its own pushed copy —", buildTrackedBranch, "victimcheck" ) + sys.exit( bad ) PY diff --git a/test/lpincheck.sh b/test/lpincheck.sh index e2870c807..b0f62d5da 100755 --- a/test/lpincheck.sh +++ b/test/lpincheck.sh @@ -13,11 +13,17 @@ # re-applies the `Graph::localityKey` tie-break (an unscoped def is compared as `path::name`, not its bare # name) so a module-level function is no longer auto-lost to a same-file class method. # -# THE FIXTURE (test/lpinfix/, 3 Kotlin files — Python until FE-A, whose bare call reaches no method: test/falseedgecheck.sh): -# pinned.kt — `Alpha.run` -> `helper()`; `Alpha.helper` beats `Beta.helper` by scope: ONE edge, `lpin="1"`, no `amb=`. +# THE FIXTURE (test/lpinfix/: Kotlin — Python until FE-A, whose bare call reaches no method: test/falseedgecheck.sh — and +# one Objective-C file, a language FE-B's receiver evidence does not narrow: test/receiverevidencecheck.sh): +# pinnedcone.kt — `Kappa.run` -> `step()`; Kappa's override beats the `Lambda.step` it inherits by scope: ONE edge, +# `lpin="1"`, no `amb=`. (The pin shape; pinned.kt carried it until FE-B.) +# pinned.kt — `Alpha.run` -> `helper()`; since FE-B the implicit receiver PROVES `Alpha.helper` (`Beta.helper` is an +# unrelated class): ONE edge, no `amb=`, and no `lpin=` — evidence decided, not a prior. # tied.kt — `Eps.go` -> `other()`; sibling classes tie: split, `amb="1"`, no `lpin=`. -# modlevel.kt — `Caller.go` -> `compute()`; `Helper.compute` vs top-level `compute`: a full tie under -# localityKey ⇒ split, `amb="1"`, no `lpin=` (was a silent pin on Helper::compute). +# modlevel.m — `-[Caller go]` -> `compute()`; the C function vs `-[Helper compute]`: a full tie under localityKey ⇒ +# split, `amb="1"`, no `lpin=` (was a silent pin on the method). modlevel.kt carried it until FE-B. +# modlevel.kt — `Caller.go` -> `compute()`; since FE-B the implicit receiver reaches the top-level `compute` and never +# `Helper.compute`: ONE edge to the module-level def, no `amb=`, no `lpin=`. # # Exits non-zero on any failure. @@ -45,13 +51,20 @@ MAP="$( cat "$TMP/map.xml" )" row(){ _n="${1##*::}"; _r="${1#*::}"; _s="${_r%::*}"; printf '%s' "$MAP" | tr '<' '\n' | grep "n=\"$_n\" sc=\"$_s\"" | head -1; } # ── (A) the pin is DISCLOSED on its row, and it is still not an amb ────────────────────────────── -RUN_ROW="$( row 'pinned.kt::Alpha::run' )" -printf '%s' "$RUN_ROW" | grep -q 'lpin="1"' && ok "(A) pinned.kt::Alpha::run carries lpin=\"1\" — the locality pin is disclosed" \ - || no "(A) pinned.kt::Alpha::run has no lpin=\"1\": $RUN_ROW" -printf '%s' "$RUN_ROW" | grep -q 'amb=' && no "(A) pinned.kt::Alpha::run carries amb= — the marker inflated amb=: $RUN_ROW" \ +RUN_ROW="$( row 'pinnedcone.kt::Kappa::run' )" +printf '%s' "$RUN_ROW" | grep -q 'lpin="1"' && ok "(A) pinnedcone.kt::Kappa::run carries lpin=\"1\" — the locality pin is disclosed" \ + || no "(A) pinnedcone.kt::Kappa::run has no lpin=\"1\": $RUN_ROW" +printf '%s' "$RUN_ROW" | grep -q 'amb=' && no "(A) pinnedcone.kt::Kappa::run carries amb= — the marker inflated amb=: $RUN_ROW" \ || ok "(A) the pin still contributes nothing to amb=" +N_STEP="$( printf '%s' "$MAP" | tr '>' '\n' | awk '/n="run" sc="Kappa"/{f=1} f{print} /\/s/{if(f)exit}' | grep -c 'n="step"' )" +if [ "$N_STEP" = 1 ]; then ok "(A) the pin still emits ONE confident edge"; else no "(A) $N_STEP step edges on Kappa::run, want 1"; fi +# FE-B: the shape pinned.kt carried is now EVIDENCE (an implicit `this` never reaches the unrelated Beta) — still ONE +# edge and no amb=, and no lpin= because no prior picked it +RUN_ROW="$( row 'pinned.kt::Alpha::run' )" +printf '%s' "$RUN_ROW" | grep -q 'lpin=\|amb=' && no "(A2) pinned.kt::Alpha::run carries lpin=/amb= — the implicit receiver should decide it: $RUN_ROW" \ + || ok "(A2) pinned.kt::Alpha::run: neither lpin= nor amb= — the implicit receiver decided" N_HELPER="$( printf '%s' "$MAP" | tr '>' '\n' | awk '/n="run" sc="Alpha"/{f=1} f{print} /\/s/{if(f)exit}' | grep -c 'n="helper"' )" -if [ "$N_HELPER" = 1 ]; then ok "(A) the pin still emits ONE confident edge"; else no "(A) $N_HELPER helper edges on Alpha::run, want 1"; fi +if [ "$N_HELPER" = 1 ]; then ok "(A2) ONE helper edge on Alpha::run"; else no "(A2) $N_HELPER helper edges on Alpha::run, want 1"; fi # ── (B) the tied control — a split is not a pin ─────────────────────────────────────────────────── GO_ROW="$( row 'tied.kt::Eps::go' )" @@ -61,14 +74,22 @@ printf '%s' "$GO_ROW" | grep -q 'lpin=' && no "(B) tied.kt::Eps::go carries lpin || ok "(B) no lpin= on the split" # ── (C) the module-level shape — a full tie under localityKey, not a silent pin ────────────────── -CALLER_ROW="$( row 'modlevel.kt::Caller::go' )" -printf '%s' "$CALLER_ROW" | grep -q 'amb="1"' && ok "(C) modlevel.kt::Caller::go is an honest split (amb=\"1\") — the module-level def is no longer auto-lost" \ - || no "(C) modlevel.kt::Caller::go is not amb=\"1\" — Helper::compute still silently wins: $CALLER_ROW" -printf '%s' "$CALLER_ROW" | grep -q 'lpin=' && no "(C) modlevel.kt::Caller::go carries lpin= — still pinned: $CALLER_ROW" \ +# the Objective-C twin: an unscoped row (no sc=), read inside its own group +CALLER_ROW="$( printf '%s' "$MAP" | tr '<' '\n' | awk '/^f p="modlevel.m"/{f=1;next} /^f /{f=0} f && /^s [^>]*n="go"/{print; exit}' )" +printf '%s' "$CALLER_ROW" | grep -q 'amb="1"' && ok "(C) modlevel.m: -[Caller go] is an honest split (amb=\"1\") — the module-level def is no longer auto-lost" \ + || no "(C) modlevel.m: -[Caller go] is not amb=\"1\" — the method still silently wins: $CALLER_ROW" +printf '%s' "$CALLER_ROW" | grep -q 'lpin=' && no "(C) modlevel.m: -[Caller go] carries lpin= — still pinned: $CALLER_ROW" \ || ok "(C) no lpin= on the module-level site" -grep -E '^C locality ' "$TMP/c.tsv" | grep -q 'modlevel.kt::Caller::go' \ - && no "(C) the census still labels modlevel.kt::Caller::go locality-pinned" \ - || ok "(C) the census agrees: no locality row for modlevel.kt::Caller::go" +grep -E '^C locality ' "$TMP/c.tsv" | grep -q 'modlevel.m::go' \ + && no "(C) the census still labels modlevel.m::go locality-pinned" \ + || ok "(C) the census agrees: no locality row for modlevel.m::go" +# FE-B: modlevel.kt's implicit receiver resolves it to the top-level compute — one plain edge, no split, no pin +CALLER_ROW="$( row 'modlevel.kt::Caller::go' )" +printf '%s' "$CALLER_ROW" | grep -q 'amb=\|lpin=' && no "(C2) modlevel.kt::Caller::go carries amb=/lpin= — the implicit receiver should decide it: $CALLER_ROW" \ + || ok "(C2) modlevel.kt::Caller::go: neither amb= nor lpin= — the implicit receiver decided" +grep -E '^C unique ' "$TMP/c.tsv" | grep 'modlevel.kt::Caller::go' | grep -q ' modlevel.kt::compute#' \ + && ok "(C2) the census binds modlevel.kt::Caller::go to the top-level compute alone" \ + || no "(C2) the census does not bind modlevel.kt::Caller::go to the top-level compute alone: $( grep 'modlevel.kt::Caller::go' "$TMP/c.tsv" )" # ── (D) the header counter, and its equality with the census ────────────────────────────────────── HDR="$( printf '%s' "$MAP" | grep -o '' | head -1 )" @@ -134,9 +155,11 @@ class Subscriber: PYEOF "$BIN" "$SELFD" --no-cache >"$TMP/selfwin.xml" 2>/dev/null SW="$( sed 's/>\n stands for N edges (serialize.h writeMapCalleeRows), so edges are summed +SW_EDGES="$( printf '%s' "$SW" | grep -o ']*>' | awk '{ n = 1; if ( match( $0, / x="[0-9]+"/ ) ) n = substr( $0, RSTART + 4, RLENGTH - 5 ) + 0; s += n } END { print s + 0 }' )" +[ "$SW_EDGES" = 2 ] \ && ok "(I) the facade keeps BOTH real targets (2 edges) — the caller no longer wins its own tie-break" \ - || no "(I) facade.py::Facade::publish_event has $( printf '%s' "$SW" | grep -c '/dev/null | grep -o '' | head -1 )" -printf '%s' "$RESH" | grep -q ' edges=0 ' \ - && ok "(I) stated floor holds: a caller alone in the SAME-FILE tier still yields no edge (tier 1 is untouched)" \ - || no "(I) the tier-1 residual moved — update this arm AND src/graph.h's stated floor: $RESH" +printf '%s' "$RESH" | grep -q ' edges=1 ' \ + && ok "(I) the name-only residual lists ONE edge (the cross-file candidate; the caller's own def stays a dropped self-loop)" \ + || no "(I) the tier-1 residual moved again — update this arm AND src/graph.h's stated floor: $RESH" +RESC="$( cd "$RESD" && "$BIN" . --no-cache --callees=a.py:ping 2>/dev/null )" +printf '%s' "$RESC" | grep -q '' \ + && ok "(I) …and that edge is HEDGED: --callees marks Beta.ping via=\"name\" (no confident cross-file edge)" \ + || no "(I) the residual's cross-file edge is not marked via=\"name\": $( printf '%s' "$RESC" | grep -o ']*/>' | tr '\n' ' ' )" # ── (H) determinism + well-formedness ───────────────────────────────────────────────────────────── # L1 (2026-09-19): the --pin-census run above asks for the full legend; the rerun it is compared to asks for the same. diff --git a/test/lpinfix/modlevel.kt b/test/lpinfix/modlevel.kt index 5eb40af51..a2c61008a 100644 --- a/test/lpinfix/modlevel.kt +++ b/test/lpinfix/modlevel.kt @@ -1,11 +1,10 @@ // The phase-3b repro: a TOP-LEVEL function against a same-file class method. // -// `Caller.go` bare-calls `compute()`. Two defs answer: `Helper.compute` (canonical id -// `modlevel.kt::Helper::compute`) and the top-level `compute` — whose canonical id degrades to the BARE -// NAME `compute`, sharing ZERO segments with any caller. Before phase 4 S6-C pinned `Helper::compute` -// silently on that asymmetry. With `Graph::localityKey` (`modlevel.kt::compute` for the unscoped def) both -// candidates share exactly `modlevel.kt::` — a full tie — so the call is an honest split: `amb="1"` on -// `Caller::go`, no `lpin=`, and `ambiguous=` counts it. (Kotlin: Python, the vehicle until FE-A, now binds the module def.) +// `Caller.go` bare-calls `compute()`. Before phase 4 S6-C pinned `Helper::compute` silently; phase 4's +// `Graph::localityKey` made it a full tie and an honest split. Since FE-B (test/receiverevidencecheck.sh) Kotlin's +// implicit receiver decides it: a bare call inside `Caller` reaches Caller's own members, its bases, or a top-level +// function — never the unrelated `Helper.compute`. ONE plain edge to the top-level `compute`, no `amb=`, no `lpin=`: +// the module-level def is not lost, it is the answer. The honest-split shape lives on in modlevel.m (Objective-C). class Caller { fun go(): Int { return compute() diff --git a/test/lpinfix/modlevel.m b/test/lpinfix/modlevel.m new file mode 100644 index 000000000..cc931792b --- /dev/null +++ b/test/lpinfix/modlevel.m @@ -0,0 +1,30 @@ +// The phase-3b repro, in a language FE-B (test/receiverevidencecheck.sh) does not narrow: a C FUNCTION against a +// same-file Objective-C method. `-[Caller go]` bare-calls `compute()`; the C function `compute` and `-[Helper compute]` +// share exactly `modlevel.m::` with the caller under Graph::localityKey — a full tie — so the call stays an honest split: +// `amb="1"` on `go`, no `lpin=`, and `ambiguous=` counts it. (modlevel.kt was this shape until FE-B: Kotlin's implicit +// receiver now proves the top-level `compute` and never `Helper.compute`, so that file pins the new resolution instead.) +#import + +int compute(void) { + return 6; +} + +@interface Caller : NSObject +- (int)go; +@end + +@interface Helper : NSObject +- (int)compute; +@end + +@implementation Caller +- (int)go { + return compute(); +} +@end + +@implementation Helper +- (int)compute { + return 5; +} +@end diff --git a/test/lpinfix/pinned.kt b/test/lpinfix/pinned.kt index a048607e1..164898e4b 100644 --- a/test/lpinfix/pinned.kt +++ b/test/lpinfix/pinned.kt @@ -1,9 +1,7 @@ -// The S6-C locality tie-break's PINNING shape (same as test/pincensusfix/pinned.kt). -// -// `Alpha.run` makes a BARE (implicit-`this`) `helper()` call. `Alpha.helper` and `Beta.helper` both live in THIS file, -// so the same-file rung keeps both and S6-C decides: `pinned.kt::Alpha::` beats `pinned.kt::`. ONE confident edge, NO -// `amb=` — and, since phase 4 (docs/EVALS.md "Phase 4"), `lpin="1"` on the caller row: the pin is a prior's guess and -// the map now says so instead of dressing it as an evidence-backed resolution. (Python until FE-A, see pincensusfix.) +// Was the S6-C locality tie-break's PINNING shape (same as test/pincensusfix/pinned.kt); since FE-B +// (test/receiverevidencecheck.sh) it is EVIDENCE: `Alpha.run` makes a BARE (implicit-`this`) `helper()` call, and an +// implicit `this` reaches Alpha's own members and its bases, never the unrelated `Beta.helper`. ONE confident edge to +// `Alpha.helper`, NO `amb=`, and NO `lpin=` — nothing was left for a prior to pick. The pin itself is pinnedcone.kt. class Alpha { fun helper(): Int { return 1 diff --git a/test/lpinfix/pinnedcone.kt b/test/lpinfix/pinnedcone.kt new file mode 100644 index 000000000..c13e01704 --- /dev/null +++ b/test/lpinfix/pinnedcone.kt @@ -0,0 +1,19 @@ +// The S6-C locality tie-break's PINNING shape, as FE-B (test/receiverevidencecheck.sh) leaves it. +// +// `Kappa.run` makes a BARE (implicit-`this`) `step()` call. The receiver is `this`, so only Kappa's own `step` and the +// one it inherits from `Lambda` can answer — both are proven candidates, both live in THIS file, and S6-C decides: +// `pinnedcone.kt::Kappa::` beats `pinnedcone.kt::Lambda::`. ONE confident edge, NO `amb=`, and `lpin="1"` on the caller +// row: which of an override and its base a call reaches is still a prior's pick, and the map says so. +open class Lambda { + open fun step(): Int { + return 1 + } +} +class Kappa : Lambda() { + override fun step(): Int { + return 2 + } + fun run(): Int { + return step() + } +} diff --git a/test/mcpincrementalcheck.sh b/test/mcpincrementalcheck.sh index 239666aa2..b18be999c 100755 --- a/test/mcpincrementalcheck.sh +++ b/test/mcpincrementalcheck.sh @@ -108,12 +108,23 @@ wait_for_id() { } # ─── the long-lived WARM server ─────────────────────────────────────────────────────────────────── +# lean-answers lane: a stdio session's legend posture is now "ref" after its first answer (--mcp-legend=session, the +# default), so a warm server's later answers carry their legend once per session while a cold server's single answer is +# inline. That posture is orthogonal to what this gate measures (the incremental INDEX), so the byte-identity arms run +# on --mcp-legend=inline (the pre-lane posture, unchanged assertions) and a TWIN warm server on the default posture is +# held to the same tree-state rows (equiv's twin arm below). FIFO="$WORK/in.fifo"; mkfifo "$FIFO" -TMPDIR="$WARMTMP" "$BIN" --mcp <"$FIFO" >"$TMP/warm.out" 2>/dev/null & +TMPDIR="$WARMTMP" "$BIN" --mcp --mcp-legend=inline <"$FIFO" >"$TMP/warm.out" 2>/dev/null & SRV=$! exec 9>"$FIFO" -trap 'exec 9>&- 2>/dev/null; kill "$SRV" 2>/dev/null; rm -rf "$TMP"' EXIT +WARMTMP_D="$TMP/warmtmp_d"; mkdir -p "$WARMTMP_D" +FIFO_D="$WORK/in_d.fifo"; mkfifo "$FIFO_D" +TMPDIR="$WARMTMP_D" "$BIN" --mcp <"$FIFO_D" >"$TMP/warm_d.out" 2>/dev/null & +SRV_D=$! +exec 8>"$FIFO_D" +trap 'exec 9>&- 2>/dev/null; exec 8>&- 2>/dev/null; kill "$SRV" "$SRV_D" 2>/dev/null; rm -rf "$TMP"' EXIT printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize"}' >&9 +printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize"}' >&8 # ask the WARM server for verb `for` with task $2, response id $1 → sets WTEXT / WSTAMP / WREINGEST. warm_for() { @@ -124,6 +135,11 @@ warm_for() { WTEXT="$( inner_for_id "$TMP/warm.out" "$id" )" WSTAMP="$( stamp_for_id "$TMP/warm.out" "$id" )" WREINGEST="$( reingest_for_id "$TMP/warm.out" "$id" )" + # the default-posture twin server gets the same request (WTEXT_D; empty when it never answered) + printf '{"jsonrpc":"2.0","id":%s,"method":"tools/call","params":{"name":"for","arguments":{"path":"%s","task":"%s"}}}\n' \ + "$id" "$WORK" "$task" >&8 + WTEXT_D="" + wait_for_id "$TMP/warm_d.out" "$id" && WTEXT_D="$( inner_for_id "$TMP/warm_d.out" "$id" )" } # ask a FRESH server (fresh TMPDIR ⇒ no cache blob ⇒ genuine COLD full reingest) → CTEXT / CSTAMP. @@ -132,7 +148,7 @@ cold_for() { local ct; ct="$( mktemp -d "$TMP/cold.XXXXXX" )" printf '%s\n{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"for","arguments":{"path":"%s","task":"%s"}}}\n' \ '{"jsonrpc":"2.0","id":1,"method":"initialize"}' "$WORK" "$task" \ - | TMPDIR="$ct" "$BIN" --mcp >"$ct/out" 2>/dev/null + | TMPDIR="$ct" "$BIN" --mcp --mcp-legend=inline >"$ct/out" 2>/dev/null CTEXT="$( inner_for_id "$ct/out" 7 )" CSTAMP="$( stamp_for_id "$ct/out" 7 )" } @@ -150,6 +166,24 @@ equiv() { no "$what: incremental verb text DIFFERS from a cold full reingest" diff <( printf '%s\n' "$WTEXT" ) <( printf '%s\n' "$CTEXT" ) | head -20 fi + # twin (default session posture): the warm answer is legend="ref" and its rows and root facts equal the cold answer's + local tw + tw="$( printf '%s\n%s\n' "$WTEXT_D" "$CTEXT" | python3 -c ' +import sys, json, re +w, c = [ json.loads( l ) for l in sys.stdin.read().splitlines()[:2] ] +A = re.compile( r"\s([\w:.-]+)=\"([^\"]*)\"" ) +def rows( t ): + m = re.search( r"]*>.*?]*>.*?", t, re.S ); return m.group( 0 ) if m else None # may carry docs_after_code= +ab = re.search( r"]*/>", w ); rt = re.search( r"]*>", c ) +if not ab or "legend=\"ref\"" not in ab.group( 0 ): print( "NOREF" ); sys.exit() +wa = { k: v for k, v in A.findall( ab.group( 0 ) ) if k not in ( "legend", "dict", "dictv" ) } +ca = { k: v for k, v in A.findall( rt.group( 0 ) ) if k != "task" } if rt else None +print( "SAME" if rows( w ) is not None and rows( w ) == rows( c ) and wa == ca else "DIFF" ) +' 2>&1 )" + case "$tw" in + SAME) ok "$what (default legend posture twin): warm legend=\"ref\" answer has the cold answer's rows and root facts";; + *) no "$what (default legend posture twin): warm answer vs cold: $tw";; + esac if [ "$WSTAMP" = "$CSTAMP" ]; then ok "$what: _index stamp matches cold ('$WSTAMP')" else @@ -403,7 +437,8 @@ esac # ═══════════════════════════════════════════════════════════════════════════════════════════════ exec 9>&- -wait "$SRV" 2>/dev/null +exec 8>&- +wait "$SRV" "$SRV_D" 2>/dev/null echo if [ "$fail" -eq 0 ]; then diff --git a/test/mcpmanifestcheck.sh b/test/mcpmanifestcheck.sh index 24d6e1591..e62877ba5 100755 --- a/test/mcpmanifestcheck.sh +++ b/test/mcpmanifestcheck.sh @@ -284,7 +284,33 @@ tools = json.loads( line )[ "result" ][ "tools" ] # description, so each says "value uses (tables, args), not a proven call" (+70 B and +71 B, 141 B in all, no schema # byte moved, no tool added). Headroom after this line: 28 B (the final review tightened a first 46,800 anchor to the # #214-sized margin: 78 B of unattributed headroom was wider than any re-anchor here has taken). -CEILING = 46750 +# RE-ANCHORED 2026-10-04 (lane/lean-answers-068): 46,750 -> 46,850, measured 46,828 (from 46,732, 33 tools). ONE DECLARED +# ARGUMENT, no prose: whereis takes `listing` (defs|refs|all, the CLI --whereis-listing= twin), whose default (defs) lists the +# definitions and counts the references. Attributed: whereis schema 714 -> 810 B (+96 B, the field and its obliged +# description), whereis description unchanged at 701 B, every other tool unchanged. Headroom after this line: 22 B. +# RE-MEASURED 2026-10-07 (lane/lean-answers-068 fix round 1), CEILING UNMOVED at 46,850: 46,828 -> 46,846 B. The listing +# field's description now says what its default does ("defs (refs counted), refs or all; default: the shorter page": +# absent `listing` serves the defs page only when it is strictly shorter than the all page), +18 B in whereis's schema; +# every other tool unchanged. Headroom after this line: 4 B. +# RE-ANCHORED 2026-10-08 (lane/lean-answers-068 fix round 2, review D1): 46,850 -> 46,890, measured 46,869 (from 46,846, +# 33 tools). The default `listing` is no longer "the shorter page": absent `listing` serves the defs page when it lists MORE +# definitions than the all page under the row cap (a capped all page can list fewer), whatever its bytes, and otherwise the +# strictly shorter of the two. The field's description follows the mechanism, "default: the shorter page" -> +# "default: more defs listed, else the shorter page" (+23 B, the minimum that is true). Attributed: whereis schema 828 -> 851 B +# (+23 B), whereis description unchanged at 701 B, every other tool unchanged. Headroom after this line: 21 B. The twin below +# (1c) pins what must not move with it: whereis's description stays 701 B and the listing field says the exact rule. +# RE-ANCHORED 2026-10-07 (lane/cr-qd-kinds-068, the twelfth --quality-delta kind): 46,750 -> 46,800, measured 46,793 (from +# 46,732, 33 tools). The #214 precedent again: the clause describes the RESPONSE. quality_delta's description now +# counts 12 kinds (its "11 measured failure modes" became "12 kinds", -16 B), names the new kind ("a defect shape", +# +16 B), and states the one row family that gates on new code — "new-symbol rows never gate, except defect-shape +# format-arity" (+61 B) — because an MCP client reading "gating" would otherwise take a gating new-symbol row for a +# contradiction of the description. No schema byte moved, no tool added. Headroom after this line: 7 B. +# RE-ANCHORED 2026-10-08 (train 26b, the final regeneration): 46,890 -> 46,950, measured 46,930 (33 tools). The two +# re-anchors above were measured on different bases — lean-answers 46,869 (whereis `listing`), cr-qd-kinds 46,793 (from +# 46,732: the quality_delta description's twelfth kind, +61 B) — and the merged tree carries both: 46,869 + 61 = 46,930, +# every byte attributed (whereis schema 851 B and quality_delta's description as their lanes measured them; no other +# tool moved; knob-honesty and the path-gap fix touch no tool description or schema). Headroom after this line: 20 B. +CEILING = 46950 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 ) @@ -293,6 +319,14 @@ print( " INFO %d tools, manifest %d B (~%d tokens): descriptions %d B, schemas check( manifest <= CEILING, "(1) tools/list is %d B, within the %d B per-session ceiling" % ( manifest, CEILING ) ) +# (1c) TWIN of the ceiling re-anchor (PROCESS rule 4): the growth is the listing field's description and nothing else. +whereisTool = [ t for t in tools if t[ "name" ] == "whereis" ] +check( len( whereisTool ) == 1 and len( whereisTool[ 0 ][ "description" ] ) == 701, + "(1c) whereis's own description is still 701 B (the re-anchor was spent in the listing field, not in prose)" ) +listingDesc = whereisTool[ 0 ][ "inputSchema" ].get( "properties", {} ).get( "listing", {} ).get( "description", "" ) if whereisTool else "" +check( listingDesc.endswith( "default: more defs listed, else the shorter page" ), + "(1c) whereis's listing field states the default rule exactly (got: %r)" % listingDesc ) + # ── (1b) the CHANGELOG's own copy of this figure does not silently rot ────────────────────────────── # train10.md's "manifest anchor" finding: the byte figure above lives in two places — this file's own # RE-ANCHORED/RE-MEASURED comment history (checked live, every run) and CHANGELOG.md's prose restating it diff --git a/test/mcpreloadasancheck.sh b/test/mcpreloadasancheck.sh new file mode 100644 index 000000000..8730922bb --- /dev/null +++ b/test/mcpreloadasancheck.sh @@ -0,0 +1,936 @@ +#!/usr/bin/env bash +# mcpreloadasancheck.sh — ONE long-lived `ripwire --mcp` session on a sanitizer build, put through every index +# REBUILD path 20 times over, with each answer checked against a fresh one-shot CLI run on the same tree state. +# +# THE CLASS. The MCP server keeps one McpIndex for its whole life and rebuilds it IN PLACE when the tree moves. +# Anything derived from the index that holds pointers or ids into it dangles the moment the index is reassigned. +# A cached ValueRefIndex shipped doing exactly that: it outlived the IngestResult it pointed into after a rebuild +# that kept its content stamp (a chmod, a new non-source file) and read freed bindings — a heap-use-after-free that a +# plain build only shows as different rows. It is a LIFETIME bug: no single query shows it, only query → rebuild → +# query on one process does, and only a sanitizer makes it loud. `ix.ing` is rewritten in exactly two places +# (getIndex's rebuild and releaseMcpIndexMemory), so this gate drives both, and every cause that reaches them. +# +# EVERY CACHED VIEW THE INDEX HOLDS (src/mcpindex.h McpIndex), and the query that reads it here: +# ing IngestResult (symbols, references, import bindings) every verb below; fetch_body spans +# g Graph (edges, canonId) find_symbol / find_referencing_symbols / impact / path_between +# valueRefs ValueRefIndex, built lazily, points INTO ing find_symbol valueRefs/valueCallees, find_referencing_symbols, impact, uses, path_between +# rank, prDisclosure PageRank of the graph (+ what the iteration did) analyze, rank_by (pagerank|authority|rrf), under --top-k=6 so rank picks the rows +# workingSetHash, the uncommitted-diff mask the rank was biased to analyze on a DIRTY git root, against a cold server on the same tree +# isCleanWorkingSet +# fileMtime/Size/Ctime, per-file stat + byte-hash arrays, the content stamp fetch_body with a handle minted before a mutation (must refuse, not +# fileByteHash, the handles carry serve the old body) and with a fresh one (must equal the bytes on disk) +# contentHash +# dirMtime, watcher the staleness watch-list and the FS-event watcher the three stamp-keeping mutations below +# incrementalPasses, process history behind _reingest / _fresh asserted through RIPWIRE_MCP_TIMINGS rebuilt= on every request +# lastReingestFiles, +# lastStale/ChangedFiles +# mcpWorkspaceRegistry the multi-root key → roots table find_symbol with `paths` [A,B] +# the ingest cache blob (ix.cacheFile) the warm rebuild reads every rebuild goes through it +# dead-code is a CLI-only verb and holds no index cache; it is read on the CLI side and cross-checked against the +# MCP valueRefs (a function with a value-site is not dead, a static one with neither is). +# +# FIXTURES (generated, never committed: nothing here may move another gate's crawl): JS and TS (import bindings, a +# callback passed to run(), a table), Go (a same-package handler passed and stored), Python (imports, a list, a dict), +# C (a function pointer, a table, static functions). Per language a `gen` file is regenerated every cycle. +# +# ONE CYCLE (k = 1..N), all on one session started with --top-k=6, ASAN_OPTIONS halt/abort + a log dir: +# Q0 query every view on tree A, compare with the CLI on the same state +# M1 a STAMP-KEEPING mutation (k%3: chmod / a new non-source file / an empty directory): content unchanged, so +# contentHash and the reference count survive. Rebuild must fire (rebuilt=1) and every answer must still match. +# This is the arm that went red at the pre-fix head. +# M2 a CONTENT mutation: a gen function added and the previous one removed, a value reference retargeted, written +# by the shell on even cycles and through replace_symbol_body / insert_after_symbol on odd ones (invalidateMcpIndex) +# RS root switch: dirty git root B (its rank checked against a cold server), then the workspace [A,B], then back to A +# RL memory release (cycles 1, 6, 11, ...): --max-memory has a 64M floor and is server-wide, and a sanitizer process +# that has run the whole cycle never falls back under it, so this arm gets its own short process (same binary, +# same options): root R (2400 files) carries the footprint over the floor; the NEXT call on R releases the index +# (releaseMcpIndexMemory) and rebuilds the very same tree, then A again +# Each rebuild is proved by the server's own timing line (rebuilt=1), so an arm that no longer reaches its path FAILs +# instead of passing blind. A mutation that no compared answer shows (the new reference equal to the old) FAILs too. +# +# LeakSanitizer is OFF here on BOTH CI legs (detect_leaks=0 in every sanitizer process of this gate; the ubuntu leg's job-level +# detect_leaks=1 is overridden inside them, and macOS has no LeakSanitizer): the subject is lifetime, not leaks, and a leak +# report at the exit of a long-lived server would be a different finding with its own gate. It is not switched on for Linux +# because the Linux leg has never run this gate, so nothing proves that leg's exit-time leak set is empty, and an unproven +# rc 23 would fail the gate for a reason that is not its subject. Turn it on once the train's first Linux run shows a clean set. +# The freed-chunk quarantine stays ON everywhere but the memory-release sessions (RLSANENV: quarantine_size_mb=0 and +# allocator_release_to_os_interval_ms=0, because that arm's footprint must fall back under the 64M floor, which a quarantine +# prevents). With it off, a freed chunk is handed out again at once, and a use-after-free that lands on a same-size reuse reads +# live memory and reports nothing: the class this gate exists to catch. (The known bug reported as heap-buffer-overflow with +# the quarantine off everywhere, and as the heap-use-after-free it is with it on.) +# +# Usage: +# bash test/mcpreloadasancheck.sh [PLAIN_BIN] # CLI reference binary (default build/ripwire); ASan binary asan/ripwire +# RIPWIRE_ASAN_BIN=path bash test/mcpreloadasancheck.sh +# RIPWIRE_BIN=asan/ripwire bash test/mcpreloadasancheck.sh # CI's asan job: one binary plays both roles +# RIPWIRE_RELOAD_CYCLES=20 (default) +# No sanitizer build (no asan/ripwire, llvm@22 tree: cmake -DRIPWIRE_ASAN=ON, see CONTRIBUTING.md) → SKIP, named. +# Exits non-zero on any failure. The memory-release arm needs two host premises (R's parse inside the memory guard's five-second +# clock; the footprint back under the 64M floor after the release): when none of its attempts met them it prints a named SKIP row +# and the other arms still decide the verdict. + +set -u +ROOT="$( cd "$( dirname "$0" )/.." && pwd )" +. "$ROOT/test/lib/clean-env.sh" +. "$ROOT/scripts/gatebound.sh" +BIN="${1:-${RIPWIRE_BIN:-$ROOT/build/ripwire}}" +[ "${BIN#/}" = "$BIN" ] && BIN="$ROOT/$BIN" +CYCLES="${RIPWIRE_RELOAD_CYCLES:-20}" +SESSION_CAP="${RIPWIRE_GATE_SESSION_CAP_SEC:-1500}" +fail=0 +ok(){ printf ' PASS %s\n' "$*" || { fail=1; printf ' FAIL could not write the PASS line for: %s\n' "$*"; }; return 0; } +no(){ printf ' FAIL %s\n' "$*"; fail=1; } +skip(){ printf ' SKIP %s\n' "$*"; } # an ABSENT PRECONDITION with a named reason — never a silent pass + +# the premises that are FAILURES, not skips: the binary under test and the tools the driver needs +[ -x "$BIN" ] || { echo "no ripwire binary at $BIN — build first (cmake --build build -j)"; exit 2; } +command -v python3 >/dev/null 2>&1 || { echo "python3 required"; exit 2; } +command -v git >/dev/null 2>&1 || { echo "git required"; exit 2; } +case "$CYCLES" in ''|*[!0-9]*) echo "RIPWIRE_RELOAD_CYCLES='$CYCLES' is not a number"; exit 2;; esac +[ "$CYCLES" -ge 1 ] || { echo "RIPWIRE_RELOAD_CYCLES must be >= 1"; exit 2; } +BIN_VER="$( "$BIN" --version 2>&1 )" || { echo "$BIN does not run (--version failed): $BIN_VER"; exit 2; } +case "$BIN_VER" in ripwire*) ;; *) echo "$BIN --version printed no ripwire banner: $BIN_VER"; exit 2;; esac + +# is a binary sanitizer-instrumented? (the runtime symbol cachefuzzcheck's bound_mode_of reads) +is_asan(){ LC_ALL=C grep -q -a '__asan_init' "$1" 2>/dev/null; } + +# the sanitizer binary: RIPWIRE_ASAN_BIN, else the binary under test when it is one (RIPWIRE_BIN=asan/ripwire), else asan/ripwire +if [ -n "${RIPWIRE_ASAN_BIN:-}" ]; then + ASAN_BIN="$RIPWIRE_ASAN_BIN"; [ "${ASAN_BIN#/}" = "$ASAN_BIN" ] && ASAN_BIN="$ROOT/$ASAN_BIN" + # an override that names nothing is a typo, not the expected "this checkout has no asan/ build": exit 2, never SKIP + [ -x "$ASAN_BIN" ] || { echo "RIPWIRE_ASAN_BIN is set to '$RIPWIRE_ASAN_BIN' but no executable is there"; exit 2; } +elif is_asan "$BIN"; then + ASAN_BIN="$BIN" +else + ASAN_BIN="$ROOT/asan/ripwire" +fi +echo "mcpreloadasancheck: BIN=$BIN ASAN_BIN=$ASAN_BIN cycles=$CYCLES" + +# the named, expected missing premise: this checkout has no sanitizer build. Announced BEFORE any PASS row. +if [ ! -x "$ASAN_BIN" ]; then + skip "no sanitizer binary at $ASAN_BIN (configure the llvm@22 tree: cmake -S . -B asan -DRIPWIRE_ASAN=ON, CONTRIBUTING.md; or set RIPWIRE_ASAN_BIN)" + echo "SKIP — nothing asserted (no sanitizer build)" + exit 0 +fi +# present but not a sanitizer build, or one that cannot start: a different premise, and a failure +if ! is_asan "$ASAN_BIN"; then + echo "$ASAN_BIN is not a sanitizer build (no __asan_init): this gate would test a plain binary and say it was ASan"; exit 2 +fi +ASAN_VER="$( gate_bounded 90 "$ASAN_BIN" --version 2>&1 )" || { echo "$ASAN_BIN does not start within 90 s (AppleClang's ASan hangs before main on macOS 26: build the asan tree with llvm@22, CONTRIBUTING.md): $ASAN_VER"; exit 2; } +fromOf(){ printf '%s\n' "$1" | sed -n 's/.*built_from=\([0-9a-f]*\).*/\1/p' | head -1; } +BIN_FROM="$( fromOf "$BIN_VER" )"; ASAN_FROM="$( fromOf "$ASAN_VER" )" +if [ -z "$BIN_FROM" ] || [ -z "$ASAN_FROM" ] || [ "$BIN_FROM" != "$ASAN_FROM" ]; then + echo "the CLI reference binary (built_from='$BIN_FROM') and the sanitizer binary (built_from='$ASAN_FROM') are not one source tree: rebuild asan/ so the answers compared are from the same code"; exit 2 +fi + +TMP="$( mktemp -d )" +TMP="$( cd "$TMP" && pwd -P )" # realpath: both surfaces print the root they were given, and /var is a symlink on macOS +cleanup(){ gate_bounded_reap; chmod -R u+w "$TMP" 2>/dev/null; rm -rf "$TMP"; } +trap cleanup EXIT; gate_bounded_arm +mkdir -p "$TMP/san" "$TMP/srvtmp" "$TMP/clitmp" + +cat >"$TMP/driver.py" <<'PYEOF' +#!/usr/bin/env python3 +"""The session driver. argv: PLAIN_BIN ASAN_BIN WORK CYCLES. Prints PASS/FAIL/SKIP/NOTE rows and one SUMMARY line.""" +import hashlib, json, os, re, select, shutil, signal, stat, subprocess, sys, time +import xml.etree.ElementTree as ET +from concurrent.futures import ThreadPoolExecutor + +BIN, ASAN, WORK, CYCLES = sys.argv[1], sys.argv[2], sys.argv[3], int(sys.argv[4]) +A, B, R = (os.path.join(WORK, d) for d in ("a", "b", "r")) +SAN = os.path.join(WORK, "san") +SRVTMP, CLITMP = os.path.join(WORK, "srvtmp"), os.path.join(WORK, "clitmp") +DARWIN = sys.platform == "darwin" +SANENV = { + "ASAN_OPTIONS": "detect_leaks=0:halt_on_error=1:abort_on_error=1:log_path=%s/asan" % (SAN,), + "UBSAN_OPTIONS": "halt_on_error=1:print_stacktrace=1:log_path=%s/ubsan" % SAN, +} +for cand in ("/opt/homebrew/opt/llvm@22/bin/llvm-symbolizer", "/usr/bin/llvm-symbolizer"): + # the sanitizer's own symbolizer: macOS's default (atos) can take minutes on a loaded host while the process waits to abort + if os.path.exists(cand): + SANENV["ASAN_OPTIONS"] += ":external_symbolizer_path=" + cand + SANENV["UBSAN_OPTIONS"] += ":external_symbolizer_path=" + cand + break +TOPK = "6" +# the release arm only: its footprint must fall under the 64M floor, so the freed-chunk quarantine is off THERE +RLSANENV = dict(SANENV, ASAN_OPTIONS=SANENV["ASAN_OPTIONS"] + ":quarantine_size_mb=0:allocator_release_to_os_interval_ms=0") +fails, notes = [], [] +checks = 0 +stats = {"stamp": 0, "content": 0, "edit": 0, "switch": 0, "release": 0, "cold": 0, "changed": 0} +server = None + + +def row(kind, text): + print(" %-4s %s" % (kind, text), flush=True) + + +def fail(text): + fails.append(text) + row("FAIL", text[:3500]) + + +def check(cond, label, detail=""): + global checks + checks += 1 + if not cond: + fail("%s%s" % (label, (": " + detail) if detail else "")) + return cond + + +# ───────────────────────────────────────────────── the one session ────────────────────────────────────────────────── +class Dead(Exception): + pass + + +class Server: + def __init__(self, binp, args, tmpdir, errpath, extra_env): + env = dict(os.environ, RIPWIRE_MCP_TIMINGS="1", TMPDIR=tmpdir) + env.update(extra_env) + self.err = open(errpath, "w+") + self.errpath = errpath + # --mcp-legend=inline: every answer carries its root facts on its own root, the spelling the one-shot CLI oracle + # writes. The default session posture moves them to a closing after the first answer; its + # equality with the CLI is held by mcpincrementalcheck's and pathgapcheck's posture twins and by legendrefcheck. + self.p = subprocess.Popen([binp, "--mcp", "--mcp-legend=inline"] + args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=self.err, + text=True, env=env) + self.id = 0 + self.timing_seen = 0 + + def timings(self): + self.err.flush() + with open(self.errpath, errors="replace") as f: + return [l for l in f.read().splitlines() if l.startswith("ripwire-timing ")] + + def rpc(self, method, params=None, wait=240): + t0 = time.time() + try: + return self.rpc_(method, params, wait) + finally: + T["mcp"] += time.time() - t0 + T["nmcp"] += 1 + + def rpc_(self, method, params=None, wait=240): + self.id += 1 + msg = {"jsonrpc": "2.0", "id": self.id, "method": method} + if params is not None: + msg["params"] = params + try: + self.p.stdin.write(json.dumps(msg) + "\n") + self.p.stdin.flush() + except (BrokenPipeError, ValueError): + raise Dead("the server closed its input (died)") + waited = 0 + while True: + r, _, _ = select.select([self.p.stdout], [], [], 1.0) + if r: + break + waited += 1 + if self.p.poll() is not None: + raise Dead("the server exited (rc=%s) before answering %s" % (self.p.returncode, method)) + if waited >= 8 and os.listdir(SAN): + # a sanitizer report is on disk and the process has not answered: it is aborting (the abort and the symbolizer + # can take minutes on a loaded macOS host); the report is the finding, so stop waiting + raise Dead("a sanitizer report was written and the server stopped answering %s" % method) + if waited >= wait: + raise Dead("no answer within %d s to %s" % (wait, method)) + line = self.p.stdout.readline() + if not line: + raise Dead("the server exited mid-request (rc=%s)" % self.p.poll()) + resp = json.loads(line) + # one timing line per request, printed after the response: wait for it so rebuilt= is read for THIS request + t0 = time.time() + while True: + tl = self.timings() + if len(tl) > self.timing_seen or time.time() - t0 > 20: + break + time.sleep(0.005) + t = {} + if len(tl) > self.timing_seen: + t = dict(kv.split("=", 1) for kv in tl[self.timing_seen].split()[1:] if "=" in kv) + self.timing_seen = len(tl) + return resp, t + + def tool(self, name, **args): + """-> (kind, payload, timing): kind 'ok' (payload = text) or 'err' (payload = (code, message)).""" + resp, t = self.rpc("tools/call", {"name": name, "arguments": args}) + if "error" in resp: + return "err", (resp["error"].get("code"), resp["error"].get("message", "")), t + return "ok", resp["result"]["content"][0]["text"], t + + def close(self): + try: + self.p.stdin.close() + except Exception: + pass + try: + return self.p.wait(timeout=180) + except subprocess.TimeoutExpired: + self.p.kill() + self.p.wait() + return "timeout" + + def kill(self): + try: + self.p.kill() + self.p.wait(timeout=10) + except Exception: + pass + + +def on_signal(signum, _frame): + if server is not None: + server.kill() + row("FAIL", "session stopped by signal %d (the wall-clock cap, or the gate was stopped)" % signum) + row("NOTE", "time: CLI %.0fs in %d runs, MCP %.0fs in %d requests" % (T["cli"], T["ncli"], T["mcp"], T["nmcp"])) + print("SUMMARY checks=%d fails=%d cycles=-1 aborted=1" % (checks, len(fails) + 1), flush=True) + sys.exit(124) + + +for s in (signal.SIGALRM, signal.SIGTERM, signal.SIGHUP, signal.SIGINT): + signal.signal(s, on_signal) + + +# ───────────────────────────────────────────────── fixtures ───────────────────────────────────────────────────────── +def put(path, text): + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w") as f: + f.write(text) + + +def base_tree(a, v): + """The static + variant part of tree A. v (0/1) retargets the value references the main files pass.""" + cbx = "cb2" if v else "cb" + ev = "on_event2" if v else "on_event" + put(a + "/js/cb.js", "export function cb(x) { return x + 1; }\nexport function cb2(x) { return x + 2; }\n") + put(a + "/js/run.js", "export function run(f, x) { return f(x); }\n") + put(a + "/js/main.js", "import { run } from './run.js';\nimport { cb, cb2 } from './cb.js';\n" + "export function go() { return run(%s, 1); }\nexport const table = { onEvent: cb2 };\n" + "function jsDead() { return 0; }\nfunction jsVonly() { return 1; }\nconst jsSlot = { h: jsVonly };\n" % cbx) + put(a + "/ts/handlers.ts", "export function onTs(x: number): number { return x * 2; }\n" + "export function onTs2(x: number): number { return x * 3; }\n") + put(a + "/ts/app.ts", "import { onTs, onTs2 } from './handlers';\n" + "export function registerTs(f: (n: number) => number): number { return f(1); }\n" + "export function bootTs(): number { return registerTs(%s); }\n" + "export const tsTable: { [k: string]: (n: number) => number } = { k: onTs2 };\n" % ("onTs2" if v else "onTs")) + put(a + "/go/go.mod", "module example.com/gate\n\ngo 1.21\n") + put(a + "/go/handlers/handlers.go", "package handlers\n\nfunc OnEvent(x int) int { return x + 1 }\nfunc OnEvent2(x int) int { return x + 2 }\n") + put(a + "/go/main.go", "package main\n\nimport \"example.com/gate/handlers\"\n\nfunc register(f func(int) int) int { return f(1) }\n\n" + "func localHandler(x int) int { return x * 4 }\n\nfunc Boot() int { return register(handlers.OnEvent) + register(%s) }\n\n" + "var table = map[string]func(int) int{\"k\": handlers.OnEvent2, \"l\": localHandler}\n\nfunc main() { _ = Boot() }\n\n" + "func deadGo() int { return 0 }\n\nfunc vonlyGo() int { return 1 }\n\nvar slots = []func() int{vonlyGo}\n" + % ("localHandler" if not v else "func(x int) int { return x }")) + put(a + "/py/handlers.py", "def on_event(x):\n return x + 1\n\n\ndef on_event2(x):\n return x + 2\n") + put(a + "/py/app.py", "from handlers import on_event, on_event2\n\n\ndef register(f):\n return f(1)\n\n\ndef boot():\n return register(%s)\n\n\n" + "TABLE = {\"k\": on_event2}\n\n\ndef _py_dead():\n return 0\n\n\ndef _py_vonly():\n return 1\n\n\nSLOTS = [_py_vonly]\n" % ev) + put(a + "/c/handlers.h", "int c_on_event(int x);\nint c_on_event2(int x);\n") + put(a + "/c/handlers.c", "#include \"handlers.h\"\nint c_on_event(int x) { return x + 1; }\nint c_on_event2(int x) { return x + 2; }\n") + put(a + "/c/app.c", "#include \"handlers.h\"\ntypedef int (*fn_t)(int);\nstatic fn_t fp;\nstruct ops { fn_t op; };\n" + "static const struct ops tbl = { c_on_event2 };\nvoid c_setup(void) { fp = %s; }\nint c_boot(void) { return fp(1) + tbl.op(2); }\n" + "static int c_dead(void) { return 0; }\nstatic int c_vonly(void) { return 1; }\nstatic fn_t slot2 = c_vonly;\n" + % ("c_on_event2" if v else "c_on_event")) + + +def gen_tree(a, k, v): + """The gen files of cycle k: gen added, the previous one gone, the value reference retargeted by v.""" + put(a + "/js/gen.js", "import { cb, cb2 } from './cb.js';\nexport function jsgen%d() { return %d; }\n" + "export const JSGEN = { h: jsgen%d, c: %s };\n" % (k, k, k, "cb2" if v else "cb")) + put(a + "/ts/gen.ts", "import { onTs, onTs2 } from './handlers';\nexport function tsgen%d(): number { return %d; }\n" + "export const TSGEN = [tsgen%d, %s];\n" % (k, k, k, "onTs2" if v else "onTs")) + put(a + "/go/gen.go", "package main\n\nfunc gogen%d() int { return %d }\n\nvar genSlots = []func() int{gogen%d}\n" % (k, k, k)) + put(a + "/py/gen.py", "from handlers import on_event, on_event2\n\n\ndef pygen_%d():\n return %d\n\n\nPYGEN = [pygen_%d, %s]\n" + % (k, k, k, "on_event2" if v else "on_event")) + put(a + "/c/gen.c", "#include \"handlers.h\"\nstatic int cgen_%d(void) { return %d; }\nstatic int cdead_%d(void) { return 0; }\n" + "static int (*cgen_slot)(void) = cgen_%d;\nint cgen_use(void) { return cgen_slot() + %s(1); }\n" + % (k, k, k, k, "c_on_event2" if v else "c_on_event")) + + +def bulk_tree(r): + """Root R: the JS value-reference trio plus 2400 small C files — enough that the resident index alone carries the + server over the 64M floor, so the NEXT call releases it.""" + put(r + "/js/cb.js", "export function cb(x) { return x + 1; }\n") + put(r + "/js/run.js", "export function run(f, x) { return f(x); }\n") + put(r + "/js/main.js", "import { run } from './run.js';\nimport { cb } from './cb.js';\nexport function go() { return run(cb, 1); }\n") + for d in range(12): + for f in range(200): + put("%s/bulk/d%d/f%d.c" % (r, d, f), "".join( + "int b%d_%d_%d(int x) { return x > 0 ? b%d_%d_%d(x - 1) + %d : %d; }\n" % (d, f, k, d, f, (k + 1) % 30, k, k) + for k in range(30))) + + +def tree_b(b, k): + """Root B: a git repo (so the rank is biased to the uncommitted diff); bgen.js is rewritten every cycle.""" + put(b + "/js/bcb.js", "export function bcb(x) { return x + 1; }\n") + put(b + "/js/brun.js", "export function brun(f, x) { return f(x); }\n") + put(b + "/js/bmain.js", "import { brun } from './brun.js';\nimport { bcb } from './bcb.js';\nexport function bgo() { return brun(bcb, 1); }\n") + put(b + "/py/bh.py", "def bh_event(x):\n return x\n\n\ndef bh_reg(f):\n return f(1)\n\n\ndef bh_boot():\n return bh_reg(bh_event)\n") + bump_b(b, k) + + +def bump_b(b, k): + put(b + "/js/bgen.js", "import { bcb } from './bcb.js';\nexport function bgen%d() { return %d; }\nexport const BGEN = { h: bgen%d, c: bcb };\n" % (k, k, k)) + + +def fingerprint(root): + h = hashlib.sha1() + for dp, dn, fn in os.walk(root): + dn[:] = sorted(d for d in dn if d != ".git") + for f in sorted(fn): + p = os.path.join(dp, f) + h.update(os.path.relpath(p, root).encode() + b"\0") + with open(p, "rb") as fh: + h.update(fh.read()) + return h.hexdigest() + + +# ───────────────────────────────────────── the two surfaces, normalised ───────────────────────────────────────────── +COMMENT = re.compile(r"", re.S) +HINT = re.compile(r' hint="[^"]*"') +VRKEYS = ("in_id", "to", "def", "bind", "into", "called_by", "through", "sites") + + +def xml_clean(t): + return HINT.sub("", COMMENT.sub("", t)).strip() + + +def boolstr(v): + return "1" if v is True else "0" if v is False else str(v) + + +def vr_window(total, shown, capped, rows): + return (str(total), str(shown), boolstr(capped), tuple(sorted(tuple(str(r.get(k, "")) for k in VRKEYS) for r in rows))) + + +NOWIN = ("0", "0", "0", ()) + + +T = {"cli": 0.0, "mcp": 0.0, "ncli": 0, "nmcp": 0} + + +def cli(binp, root, *args, tmp=CLITMP): + t0 = time.time() + try: + return cli_(binp, root, *args, tmp=tmp) + finally: + T["cli"] += time.time() - t0 # summed over the worker threads + T["ncli"] += 1 + + +def cli_(binp, root, *args, tmp=CLITMP): + env = dict(os.environ, TMPDIR=tmp) + env.update(SANENV) + p = subprocess.run([binp, root, "--no-cache", "--legend=compact"] + list(args), capture_output=True, text=True, timeout=300, env=env) + return p.returncode, p.stdout, p.stderr + + +def cli_view(root, verb, sym): + """CLI answer of --callers/--callees as (kind, data); NOTFOUND when the CLI says found=0 (rc 1).""" + rc, out, err = cli(BIN, root, "--%s=%s" % (verb, sym)) + c = xml_clean(out) + if rc == 1 and 'found="0"' in c: + return ("NOTFOUND",) + if rc != 0 or not c.startswith("<"): + return ("CLIERR", rc, err[:200], c[:200]) + el = ET.fromstring(c) + syms = tuple(sorted((s.get("n"), s.get("p")) for s in el.findall("s"))) + w = el.find("vrs") + win = vr_window(w.get("total"), w.get("shown"), w.get("capped"), [v.attrib for v in w.findall("vr")]) if w is not None else NOWIN + return ("OK", el.get("defs"), syms, win) + + +def mcp_fs_view(kind, payload): + if kind == "err": + return ("NOTFOUND",) if payload[0] == -32602 and "not found" in payload[1] else ("MCPERR", payload) + j = json.loads(payload) + + def win(k): + w = j.get(k) + return vr_window(w["total"], w["shown"], w["capped"], w["rows"]) if w else NOWIN + + def syms(k): + return tuple(sorted((s["name"], "%s:%s" % (s["file"], s["line"])) for s in j.get(k, []))) + return ("OK", str(j.get("defs")), syms("calledBy"), win("valueRefs"), syms("calls"), win("valueCallees")) + + +def cli_refs(root, syms, paths, greps, rankbys, with_map=True): + """The one-shot CLI answers for one tree state, keyed like the MCP side (the runs are independent: six at a time).""" + def fs_task(s): + cal = cli_view(root, "callers", s) + # a leaf (a handler or a generated function) calls nothing: the callees run is skipped and the MCP side must say the same + cee = cli_view(root, "callees", s) if s in CALLERS_OF else ("OK", None, (), NOWIN) + if cal[0] == "NOTFOUND" or cee[0] == "NOTFOUND": + return [(("fs", s), ("NOTFOUND",)), (("frs", s), ("NOTFOUND",))] + return [(("fs", s), ("OK", cal[1], cal[2], cal[3], cee[2], cee[3])), (("frs", s), ("OK", cal[1], cal[2], cal[3]))] + + def xml_task(key, *args): + rc, out, err = cli(BIN, root, *args) + return [(key, ("NOTFOUND",) if (rc == 1 and 'found="0"' in out) else ("OK", xml_clean(out)) if rc == 0 else ("CLIERR", rc, err[:200]))] + + def grep_task(g): + rc, out, err = cli(BIN, root, "--grep=%s" % g) + hits = set() + if rc == 0: + for f in ET.fromstring(xml_clean(out)).findall("f"): + for h in f.findall("hit"): + hits.add((f.get("p"), int(h.get("l")))) + return [(("grep", g), ("OK", tuple(sorted(hits))) if rc == 0 else ("CLIERR", rc, err[:200]))] + + def dead_task(): + rc, out, err = cli(BIN, root, "--dead-code") + dead = set() + if rc == 0: + for d in ET.fromstring(xml_clean(out)).findall("d"): + dead.add(d.get("n")) + return [(("dead",), ("OK", tuple(sorted(dead))) if rc == 0 else ("CLIERR", rc, err[:200]))] + + tasks = [lambda s=s: fs_task(s) for s in syms] + for s in syms[:5]: + for verb in ("impact", "uses"): + tasks.append(lambda s=s, verb=verb: xml_task((verb, s), "--%s=%s" % (verb, s))) + for f, t in paths: + tasks.append(lambda f=f, t=t: xml_task(("path", f, t), "--path=%s,%s" % (f, t))) + tasks += [lambda g=g: grep_task(g) for g in greps] + if with_map: + tasks.append(lambda: xml_task(("map",), "--order=stable", "--top-k=" + TOPK)) + for rb in rankbys: + tasks.append(lambda rb=rb: xml_task(("rank_by", rb), "--rank-by=" + rb, "--order=stable", "--top-k=" + TOPK)) + tasks.append(dead_task) + ref = {} + with ThreadPoolExecutor(max_workers=6) as ex: + for rows in ex.map(lambda t: t(), tasks): + ref.update(rows) + return ref + + +def mcp_xml(srv, name, notfound_ok=True, **args): + kind, payload, t = srv.tool(name, **args) + if kind == "err": + return (("NOTFOUND",) if (payload[0] == -32602 and "not found" in payload[1]) else ("MCPERR", payload)), t + return ("OK", xml_clean(payload)), t + + +def mcp_views(srv, root, syms, paths, greps, rankbys, with_map=True): + """Every view, asked of the long-lived session. -> (answers, timings of the first request).""" + got, first = {}, None + for s in syms: + kind, payload, t = srv.tool("find_symbol", path=root, symbol=s) + first = first or t + got[("fs", s)] = mcp_fs_view(kind, payload) + kind, payload, t = srv.tool("find_referencing_symbols", path=root, symbol=s) + v = mcp_fs_view(kind, payload) + got[("frs", s)] = v if v[0] != "OK" else v[:4] + for s in syms[:5]: + for verb in ("impact", "uses"): + got[(verb, s)], _ = mcp_xml(srv, verb, path=root, symbol=s) + for f, tt in paths: + got[("path", f, tt)], _ = mcp_xml(srv, "path_between", path=root, **{"from": f, "to": tt}) + for g in greps: + kind, payload, _ = srv.tool("grep", path=root, pattern=g) + if kind == "err": + got[("grep", g)] = ("MCPERR", payload) + else: + j = json.loads(payload) + got[("grep", g)] = ("OK", tuple(sorted(set((h["file"], int(h["line"])) for h in j["hits"])))) + if with_map: + got[("map",)], _ = mcp_xml(srv, "analyze", path=root) + for rb in rankbys: + got[("rank_by", rb)], _ = mcp_xml(srv, "rank_by", path=root, rank_by=rb) + return got, first + + +def compare(label, ref, got, skip=()): + """Every key the reference has must be answered identically; returns (compared, bad keys).""" + bad = [] + for key, rv in ref.items(): + if key[0] in skip or key[0] == "dead": + continue + gv = got.get(key) + if gv != rv: + bad.append((key, rv, gv)) + return bad + + +def short(v, n=330): + s = repr(v) + return s if len(s) <= n else s[:n] + "...(%d)" % len(s) + + +def check_dead(label, ref, got): + """dead-code (CLI, fresh) against the session's valueRefs: a static function with no caller is dead only when no + value-site holds it. The CLI lists the dead set; the session says per function whether anything holds it.""" + dead = set(ref[("dead",)][1]) if ref[("dead",)][0] == "OK" else None + if dead is None: + check(False, "%s dead-code ran on the CLI" % label, short(ref[("dead",)])) + return + for s, expectDead in (("c_dead", True), ("c_vonly", False)): + g = got.get(("fs", s)) + if not g or g[0] != "OK": + check(False, "%s MCP find_symbol(%s) answered" % (label, s), short(g)) + continue + noCallers = g[2] == () + holdsValue = g[3][0] != "0" + check(noCallers and ((s in dead) == expectDead) and (holdsValue == (not expectDead)), + "%s dead-code agrees with the session's valueRefs for %s" % (label, s), + "dead=%s callers=%s valueRefs=%s" % (s in dead, g[2], g[3][0])) + + +# ───────────────────────────────────────────────── the session ────────────────────────────────────────────────────── +CALLERS_OF = {"run", "register", "c_boot", "go", "Boot", "cgen_use", "brun", "bgo", "bh_reg", "bh_boot"} # the symbols that call something; every other one is a leaf + + +def symbols_at(k): + cur = ["jsgen%d" % k, "pygen_%d" % k, "cgen_%d" % k, "cdead_%d" % k] + old = ["jsgen%d" % (k - 1)] if k > 1 else [] + return ["cb", "cb2", "run", "on_event", "c_on_event", "onTs", "localHandler", "c_vonly", "c_dead", "jsVonly", + "register", "c_boot", "go", "Boot", "cgen_use"] + cur + old + + +PATHS = [("Boot", "register"), ("go", "cb"), ("c_boot", "c_on_event")] +GREPS = ["cb2"] +RANKBYS = ["pagerank", "rrf"] + + +def cold_map(root): + """A fresh server (fresh TMPDIR, plain binary) answering `analyze` on `root`: the instrument for a rank this CLI has no twin of.""" + tmp = os.path.join(WORK, "coldtmp") + shutil.rmtree(tmp, ignore_errors=True) + os.makedirs(tmp) + s = Server(BIN, ["--top-k=" + TOPK], tmp, os.path.join(WORK, "cold.err"), {}) + try: + s.rpc("initialize") + kind, payload, _ = s.tool("analyze", path=root) + finally: + s.close() + stats["cold"] += 1 + return ("OK", xml_clean(payload)) if kind == "ok" else ("MCPERR", payload) + + +def release_attempt(k, refR, refA, kk): + """One fresh process under the 64M floor. -> 'ok' | 'partial' | 'refused' | 'noreleased'; the last three are premises this + host did not meet (named by the caller), never a pass. --max-memory has a 64M floor and is server-wide, and a sanitizer + process that has run the whole cycle never falls back under it, so the release path gets a fresh process. Root R's + resident index alone carries it over the line, so the SECOND call on R releases that index (releaseMcpIndexMemory) and + rebuilds the unchanged tree; the next call, on A, releases R's rebuild again and rebuilds A. Only those calls are made.""" + s = Server(ASAN, ["--top-k=" + TOPK, "--max-memory=64M"], SRVTMP + "_rl", os.path.join(WORK, "release.err"), RLSANENV) + status = "ok" + try: + s.rpc("initialize") + kind, payload, t1 = s.tool("find_symbol", path=R, symbol="cb") + v1 = mcp_fs_view(kind, payload) + if v1[0] != "OK": + return "partial" # the memory guard's soft stop (a parse past five seconds under a 64M limit) left R without its symbols + check(t1.get("rebuilt") == "1", "cycle %d RL: root R built (rebuilt=1)" % k, short(t1)) + check(v1 == refR, "cycle %d RL: root R's answer equals the one-shot CLI" % k, "cli=%s mcp=%s" % (short(refR), short(v1))) + kind, payload, t2 = s.tool("find_symbol", path=R, symbol="cb") + if kind == "err" and "memory limit" in payload[1]: + return "refused" + v2 = mcp_fs_view(kind, payload) + if v2[0] != "OK": + return "partial" + if t2.get("rebuilt") != "1": + return "noreleased" + stats["release"] += 1 + check(v2 == refR, "cycle %d RL: the unchanged tree R, released and rebuilt, answers equal the one-shot CLI" % k, "cli=%s mcp=%s" % (short(refR), short(v2))) + gotA, fA = mcp_views(s, A, symbols_at(kk), PATHS, GREPS, RANKBYS) + # the memory guard may refuse ANY call on A by name, not only the first (a bigger index reaches the floor later in the + # sequence): such a refusal is the named premise "the footprint stayed over the floor", and every call it did + # answer must still equal the one-shot CLI + refusedA = [key for key, v in gotA.items() if v and v[0] == "MCPERR" and "memory limit" in str(v[1])] + if refusedA: + row("NOTE", "cycle %d RL: the release was proved; %d call(s) on A after it were refused by name (the footprint stayed over the floor)" + % (k, len(refusedA))) + badA = [b_ for b_ in compare("RL A", refA, gotA) if b_[0] not in refusedA] + check(not badA, "cycle %d RL: after the release, every call on tree A the guard answered equals the one-shot CLI" % k, + "; ".join("%s: cli=%s mcp=%s" % (a_, short(b_), short(c_)) for a_, b_, c_ in badA[:2])) + return "ok" + bad = compare("RL A", refA, gotA) + check(not bad, "cycle %d RL: after the release, tree A answers equal the one-shot CLI" % k, + "; ".join("%s: cli=%s mcp=%s" % (a_, short(b_), short(c_)) for a_, b_, c_ in bad[:2])) + check(fA and fA.get("rebuilt") == "1", "cycle %d RL: A was rebuilt after R's release" % k, short(fA)) + finally: + rc = s.close() + check(rc == 0, "cycle %d RL: the release session ended with exit status 0" % k, "rc=%s" % rc) + with open(os.path.join(WORK, "release.err"), errors="replace") as fh: + e = fh.read() + check("Sanitizer" not in e and "runtime error:" not in e, "cycle %d RL: no sanitizer text on the release session's stderr" % k, e[:600]) + return status + + +def release_session(k, refR, refA, kk): + os.makedirs(SRVTMP + "_rl", exist_ok=True) + if not stats.get("rl_warm"): + # the memory guard stops a parse that runs past five seconds, and a cold sanitizer parse of R can: warm the ingest + # cache once, unlimited, so every limited session below reads facts from it and its ingest is short + w = Server(ASAN, ["--top-k=" + TOPK], SRVTMP + "_rl", os.path.join(WORK, "warm.err"), RLSANENV) + try: + w.rpc("initialize") + kind, payload, _ = w.tool("find_symbol", path=R, symbol="cb") + check(kind == "ok", "release sessions: the unlimited warm-up build of R answered", short(payload)) + finally: + check(w.close() == 0, "release sessions: the warm-up session ended with exit status 0") + stats["rl_warm"] = 1 + for attempt in range(1, 5): + st = release_attempt(k, refR, refA, kk) + if st == "ok": + return + stats["rl_miss"] = stats.get("rl_miss", 0) + 1 + row("NOTE", "cycle %d RL attempt %d: premise not met (%s)" % (k, attempt, { + "partial": "the memory guard's 5 s soft-stop clock cut R's parse on this host", + "refused": "the footprint stayed over the 64M floor after the release, so the rebuild was refused by name", + "noreleased": "the footprint stayed under the floor, so no release fired"}[st])) + + +def sanitizer_findings(): + found = [] + for f in sorted(os.listdir(SAN)): + with open(os.path.join(SAN, f), errors="replace") as fh: + found.append((f, fh.read())) + err = "" + if server is not None: + server.err.flush() + with open(server.errpath, errors="replace") as fh: + err = fh.read() + for pat in ("ERROR: AddressSanitizer", "ERROR: LeakSanitizer", "runtime error:", "SUMMARY: AddressSanitizer", "SUMMARY: UndefinedBehaviorSanitizer"): + if pat in err: + found.append(("server stderr", err[err.index(pat):err.index(pat) + 3000])) + break + return found + + +def main(): + global server + shutil.rmtree(WORK + "/a", ignore_errors=True) + for r in (A, B, R): + os.makedirs(r, exist_ok=True) + base_tree(A, 0) + gen_tree(A, 0, 0) + tree_b(B, 0) + bulk_tree(R) + gitenv = dict(os.environ) + for cmd in (["init", "-q"], ["add", "-A"], ["-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", "init"]): + subprocess.run(["git", "-C", B] + cmd, check=True, capture_output=True, env=gitenv) + row("NOTE", "fixtures: A (js/ts/go/py/c, %d files), B (git repo, dirty), R (%d files); CLI reference binary %s" % ( + sum(len(f) for _, _, f in os.walk(A)), sum(len(f) for _, _, f in os.walk(R)), BIN)) + + server = Server(ASAN, ["--top-k=" + TOPK], SRVTMP, os.path.join(WORK, "server.err"), SANENV) + refs = {} # fingerprint of tree A -> CLI answers (the CLI is a function of tree state) + refR = None + prev_ref = None + live_handles = {} # symbol -> handle minted at some earlier state + done = 0 + try: + server.rpc("initialize") + v = 0 + state = {"k": 0, "v": 0} + + def ref_for(k, root=A, syms=None): + fp = fingerprint(root) + if fp not in refs: + refs[fp] = cli_refs(root, syms or symbols_at(k), PATHS, GREPS, RANKBYS) + return refs[fp] + + def qpoint(label, k, expect_rebuild, kind): + """query every view on A, compare with the CLI on this state; assert the first request rebuilt (or not).""" + nonlocal prev_ref + ref = ref_for(k) + got, first = mcp_views(server, A, symbols_at(k), PATHS, GREPS, RANKBYS) + bad = compare(label, ref, got) + check(not bad, "cycle %d %s: all %d answers equal the one-shot CLI" % (k, label, len(ref) - 1), + "; ".join("%s: cli=%s mcp=%s" % (key, short(rv), short(gv)) for key, rv, gv in bad[:3]) + (" (+%d more)" % (len(bad) - 3) if len(bad) > 3 else "")) + check_dead("cycle %d %s" % (k, label), ref, got) + if expect_rebuild is not None: + check(first and first.get("rebuilt") == ("1" if expect_rebuild else "0"), + "cycle %d %s: the first request %s the index (rebuilt=%s)" % (k, label, "rebuilt" if expect_rebuild else "reused", first and first.get("rebuilt")), + "timing %s" % first) + if expect_rebuild and first and first.get("rebuilt") == "1": + stats[kind] += 1 + return ref, got + + # cycle 0: the cold build + ref0, got0 = qpoint("cold", 0, True, "stamp") + stats["stamp"] -= 1 + prev_ref = ref0 + stableK = 0 + + for k in range(1, CYCLES + 1): + kk = k + # ── M1: a stamp-keeping mutation ────────────────────────────────────────────────────────────────────── + which = k % 3 + if which == 1: + f = A + "/js/main.js" + m = os.stat(f).st_mode + os.chmod(f, m ^ stat.S_IXUSR) + os.chmod(f, m) + what = "chmod (ctime only)" + elif which == 2: + put(A + "/notes_%d.txt" % k, "x\n") + what = "a new non-source file" + else: + os.makedirs(A + "/empty_%d" % k, exist_ok=True) + what = "an empty directory" + # the state before this cycle's content change (the previous cycle's, plus the stamp-keeping change) + ref, got = qpoint("M1 " + what, kk - 1, True, "stamp") + changed_by_m1 = ref != prev_ref + prev_ref = ref + + # ── M2: a content mutation ──────────────────────────────────────────────────────────────────────────── + v = k % 2 + old_handles = dict(live_handles) + base_tree(A, v) + gen_tree(A, k, v) + via_edit = (k % 2 == 1) + if via_edit: + kind, payload, t = server.tool("insert_after_symbol", path=A, symbol="jsgen%d" % k, file="js/gen.js", + text="export function jsins%d() { return %d; }" % (k, k)) + check(kind == "ok" and t.get("rebuilt") == "1", "cycle %d M2: insert_after_symbol applied and the index was rebuilt around it" % k, short((kind, payload, t))) + kind, payload, t = server.tool("replace_symbol_body", path=A, symbol="jsVonly", file="js/main.js", + new_body="function jsVonly() { return %d; }" % (100 + k)) + check(kind == "ok", "cycle %d M2: replace_symbol_body applied" % k, short((kind, payload))) + stats["edit"] += 1 + # an edit verb rebuilds the index itself, after its write: the first QUERY then reuses it (rebuilt=0) + ref2, got2 = qpoint("M2 content (%s)" % ("MCP edit verbs" if via_edit else "file rewrite"), kk, None if via_edit else True, "content") + if via_edit: + stats["content"] += 1 + check(ref2 != prev_ref, "cycle %d M2: the mutation is visible in at least one compared answer (a stale index would be caught)" % k) + stats["changed"] += 1 if ref2 != prev_ref else 0 + prev_ref = ref2 + + # handles: minted before the mutation must refuse, minted now must serve the bytes on disk + for sym in ("jsgen%d" % (k - 1), "jsVonly"): + h = old_handles.get(sym) + if h: + kind, payload, _ = server.tool("fetch_body", path=A, handle=h) + check(kind == "err" and ("stale handle" in payload[1] or "does not resolve" in payload[1]), "cycle %d: a handle minted before the rewrite is refused as stale (%s)" % (k, sym), short((kind, payload))) + for sym, rel in (("jsgen%d" % k, "js/gen.js"), ("cgen_%d" % k, "c/gen.c"), ("pygen_%d" % k, "py/gen.py")): + kind, payload, _ = server.tool("find_symbol", path=A, symbol=sym) + if kind == "ok": + h = json.loads(payload)["symbol"]["handle"] + live_handles[sym] = h + kind, payload, _ = server.tool("fetch_body", path=A, handle=h) + body = json.loads(payload)["body"] if kind == "ok" else None + text = open(os.path.join(A, rel)).read() + check(body is not None and sym in body and body in text, "cycle %d: fetch_body(%s) with a fresh handle is the bytes on disk" % (k, sym), short((kind, payload))) + else: + check(False, "cycle %d: find_symbol(%s) answered" % (k, sym), short(payload)) + for sym in ("jsVonly",): + kind, payload, _ = server.tool("find_symbol", path=A, symbol=sym) + if kind == "ok": + live_handles[sym] = json.loads(payload)["symbol"]["handle"] + + # ── RS: root switches ───────────────────────────────────────────────────────────────────────────────── + bump_b(B, k) + bsyms = ["bcb", "bgen%d" % k, "bh_event", "bh_reg"] + bref = cli_refs(B, bsyms, [("bh_boot", "bh_reg")], ["bcb"], [], with_map=False) + bgot, first = mcp_views(server, B, bsyms, [("bh_boot", "bh_reg")], ["bcb"], [], with_map=False) + check(first and first.get("rebuilt") == "1", "cycle %d RS: switching to root B rebuilt the index" % k, short(first)) + stats["switch"] += 1 if first and first.get("rebuilt") == "1" else 0 + bad = compare("B", bref, bgot) + check(not bad, "cycle %d RS: root B (dirty git repo) answers equal the one-shot CLI" % k, "; ".join("%s: cli=%s mcp=%s" % (a, short(b), short(c)) for a, b, c in bad[:2])) + kind, payload, _ = server.tool("analyze", path=B) + coldB = cold_map(B) + check(kind == "ok" and ("OK", xml_clean(payload)) == coldB, "cycle %d RS: analyze on the dirty root equals a cold server's (working-set rank)" % k, short(coldB)) + # the workspace key + kind, payload, t = server.tool("find_symbol", paths=[A, B], symbol="c_vonly") + wsOK = kind == "ok" + check(wsOK and t.get("rebuilt") == "1", "cycle %d RS: the workspace [A,B] rebuilt the index" % k, short((kind, payload if kind == "err" else "", t))) + if wsOK: + wj = json.loads(payload) + check(wj.get("valueRefs", {"total": 0})["total"] == int(ref2[("fs", "c_vonly")][3][0]), "cycle %d RS: the workspace answer for c_vonly carries A's value references" % k, + "ws=%s a=%s" % (wj.get("valueRefs", {}).get("total"), ref2[("fs", "c_vonly")][3][0])) + stats["switch"] += 1 if t.get("rebuilt") == "1" else 0 + # and back to A + refA, gotA = qpoint("RS back to A", kk, True, "switch") + + # ── RL: the memory-release path, in its own short session under the 64M floor ──────────────────────── + if refR is None: + cal, cee = cli_view(R, "callers", "cb"), cli_view(R, "callees", "cb") + refR = ("OK", cal[1], cal[2], cal[3], cee[2], cee[3]) + if k % 5 == 1: + release_session(k, refR, ref_for(kk), kk) + stats["rl_cycles"] = stats.get("rl_cycles", 0) + 1 + done = k + row("PASS" if not fails else "NOTE", "cycle %d/%d done (checks so far %d, failures %d)" % (k, CYCLES, checks, len(fails))) + except Dead as e: + fail("the server died: %s" % e) + # let the report finish landing in the log directory (the symbolizer can take a while on a loaded host): wait for its + # SUMMARY line, or for the process to be gone, for at most two minutes + t0 = time.time() + while time.time() - t0 < 120 and server.p.poll() is None: + if any("SUMMARY:" in open(os.path.join(SAN, f), errors="replace").read() for f in os.listdir(SAN)): + break + time.sleep(1) + server.kill() + except Exception as e: # a driver bug must be loud, never a pass + import traceback + fail("driver error: %r\n%s" % (e, traceback.format_exc()[-1500:])) + rc = server.close() if server.p.poll() is None else server.p.returncode + found = sanitizer_findings() + check(not found, "no sanitizer report in the log directory or on stderr (halt_on_error, abort_on_error, log_path)", + " | ".join("%s: %s" % (n, t[:3000]) for n, t in found[:1])) + check(rc == 0, "the session ended with exit status 0 after its input closed", "rc=%s" % rc) + row("NOTE", "time: CLI %.0fs in %d runs, MCP %.0fs in %d requests" % (T["cli"], T["ncli"], T["mcp"], T["nmcp"])) + print("SUMMARY checks=%d fails=%d cycles=%d stamp=%d content=%d edit=%d switch=%d release=%d cold=%d changed=%d missed=%d rc=%s san=%d" % ( + checks, len(fails), done, stats["stamp"], stats["content"], stats["edit"], stats["switch"], stats["release"], stats["cold"], + stats["changed"], stats.get("rl_miss", 0), rc, len(found)), flush=True) + sys.exit(1 if fails else 0) + + +main() +PYEOF + +echo "-- one session, $CYCLES cycles: stamp-keeping rebuild, content rebuild (shell + MCP edit verbs), root switch, workspace, memory release" +START="$( date +%s )" +# the driver's stdout goes to a file so its status is the gate's own: $( ) would swallow a stop +gate_bounded "$SESSION_CAP" python3 "$TMP/driver.py" "$BIN" "$ASAN_BIN" "$TMP" "$CYCLES" >"$TMP/driver.out" 2>"$TMP/driver.err" +drc=$? +ELAPSED=$(( $( date +%s ) - START )) +cat "$TMP/driver.out" +[ -s "$TMP/driver.err" ] && sed 's/^/ | /' "$TMP/driver.err" | head -20 + +# ── the verdict, read off values the run produced present, numeric, and the premises met ───────────── +SUMMARY="$( grep '^SUMMARY ' "$TMP/driver.out" | tail -1 )" +getn(){ printf '%s\n' "$SUMMARY" | tr ' ' '\n' | sed -n "s/^$1=//p" | head -1; } +if [ -z "$SUMMARY" ]; then + no "the driver printed no SUMMARY line (rc=$drc): the session did not run to its end" +else + for key in checks fails cycles stamp content edit switch release cold changed missed san; do + val="$( getn "$key" )" + case "$val" in ''|*[!0-9-]*) no "SUMMARY field $key='$val' is not numeric: $SUMMARY"; val=-9;; esac + eval "S_$key=\$val" + done + if [ "${S_cycles:--9}" = "$CYCLES" ]; then ok "all $CYCLES cycles ran (checks=$S_checks)"; else no "cycles=$S_cycles, expected $CYCLES"; fi + if [ "${S_san:--9}" = 0 ]; then ok "zero sanitizer reports (ASAN_OPTIONS halt_on_error=1 abort_on_error=1 log_path; log directory and stderr empty of reports)"; else no "sanitizer reports: $S_san"; fi + if [ "${S_stamp:--9}" -ge "$CYCLES" ]; then ok "stamp-keeping rebuilds proved by rebuilt=1: $S_stamp"; else no "only $S_stamp stamp-keeping rebuilds were proved (need >= $CYCLES)"; fi + if [ "${S_content:--9}" -ge "$CYCLES" ]; then ok "content rebuilds proved: $S_content (MCP edit-verb cycles: $S_edit)"; else no "only $S_content content rebuilds were proved"; fi + if [ "${S_switch:--9}" -ge $(( CYCLES * 3 )) ]; then ok "root-switch / workspace rebuilds proved: $S_switch"; else no "only $S_switch root-switch rebuilds were proved (need >= $(( CYCLES * 3 )))"; fi + if [ "${S_cold:--9}" -ge "$CYCLES" ]; then ok "cold-server rank comparisons: $S_cold"; else no "only $S_cold cold-server comparisons ran"; fi + if [ "${S_changed:--9}" -ge "$CYCLES" ]; then ok "every content mutation changed a compared answer ($S_changed/$CYCLES)"; else no "only $S_changed of $CYCLES mutations changed a compared answer"; fi + # the release arm needs the host to meet two premises (R's parse inside the memory guard's 5 s clock; the footprint back + # under the 64M floor after the release). Each missed attempt is counted and named above; with NONE proven the arm is a + # named SKIP, never a pass, and the other arms still stand. + if [ "${S_release:--9}" -ge 1 ]; then + ok "memory-release rebuilds proved (rebuilt=1 on an unchanged tree, in a session that released the index): $S_release (premise misses: $S_missed)" + elif [ "${S_release:--9}" = 0 ]; then + skip "memory-release arm: not one release session met its premises on this host in $S_missed attempts (a parse past the memory guard's 5 s clock, or a footprint that stayed over/under the 64M floor); the stamp-keeping, content and root-switch arms are unaffected" + else + no "memory-release counter missing" + fi + if [ "${S_fails:--9}" = 0 ]; then ok "no comparison failed"; else no "$S_fails comparison(s) failed"; fi +fi +if [ "$drc" = 0 ]; then ok "the session driver exited 0"; else no "the session driver exited $drc (124 = the ${SESSION_CAP} s cap)"; fi +# an orphan is a failure of this gate, whatever else passed (no orphans) +if pgrep -f "$TMP" >/dev/null 2>&1; then + no "a process started by this gate is still running: $( pgrep -fl "$TMP" | head -3 | tr '\n' ' ' )" + pkill -f "$TMP" 2>/dev/null +else + ok "no process of this gate outlives it" +fi +echo "mcpreloadasancheck: ${ELAPSED}s" +# keep the sanitizer reports past the mktemp cleanup, where CI's failure upload (/tmp/asanlog*) finds them: the FAIL row above +# keeps only the first 3000 characters of one report, and the freed-by / allocated-by stacks are the part that follows +if [ "$fail" -ne 0 ] && [ -n "$( ls -A "$TMP/san" 2>/dev/null )" ]; then + KEEP="$( mktemp -d "${RIPWIRE_SAN_KEEP_DIR:-/tmp}/asanlog-mcpreload.XXXXXX" 2>/dev/null )" \ + && cp "$TMP"/san/* "$TMP"/*.err "$KEEP"/ 2>/dev/null \ + && echo "mcpreloadasancheck: sanitizer reports kept in $KEEP" +fi +if [ "$fail" -eq 0 ]; then echo "mcpreloadasancheck: ALL PASS"; else echo "mcpreloadasancheck: FAIL"; fi +exit "$fail" diff --git a/test/mcptranchecheck.sh b/test/mcptranchecheck.sh index 1dc0bb776..add2882ab 100755 --- a/test/mcptranchecheck.sh +++ b/test/mcptranchecheck.sh @@ -186,9 +186,26 @@ IMP_CLI="$( "$BIN" "$ROOT" --impact=escapeXml --limit=3 --offset=2 --legend=full [ "$IMP_BAT" = "$IMP_CLI" ] \ && ok "M4 [batch]: paged is byte-identical to the CLI page" \ || no "M4 [batch]: CLI [$IMP_CLI] vs batch [$IMP_BAT]" -WH="$( mcp_text "$( call whereis '{"path":"'"$ROOT"'","symbol":"runMcp","limit":2}' )" | tr '<' '\n' | grep '^whereis ' | head -1 )" +# lane/lean-answers-068: the whereis default may list only the definitions (listing="defs", references counted), and on a +# single-branch checkout runMcp has ONE def row, so limit=2 there cannot show 2. The arm keeps its exact assertion on the +# whole list (listing:"all", the pre-listing answer); the twin below holds limit= on the default's LISTED rows. +WH="$( mcp_text "$( call whereis '{"path":"'"$ROOT"'","symbol":"runMcp","limit":2,"listing":"all"}' )" | tr '<' '\n' | grep '^whereis ' | head -1 )" case "$WH" in *'shown="2"'*'has_more="1"'*'next_offset="2"'*) ok "M4 [live]: whereis honors limit= and discloses has_more/next_offset";; *) no "M4 [live]: whereis still ignores limit= — $WH";; esac +WHD="$( mcp_text "$( call whereis '{"path":"'"$ROOT"'","symbol":"runMcp","limit":1}' )" | tr '<' '\n' | grep '^whereis ' | head -1 )" +WHD_TOTAL="$( printf '%s' "$WHD" | grep -oE ' total="[0-9]+"' | grep -oE '[0-9]+' )" +WHD_SHOWN="$( printf '%s' "$WHD" | grep -oE ' shown="[0-9]+"' | grep -oE '[0-9]+' )" +if [ -n "$WHD_TOTAL" ] && [ -n "$WHD_SHOWN" ] && [ "$WHD_TOTAL" -ge 1 ] && [ "$WHD_SHOWN" = "1" ]; then + if [ "$WHD_TOTAL" -gt 1 ]; then + case "$WHD" in *'has_more="1"'*'next_offset="1"'*) ok "M4 [live, default listing twin]: limit=1 shows 1 of the $WHD_TOTAL listed rows and discloses has_more/next_offset";; + *) no "M4 [live, default listing twin]: $WHD_TOTAL listed rows, limit=1, no has_more/next_offset — $WHD";; esac + else + case "$WHD" in *'has_more="0"'*) ok "M4 [live, default listing twin]: limit=1 shows the 1 listed row, has_more=0";; + *) no "M4 [live, default listing twin]: 1 listed row but has_more is not 0 — $WHD";; esac + fi +else + no "M4 [live, default listing twin]: limit=1 on the default listing — shown='${WHD_SHOWN:-none}' total='${WHD_TOTAL:-none}' — $WHD" +fi echo echo "=== M5 — quality_delta speaks the CLI's key vocabulary ===" diff --git a/test/mcptwinclaimscheck.sh b/test/mcptwinclaimscheck.sh index 3b2593d84..013ad81e6 100755 --- a/test/mcptwinclaimscheck.sh +++ b/test/mcptwinclaimscheck.sh @@ -100,8 +100,10 @@ def headRows( xml ): return rows # ── (A) whereis: the call site on use.c:4 is a reference on BOTH surfaces, labelled by the same mechanism ── -cx, _, _ = cli( [ "--whereis=helper" ] ) -mx, merr = mcp( "whereis", { "path": FIX, "symbol": "helper" } ) +# lean-answers lane: the use.c:4 row is a kind="ref" row, which the DEFAULT listing counts rather than prints, so (A) reads +# the whole list on both surfaces (--whereis-listing=all / listing:"all"); (A-default) below is its twin on the default. +cx, _, _ = cli( [ "--whereis=helper", "--whereis-listing=all" ] ) +mx, merr = mcp( "whereis", { "path": FIX, "symbol": "helper", "listing": "all" } ) if mx is None: no( "(A) MCP whereis refused: %s" % merr ) else: @@ -112,6 +114,18 @@ else: hl = lambda x: ( re.search( r'head_labels="([^"]*)"', x ) or [ None, "" ] )[ 1 ] ( ok if hl( cx ) == hl( mx ) == "index" else no )( "(A) head_labels= is index on both surfaces (cli=%s mcp=%s)" % ( hl( cx ), hl( mx ) ) ) +# ── (A-default) the twin on the default listing: the same def rows on both surfaces, the ref rows counted identically ── +cd, _, _ = cli( [ "--whereis=helper" ] ) +md, mderr = mcp( "whereis", { "path": FIX, "symbol": "helper" } ) +if md is None: + no( "(A-default) MCP whereis refused: %s" % mderr ) +else: + crd, mrd = headRows( cd ), headRows( md ) + rc = lambda x: ( re.search( r'), not printed, on both surfaces (mcp count=%s)" % ( rc( cd ), rc( md ) ) ) + # ── (B) uses: a refusal claims only what was checked ── _, cerr, crc = cli( [ "--uses=valueToken" ] ) ( ok if crc != 0 and "no indexed definition" in cerr else no )( "(B) CLI --uses=valueToken refuses on no indexed definition (fixture sanity; rc=%d)" % crc ) diff --git a/test/oswin32logiccheck.sh b/test/oswin32logiccheck.sh index 0551cff24..86bc2eeb7 100755 --- a/test/oswin32logiccheck.sh +++ b/test/oswin32logiccheck.sh @@ -32,9 +32,11 @@ ok(){ printf ' PASS %s\n' "$*" || { fail=1; printf ' FAIL could not write th no(){ printf ' FAIL %s\n' "$*"; fail=1; } . "$ROOT/scripts/cxxstd.sh" +. "$ROOT/scripts/asanprobe.sh" CXXSTD="$( ripwire_cxx_std_flag "$CXX" )" SRC="$ROOT/test/verify_os_win32_logic.cpp" -WORK="$( mktemp -d )"; trap 'rm -rf "$WORK"' EXIT +WORK="$( mktemp -d )"; trap 'gate_bounded_reap; rm -rf "$WORK"' EXIT; gate_bounded_arm +ASAN_SKIPPED="" MIN_ASSERTIONS=4000000 # the full-range UTF round trip alone is 3.3 M assertions; far below this, something did not run CM_ARGS=() if [ "${OS:-}" = Windows_NT ]; then @@ -75,8 +77,14 @@ SAN="-fsanitize=address,undefined -fno-sanitize-recover=all -fno-omit-frame-poin if "$CXX" -fsanitize=integer -x c++ -o /dev/null - <<<'int main(){}' >/dev/null 2>&1; then SAN="-fsanitize=address,undefined,integer -fno-sanitize-recover=all -fno-omit-frame-pointer" fi -if "$CXX" "$CXXSTD" -O1 -g $SAN -I"$ROOT/src" -I"$ROOT/third_party/deps/doctest" "$SRC" -o "$WORK/san" > "$WORK/san.log" 2>&1; then - ASAN_OPTIONS=detect_leaks=0:halt_on_error=1 UBSAN_OPTIONS=halt_on_error=1:print_stacktrace=1 "$WORK/san" > "$WORK/san.out" 2>&1 +# Deliberately NOT switched to llvm@22 (unlike connectcorecheck): its libc++ 22 trips an -fsanitize=integer report in +# itself (CONTRIBUTING.md, Sanitizer build), which would turn a hang into a red that is not ripwire's. Probe the +# compiler the caller chose; a start-up hang is a named missing premise, so this arm SKIPs and (A) and (C) still gate. +if ! ripwire_asan_probe "$CXX" "$WORK"; then + echo " SKIP (B) sanitizer run: $RIPWIRE_ASAN_PROBE_WHY. Set CXX to a toolchain whose ASan runtime starts, or update the Command Line Tools/Xcode (CONTRIBUTING.md, Sanitizer build)." + ASAN_SKIPPED=1 +elif "$CXX" "$CXXSTD" -O1 -g $SAN -I"$ROOT/src" -I"$ROOT/third_party/deps/doctest" "$SRC" -o "$WORK/san" > "$WORK/san.log" 2>&1; then + ASAN_OPTIONS=detect_leaks=0:halt_on_error=1 UBSAN_OPTIONS=halt_on_error=1:print_stacktrace=1 gate_bounded 300 "$WORK/san" > "$WORK/san.out" 2>&1 rc=$? read_counts "$WORK/san.out" if [ "$rc" -eq 0 ] && [ "$FAILED" = 0 ] && ! grep -qE 'runtime error|AddressSanitizer' "$WORK/san.out"; then @@ -102,7 +110,9 @@ else no "(C) the mutant build failed to compile"; head -20 "$WORK/mut.log" | sed 's/^/ /' fi -if [ "$fail" = 0 ]; then +if [ "$fail" = 0 ] && [ -n "$ASAN_SKIPPED" ]; then + echo "PASS (arms A and C; arm B was skipped above)" +elif [ "$fail" = 0 ]; then echo "ALL PASS" else echo "SOME CHECKS FAILED" diff --git a/test/pargates.py b/test/pargates.py index 7d4682fb1..ad67f5a50 100644 --- a/test/pargates.py +++ b/test/pargates.py @@ -252,6 +252,9 @@ def shard_plan_for(gate_names, n): "mcpframehonestycheck.sh": 900, # 2026-09-07 (first sharded CI run 34145918269): rc=124 at 300.1 s on three of # four Linux legs' shard 2 -- "exactly the cap" again. ~150 s local; a shard # job hands it fewer neighbours to hide behind than the whole suite did. + "mcpreloadasancheck.sh": 1800, # one long-lived sanitizer MCP session, 20 cycles, every answer checked against a one-shot CLI run + # (a few hundred of them); its own wall-clock cap is 1500 s (RIPWIRE_GATE_SESSION_CAP_SEC), this is + # the hang tripwire above it. Idle local wall time is in the lane report, not here. "knownitemcheck.sh": 900, # 2026-09-05: --eval-retrieval stopped sampling 150 symbols in PATH order and # now grades its whole population exhaustively (the sampler measured the corpus, # not the ranker -- docs/EVALS.md section 7). The gate runs it twice on src/ for diff --git a/test/partitioncheck.sh b/test/partitioncheck.sh index bcd65e356..2cf741ada 100755 --- a/test/partitioncheck.sh +++ b/test/partitioncheck.sh @@ -293,8 +293,28 @@ fi # legend DEFINES the partition envelope ( counts, each 's role/symbols/bytes/tokens=bytes/2.36, the # inner ctx attributes) that a single bundle does not carry, so its ratio measures those definitions, not repetition; the # default's own contract — ONE legend, none per slice — is asserted right after this arm. -"$BIN" "$ROOT" --pack-task="$TASK" --partition=3 --no-cache --legend=full >"$TMP/p10.part" 2>/dev/null -"$BIN" "$ROOT" --pack-task="$TASK" --no-cache --legend=full >"$TMP/p10.single" 2>/dev/null +# CORPUS PINNED 2026-10-08: the ratio is measured on this repo's tree AS OF P10_CORPUS_REV, not on the live checkout. +# Both legends carry DATA clauses besides their fixed prose — the single bundle's "[doc mentions: …]" and its +# "| bodies: kept X of Y | far: …" tail, the partitioned document's conditional 146 B b truncated= definition — so the +# DENOMINATOR is a function of whatever source the bundle ranks. Measured with ONE binary (cd87e30e) on the same day: +# the 60dd3b3f tree gives 2443 vs 1916 B (PASS); a lane tree that edits 14 src/ headers, none of them partition.h or a +# legend this arm reads, gives 2443 vs 1872 B (FAIL, 1.3x = 2433.6); src/ alone 2443 vs 1805; src/+docs/ 2297 vs 1772. Every lane edits the corpus this arm read, so the +# arm went red on lanes that never touched partition output. Pinned, the corpus moves only when this line does, and a +# change in either legend's prose — the thing the 1.3x bar is for — is the only thing left that can move the ratio. +# third_party/ and bench/ are left out of the archive: byte-identical result at the pin (2443/1916), 25 MB vs 107 MB. +# Run from INSIDE the corpus with a "." root, as the ceiling arms above do, so the checkout's path depth cannot enter. +# CI checks out full history (fetch-depth: 0), so the pinned commit is present on every lane; a shallow clone FAILs +# this arm by name rather than silently measuring the live tree. +P10_CORPUS_REV="60dd3b3f2246ce0284cfa4d4ed48694eafa69c2b" +P10_CORPUS="$TMP/p10corpus"; mkdir -p "$P10_CORPUS" +if git -C "$ROOT" cat-file -e "$P10_CORPUS_REV^{commit}" 2>/dev/null \ + && git -C "$ROOT" archive "$P10_CORPUS_REV" -- . ':(exclude)third_party' ':(exclude)bench' | tar -x -C "$P10_CORPUS"; then + ( cd "$P10_CORPUS" && "$BIN" . --pack-task="$TASK" --partition=3 --no-cache --legend=full ) >"$TMP/p10.part" 2>/dev/null + ( cd "$P10_CORPUS" && "$BIN" . --pack-task="$TASK" --no-cache --legend=full ) >"$TMP/p10.single" 2>/dev/null +else + no "P10: pinned corpus commit $P10_CORPUS_REV is not in this clone (shallow, or no .git) — run from a full clone" + : >"$TMP/p10.part"; : >"$TMP/p10.single" +fi read -r P10_PART P10_SINGLE P10_INNER < rows +# (nearest from= first, at most 3, gap_syms_capped="1" past that), a hint that says the search is incomplete, a next= +# that reads the rows' bodies — and never the text "no directed call path". When the cone met none, the old answer +# stands byte for byte. Ambiguous calls are NOT a gap (every candidate has an edge the search follows). The MCP twin +# (path_between) carries the same clause. +# +# ARMS +# (P1) declined: a platform-split callee (two same-language defs in sibling dirs, none in the caller's) — gaps=declined. +# (P2) value: a callback handed to .then() — gaps=value, the row names the function that hands it off. +# (P3) through: a call through a parameter — gaps=through. +# (P4) unresolved: a call whose only same-named definition is in another language — gaps=unresolved. +# (P5) runaway guard: 6 gap symbols — 3 rows, gap_syms="6", gap_syms_capped="1", nearest first. +# (P6) next= is pasteable: running it serves the gap row's body. +# (P7) name: run(obj) calls obj.process(), bound by name alone (via="name") to the same-directory A.process only; the +# tree's other/B.process (same language, same kind, calls the target) is never searched — gaps=name, row run. +# (N1) a fully resolved graph with truly no path keeps "no directed call path" and no gap attribute. +# (N2) a gap OUTSIDE the search cone (a declined call reachable only from elsewhere) does not count. +# (N3) ambiguous-only cone (a k-way split the search followed) with no path keeps "no directed call path". +# (N4) a found path (reachable="1") through a cone with gaps carries no gap clause. +# (N5) a function passed as a value that the search ALSO reaches by a call is not a gap. +# (N6) a via="name" call whose namesakes are a DIFFERENT language (Ruby) or a different KIND (a Python variable) is not a gap. +# (N7) a via="name" call whose unlisted namesake the search REACHED anyway (another cone symbol calls it) is not a gap: +# helper's B().process() lists only other/B.process, but pkg/x/A.process is in the cone through run, so it was searched. +# (N3) above doubles as the name-gap near miss: run's obj.process() is via="name" too, but it has an edge to EVERY +# same-language definition of process, so nothing was left unsearched. +# (M) MCP path_between carries the same searched/gaps/gap_syms/rows/next as the CLI (P1-P3, P7), and the same no-gap hint shape. +# +# Usage: test/pathgapcheck.sh [BIN] (BIN defaults to RIPWIRE_BIN, then build/ripwire) +set -u +ROOT="$( cd "$( dirname "$0" )/.." && pwd )" +BIN="${1:-${RIPWIRE_BIN:-$ROOT/build/ripwire}}" +[ "${BIN#/}" = "$BIN" ] && BIN="$ROOT/$BIN" +TMP="$( mktemp -d )"; trap 'rm -rf "$TMP"' EXIT +fail=0 +ok(){ printf ' PASS %s\n' "$*" || { fail=1; printf ' FAIL could not write the PASS line for: %s\n' "$*"; }; return 0; } +no(){ printf ' FAIL %s\n' "$*"; fail=1; } + +[ -x "$BIN" ] || { echo "no ripwire binary at $BIN — build first"; exit 2; } +command -v python3 >/dev/null 2>&1 || { echo "python3 required"; exit 2; } + +# ── fixtures ──────────────────────────────────────────────────────────────────────────────────────────────────────── +mkdir -p "$TMP/decl/core" "$TMP/decl/linux" "$TMP/decl/darwin" "$TMP/val" "$TMP/thr" "$TMP/unr" "$TMP/clean" \ + "$TMP/out/core" "$TMP/out/linux" "$TMP/out/darwin" "$TMP/amb" "$TMP/many/core" "$TMP/many/linux" "$TMP/many/darwin" \ + "$TMP/found" "$TMP/valin" "$TMP/lpy/pkg/x" "$TMP/lpy/other" "$TMP/lpyx/pkg/x" "$TMP/lpyx/other" \ + "$TMP/lpyin/pkg/x" "$TMP/lpyin/other" +cat > "$TMP/decl/core/machine.c" <<'EOF' +void Machine_scan( void ); +void Machine_run( void ) { Machine_scan(); } +EOF +cat > "$TMP/decl/linux/scan.c" <<'EOF' +void Process_update( void ) { } +void Machine_scan( void ) { Process_update(); } +EOF +cat > "$TMP/decl/darwin/scan.c" <<'EOF' +void Machine_scan( void ) { } +EOF +cat > "$TMP/val/app.js" <<'EOF' +function respond(ctx) { return ctx; } +function handleResponse(ctx) { return respond(ctx); } +function runMiddleware(ctx) { return Promise.resolve(ctx); } +function handleRequest(ctx) { return runMiddleware(ctx).then(handleResponse); } +function listen() { return handleRequest({}); } +module.exports = { listen }; +EOF +cat > "$TMP/thr/t.py" <<'EOF' +def target(): + return 1 + +def apply(fn): + return fn() + +def start(): + return apply(None) + +def wire(): + return apply(target) +EOF +cat > "$TMP/unr/main.py" <<'EOF' +def finish(): + return 0 + +def begin(): + return render_page() +EOF +cat > "$TMP/unr/render.rb" <<'EOF' +def render_page + finish +end +EOF +cat > "$TMP/clean/c.c" <<'EOF' +static int d( void ) { return 1; } +static int c( void ) { return d(); } +static int b( void ) { return 2; } +int a( void ) { return b(); } +int z( void ) { return c(); } +EOF +# N2: the declined call lives in other(), which only elsewhere() reaches; start() -> helper() is fully resolved. +cat > "$TMP/out/core/m.c" <<'EOF' +void scan( void ); +static int sink( void ) { return 1; } +static int helper( void ) { return 2; } +int start( void ) { return helper(); } +int other( void ) { scan(); return 0; } +int elsewhere( void ) { return other() + sink(); } +EOF +cat > "$TMP/out/linux/s.c" <<'EOF' +void scan( void ) { } +EOF +cat > "$TMP/out/darwin/s.c" <<'EOF' +void scan( void ) { } +EOF +# N3: two same-file definitions of one method name: obj.process() splits over both (ambiguous, every arm an edge). +cat > "$TMP/amb/a.py" <<'EOF' +class A: + def process(self): + return 1 + +class B: + def process(self): + return 2 + +def unrelated(): + return 3 + +def run(obj): + return obj.process() +EOF +# P5: six functions each with a declined call (f1..f4, mid, root), all reachable from root(). +cat > "$TMP/many/core/m.c" <<'EOF' +void scan( void ); +static int end_( void ) { return 0; } +int f1( void ) { scan(); return 1; } +int f2( void ) { scan(); return 2; } +int f3( void ) { scan(); return 3; } +int f4( void ) { scan(); return 4; } +int mid( void ) { scan(); return f3() + f4(); } +int root( void ) { scan(); return f1() + f2() + mid(); } +EOF +cat > "$TMP/many/linux/s.c" <<'EOF' +void scan( void ) { } +EOF +cat > "$TMP/many/darwin/s.c" <<'EOF' +void scan( void ) { } +EOF +# N4: a real path through a cone that also holds a declined call. +mkdir -p "$TMP/found/core" "$TMP/found/linux" "$TMP/found/darwin" +cat > "$TMP/found/core/m.c" <<'EOF' +void scan( void ); +static int goal( void ) { return 1; } +int step( void ) { scan(); return goal(); } +int go( void ) { return step(); } +EOF +cat > "$TMP/found/linux/s.c" <<'EOF' +void scan( void ) { } +EOF +cat > "$TMP/found/darwin/s.c" <<'EOF' +void scan( void ) { } +EOF +# N5: handleResponse is passed as a value AND called directly, so the search reaches it anyway. +cat > "$TMP/valin/app.js" <<'EOF' +function other() { return 1; } +function handleResponse(ctx) { return ctx; } +function runMiddleware(ctx) { return Promise.resolve(ctx); } +function handleRequest(ctx) { handleResponse(ctx); return runMiddleware(ctx).then(handleResponse); } +module.exports = { handleRequest, other }; +EOF + +# P7 / N6: a call bound by name alone (FE-B via="name"). In lpy the namesake other/B.process is Python, a method, and +# calls the target; in lpyx the only other process definitions are a Ruby method and a Python variable. +cat > "$TMP/lpy/pkg/x/a.py" <<'EOF' +class A: + def process(self): + return 1 +EOF +cat > "$TMP/lpy/pkg/x/c.py" <<'EOF' +def run(obj): + return obj.process() +EOF +cat > "$TMP/lpy/other/b.py" <<'EOF' +def target(): + return 2 + +class B: + def process(self): + return target() +EOF +cp "$TMP/lpy/pkg/x/a.py" "$TMP/lpy/pkg/x/c.py" "$TMP/lpyx/pkg/x/" +cat > "$TMP/lpyx/other/b.rb" <<'EOF' +def target + 2 +end + +class B + def process + target + end +end +EOF +cat > "$TMP/lpyx/other/v.py" <<'EOF' +process = 3 +EOF +# N7: every namesake is in the cone. run reaches A.process (by name) and helper; helper's B().process() lists B.process +# only, and its unlisted namesake A.process is searched through run. Nothing reaches target. +cp "$TMP/lpy/pkg/x/a.py" "$TMP/lpyin/pkg/x/" +cat > "$TMP/lpyin/pkg/x/c.py" <<'EOF' +from other.b import helper +def run(obj): + obj.process() + helper() +EOF +cat > "$TMP/lpyin/other/b.py" <<'EOF' +class B: + def process(self): + return 2 +def helper(): + B().process() +def target(): + return 3 +EOF + +# One --path answer, run unpiped so rc is ripwire's; the head and the rest are read off the saved file. +path(){ # $1 dir, $2 FROM,TO, $3 out file + "$BIN" "$1" --no-cache --path="$2" > "$3" 2>&1 +} +attr(){ # $1 file, $2 attribute name -> its value on the root (empty if absent) + python3 - "$1" "$2" <<'PY' +import re, sys +t = open(sys.argv[1], encoding="utf-8", errors="replace").read() +m = re.search(r']*>', t) +if not m: sys.exit(0) +a = re.search(r'\s' + re.escape(sys.argv[2]) + r'="([^"]*)"', m.group(0)) +print(a.group(1) if a else "") +PY +} +rows(){ grep -o ']*/>' "$1"; } +premise(){ # $1 label, $2 rc, $3 file — the answer ran and printed a root (checklist 14: an absence is read only off a real answer) + if [ "$2" -ne 0 ] || ! grep -q ' root: $( head -c 300 "$3" )"; return 1; fi + return 0 +} +gapArm(){ # $1 label, $2 dir, $3 FROM,TO, $4 expected gaps=, $5 expected first row name + local f="$TMP/$( echo "$1" | tr -cd 'A-Za-z0-9' ).xml" rc + path "$2" "$3" "$f"; rc=$? + premise "$1" "$rc" "$f" || return + local g; g="$( attr "$f" gaps )" + local first; first="$( rows "$f" | head -1 | sed 's/.* n="\([^"]*\)".*/\1/' )" + if [ "$( attr "$f" reachable )" = "0" ] && [ "$g" = "$4" ] && ! grep -q 'no directed call path' "$f" \ + && [ -n "$( attr "$f" searched )" ] && [ "$first" = "$5" ] && grep -q 'search is incomplete' "$f"; then + ok "$1 reachable=0, gaps=\"$g\", first row $first, no \"no directed call path\"" + else + no "$1 expected gaps=\"$4\" first row $5 and no 'no directed call path'; got gaps=\"$g\" first=[$first]: $( grep -o ' "$f" 2>&1 +if [ "$( grep -o ']*n="process"[^>]*/>' "$f" | wc -l | tr -d ' ' )" = "1" ] && grep -q ']*n="process" p="pkg/x/a.py:2" via="name"' "$f"; then + ok "(P7) premise: run's obj.process() is via=\"name\" to pkg/x/a.py's A.process only" + gapArm "(P7) name" "$TMP/lpy" "run,target" "name:1" "run" +else + no "(P7) premise: run's callees are not the one via=name A.process row: $( grep -o ' "$TMP/next.xml" 2>&1; rc=$? + if [ "$rc" -eq 0 ] && grep -q 'then(handleResponse)' "$TMP/next.xml"; then + ok "(P6) next=\"$nx\" serves handleRequest's body, where the value hand-off is" + else + no "(P6) next=\"$nx\" rc=$rc did not serve the body: $( head -c 300 "$TMP/next.xml" )" + fi +else + no "(P6) next= on the value arm is [$nx], expected --expand=app.js:handleRequest" +fi + +echo "(N) near-miss negatives" +cleanArm "(N1) fully resolved, no path" "$TMP/clean" "a,d" +cleanArm "(N2) a gap outside the cone" "$TMP/out" "start,sink" +f="$TMP/ambprobe.xml"; "$BIN" "$TMP/amb" --no-cache > "$f" 2>&1 +if grep -q ']*n="run"[^>]*amb="1"' "$f"; then + ok "(N3) premise: run's obj.process() is an ambiguous split (amb=1 on its row)" + cleanArm "(N3) ambiguous-only cone" "$TMP/amb" "run,unrelated" +else + no "(N3) premise: run's call is not an ambiguous split here, the arm would assert nothing: $( grep -o ']*n="run"[^>]*>' "$f" )" +fi +f="$TMP/found.xml"; path "$TMP/found" "go,goal" "$f"; rc=$? +if premise "(N4)" "$rc" "$f"; then + if [ "$( attr "$f" reachable )" = "1" ] && [ -z "$( attr "$f" gaps )" ] && [ -z "$( attr "$f" hint )" ] && [ -z "$( rows "$f" )" ]; then + ok "(N4) a found path carries no gap clause and no hint" + else + no "(N4) found path: $( grep -o ' "$f" 2>&1 +if grep -q ']*n="process" p="pkg/x/a.py:2" via="name"' "$f"; then + cleanArm "(N6) via=name, namesakes of another language/kind" "$TMP/lpyx" "run,target" +else + no "(N6) premise: run's obj.process() is not via=name to A.process here: $( grep -o ' "$f" 2>&1; fh_rc=$? +f2="$TMP/lpyinrun.xml"; "$BIN" "$TMP/lpyin" --no-cache --callees=run > "$f2" 2>&1; fr_rc=$? +if [ "$fh_rc" -eq 0 ] && [ "$fr_rc" -eq 0 ] \ + && grep -q ']*n="process" p="other/b.py:2" via="name"' "$f" && ! grep -q 'p="pkg/x/a.py:2"' "$f" \ + && grep -q ']*n="process" p="pkg/x/a.py:2"' "$f2"; then + cleanArm "(N7) via=name, the unlisted namesake is in the searched cone" "$TMP/lpyin" "run,target" +else + no "(N7) premise: helper lists only other/B.process by name and run reaches pkg/x/A.process (rc=$fh_rc/$fr_rc): $( grep -o '; a TWIN runs the default session posture, whose answers after the +# first are legend="ref" and carry their root facts on the closing instead. Both must match the CLI. +for MPOST in inline session; do +python3 - "$BIN" "$TMP" "$MPOST" <<'PY' +import json, re, subprocess, sys +binp, tmp, posture = sys.argv[1], sys.argv[2], sys.argv[3] +cases = [ ( "decl", "Machine_run", "Process_update", True ), ( "val", "listen", "respond", True ), + ( "thr", "start", "target", True ), ( "lpy", "run", "target", True ), ( "clean", "a", "d", False ) ] +reqs = [ { "jsonrpc": "2.0", "id": 0, "method": "initialize", "params": { "protocolVersion": "2024-11-05", "capabilities": {}, "clientInfo": { "name": "g", "version": "1" } } } ] +for i, ( d, a, b, _ ) in enumerate( cases ): + reqs.append( { "jsonrpc": "2.0", "id": i + 1, "method": "tools/call", "params": { "name": "path_between", "arguments": { "path": tmp + "/" + d, "from": a, "to": b } } } ) +argv = [ binp, "--mcp" ] + ( [ "--mcp-legend=inline" ] if posture == "inline" else [] ) +p = subprocess.run( argv, input = "".join( json.dumps( r ) + "\n" for r in reqs ), capture_output = True, text = True, timeout = 120 ) +refs = 0 +resp = {} +for line in p.stdout.splitlines(): + try: + o = json.loads( line ) + except ValueError: + continue + if isinstance( o.get( "id" ), int ) and o[ "id" ] > 0: + resp[ o[ "id" ] ] = "".join( c.get( "text", "" ) for c in o.get( "result", {} ).get( "content", [] ) ) +def head( t ): + m = re.search( r']*>', t ) + return m.group( 0 ) if m else "" +def facts( t ): # the root's facts: , plus a legend="ref" answer's closing (where they move) + ab = re.search( r']*legend="ref"[^>]*/>', t ) + return head( t ) + ( ab.group( 0 ) if ab else "" ) +def pick( t, name ): + m = re.search( r'\s' + name + r'="([^"]*)"', facts( t ) ) + return m.group( 1 ) if m else None +bad = 0 +for i, ( d, a, b, gap ) in enumerate( cases ): + mcp = resp.get( i + 1, "" ) + refs += 1 if re.search( r']*legend="ref"', mcp ) else 0 + cli = subprocess.run( [ binp, tmp + "/" + d, "--no-cache", "--path=%s,%s" % ( a, b ) ], capture_output = True, text = True ).stdout + if not head( mcp ): + print( " MCP gave no root for", d, repr( mcp[:300] ), p.stderr[:300] ); bad = 1; continue + for name in ( "searched", "gaps", "gap_syms", "gap_syms_capped", "next", "reachable" ): + if pick( mcp, name ) != pick( cli, name ): + print( " %s: %s= MCP %r vs CLI %r" % ( d, name, pick( mcp, name ), pick( cli, name ) ) ); bad = 1 + if re.findall( r']*/>', mcp ) != re.findall( r']*/>', cli ): + print( " %s: gap rows differ" % d ); bad = 1 + if gap and ( "no directed call path" in mcp or "search is incomplete" not in mcp ): + print( " %s: MCP hint still claims no path" % d ); bad = 1 + if not gap and "no directed call path" not in mcp: + print( " %s: MCP lost the plain no-path hint" % d ); bad = 1 +# the posture premise: inline answers never take the ref posture; the session twin's answers after the first all do +if posture == "inline" and refs != 0: + print( " inline posture: %d answer(s) came back legend=\"ref\"" % refs ); bad = 1 +if posture == "session" and refs != len( cases ) - 1: + print( " session posture: %d of the %d later answers are legend=\"ref\" (the twin tests nothing new)" % ( refs, len( cases ) - 1 ) ); bad = 1 +sys.exit( bad ) +PY +mrc=$? +if [ "$mrc" -eq 0 ]; then + ok "(M) path_between ($MPOST legend posture): same searched/gaps/gap_syms/rows/next as the CLI on P1-P3 and P7, plain hint on N1" +else + no "(M) MCP path_between ($MPOST legend posture) disagrees with the CLI or the helper crashed (rc=$mrc, details above)" +fi +done + +if [ "$fail" -eq 0 ]; then echo "ALL PASS"; else echo "FAIL"; fi +exit "$fail" diff --git a/test/pincensuscheck.sh b/test/pincensuscheck.sh index 206391d61..08ee58637 100755 --- a/test/pincensuscheck.sh +++ b/test/pincensuscheck.sh @@ -20,8 +20,9 @@ # # THE FIXTURE (test/pincensusfix/, 2 Kotlin files, ~40 lines; Python until FE-A, whose bare call reaches no method — # test/falseedgecheck.sh) reproduces both shapes in the smallest form: -# pinned.kt — `Alpha.run` bare-calls `helper()`; `Alpha.helper` and `Beta.helper` both live in this -# file, so tier 1 keeps BOTH and S6-C decides: `pinned.kt::Alpha::` beats `pinned.kt::`. +# pinned.kt — `Alpha.run` bare-calls `helper()`; `Alpha.helper` and its BASE `Beta.helper` both live in this +# file (Beta became Alpha's base with FE-B, whose implicit receiver proves only the class and its +# bases), so tier 1 keeps BOTH and S6-C decides: `pinned.kt::Alpha::` beats `pinned.kt::Beta::`. # ONE edge, NO `amb=` — the silent pin. # tied.kt — `Eps.go` bare-calls `other()`; `Gamma.other` and `Delta.other` are SIBLINGS, so both # share exactly `tied.kt::` and NEITHER is more local. The tier stays full, the call diff --git a/test/pincensusfix/pinned.kt b/test/pincensusfix/pinned.kt index f04df9459..14f001b52 100644 --- a/test/pincensusfix/pinned.kt +++ b/test/pincensusfix/pinned.kt @@ -7,16 +7,16 @@ // Alpha.helper is pinned (the right answer: `this.helper()`), ONE confident edge is emitted, and `amb=` is NOT // incremented. That silent commitment is what the census exists to make visible. // -class Alpha { - fun helper(): Int { +class Alpha : Beta() { + override fun helper(): Int { return 1 } fun run(): Int { return helper() } } -class Beta { - fun helper(): Int { +open class Beta { + open fun helper(): Int { return 2 } } diff --git a/test/pmccheck.sh b/test/pmccheck.sh index d3b007c23..6d42edb5f 100755 --- a/test/pmccheck.sh +++ b/test/pmccheck.sh @@ -107,4 +107,27 @@ if [ "$STATE" = "INACTIVE" ]; then echo " PASS quiet degrade (INACTIVE, stderr carries only the report)" fi +# ── diagnostics compile: -DPROFILE_PMC_VERBOSE=1 must build (PMC_DIAG once spelled printf varargs against an emitter that +# had become (stream, text) only — the trace for "which arming step failed" was uncompilable exactly when wanted) and +# must stay on stderr: stdout is the data stream. The run's own contract is the same one asserted above. ────────────── +VBIN="$WORK/pmccheckharness_verbose" +if ! "$CXX" "$CXXSTD" -O2 -g -Wall -Wextra -pthread \ + -DPROFILE_ENABLED=1 -DPROFILE_AUTO_REPORT=0 -DPROFILE_PMC_VERBOSE=1 \ + -I"$ROOT/src/infra" -I"$ROOT/src" -I"$ROOT/third_party" \ + "$HARNESS" -o "$VBIN" 2> "$WORK/cc_verbose.log"; then + echo " FAIL verbose diagnostics (-DPROFILE_PMC_VERBOSE=1 failed to compile)"; sed 's/^/ /' "$WORK/cc_verbose.log" | head -10; exit 2 +fi +if ! "$VBIN" > "$WORK/out_verbose.log" 2> "$WORK/err_verbose.log"; then + echo " FAIL verbose diagnostics (harness contract violated under -DPROFILE_PMC_VERBOSE=1)"; exit 2 +fi +# the real backends (Apple Silicon, Linux) always trace something while arming; the inert stubs elsewhere cannot +case "$( uname -s )-$( uname -m )" in + Darwin-arm64|Linux-*) NEED_TRACE=yes ;; + *) NEED_TRACE=no ;; +esac +if grep -q 'prof::pmc:' "$WORK/out_verbose.log" || { [ "$NEED_TRACE" = yes ] && ! grep -q 'prof::pmc:' "$WORK/err_verbose.log"; }; then + echo " FAIL verbose diagnostics (the arming trace must reach stderr, and only stderr)"; exit 2 +fi +echo " PASS verbose diagnostics (-DPROFILE_PMC_VERBOSE=1 compiles; the trace is on stderr, not stdout)" + echo "pmccheck: PASS" diff --git a/test/pmccheck_harness.cpp b/test/pmccheck_harness.cpp index 3992a9ffb..8eef31105 100644 --- a/test/pmccheck_harness.cpp +++ b/test/pmccheck_harness.cpp @@ -87,6 +87,22 @@ static std::uint64_t busyWork( std::uint64_t iterationCount ) return acc; } +// teardown: release() hands the counters back (Apple: counting off + force-state restored; Linux: the calling +// thread's group closed) and ends the "active" claim, so a read after it is zeros in both states. It is +// idempotent (the exit reporter calls it again at static destruction). +static void checkRelease() +{ + prof::pmc::release(); + prof::pmc::release(); + const prof::pmc::Snapshot afterRelease = prof::pmc::read(); + bool allZero = true; + for( unsigned slot = 0; slot < prof::pmc::kMaxEvents; ++slot ) + { + allZero = allZero && afterRelease.values[ slot ] == 0; + } + check( !prof::pmc::active() && allZero, "release: counters handed back (inactive, reads zero) and a second release is harmless" ); +} + int main() { // a nested pair of scopes so the report has rows in both states (and the PMC bracket runs when active) @@ -160,5 +176,7 @@ int main() // script captures the streams separately to assert that and the quiet-degrade arm. prof::report(); + checkRelease(); + return g_fail; } diff --git a/test/printf_parity.manifest b/test/printf_parity.manifest index 41c8cfe8b..c44bce03f 100644 --- a/test/printf_parity.manifest +++ b/test/printf_parity.manifest @@ -9,7 +9,7 @@ pattern 0 b7a2467faab2b69e4fe8e3d8204f158329f6b349d644e5681af860ce6f8ba6ca e3b0c callers 0 31971e419a6df4d59c4464fcdd1afa56c6125e4fc76d096f0203c80cedd9a4a8 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 impact 0 c48ba09589d13cad6011bd6fadbb2898d68e2f5bd97a10bf44324901cfa08d49 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 clones 0 a082f92ce84cd952ced8341e3faff33a23685729bfa4ab6f0eda0f7867e41d7c e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 -help 0 c74a677dde09eb76a71bfd0b9e2c75d074c47ecfac838654f84df80af6725b02 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 +help 0 62646b47d6faa9f090934dc19830bfae16593386c7c361c3cedcf1a0084e1b84 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 expand 0 f3e60d37ab964c4772d794326420441e0b027d1970ea5fb0c60efe6c89f808b8 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 callees 0 cbd133fa53917bad05926d46615d4d8b4457ba9285513dc4b193fa57a1ef8b85 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 around 0 ff0b962658da396e7bf3360584de21ab2f0e8bebcd8c0e4e997a77994b004a9f e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 @@ -34,9 +34,9 @@ uses_json 1 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 96c grep_json 1 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 278827e2a7740aeeefa653b87ec47f74c98ec97a1758ce2e5426855a845a80c4 expand_missing 1 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 10f2dda880ec34c8d5a3f5bc50bd1bf50d0abe20c308de11ca8a16714f87b496 callers_missing 1 39a4cf4b2702863152edcc6c270255bac015a17d25e834dbdff181d809181326 0770e05552f4baf1c25d213e35c5f273c6f341e3c518e8233a83ff849abcd1c9 -safe_delete 0 8b99d83566b89332ac4b2967abbb39284db775bbed601628b97b8c7909f04ac2 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 +safe_delete 0 d010f9ca7e45d5bedcde9be49875bbc705632b3011cb9c8c4185f47957d51504 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 verify_layer 1 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 5288c345d7d6e335f88b9c1daa8935db22e1dcf89c0c8bc1f6140d4cb5af0b48 graph_query 1 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 bfa4319feb9dee09cfbd3991cf6fbd752297e99d14de9e75768820d2a9c8832f callers_limit 0 e40ee1ddba0fbf548c58c98b91fe052af8c34f8913edb3edd85245b12a6967b9 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 -help_all 0 5e82bb6e129da4086c1e64f5939be191964e5db6e7c49154edfee9d1cfdc44e6 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 +help_all 0 d6c3f9a001f3eb341b5bc7cec50a6647b7f2814cb28726ae310640ed977a7293 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 help_one 0 938ff4f8802a8f5d9cb10f9a1f3804ac755be44c8ab00e24e45bfe8255224a21 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 diff --git a/test/qchurncheck.sh b/test/qchurncheck.sh index 3800e0ae9..481ba8264 100755 --- a/test/qchurncheck.sh +++ b/test/qchurncheck.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# qchurncheck.sh — gate for Y2 (P2): quality::gitCoChangeAndChurnCached memoizes gitmine's +# qchurncheck.sh — gate for Y2 (P2): quality::gitRawCommitStreamCached memoizes gitmine's # `git log --name-only` walk (431 ms on a large private C++ corpus; every rich verb — --for, --metrics, # --exemplar — pays it once per invocation, main.cpp:5392/5404 call it via gitmine::gitCoChangeAndChurn). # @@ -43,7 +43,7 @@ echo "qchurncheck: BIN=$BIN" qchurnfiles(){ find "$QCACHE" -maxdepth 2 -type f -name 'ripwire-qchurn-*.bin' 2>/dev/null; } nqchurn(){ qchurnfiles | wc -l | tr -d ' '; } # name_only_count TRACEFILE — how many git child processes in this run's trace invoked --name-only (the -# expensive walk gitCoChangeAndChurnCached guards). Any OTHER git call this run makes (rev-parse, HEAD +# expensive walk gitRawCommitStreamCached guards). Any OTHER git call this run makes (rev-parse, HEAD # resolution, etc.) never contains "name-only", so this is a clean, argv-based signal — not a timing guess. name_only_count(){ grep -c "name-only" "$1" 2>/dev/null || true; } # run TRACEFILE ARGS... — invokes ripwire against $REPO with our private TMPDIR + a fresh GIT_TRACE file. diff --git a/test/qschemetrip.hash b/test/qschemetrip.hash index 373536ec8..3427110c1 100644 --- a/test/qschemetrip.hash +++ b/test/qschemetrip.hash @@ -1 +1 @@ -9fe75d7e33ccde42c2d7f3413786598e09784269d7378a520127681836283213 +a1e54fc30bfbc81a61b2a72823d5b9821575bc69d1bec0e878bdba09c9141af3 diff --git a/test/qschemetripcheck.sh b/test/qschemetripcheck.sh index 047629dbc..880cc12dc 100755 --- a/test/qschemetripcheck.sh +++ b/test/qschemetripcheck.sh @@ -34,12 +34,52 @@ SRC="$ROOT/src/quality.h" ING="$ROOT/src/ingest_cache.h" # extraction-identity constants moved here (2026-08-29 ingest.cpp section split); the hashed CONCAT label keeps its historical spelling so the pin holds PIN="$ROOT/test/qschemetrip.hash" # RE-PIN LOG (the pin is a bare hash, so its justification has to live here). +# 2026-10-08, train 26b final regeneration (all thirteen lanes merged, the path-gap name fix on top): RE-DERIVED ONCE with +# UPDATE_GOLDEN=1 on the merged tree (hash a1e54fc30b…9141af3). Watched text: kParserVer 156 (rich file tag 157; above +# FE-B's 154/155, honesty-small's and the cr2 follow-up's 145, cr-qd's 148), kCacheVersion 29 (FE-B's ref record), +# kQSnapCacheScheme 19 (the cr2 follow-up's dead-set move took 18 and cr-qd-kinds' defect-shape records also took 18 for a +# different scheme: renumbered 19, both quality.h notes kept), quality.h's mirrors 156/29, and cr-qd's defectSitesOf +# in the manifest. Replaces the PENDING line that stood here since the FE-B merge; the lane entries below are history. +# 2026-10-08, lane FE-B fix round 2 (review B4): RE-DERIVED with UPDATE_GOLDEN=1 (hash 34d7ef2e81…a5bd559d). kParserVer +# 152 -> 154 (binding spans and tombstones, type parameters; 153 is 152's rich tag); quality.h's mirror with it. +# kCacheVersion and kQSnapCacheScheme unchanged. The train renumbers if needed. +# 2026-10-07, lane FE-B fix round 1 (review B3): RE-DERIVED with UPDATE_GOLDEN=1 (hash e6abc48760…048773). kParserVer +# 150 -> 152 (Java/C#/Kotlin/Swift declarations record RecvType/MemberType; 151 is 150's rich tag), the quality.h +# mirror with it. kCacheVersion and kQSnapCacheScheme unchanged. The train renumbers if needed. +# 2026-10-07, lane FE-B merged onto train 26a (cd87e30e): RE-DERIVED with UPDATE_GOLDEN=1 (hash abf245ceff…678aeb). +# kParserVer 148 -> 150 (FE-B's receiver chain + bind kinds on top of #373; 149 is 148's rich tag), kCacheVersion +# 28 -> 29 (FE-B's ref record), quality.h mirrors with them. kQSnapCacheScheme stays 17. The train renumbers if needed. +# 2026-10-04, lane/fe-b-receiver-evidence: RE-DERIVED with UPDATE_GOLDEN=1 (hash 9e06e28ba5…36253dbb). kParserVer 141 -> 146 +# (above train 25's 143/144; the ref record gains the receiver chain memberPath/memberCtor and the receiver-evidence +# binding kinds), kCacheVersion 28 -> 29; quality.h's mirrors move with them. kQSnapCacheScheme stays 17: a snapshot's meaning is unchanged, and the +# edges it reads are re-derived under the new kParserVer. The train renumbers and re-derives. +# 2026-10-08, lane/cr-qd-kinds-068 fix round 1: RE-PINNED with UPDATE_GOLDEN=1. defectSitesOf collects the tree's +# macro names for the C++ scanner (format-arity's F1 skip) and the scanners record fewer sites; the blob shape is +# unchanged and 18 never shipped, so kQSnapCacheScheme stays 18 (its v18 note says so); the producer identity keeps +# this build's blobs apart from the phase-2 build's. +# 2026-10-08, lane/cr-qd-kinds-068 cleanup: RE-PINNED with UPDATE_GOLDEN=1 (hash 2fdb42dc0a…3c1b8d). Refactor only: +# computeSnapshot projects defectSitesOf's records with std::ranges::transform instead of a helper loop (the same +# records in the same order); kQSnapCacheScheme stays 18. +# 2026-10-07, lane/cr-qd-kinds-068 (the defect-shape kind): RE-DERIVED with UPDATE_GOLDEN=1 (hash 2244e7390c…a55ef7). +# kQSnapCacheScheme 17 -> 18: the blob gained the defect-shape (anchor, site) records after publicApi (serializeSnapshot, +# deserializeSnapshot and computeSnapshot all moved), a BLOB SHAPE change. The manifest gains defectSitesOf (the site +# identity the baseline stores); the scanners it calls live in defectshape.h, outside the manifest — disclosed in the +# quality.h comment. kParserVer / kCacheVersion unchanged. The train may renumber the scheme. # 2026-10-04, train 26a (PR #373, Ruby method lookup, merged onto main 255dc199): RE-DERIVED ONCE on the merged tree with # UPDATE_GOLDEN=1 (hash 9fe75d7e33…283213). The only watched text that moved is the kParserVer declaration: 143 -> 148, # above the branch's 145 and the 145–147 other branch builds have used (148's full-use file tag is 149); quality.h's # kIngestParserVerMirror moves with it. kCacheVersion stays main's 28 (the branch side said 27). kQSnapCacheScheme # stays 17: what a Snapshot means is unchanged — with kParserVer set back to 143, the merged tree hashes to main's pin # 32240f4552…2dafcc. The two feat/ruby-method-lookup entries below are the branch's own history. +# 2026-10-04, lane honesty-small-068: RE-DERIVED with UPDATE_GOLDEN=1 (hash a4e37a094b…735858). kParserVer 143 -> 145 (Go named +# types get their kind from the type_spec form and `type A = B` is indexed; 145 because 143's full-use file tag is 144) +# with quality.h's mirror; one hashed quality.h predicate reads isStructOrNamedType (behaviour-identical: the new kinds +# admit exactly what Struct did). kQSnapCacheScheme stays 17. A lane-local number: the train renumbers. +# 2026-10-04, train 25 re-review fixes: RE-DERIVED with UPDATE_GOLDEN=1 (hash a4e37a094b…735858). kParserVer 143 -> 145 +# (a prototype's / func type's parameter declares nothing in the enclosing scope; a file's value walk cut at +# kVrMaxDepth gains a depth-cut record; the remaining written key slots are capped) with quality.h's mirror; 145 +# because 143's full-use file tag is 144. kQSnapCacheScheme 17 -> 18: the dead set moved (a function that only +# stores itself is no longer value-referenced). No assertion changed. # 2026-10-04, train 25 review fixes: RE-DERIVED with UPDATE_GOLDEN=1 (hash 32240f4552…2dafcc). kParserVer 141 -> 143 (two # extraction changes: a declaration named like the JS global object is a JsShadow binding; value-reference slot text # is cut on a UTF-8 boundary and JS string keys are capped) with quality.h's mirror; 143 because 141's full-use file diff --git a/test/qsnapproducercheck.sh b/test/qsnapproducercheck.sh index ed30d0c4d..76fceb948 100755 --- a/test/qsnapproducercheck.sh +++ b/test/qsnapproducercheck.sh @@ -190,6 +190,7 @@ def parse(path, sha): dead = [struct.unpack_from(" "$WORK/rosetta_probe.cpp" if "$CXX" "$CXXSTD" -arch x86_64 $SAN -fno-sanitize-recover=all "$WORK/rosetta_probe.cpp" -o "$WORK/rosetta_probe" 2>/dev/null \ - && "$WORK/rosetta_probe" 2>/dev/null; then + && gate_bounded 10 "$WORK/rosetta_probe" 2>/dev/null; then run_pass rosetta-x86_64 "SSE" -arch x86_64 else echo " SKIP [rosetta-x86_64] Rosetta or the x86_64 sanitizer runtime is unavailable — SSE2 kernels NOT gated on this machine" diff --git a/test/recallshapecheck.sh b/test/recallshapecheck.sh index 640842f2b..f55584c5b 100755 --- a/test/recallshapecheck.sh +++ b/test/recallshapecheck.sh @@ -1615,6 +1615,250 @@ vri_arm "RV3 MCP session: a chmod rebuilds the index with unchanged content — vri_arm "RV4 MCP session: a new non-source file rebuilds the index — the value-ref index is rebuilt too (vri=1), same rows" 's["got"][3] == ["1","1"] and s["rows"][3] == s["rows"][0]' vri_arm "RV5 MCP session: the server exited 0 with no sanitizer report (meaningful on an ASan build)" 's["rc"] == 0 and s["san"] == 0' +# ── re-review fixes (CodeRabbit's second pass on the reference-as-value round) ───────────────────────────────────── +# RP: a parameter declares its name only inside the function that OWNS the list — a prototype, a function-pointer +# typedef or parameter, a C++ member declaration, a Go func type / interface method and a TS function type, `declare +# function` or interface signature hide nothing (they used to land in the enclosing scope: at file scope they hid the +# function in the whole file). Near misses: a defined function's own parameter still hides it. +# RS: a function that only stores ITSELF (`timer_set( tick )` inside tick) is still dead — the CSR drops a recursive +# self-call too; one stored by another function is not. --safe-delete reads the same rule; the row is still served. +# RD: --callees rows are per binding site: two calls through one slot are one row, and sites= counts binding sites. +# RZ: the value walk's depth cap is disclosed (value_refs_depth_capped= / value_refs_depth_at=) on every answer that +# reads value references for the cut file's language family — absent below the cap, absent for another family. +# RK: every written key slot is capped and newline-free (a C `[expr]` designator, a numeric key, an identifier key). +echo "-- re-review fixes" +mkdir -p "$FX/proto" "$FX/protogo" "$FX/protots" "$FX/self" "$FX/dup" "$FX/deep" "$FX/deep2" "$FX/deepneg" "$FX/keys" +printf 'void handler(int sig) { (void)sig; }\n' >"$FX/proto/a.c" +cat >"$FX/proto/b.c" <<'EOF' +#include +extern void handler(int); +void set_handler(void (*handler)(int)); /* @P_PROTO */ +typedef void (*fn_t)(int handler); /* @P_TYPEDEF */ +void install(void) { signal(SIGINT, handler); } /* @P_USE */ +void wrap(void (*handler)(int)) { signal(SIGINT, handler); } /* @P_PARAM */ +void wrap2(void (*cb)(int handler)) { signal(SIGINT, handler); } /* @P_INNER */ +EOF +cat >"$FX/proto/c.cpp" <<'EOF' +void hook(int x) { (void)x; } +int reg(void (*f)(int)); +struct S { void set(int hook); int go() { return reg(hook); } }; // @P_MEMBER +EOF +printf 'module example.com/p\n\ngo 1.22\n' >"$FX/protogo/go.mod" +printf 'package p\n\nfunc w(x int) int { return x }\n' >"$FX/protogo/a.go" +cat >"$FX/protogo/b.go" <<'EOF' +package p + +type H func(w int) int // @G_TYPE + +type I interface{ Do(w int) int } // @G_IFACE + +func Use() H { return w } // @G_USE + +func Run(w int) H { _ = w; return nil } // @G_PARAM +EOF +printf 'export function onDone(x: number) { return x; }\n' >"$FX/protots/a.ts" +cat >"$FX/protots/b.ts" <<'EOF' +import { onDone } from './a'; +type Cb = (onDone: number) => void; // @T_TYPE +declare function later(onDone: number): void; // @T_DECL +interface Hooks { fire(onDone: number): void; } // @T_IFACE +export function go(): (x: number) => number { return onDone; } // @T_USE +export function wrapT(onDone: number) { return [onDone]; } // @T_PARAM +EOF +arm "RP1 callers handler: a prototype's and a typedef's parameter named handler hide nothing in the file" \ + proto --callers=a.c:handler attr:value_refs=2 nvr:2 'vr:bind=@P_USE;into=signal#arg1' 'vr:bind=@P_INNER;into=signal#arg1' +arm "RP2 callers handler (near miss): a defined function's own parameter still hides it" proto --callers=a.c:handler attr:value_refs=2 'novr:bind=@P_PARAM' +arm "RP3 callers hook (C++): a member DECLARATION's parameter is no class member — the method body still sees ::hook" \ + proto --callers=c.cpp:hook attr:value_refs=1 'vr:bind=@P_MEMBER;into=reg#arg0' +arm "RP4 callers w (Go): a func type's and an interface method's parameter hide nothing; a function's own parameter does" \ + protogo --callers=a.go:w attr:value_refs=1 nvr:1 'vr:bind=@G_USE;into=(return)' 'novr:bind=@G_PARAM' +arm "RP5 callers onDone (TS): a function type's, a declare function's and an interface signature's parameter hide nothing; a function's own parameter does" \ + protots --callers=a.ts:onDone attr:value_refs=1 nvr:1 'vr:bind=@T_USE;into=(return)' 'novr:bind=@T_PARAM' + +cat >"$FX/self/t.c" <<'EOF' +void timer_set(void (*f)(void)); +static void tick(void) { timer_set(tick); } /* @S_TICK */ +static void tock(void) { timer_set(tock); } +void boot(void) { timer_set(tock); } /* @S_BOOT */ +EOF +arm "RS1 dead-code: tick, stored only by itself, is listed; value-ref-excluded= counts tock alone" \ + self --dead-code attr:count=1 nd:1 'd:n=tick' 'nod:n=tock' attr:value-ref-excluded=1 'legend:in another function or at file scope' +arm "RS2 safe-delete tick: a dead-code candidate again — its self-row is still served and still a use site" \ + self --safe-delete=tick attr:dead_code_candidate=1 attr:value_refs=1 attr:uses=1 'vr:bind=@S_TICK;in_id=tick' +arm "RS3 safe-delete tock (near miss): another function stores it — not a candidate" \ + self --safe-delete=tock attr:dead_code_candidate=0 attr:value_refs=2 'vr:bind=@S_BOOT;in_id=boot' + +cat >"$FX/dup/t.js" <<'EOF' +function f() { return 1; } +function g() { return 2; } +function h() { return 3; } +const TABLE = { k: g }; // @D_TABLE +const T2 = { a: h, b: h }; +function store() { const tbl = {}; tbl.k = f; tbl.k(); tbl.k(); return tbl; } // @D_STORE +function store2() { const t = {}; t.k = f; t.j = f; t.j(); t.k(); return t; } +function run() { TABLE.k(); TABLE.k(); } +function run2(x) { T2[x](); T2[x](); } +module.exports = { store, store2, run, run2 }; +EOF +arm "RD1 callees store: two calls through one stored slot are ONE row" \ + dup --callees=store attr:value_refs=1 el:vrs:total=1 nvr:1 'vr:to=f;bind=@D_STORE;into=tbl.k;through=tbl.k;sites=-' +arm "RD2 callees run: two calls through a slot one site fills — sites= absent (one binding site)" \ + dup --callees=run attr:value_refs=1 nvr:1 'vr:to=g;bind=@D_TABLE;through=TABLE.k;sites=-' +arm "RD3 callees run2 (near miss): two binding sites behind one computed slot, called twice — sites=2, not 4" \ + dup --callees=run2 attr:value_refs=1 nvr:1 'vr:to=h;through=T2[x];sites=2' +arm "RD4 callees store2 (near miss): two binding sites in one function are two rows, each with its own call (called in reverse order)" \ + dup --callees=store2 attr:value_refs=2 nvr:2 'vr:to=f;into=t.k;through=t.k' 'vr:to=f;into=t.j;through=t.j' +cli dup --callees=store "$TMP/cli.dupstore.xml" +mcp_call find_symbol '{"path":"'"$FX/dup"'","symbol":"store"}' >"$TMP/mcp.dupstore.json" +verdict "RD5 MCP find_symbol(store).valueCallees == CLI --callees (one row)" "$( python3 "$TMP/par.py" json "$TMP/cli.dupstore.xml" "$TMP/mcp.dupstore.json" valueCallees )" +cli dup --callees=run2 "$TMP/cli.duprun2.xml" +mcp_call find_symbol '{"path":"'"$FX/dup"'","symbol":"run2"}' >"$TMP/mcp.duprun2.json" +verdict "RD6 MCP find_symbol(run2).valueCallees == CLI --callees (sites=2)" "$( python3 "$TMP/par.py" json "$TMP/cli.duprun2.xml" "$TMP/mcp.duprun2.json" valueCallees )" + +python3 - "$FX" <<'EOF' +import os, sys +fx = sys.argv[1] +s = "static int handler(int x) { return x; }\nint reg(int (*f)(int));\nint pick(int k) {\n if (k == 0) return 0;\n" +for i in range( 1, 300 ): + s += " else if (k == %d) return %d;\n" % ( i, i ) +s += " else return reg(handler);\n}\n" +open( os.path.join( fx, "deep", "elif.c" ), "w" ).write( s ) # a 300-link else-if chain nests past the 512-level cap +open( os.path.join( fx, "deep", "side.py" ), "w" ).write( "def py_fn():\n return 1\n\nPT = [py_fn]\n" ) +# deep2: the same chain, plus a SHALLOW store of the same function — the shape --callers' next=--uses sends a reader to: +# the shallow use is a row, the one below the cap is not, and a count that reads as complete is the false answer. +d2 = ( "static void handler( int s ) { (void)s; }\nvoid reg( void (*)( int ) );\n" + "static void table_user( void ) { reg( handler ); } /* @DZ_SHALLOW */\nvoid run( int x )\n{\n if( x == 0 ) { }\n" ) +for i in range( 1, 300 ): + d2 += " else if( x == %d ) { }\n" % i +d2 += " else { reg( handler ); }\n}\n" +open( os.path.join( fx, "deep2", "elif.c" ), "w" ).write( d2 ) +open( os.path.join( fx, "deepneg", "near.c" ), "w" ).write( + "static int handler(int x) { return x; }\nint reg(int (*f)(int));\nint install(void) { return reg(" + + "(" * 200 + "handler" + ")" * 200 + "); }\n" ) # deep, but under the cap +EOF +arm "RZ1 callers handler: the use sits below the value walk's depth cap — no row, and the root says the count is a floor" \ + deep --callers=handler attr:defs=1 noattr:value_refs attr:value_refs_depth_capped=1 'attr:value_refs_depth_at!=' 'legend:value_refs_depth_capped=' 'legend:value_refs_depth_at=' +cli deep --callers=handler "$TMP/cli.deep.xml" +res="$( python3 - "$TMP/cli.deep.xml" <<'EOF' +import re, sys +m = re.search( r'value_refs_depth_at="elif\.c:([0-9]+)"', open( sys.argv[1], encoding="utf-8" ).read() ) +print( "OK" if m and 5 <= int( m.group( 1 ) ) <= 303 else "FAIL value_refs_depth_at is not a line of the else-if chain: %r" % ( m.group( 0 ) if m else None ) ) +EOF +)" +verdict "RZ2 value_refs_depth_at= names the cut file and a line inside the chain (the clue where to read)" "$res" +arm "RZ3 dead-code: handler is listed AND the root says a table below the cap would be unseen" \ + deep --dead-code 'd:n=handler' attr:value_refs_depth_capped=1 'attr:value_refs_depth_at!=' 'legend:value_refs_depth_capped=' +arm "RZ4 safe-delete / impact carry the same disclosure (every surface that reads value references)" \ + deep --safe-delete=handler attr:dead_code_candidate=1 attr:value_refs_depth_capped=1 'legend:value_refs_depth_capped=' +arm "RZ4b impact handler: the disclosure beside reaches=" deep --impact=handler attr:value_refs_depth_capped=1 'legend:value_refs_depth_capped=' +arm "RZ5 callers py_fn (near miss): a Python function — the C file's cut is another family's, no disclosure" \ + deep --callers=py_fn attr:value_refs=1 noattr:value_refs_depth_capped noattr:value_refs_depth_at 'nolegend:value_refs_depth_capped=' +arm "RZ6 callers handler (near miss): 200 levels deep is under the cap — the row, no disclosure" \ + deepneg --callers=handler attr:value_refs=1 nvr:1 noattr:value_refs_depth_capped +arm "RZ6b dead-code (near miss): under the cap, handler is held and no disclosure rides" \ + deepneg --dead-code attr:value-ref-excluded=1 'nod:n=handler' noattr:value_refs_depth_capped +( cd "$FX/deep" && "$BIN" . --callers=handler --no-cache --json >"$TMP/j.deep.json" 2>/dev/null ) +mcp_call find_referencing_symbols '{"path":"'"$FX/deep"'","symbol":"handler"}' >"$TMP/mcp.deep.json" +mcp_call find_symbol '{"path":"'"$FX/deep"'","symbol":"handler"}' >"$TMP/mcp.deep2.json" +res="$( python3 - "$TMP/cli.deep.xml" "$TMP/j.deep.json" "$TMP/mcp.deep.json" "$TMP/mcp.deep2.json" <<'EOF' +import json, re, sys +x = open( sys.argv[1], encoding="utf-8" ).read() +want = ( re.search( r'value_refs_depth_capped="([0-9]+)"', x ), re.search( r'value_refs_depth_at="([^"]*)"', x ) ) +if not all( want ): + print( "FAIL the CLI XML carries no depth disclosure" ); raise SystemExit +want = ( int( want[0].group( 1 ) ), want[1].group( 1 ) ) +for f in sys.argv[2:]: + raw = open( f ).read() + try: + j = json.loads( raw ) + except ValueError: + print( "FAIL %s is not JSON: %r" % ( f.rsplit( "/", 1 )[-1], raw[:120] ) ); raise SystemExit + got = ( j.get( "value_refs_depth_capped" ), j.get( "value_refs_depth_at" ) ) + if got != want: + print( "FAIL %s carries %r, the CLI %r" % ( f.rsplit( "/", 1 )[-1], got, want ) ); raise SystemExit +print( "OK" ) +EOF +)" +verdict "RZ7 CLI --json, MCP find_referencing_symbols and find_symbol carry the CLI's value_refs_depth_capped / _at" "$res" +cache="$TMP/deepcache"; mkdir -p "$cache" +( cd "$FX/deep" && TMPDIR="$cache/" "$BIN" . --callers=handler >"$TMP/deep.cold.xml" 2>/dev/null; TMPDIR="$cache/" "$BIN" . --callers=handler >"$TMP/deep.warm.xml" 2>/dev/null ) +if grep -q 'value_refs_depth_capped="1"' "$TMP/deep.warm.xml" && cmp -s "$TMP/deep.cold.xml" "$TMP/deep.warm.xml"; then + ok "RZ8 a WARM run (cached facts) discloses the cut exactly as the cold run did" +else + no "RZ8 the warm run lost or changed the depth disclosure: $( grep -o 'value_refs_depth[a-z_]*="[^"]*"' "$TMP/deep.warm.xml" | tr '\n' ' ' )" +fi +( cd "$FX/deep" && "$BIN" . --callers=handler --no-cache --legend=full >"$TMP/full.deep.xml" 2>/dev/null ) +run_check "RZ9 callers legend (full) defines value_refs_depth_capped= and value_refs_depth_at=" "$FX/deep" "$TMP/full.deep.xml" \ + 'attr:value_refs_depth_capped=1' 'legend:value_refs_depth_capped=' 'legend:value_refs_depth_at=' + +# RZ10..RZ17: the readers a --callers answer's next= sends the reader to. --uses and --path read the same value references, +# so a count beside a cut file is a floor on them too (a shallow use is a row; the one below the cap is not). +arm "RZ10 uses handler: count=1 (the shallow store) and the root says the use below the cap is unseen" \ + deep2 --uses=handler attr:count=1 nu:1 'u:role=value;p=@DZ_SHALLOW' attr:value_refs_depth_capped=1 'attr:value_refs_depth_at!=' 'legend:value_refs_depth_capped=' 'legend:value_refs_depth_at=' +arm "RZ11 path run,handler: no directed call path, to_value_refs=1 (the shallow store) and the root says it is a floor" \ + deep2 --path=run,handler attr:reachable=0 attr:to_value_refs=1 attr:value_refs_depth_capped=1 'attr:value_refs_depth_at!=' 'legend:value_refs_depth_capped=' +arm "RZ11b callers handler: the disclosure --uses and --path repeat (one cut, read by every reader)" \ + deep2 --callers=handler attr:value_refs=1 attr:value_refs_depth_capped=1 'attr:value_refs_depth_at!=' +cli deep2 --uses=handler "$TMP/cli.deep2.uses.xml" +cli deep2 --path=run,handler "$TMP/cli.deep2.path.xml" +mcp_call uses '{"path":"'"$FX/deep2"'","symbol":"handler","legend":"compact"}' >"$TMP/mcp.deep2.uses.xml" +mcp_call path_between '{"path":"'"$FX/deep2"'","from":"run","to":"handler","legend":"compact"}' >"$TMP/mcp.deep2.path.xml" +run_check "RZ12 MCP uses: count=1 and the depth disclosure, root and legend, as the CLI" "$FX/deep2" "$TMP/mcp.deep2.uses.xml" \ + attr:count=1 'u:role=value;p=@DZ_SHALLOW' attr:value_refs_depth_capped=1 'attr:value_refs_depth_at!=' 'legend:value_refs_depth_capped=' 'legend:value_refs_depth_at=' +run_check "RZ13 MCP path_between: to_value_refs=1 and the depth disclosure, root and legend, as the CLI" "$FX/deep2" "$TMP/mcp.deep2.path.xml" \ + attr:reachable=0 attr:to_value_refs=1 attr:value_refs_depth_capped=1 'attr:value_refs_depth_at!=' 'legend:value_refs_depth_capped=' +res="$( python3 - "$TMP/cli.deep2.uses.xml" "$TMP/mcp.deep2.uses.xml" "$TMP/cli.deep2.path.xml" "$TMP/mcp.deep2.path.xml" <<'EOF' +import re, sys +def disc( f ): + x = open( f, encoding="utf-8" ).read() + root = re.search( r"<(uses|path)\b[^>]*>", x ).group( 0 ) + return re.findall( r'value_refs_depth_(?:capped|at)="[^"]*"', root ) +for cli, mcp in ( ( sys.argv[1], sys.argv[2] ), ( sys.argv[3], sys.argv[4] ) ): + a, b = disc( cli ), disc( mcp ) + if len( a ) != 2 or a != b: + print( "FAIL %s carries %r, %s carries %r" % ( cli.rsplit( "/", 1 )[-1], a, mcp.rsplit( "/", 1 )[-1], b ) ); raise SystemExit +print( "OK" ) +EOF +)" +verdict "RZ14 the CLI and the MCP twin carry byte-equal value_refs_depth_capped= / _at= on --uses and --path" "$res" +arm "RZ15 uses handler (near miss): 200 levels is under the cap — the row, no disclosure" \ + deepneg --uses=handler attr:count=1 'u:role=value' noattr:value_refs_depth_capped noattr:value_refs_depth_at 'nolegend:value_refs_depth_capped=' +arm "RZ15b path install,handler (near miss): under the cap — to_value_refs=1 and no disclosure" \ + deepneg --path=install,handler attr:reachable=0 attr:to_value_refs=1 noattr:value_refs_depth_capped noattr:value_refs_depth_at 'nolegend:value_refs_depth_capped=' +arm "RZ15c uses py_fn (near miss): a Python function beside the cut C file — another family, no disclosure" \ + deep --uses=py_fn 'u:role=value' noattr:value_refs_depth_capped noattr:value_refs_depth_at 'nolegend:value_refs_depth_capped=' +mcp_call uses '{"path":"'"$FX/deepneg"'","symbol":"handler","legend":"compact"}' >"$TMP/mcp.deepneg.uses.xml" +mcp_call path_between '{"path":"'"$FX/deepneg"'","from":"install","to":"handler","legend":"compact"}' >"$TMP/mcp.deepneg.path.xml" +run_check "RZ16 MCP uses (near miss): under the cap — the row, no disclosure" "$FX/deepneg" "$TMP/mcp.deepneg.uses.xml" \ + attr:count=1 'u:role=value' noattr:value_refs_depth_capped noattr:value_refs_depth_at 'nolegend:value_refs_depth_capped=' +run_check "RZ16b MCP path_between (near miss): under the cap — to_value_refs=1 and no disclosure" "$FX/deepneg" "$TMP/mcp.deepneg.path.xml" \ + attr:to_value_refs=1 noattr:value_refs_depth_capped noattr:value_refs_depth_at 'nolegend:value_refs_depth_capped=' +arm "RZ17 path run,reg where a directed path EXISTS (near miss): no value-reference read, so no disclosure" \ + deep2 --path=run,reg attr:reachable=1 noattr:to_value_refs noattr:value_refs_depth_capped 'nolegend:value_refs_depth_capped=' + +python3 - "$FX" >"$TMP/keys.expect" <<'EOF' +import os, sys +fx = sys.argv[1] +open( os.path.join( fx, "keys", "k.c" ), "w" ).write( + "static int fk(int x) { return x; }\nstatic int fs(int x) { return x; }\nenum { A = 1, B = 2 };\n" + "static int (*tk[8])(int) = { [ A +\n B ] = fk };\n" + "static int (*ts[8])(int) = { [ 2 ] = fs };\n" ) +num, ident = "1" * 300, "a" * 300 +open( os.path.join( fx, "keys", "k.js" ), "w" ).write( + "function jn() { return 1; }\nfunction ji() { return 2; }\n" + "const JN = { %s: jn };\nconst JI = { %s: ji };\nmodule.exports = { JN, JI };\n" % ( num, ident ) ) +E = "…" +print( "tk[A + B]" ) # the designator expression, its newline collapsed — no in the attribute +print( "ts[2]" ) # a short designator: whole +print( "JN[" + "1" * 96 + E + "]" ) # a 300-digit numeric key: capped +print( "JI." + "a" * 96 + E ) # a 300-character identifier key: capped +EOF +i=0; while IFS= read -r want; do i=$(( i + 1 )); kexp[$i]="$want"; done <"$TMP/keys.expect" +arm "RK1 callers fk: a multi-line C designator [A + B] is written on one line" keys --callers=fk attr:value_refs=1 "vr:into=${kexp[1]}" +arm "RK2 callers fs (near miss): a short designator is whole" keys --callers=fs attr:value_refs=1 "vr:into=${kexp[2]}" +arm "RK3 callers jn: a 300-digit numeric key is capped" keys --callers=jn attr:value_refs=1 "vr:into=${kexp[3]}" +arm "RK4 callers ji: a 300-character identifier key is capped" keys --callers=ji attr:value_refs=1 "vr:into=${kexp[4]}" + echo if [ "$fail" -eq 0 ]; then echo "recallshapecheck: ALL PASS ($n CLI arms + parity)"; else echo "recallshapecheck: FAIL"; fi exit "$fail" diff --git a/test/receiverevidencecheck.sh b/test/receiverevidencecheck.sh new file mode 100755 index 000000000..f8afcccae --- /dev/null +++ b/test/receiverevidencecheck.sh @@ -0,0 +1,1149 @@ +#!/usr/bin/env bash +# receiverevidencecheck.sh — a call matched to a definition by NAME ALONE never presents as a single proven edge. +# +# test/receiverevidencecheck.sh # uses build/ripwire on test/receiverevidencefix +# RIPWIRE_BIN=asan/ripwire test/receiverevidencecheck.sh +# test/receiverevidencecheck.sh build_base/ripwire # the RED run (a pre-change binary) +# +# THE DEFECT. A member call `x.m()` (or, in a language with an implicit receiver, a bare `m()`) was bound to an +# in-repo definition of `m` because the NAME matched, with nothing proving the receiver is that definition's +# class. The answer then showed one confident row — and agents adopt a confident wrong row. Each shape below was +# a graded false row (paraphrased; the code is minimal and never built): +# (Bm) a dynamic receiver bound to the wrong in-repo class: a request context's `ctx.onerror()` drawn to the +# Application.onerror its file defines; an item's typed text field `item.text.Get()` drawn to Merger.Get; +# a stored ASGI callable `self._send()` drawn to another class's nested `_send`; a constructor-assigned +# field `this.bucket.listSchemas()` drawn to the caller's own class; a loop variable over inner routers +# drawn to SmartRouter.add; `ctx.req.raw.headers.get()` drawn to Context.get because the file imports +# Context; a member call `context.handler()` drawn to a same-named FREE function; an object-literal +# module's `this.ctx.get()` drawn to that module's own `get`; a Go struct field or local named like a +# package (`s.log.Errorf`) drawn to the in-repo `log.Errorf`. +# (Bd) a builtin/library object behind a variable, in a file that names the class: WeakMap `.set/.get` → the +# same file's accessors, Promise `.then` → Reply.prototype.then, a stream's `.on` → a TEST double, +# `dict.get/keys/items` → StylesBase in a file that imports Styles, `list.append`/`str.split` → +# Content.append/split, URLSearchParams `.append`, Set `.delete`, an outside router's `.all`, an asyncio +# handle's `.cancel`, an outside parser's `.write`, an outside tcell screen's `.Size`, the globals +# `Reflect.get` / `Atomics.add` beside in-repo `get` / `add`, a Python outside from-import +# (`from json import dumps as stringify`) beside in-repo `stringify` / `dumps`. +# (Bp) a parameter or local callable bound to a def elsewhere: `done()` (a parameter) → another file's +# closure `done`; `read = os.read; read( fd )` → Stylesheet.read and `feed = parser.feed` → the base +# class's feed when the parser is a constructed XTermParser (the rows FE-A's local-Keep rule restored); +# `add_widget = widgets.append; add_widget( n )` → another class's nested add_widget; a JS alias +# `const onerror = ctx.onerror` and a destructured `const { onerror } = ctx`, then `onerror( e )`. +# (Bi) an implicit receiver (Java, Kotlin, C#, C++, Swift, Ruby): a bare `render()` / `flush()` inside a class +# bound to an UNRELATED class's method, though the language reaches only the class's own members, its +# superclasses and mixins, a free/top-level function, or an import. +# +# THE CONTRACT. Such a call either (a) RESOLVES WITH EVIDENCE — the receiver is `this`/`self`/`cls` inside the +# class or its cone, a construction in scope (`x = new Cls()`, `x = Cls()`, `m := &Merger{}`), a constructor- +# assigned field, a type annotation or declaration on the parameter/local (Go struct field, embedded field, +# aliased import), an import — and then shows one plain row; or (b) KEEPS ITS ROW WITH AN ON-ROW HEDGE: +# `via="name"` on the row itself (XML) / `"via":"name"` on the entry (MCP JSON), listing the by-name candidates +# — never a legend-only hedge, never a silent drop. Every surface shows the same row with the same hedge: +# --callees, --callers, --impact, --path, --connect, --expand , --for , the default map's rows, and +# MCP find_symbol / find_referencing_symbols / analyze. A wrong candidate may also disappear (resolved elsewhere, or counted external/ +# unresolved), but a TRUE target must stay visible, hedged or not. A SINGLE by-name candidate is hedged too: +# one candidate is not evidence (ruling D1). An --impact row at d>=2 that no all-proven path reaches inherits +# the hedge; a row with some all-proven path stays plain (ruling D3). Every surface's --legend=full (and the MCP +# tool description or answer) defines via="name" and says what it does NOT mean. +# +# PREDICATES (each over one answer; an answer about no symbol is NOROOT and never a pass; rows compare +# (kind, name, file[:line]) — the line only where a spec carries one): +# notproven — the named row is absent, or present WITH the hedge (the positive arms: red when unhedged) +# proven — the named row is present WITHOUT the hedge (evidence: a near miss a new rule could kill) +# exactproven— the set of unhedged rows is exactly the given set (no false candidate rides along unhedged) +# visible — the named row is present, hedged or not (a true target is never dropped) +# marked — the named row is present WITH the hedge (a name-only true edge is kept and labelled) +# +# ARMS (one fixture root per language under test/receiverevidencefix/; each root is indexed on its own). +# (J) JS: Bm ctx.onerror (plain, optional-chained, aliased, destructured), an object-literal module's +# this.ctx.get, member call → free function; Bd WeakMap/Promise/stream/router/Set/URLSearchParams, the +# globals Reflect/Atomics; Bp a `done` parameter. Evidence that must RESOLVE: a constructor-assigned field +# (this.bucket → Schemas.listSchemas). Near misses: this.onerror, same-file bare call, relative module +# receiver (also one bound to the name `Reflect`), a closure called in its own function, construction +# in scope (`new Application()` / `new Schemas()` decide between same-named methods), super.m(), a +# class-name (static) receiver; the name-only `reply.send()` / `reply.code()` on a parameter are kept +# and marked. +# (T) TS: Bm a loop variable over Router[] → SmartRouter.add; WHATWG Headers.get → Context.get/Cache.get; +# the global Atomics.add. Near misses: an annotated parameter, construction through an import alias, a +# constructed local. +# (P) Python: Bd dict/set/list/str in a file that imports the class; an argument's .animate; asyncio handle; +# an outside package's parser; an outside from-import (plain and aliased); Bm self._send → a nested +# _send, a non-self receiver in the class's own file (mode_screen.refresh, the property self.screen); +# Bp read = os.read, feed = parser.feed (base class and an unrelated class), add_widget = +# widgets.append. Near misses: self.update, a constructor-assigned field (+ cone), a typed parameter +# (+ cone), construction, an import alias, an imported function beside same-named methods, +# cls.method(), a class-name receiver (Styles.copy), the XTermParser.feed the alias really reaches, a +# proven and a name-only site to the same method (the merged row stays plain); a name-only parameter +# call kept and marked. +# (G) Go: evidence that must RESOLVE (ruling D4): a typed struct field, a typed parameter, a typed local +# through an aliased import, an embedded field's promoted method — each exactly the util.Chars row, never +# Merger.Get/Length; Bd an outside screen's Size, an outside value's Runes, a field or local named `log` +# of an outside type beside the in-repo log.Errorf. Near misses: a pointer-literal local, the method +# receiver itself, a real import of the in-repo log package. +# (G2) Go module aliases (gomod/, one tree per directory): an import path proves a package EXACTLY — the nearest +# go.mod's module path plus the directory below it, or the path a local `replace L => ./D` gives D. Bm: +# `replace github.com/up/lib => ./third/lib` beside an in-repo lib/ drew lib.Mark() to lib/ (a directory +# that merely ends the path); `example.com/t/pkg/lib` proved both pkg/lib and lib/ (declined). Near misses: +# a replace onto lib/ itself, a replaced legacy/ with and without its own go.mod, a nested module, +# example.com/a vs example.com/ab, a vendored copy (never the in-repo lib/), a dot import (never the decoy), +# and a path-less root lib/ beside a nested module that carries the path. +# (T) Java/C#/Kotlin/Swift (review B3): a typed parameter, a typed / constructed / Java `var` local, a typed or +# constructed field or property — read bare (implicit this) or through this/self — and a Kotlin `val` +# constructor property RESOLVE to the declared class's member (Pump.*: Tank and Barrel both define spill). +# Near misses stay hedged: an untyped local (a lower-case factory), a local that hides the field, an +# interface/protocol-typed receiver with two implementors (both still listed). Every other binding form: (B). +# (B) binding scope (review B4; scope/*: Tank and Barrel both define spill, the class's field / the outer binding is a +# Tank). (a) A binding that HIDES the field or an outer typed local never lets the call prove the field's +# class: Java for/lambda/catch/resource/instanceof/case/record-pattern variables, C# foreach/lambda/is/out +# var/case/deconstruction/catch/LINQ, Kotlin for/destructuring/lambda/let/when/catch, Swift for-in/case let/ +# tuple/closure/if-guard-while let/catch, and the same shapes where FE-B reads TS/JS/Python/Go evidence +# (arrow and catch parameters, for-of, a block const, a destructuring, a JS reassignment; a Python for/lambda/comprehension/ +# with/except/walrus/match/reassignment; a Go range/if/type-switch variable). Outside its block a local never +# lends its class (a nested block, a lambda's typed parameter), and the field (or outer local) proves again. +# (b) A generic's type parameter spelled like a class (``, `Box`, `[Tank Spiller]`, `def f[Tank]`) +# never proves that class. Kept: the field itself, a typed local, a typed loop / lambda variable's own class. +# (I) implicit receiver: Java, Kotlin, C#, C++, Swift, Ruby — a bare call inside a class whose base is OUTSIDE +# the tree (or that imports the name from outside) never proves an unrelated class's method. Near +# misses: own members (private too), an in-repo superclass's member (the cone, and Java super.m()), +# a free / top-level function and a Ruby top-level def; a Ruby included module is contract-only (resolved +# or hedged, never a false proven row): Ruby's own lookup is separate work. +# (S) the two sides of a class lookup: a JS/TS call on the CLASS object (`Gauge.read()`, `this` in a static member) +# resolves to the static member only and an instance call never to it; a Kotlin companion member likewise; +# a class-name call naming an instance-only member is never proven. A Python `__getattr__` changes nothing +# for a member the class defines, and a name its lookup misses is never proven onto another class. +# (H) propagation and parity: the name-only witness `respondWith → reply.send()` is marked in --callees, +# --callers, --impact (d=1), --path, --connect, --expand , --for , the map's rows, MCP +# find_symbol, find_referencing_symbols and analyze; the false witness `respond → ctx.onerror()` is absent-or-marked on every +# one of them; the --for HOP blocks () never prove encode → Response.append, +# respond → Application.onerror, pipePayload → the test double's on, back → its own module's get; +# --impact at d>=2 inherits the hedge (viaModule through respondWith → send; serve through +# respond → onerror) unless some all-proven path reaches the row (both: direct → new Reply().send); +# the legend of every surface defines via="name" with a NOT sentence; and the hedge bit is +# VALUE-EQUAL between CLI --callees and MCP calls, CLI --callers and MCP calledBy, and --callees and +# --expand , over a set of selectors. +# (O) graded false callees seen on an earlier binary, as minimal repros: Go exec Cmd.Output → an in-repo +# Output method (fmt.Errorf / strings.Split pinned clean), TS Date#getTime → an in-repo getTime (the +# global Response pinned clean), Python list.append on a list literal → Content.append; each on +# --callees and its --for hop block, with constructed-local near misses. +# (R) the base-class receiver (CR 5469474915): C# `base` and `super` where it is a keyword (Java/Kotlin/Swift/JS/TS), +# and Python's super() call, walk the bases only; a LOCAL spelled `base` (Python/JS/TS/Go/Java/Kotlin/Swift/C++/ +# Ruby) or `super` (Go, C#, a Python name shadowing the builtin) never proves the base class's method — a typed +# one resolves to its own class, an untyped one is hedged. Twins: the keyword calls still prove the base. +# (C) conservation: each root's census dispositions still sum to calls= with unaccounted=0 (no silent drop). +# (K) the predicates can fail: a planted unhedged false row fails notproven, a planted hedged row passes it and +# fails proven, marked rejects an unhedged row, a rootless or not-found answer is NOROOT, the MCP reader +# reads the hedge, a non-zero or missing exit status fails the arm, a parser crash (a malformed defs=) +# fails the arm, and a hop reader never reads another hop's . +# GUARDS: arms marked `# guard` were already green on the pre-change binary; they pin a shape the change must +# not break and are not evidence of the fix. +# +# FLOORS — named, not gated here: +# * Objective-C (a method is only ever sent `[recv msg]`; a bare call is a C function — FE-A's ladder), PHP, Lua, +# Zig, Dart, Elixir: no arm. +# * a computed member `ctx['onerror']()` records no call at all (pinned notproven, trivially). +# * return-type inference (`mode_screen = self.get_screen(); mode_screen.refresh()` → Screen.refresh, or a +# property's return) is not required: only the false single row is gated. +# * --uses / --dead-code / --safe-delete, and --for hop blocks of symbols other than the four named in (H): not +# gated here. +# * a Python `cls()` construction inside a classmethod is polymorphic (a subclass's override is a legitimate +# candidate): no arm. +# +# Exits non-zero on any failure. + +set -u +export PYTHONDONTWRITEBYTECODE=1 +ROOT="$( cd "$( dirname "$0" )/.." && pwd )" +. "$ROOT/test/lib/clean-env.sh" # a gate that indexes a repo must not inherit GIT_DIR/GIT_WORK_TREE (gitenvhermeticcheck D) +BIN="${1:-${RIPWIRE_BIN:-$ROOT/build/ripwire}}" +[ "${BIN#/}" = "$BIN" ] && BIN="$ROOT/$BIN" # allow a repo-relative binary +CORPUS="$ROOT/test/receiverevidencefix" +TMP="$( mktemp -d )"; trap 'rm -rf "$TMP"' EXIT +fail=0 +ok(){ printf ' PASS %s\n' "$*" || { fail=1; printf ' FAIL could not write the PASS line for: %s\n' "$*"; }; return 0; } +no(){ printf ' FAIL %s\n' "$*"; fail=1; } + +[ -x "$BIN" ] || { echo "no ripwire binary at $BIN — build first (cmake --build build -j)"; exit 2; } +[ -d "$CORPUS" ] || { echo "fixture missing: $CORPUS"; exit 2; } +command -v python3 >/dev/null 2>&1 || { echo "python3 required"; exit 2; } +echo "receiverevidencecheck: BIN=$BIN CORPUS=$CORPUS" + +# rw ROOT ARGS… — run against one fixture root (every selector below is relative to that root's `.`) +rw(){ local r="$1"; shift; ( cd "$CORPUS/$r" && "$BIN" . --no-cache "$@" 2>/dev/null ); } + +# ── the one reader every predicate shares ───────────────────────────────────────────────────────────────────── +# parse.py MODE FILE [ARGS] — prints one line per row; NOROOT when the document has no answer about a symbol. +# It EXITS NON-ZERO on malformed input (a non-numeric defs=/to_defs=/reachable=) or on any crash, and every call +# site goes through P, which FAILs the arm on a non-zero exit (a crash never reads as "no rows"). +# rows rows of a callees/callers/impact/path answer: "t n path line H d" (H = name | -, d = depth or -) +# ecalls rows inside (all of them, or only those inside the body named ARG): "n line H" +# (a joined row `l="4,8"` prints one line per line number) +# hcalls rows inside the --for HOP block : "n line H" (NOROOT when that hop is not shown) +# edges rows of a connect answer: "f t H" +# mcp the entries of one array (ARG) of an MCP tools/call result: "name file line H" +# map rows of the default MAP: "caller file callee H" (NOROOT without an map root) +# mcpmap the same over the map an MCP tools/call (analyze) returned +# legend DEFINED when the answer's legend comments define via="name" — the mechanism ("name alone") and a +# NOT sentence ("does NOT mean") within the definition — else UNDEFINED +# mcplegend the same over an MCP stream: the tools/list description of tool ARG, or the tools/call answer +cat >"$TMP/parse.py" <<'PY' +import json, re, sys +mode, path = sys.argv[ 1 ], sys.argv[ 2 ] +arg = sys.argv[ 3 ] if len( sys.argv ) > 3 else "" +doc = open( path, encoding="utf-8", errors="replace" ).read() +def attr( tag, name ): + m = re.search( r'\s' + name + r'="([^"]*)"', tag ) + return m.group( 1 ) if m else None +def num( v, what ): + if v is None: + return None + if not re.fullmatch( r"[0-9]+", v ): + sys.stderr.write( "non-numeric %s=%r\n" % ( what, v ) ) + sys.exit( 3 ) + return int( v ) +def hedge( tag ): + return "name" if attr( tag, "via" ) == "name" else "-" +def crows( scope ): + blocks = re.findall( r"].*?", scope, re.S ) + if not blocks: + print( "NOROOT" ); sys.exit( 0 ) + for b in blocks: + for c in re.findall( r"]*>", b ): + for l in ( attr( c, "l" ) or "-" ).split( "," ): + print( attr( c, "n" ), l, hedge( c ) ) +DEF = re.compile( r'via="name".{0,700}?name alone.{0,700}?does NOT mean', re.S ) +if mode == "rows": + root = re.search( r"<(callees|callers|impact|path) [^>]*>", doc ) + if not root: + print( "NOROOT" ); sys.exit( 0 ) + head = root.group( 0 ) + found = num( attr( head, "defs" ), "defs" ) + if found is None: + found = num( attr( head, "to_defs" ), "to_defs" ) + if root.group( 1 ) != "path" and ( found is None or found < 1 ): + print( "NOROOT" ); sys.exit( 0 ) + depth = "-" + for s in re.findall( r"]*/>", doc[ root.start(): ] ): + t, n, p = attr( s, "t" ), attr( s, "n" ), attr( s, "p" ) + if not ( t and n and p ): + continue + if attr( s, "d" ): + depth = str( num( attr( s, "d" ), "d" ) ) + f, _, l = p.rpartition( ":" ) + print( t, n, f, l, hedge( s ), depth ) +elif mode == "ecalls": + scope = doc + if arg: + m = re.search( r']*\sn="' + re.escape( arg ) + r'"[^>]*>(.*?)', doc, re.S ) + if not m: + print( "NOROOT" ); sys.exit( 0 ) + scope = m.group( 1 ) + crows( scope ) +elif mode == "hcalls": + m = re.search( r']*\sn="' + re.escape( arg ) + r'"[^>]*>(.*?)', doc, re.S ) + if not arg or not m: + print( "NOROOT" ); sys.exit( 0 ) + crows( m.group( 1 ) ) +elif mode == "edges": + root = re.search( r"]*>", doc ) + if not root: + print( "NOROOT" ); sys.exit( 0 ) + for e in re.findall( r"]*/>", doc[ root.start(): ] ): + print( attr( e, "f" ), attr( e, "t" ), hedge( e ) ) +elif mode == "mcp": + try: + r = json.loads( doc.strip().splitlines()[ -1 ] ) + d = json.loads( r[ "result" ][ "content" ][ 0 ][ "text" ] ) + except Exception: + print( "NOROOT" ); sys.exit( 0 ) + if d.get( arg ) is None: + print( "NOROOT" ); sys.exit( 0 ) + for e in d[ arg ]: + print( e.get( "name" ), e.get( "file" ), e.get( "line" ), "name" if e.get( "via" ) == "name" else "-" ) +elif mode in ( "map", "mcpmap" ): + if mode == "mcpmap": + try: + r = json.loads( doc.strip().splitlines()[ -1 ] ) + doc = r[ "result" ][ "content" ][ 0 ][ "text" ] + except Exception: + print( "NOROOT" ); sys.exit( 0 ) + if not re.search( r"]", doc ): + print( "NOROOT" ); sys.exit( 0 ) + for f in re.finditer( r']*>(.*?)', doc, re.S ): + fp = attr( f.group( 0 )[ :f.group( 0 ).index( ">" ) + 1 ], "p" ) + for s in re.finditer( r']*?)(?:/>|>(.*?))', f.group( 1 ), re.S ): + for c in re.findall( r"]*>", s.group( 2 ) or "" ): + print( attr( "", "n" ), fp, attr( c, "n" ), hedge( c ) ) +elif mode == "legend": + text = " ".join( re.findall( r"", doc, re.S ) ) + print( "DEFINED" if DEF.search( text ) else "UNDEFINED" ) +elif mode == "mcplegend": + texts = [] + for line in doc.splitlines(): + try: + r = json.loads( line ) + except Exception: + continue + res = r.get( "result" ) or {} + for t in res.get( "tools" ) or []: + if t.get( "name" ) == arg: + texts.append( t.get( "description" ) or "" ) + for c in res.get( "content" ) or []: + texts.append( c.get( "text" ) or "" ) + if not texts: + print( "NOROOT" ); sys.exit( 0 ) + norm = " ".join( texts ).replace( '"via":"name"', 'via="name"' ) + print( "DEFINED" if DEF.search( norm ) else "UNDEFINED" ) +else: + sys.stderr.write( "unknown mode %s\n" % mode ); sys.exit( 2 ) +PY +# P MODE FILE [ARG] — the reader's output into $PARSED; a non-zero exit (crash, malformed input) FAILs the arm +P(){ + PARSED="$( python3 "$TMP/parse.py" "$@" 2>"$TMP/parse.err" )" + local rc=$? + if [ "$rc" -ne 0 ]; then no "the reader exited rc=$rc on [$*]: $( head -c 300 "$TMP/parse.err" | tr '\n' ' ' )"; return 1; fi + return 0 +} + +# answer ROOT VERB SEL [EXTRA…] — write the verb's answer to a per-call file, its exit status beside it (FILE.rc) +answer(){ + local r="$1" v="$2" s="$3"; shift 3 + local f; f="$TMP/$( printf '%s' "$r" | tr '/' '_' ).$v.$( printf '%s' "$s$*" | tr '/:.,= ' '______' ).xml" + rw "$r" "--$v=$s" "$@" >"$f"; printf '%s' "$?" >"$f.rc" + printf '%s' "$f" +} +# ran_ok LABEL FILE — the run behind FILE exited 0 (a non-zero or missing exit status FAILs the arm) +ran_ok(){ + local rc; rc="$( cat "$2.rc" 2>/dev/null )" + if [ "$rc" = "0" ]; then return 0; fi + no "$1 exited rc=${rc:-unknown}"; return 1 +} +# rowsof LABEL FILE — the rows of FILE into $GOT; a reader failure or an answer about no symbol FAILs (returns 1) +rowsof(){ + GOT="" + P rows "$2" || return 1 + GOT="$PARSED" + if [ "$GOT" = "NOROOT" ]; then no "$1 produced no answer about a symbol"; return 1; fi + return 0 +} +# getrows ROOT VERB SEL — rows of the verb's answer into $GOT ("" on a failed run or reader, FAIL already printed) +getrows(){ + local f; f="$( answer "$1" "$2" "$3" )"; GOT="" + ran_ok "($1) --$2=$3" "$f" || return 1 + rowsof "($1) --$2=$3" "$f" +} +# match SPEC — the awk condition for "n path[:line]" over a rows line "t n path line H d" +specmatch(){ # prints the matching rows of $GOT for SPEC + local n="${1%% *}" p="${1#* }" l="" + case "$p" in *:[0-9]*) l="${p##*:}"; p="${p%:*}";; esac + printf '%s\n' "$GOT" | awk -v n="$n" -v p="$p" -v l="$l" 'NF >= 5 && $2 == n && $3 == p && ( l == "" || $4 == l )' +} +show(){ printf '%s' "$GOT" | awk 'NF >= 5 { printf "%s %s %s:%s%s; ", $1, $2, $3, $4, ( $5 == "name" ? " via=name" : "" ) }'; } + +notproven(){ + local r="$1" v="$2" s="$3"; shift 3 + getrows "$r" "$v" "$s" || return + local spec hits + for spec in "$@"; do + hits="$( specmatch "$spec" | awk '$5 != "name"' )" + if [ -n "$hits" ]; then no "($r) --$v=$s shows [$spec] as a PROVEN edge (no receiver evidence, no via=\"name\"): $( show )" + else ok "($r) --$v=$s never proves [$spec] (absent or hedged)"; fi + done +} +proven(){ + local r="$1" v="$2" s="$3"; shift 3 + getrows "$r" "$v" "$s" || return + local spec t hits + for spec in "$@"; do + t="${spec%% *}"; hits="$( specmatch "${spec#* }" | awk -v t="$t" '$1 == t && $5 != "name"' )" + if [ -n "$hits" ]; then ok "($r) --$v=$s keeps the proven row [$spec]" + else no "($r) --$v=$s lost or hedged the proven row [$spec]: $( show )"; fi + done +} +exactproven(){ + local r="$1" v="$2" s="$3" want="$4" got + getrows "$r" "$v" "$s" || return + got="$( printf '%s\n' "$GOT" | awk 'NF >= 5 && $5 != "name" { print $1 " " $2 " " $3 }' | sort -u | tr '\n' ';' | sed 's/;$//' )" + want="$( printf '%s' "$want" | tr ';' '\n' | sort -u | tr '\n' ';' | sed 's/;$//' )" + if [ "$got" = "$want" ]; then ok "($r) --$v=$s unhedged rows are exactly [${want:-none}]" + else no "($r) --$v=$s unhedged rows are [${got:-none}], want [${want:-none}]"; fi +} +visible(){ + local r="$1" v="$2" s="$3"; shift 3 + getrows "$r" "$v" "$s" || return + local spec + for spec in "$@"; do + if [ -n "$( specmatch "$spec" )" ]; then ok "($r) --$v=$s shows the true target [$spec] (hedged or not)" + else no "($r) --$v=$s does not show the true target [$spec]: $( show )"; fi + done +} +marked(){ + local r="$1" v="$2" s="$3"; shift 3 + getrows "$r" "$v" "$s" || return + local spec + for spec in "$@"; do + if [ -n "$( specmatch "$spec" | awk '$5 == "name"' )" ]; then ok "($r) --$v=$s keeps [$spec] marked via=\"name\"" + elif [ -n "$( specmatch "$spec" )" ]; then no "($r) --$v=$s shows the name-only [$spec] with no via=\"name\": $( show )" + else no "($r) --$v=$s dropped the name-only true edge [$spec]: $( show )"; fi + done +} +# hopnotproven ROOT QUERY HOP NAME LINE — the --for answer shows the hop block , and that hop's row +# NAME at l=LINE is absent or via="name". A hop with no block is read through the HOP-SLOT RULE (test/forsigspancheck.sh +# (H)): a hop candidate whose every callee edge is name-only gets no row and counts in noedge=. Then the arm reads +# the same fact off --callees instead, and every premise is checked, so an absent hop is never an empty pass: HOP's +# row is in the answer with r= within (it was a hop candidate), noedge= >= 1, and --callees=FILE:HOP lists +# NAME and proves no row at all (else the hop was dropped for another reason: FAIL). +hopnotproven(){ + local r="$1" q="$2" h="$3" n="$4" l="$5" f + f="$( answer "$r" for "$q" )" + ran_ok "($r) --for=\"$q\"" "$f" || return + P hcalls "$f" "$h" || return + if [ "$PARSED" = "NOROOT" ]; then + local drow hp hr htot hne + drow="$( grep -o "]*n=\"$h\"[^>]*>" "$f" | head -1 )" + hp="$( printf '%s' "$drow" | sed -n 's/.* p="\([^"]*\)".*/\1/p' )" + hr="$( printf '%s' "$drow" | sed -n 's/.* r="\([0-9][0-9]*\)".*/\1/p' )" + htot="$( grep -o ']*>' "$f" | head -1 | sed -n 's/.* total="\([0-9][0-9]*\)".*/\1/p' )" + hne="$( grep -o ']*>' "$f" | head -1 | sed -n 's/.* noedge="\([0-9][0-9]*\)".*/\1/p' )" + case "$hp|$hr|$htot|$hne" in + *'||'*|'|'*|*'|') no "($r) --for=\"$q\" shows no hop , and the hop-slot premises are unreadable (d p=/r=: '$hp'/'$hr', hops total=/noedge=: '$htot'/'$hne')"; return ;; + esac + if [ "$hr" -gt "$htot" ] || [ "$hne" -lt 1 ]; then + no "($r) --for=\"$q\" shows no hop , and it was no dropped hop candidate (r=$hr, hops total=$htot, noedge=$hne)"; return + fi + getrows "$r" callees "$hp:$h" || return + if [ -z "$( printf '%s\n' "$GOT" | awk -v n="$n" 'NF >= 5 && $2 == n' )" ]; then + no "($r) --for=\"$q\" dropped hop $h, and --callees=$hp:$h does not list $n either: $( show )" + elif [ -n "$( printf '%s\n' "$GOT" | awk 'NF >= 5 && $5 != "name"' )" ]; then + no "($r) --for=\"$q\" dropped hop $h, yet --callees=$hp:$h proves a row (the hop-slot rule does not explain the drop): $( show )" + else + ok "($r) --for=\"$q\": hop $h takes no slot (every callee name-only: --callees=$hp:$h, noedge=$hne), so it never proves " + fi + return + fi + if [ -n "$( printf '%s\n' "$PARSED" | awk -v n="$n" -v l="$l" '$1 == n && $2 == l && $3 != "name"' )" ]; then + no "($r) --for=\"$q\": hop $h proves (no via=\"name\"): $( printf '%s' "$PARSED" | tr '\n' ';' )" + else ok "($r) --for=\"$q\": hop $h never proves (absent or hedged)"; fi +} + +echo "=== (J) JS: receivers with no evidence never prove a same-named method ===" +notproven js callees lib/application.js:respond "onerror lib/application.js" +visible js callees lib/application.js:respond "onerror lib/context.js" +notproven js callees lib/application.js:respondSafely "onerror lib/application.js" +notproven js callees lib/application.js:respondComputed "onerror lib/application.js" # guard: no call recorded today +notproven js callees lib/application.js:respondAlias "onerror lib/application.js" # guard +notproven js callees lib/application.js:respondDestructured "onerror lib/application.js" # guard +notproven js callees lib/delegate/response.js:back "get lib/delegate/response.js" +visible js callees lib/delegate/response.js:back "get lib/delegate/request.js" +notproven js callees lib/handle.js:run "handler lib/handle.js" +notproven js callees lib/request.js:compile "get lib/request.js" "set lib/request.js" +notproven js callees lib/reply.js:awaitResult "then lib/reply.js" +notproven js callees lib/reply.js:pipePayload "on test/server.test.js" +notproven js callees lib/routes.js:fallback "all lib/routes.js" +notproven js callees lib/routes.js:forget "delete lib/routes.js" +notproven js callees lib/search.js:encode "append lib/response.js" +notproven js callees lib/reply.js:writePayload "done lib/parser.js" +notproven js callees lib/meta.js:peek "get lib/meta.js" "get lib/reflect.js" # guard +notproven js callees lib/meta.js:bump "add lib/meta.js" # guard +echo "--- (J) evidence decides between same-named methods, and a constructor-assigned field RESOLVES" +exactproven js callees lib/uses.js:build "cls Application lib/application.js;method onerror lib/application.js;cls Schemas lib/schemas.js;method listSchemas lib/schemas.js" +notproven js callees lib/application.js:schemas "listSchemas lib/application.js" +exactproven js callees lib/application.js:schemas "method listSchemas lib/schemas.js" +echo "--- (J) near misses: true edges kept" +exactproven js callees lib/application.js:handle "method onerror lib/application.js;fn respond lib/application.js" +exactproven js callees lib/handle.js:wrapped "fn handler lib/handle.js" +exactproven js callees lib/uses.js:viaModule "fn respondWith lib/reply.js" +exactproven js callees lib/uses.js:make "method create lib/application.js" # guard: a class-name (static) receiver +exactproven js callees lib/listing.js:list "method listSchemas lib/schemas.js" # guard: super.listSchemas() +exactproven js callees lib/mirror.js:look "method get lib/reflect.js" # guard: a relative module bound to `Reflect` +exactproven js callees lib/trailer.js:sendTrailer "fn send lib/trailer.js" +exactproven js callees lib/reply.js:direct "fn Reply lib/reply.js;method send lib/reply.js" +notproven js callees lib/reply.js:notFound "send lib/trailer.js" +marked js callees lib/reply.js:notFound "code lib/reply.js" "send lib/reply.js" +marked js callees lib/reply.js:respondWith "send lib/reply.js" + +echo "=== (T) TS ===" +notproven ts callees src/router/smart.ts:match "add src/router/smart.ts" +notproven ts callees src/middleware/auth.ts:bearer "get src/context.ts" "get src/cache.ts" # cache.ts: guard +notproven ts callees src/atomics.ts:bump "add src/atomics.ts" "add src/router/smart.ts" "add src/router/trie.ts" # guard +echo "--- (T) near misses: annotation, alias construction, constructed local" +exactproven ts callees src/middleware/auth.ts:readUser "method get src/context.ts" +exactproven ts callees src/middleware/alias.ts:remember "cls Context src/context.ts;method set src/context.ts" +exactproven ts callees src/middleware/alias.ts:single "cls TrieRouter src/router/trie.ts;method add src/router/trie.ts;method match src/router/trie.ts" + +echo "=== (P) Python ===" +notproven py callees src/tui/css/stylesheet.py:reparse "get src/tui/css/styles.py" "keys src/tui/css/styles.py" "items src/tui/css/styles.py" "update src/tui/css/stylesheet.py" # update: guard +notproven py callees src/tui/css/stylesheet.py:refresh_rules "animate src/tui/css/styles.py" +notproven py callees src/tui/reactive.py:watch "append src/tui/content.py" +notproven py callees src/tui/reactive.py:toggle "split src/tui/content.py" "split src/tui/geometry.py" # geometry.py: guard +notproven py callees src/tui/callback.py:invoke "call_later src/tui/message_pump.py" "cancel src/tui/worker.py" +notproven py callees src/web/formparsers.py:parse_form "write src/web/datastructures.py" +notproven py callees src/web/requests.py:send_json "_send src/web/errors.py" +notproven py callees src/web/render.py:render "stringify src/web/jsonutil.py" "dumps src/web/jsonutil.py" # guard: outside from-import +notproven py callees src/tui/app.py:switch_mode "refresh src/tui/app.py" +notproven py callees src/tui/app.py:repaint_screen "refresh src/tui/app.py" +visible py callees src/tui/app.py:repaint_screen "refresh src/tui/screen.py" +notproven py callees src/tui/drivers/linux.py:process_events "read src/tui/css/stylesheet.py" "feed src/tui/xterm_parser.py:2" "feed src/tui/byte_stream.py" +notproven py callees src/tui/screen.py:collect "add_widget src/tui/compositor.py" +echo "--- (P) near misses: self, constructor-assigned field, typed parameter, construction, alias, import, cls" +proven py callees src/tui/drivers/linux.py:process_events "fn feed src/tui/xterm_parser.py:7" +exactproven py callees src/tui/css/stylesheet.py:own "fn update src/tui/css/stylesheet.py;fn get src/tui/css/styles.py" +# repaint: self.refresh() (proven) and self.screen.refresh() (a property: name-only) reach the same App.refresh; +# the merged row stays plain, and Screen.refresh may only ride along hedged +exactproven py callees src/tui/app.py:repaint "fn refresh src/tui/app.py" +exactproven py callees src/tui/uses.py:sync "fn update src/tui/css/stylesheet.py" +exactproven py callees src/tui/uses.py:apply "fn animate src/tui/css/styles.py" +exactproven py callees src/tui/uses.py:build "cls Stylesheet src/tui/css/stylesheet.py;fn update src/tui/css/stylesheet.py;fn read src/tui/css/stylesheet.py" +exactproven py callees src/tui/uses.py:clone "fn copy src/tui/css/styles.py" # guard: a class-name receiver +exactproven py callees src/tui/css/stylesheet.py:parse_rules "fn parse src/tui/css/parse.py" +exactproven py callees src/tui/css/stylesheet.py:blank "fn default_rules src/tui/css/stylesheet.py" +marked py callees src/tui/callback.py:schedule "call_later src/tui/message_pump.py" + +echo "=== (G) Go: declared types RESOLVE (D4) ===" +notproven go callees src/result.go:buildResult "Get src/merger.go" "Length src/merger.go" +exactproven go callees src/result.go:buildResult "method Get src/util/chars.go;method Length src/util/chars.go" +exactproven go callees src/result.go:fromValue "method Get src/util/chars.go" +exactproven go callees src/embed.go:aliased "method Length src/util/chars.go" +notproven go callees src/embed.go:firstRune "Get src/merger.go" +exactproven go callees src/embed.go:firstRune "method Get src/util/chars.go" +notproven go callees src/tui/tcell.go:MaxY "Size src/tui/tcell.go" +notproven go callees src/tui/tcell.go:Graphemes "Runes src/tui/tcell.go" +notproven go callees src/service.go:Fail "Errorf src/internal/log/log.go" +notproven go callees src/service.go:Report "Errorf src/internal/log/log.go" +echo "--- (G) near misses: a pointer-literal local, the method receiver itself, a real package import" +exactproven go callees src/result.go:merged "method Length src/merger.go" +exactproven go callees src/merger.go:First "method Get src/merger.go" +exactproven go callees src/tui/tcell.go:Lines "method Size src/tui/tcell.go" +exactproven go callees src/cmd/run.go:Run "fn Errorf src/internal/log/log.go" # guard + +echo "=== (G2) Go module aliases: an import path proves a package exactly ===" +mkdir -p "$TMP/gomod" +exactproven gomod/fork2 callees app/app.go:Use "fn Mark third/lib/l.go" # Bm: lib/ merely ends github.com/up/lib +notproven gomod/fork2 callees app/app.go:Use "Mark lib/l.go" +exactproven gomod/tail callees app/app.go:Use "fn Mark pkg/lib/l.go" # Bm: lib/ ends example.com/t/pkg/lib too +exactproven gomod/tail callees app/near.go:Near "fn Mark lib/l.go" +exactproven gomod/fork callees app/app.go:Use "fn Mark lib/l.go" # near miss: the replace names lib/ itself +exactproven gomod/rep callees app/app.go:Use "fn Mark legacy/lib/l.go" # near miss: replaced, with its own go.mod +exactproven gomod/rep callees app/near.go:Near "fn Mark lib/l.go" +exactproven gomod/rep2 callees app/app.go:Use "fn Mark legacy/lib/l.go" # near miss: replaced, under the root go.mod only +exactproven gomod/rep2 callees app/near.go:Near "fn Mark lib/l.go" +exactproven gomod/nest callees app/app.go:Use "fn Mark other/util/u.go" # near miss: a nested module +exactproven gomod/pfx callees app/app.go:Use "fn Mark ab/lib/l.go" # near miss: example.com/a vs example.com/ab +exactproven gomod/pfx callees app/near.go:Near "fn Mark lib/l.go" +notproven gomod/vend callees app/app.go:Use "Mark lib/l.go" # near miss: the vendored copy is the package +notproven gomod/dot callees app/app.go:Use "Mark decoy/d.go" # near miss: a dot import (lib/ unresolved: a floor) +exactproven gomod/mixed callees m/app/app.go:Use "fn Mark m/lib/l.go" # near miss: a path-less lib/ never takes a carried path + +echo "=== (T) Java/C#/Kotlin/Swift: declared receivers RESOLVE (review B3) ===" +exactproven java callees src/main/java/app/Pump.java:param "method spill src/main/java/app/Pump.java" +exactproven java callees src/main/java/app/Pump.java:typedLocal "method spill src/main/java/app/Pump.java;method makeTank src/main/java/app/Pump.java" +exactproven java callees src/main/java/app/Pump.java:ctorLocal "cls Tank src/main/java/app/Pump.java;method spill src/main/java/app/Pump.java" +exactproven java callees src/main/java/app/Pump.java:varLocal "cls Tank src/main/java/app/Pump.java;method spill src/main/java/app/Pump.java" +exactproven java callees src/main/java/app/Pump.java:implicitField "method spill src/main/java/app/Pump.java" +exactproven java callees src/main/java/app/Pump.java:thisField "method spill src/main/java/app/Pump.java" +proven java callees src/main/java/app/Pump.java:param "method spill src/main/java/app/Pump.java:7" +notproven java callees src/main/java/app/Pump.java:untyped "spill src/main/java/app/Pump.java:7" "spill src/main/java/app/Pump.java:8" # near miss: an untyped local +notproven java callees src/main/java/app/Pump.java:shadow "spill src/main/java/app/Pump.java:7" "spill src/main/java/app/Pump.java:8" # near miss: a local hides the field +notproven java callees src/main/java/app/Pump.java:viaIface "spill src/main/java/app/Pump.java:7" "spill src/main/java/app/Pump.java:8" # near miss: an interface, two implementors +visible java callees src/main/java/app/Pump.java:viaIface "spill src/main/java/app/Pump.java:7" "spill src/main/java/app/Pump.java:8" +exactproven cs callees App/Pump.cs:Param "method Spill App/Pump.cs" +exactproven cs callees App/Pump.cs:TypedLocal "method Spill App/Pump.cs;method MakeTank App/Pump.cs" +exactproven cs callees App/Pump.cs:CtorLocal "method Spill App/Pump.cs;cls Tank App/Pump.cs" +exactproven cs callees App/Pump.cs:ImplicitField "method Spill App/Pump.cs" +exactproven cs callees App/Pump.cs:ThisField "method Spill App/Pump.cs" +proven cs callees App/Pump.cs:ViaProperty "method Spill App/Pump.cs:8" +notproven cs callees App/Pump.cs:Untyped "Spill App/Pump.cs:7" "Spill App/Pump.cs:8" +notproven cs callees App/Pump.cs:Shadow "Spill App/Pump.cs:7" "Spill App/Pump.cs:8" +notproven cs callees App/Pump.cs:ViaIface "Spill App/Pump.cs:7" "Spill App/Pump.cs:8" +visible cs callees App/Pump.cs:ViaIface "Spill App/Pump.cs:7" "Spill App/Pump.cs:8" +exactproven kt callees src/app/Pump.kt:param "fn spill src/app/Pump.kt" +exactproven kt callees src/app/Pump.kt:typedLocal "fn spill src/app/Pump.kt;fn makeTank src/app/Pump.kt" +exactproven kt callees src/app/Pump.kt:ctorLocal "cls Tank src/app/Pump.kt;fn spill src/app/Pump.kt" +exactproven kt callees src/app/Pump.kt:implicitField "fn spill src/app/Pump.kt" +exactproven kt callees src/app/Pump.kt:thisField "fn spill src/app/Pump.kt" +proven kt callees src/app/Pump.kt:ctorProperty "fn spill src/app/Pump.kt:8" +proven kt callees src/app/Pump.kt:constructedProperty "fn spill src/app/Pump.kt:7" +notproven kt callees src/app/Pump.kt:untyped "spill src/app/Pump.kt:7" "spill src/app/Pump.kt:8" +notproven kt callees src/app/Pump.kt:shadow "spill src/app/Pump.kt:7" "spill src/app/Pump.kt:8" +notproven kt callees src/app/Pump.kt:viaIface "spill src/app/Pump.kt:7" "spill src/app/Pump.kt:8" +visible kt callees src/app/Pump.kt:viaIface "spill src/app/Pump.kt:7" "spill src/app/Pump.kt:8" +exactproven swift callees Sources/App/Pump.swift:param "fn spill Sources/App/Pump.swift" +exactproven swift callees Sources/App/Pump.swift:typedLocal "fn spill Sources/App/Pump.swift;fn makeTank Sources/App/Pump.swift" +exactproven swift callees Sources/App/Pump.swift:ctorLocal "cls Tank Sources/App/Pump.swift;fn spill Sources/App/Pump.swift" +exactproven swift callees Sources/App/Pump.swift:implicitField "fn spill Sources/App/Pump.swift" +exactproven swift callees Sources/App/Pump.swift:selfField "fn spill Sources/App/Pump.swift" +proven swift callees Sources/App/Pump.swift:constructedProperty "fn spill Sources/App/Pump.swift:6" +notproven swift callees Sources/App/Pump.swift:untyped "spill Sources/App/Pump.swift:5" "spill Sources/App/Pump.swift:6" +notproven swift callees Sources/App/Pump.swift:shadow "spill Sources/App/Pump.swift:5" "spill Sources/App/Pump.swift:6" +notproven swift callees Sources/App/Pump.swift:viaIface "spill Sources/App/Pump.swift:5" "spill Sources/App/Pump.swift:6" +visible swift callees Sources/App/Pump.swift:viaIface "spill Sources/App/Pump.swift:5" "spill Sources/App/Pump.swift:6" + +echo "=== (B) binding scope (review B4): a binding that hides a field or an outer local, and a type parameter ===" +# hides ROOT FILE NAME OUTER BOUND FN… — at each FN's call, a binding of the receiver's name hides the field (or outer local) +# whose class defines NAME at line OUTER: that edge is never PROVEN, and the candidate at line BOUND (the binding's own +# class) stays visible, hedged or proven. Outside a binding's scope the roles swap (OUTER = the binding's class). +hides(){ + local r="$1" f="$2" n="$3" outer="$4" bound="$5" fn; shift 5 + for fn in "$@"; do + notproven "$r" callees "$f:$fn" "$n $f:$outer" + visible "$r" callees "$f:$fn" "$n $f:$bound" + done +} +J=src/app/Scope.java; C=App/Scope.cs; K=src/app/Scope.kt; W=Sources/App/Scope.swift +hides scope/java $J spill 7 8 forEachLoop lambdaParam typedLambda catchUse tryRes patternVar negatedPattern switchPattern recordPattern +hides scope/java $J spill 8 7 nestedBlock lambdaOutside # outside its block: never the binding's Barrel +hides scope/java $J spill 7 7 methodTypeParam classTypeParam typeParamField # : any Spiller, never class Tank proven +proven scope/java callees $J:fieldUse "method spill $J:7" +proven scope/java callees $J:typedLocal "method spill $J:8" +hides scope/cs $C Spill 7 8 ForEachLoop ForEachVar LambdaParam PatternVar OutVar SwitchCase Deconstruct CatchUse Linq +hides scope/cs $C Spill 8 7 NestedBlock LambdaOutside +hides scope/cs $C Spill 7 7 MethodTypeParam ClassTypeParam TypeParamField +proven scope/cs callees $C:FieldUse "method Spill $C:7" +proven scope/cs callees $C:NestedBlock "method Spill $C:7" # the field, past the block that hid it +proven scope/cs callees $C:TypedLocal "method Spill $C:8" +proven scope/cs callees $C:ForEachLoop "method Spill $C:8" # the loop variable's own declared class +hides scope/kt $K spill 5 6 forLoop forDestructure lambdaParam letParam typedLambda destructure whenSubject catchUse +hides scope/kt $K spill 6 5 nestedBlock lambdaOutside +hides scope/kt $K spill 5 5 classTypeParam typeParamField +proven scope/kt callees $K:fieldUse "fn spill $K:5" +proven scope/kt callees $K:typedLocal "fn spill $K:6" +proven scope/kt callees $K:typedLambda "fn spill $K:6" +hides scope/swift $W spill 3 4 forLoop forCaseLet forTuple closureParam typedClosure ifLet guardLet whileLet caseLet ifCaseLet catchLet +hides scope/swift $W spill 4 3 nestedBlock +hides scope/swift $W spill 3 3 classTypeParam typeParamField +proven scope/swift callees $W:fieldUse "fn spill $W:3" +proven scope/swift callees $W:typedLocal "fn spill $W:4" +echo "--- (B) the same shapes where FE-B reads TS / JS / Python / Go evidence (an outer typed or constructed binding hidden)" +hides scope/ts scope.ts spill 4 5 arrowParam forOf blockLocal catchParam destructure +hides scope/ts scope.ts spill 4 4 typeParam classTypeParam typeParamField +proven scope/ts callees scope.ts:typedParam "method spill scope.ts:4" +hides scope/js scope.js spill 2 3 arrowParam forOf catchParam reassign +proven scope/js callees scope.js:blockOutside "method spill scope.js:2" # the outer local, past the block that hid it +proven scope/js callees scope.js:constructed "method spill scope.js:2" +hides scope/py scope.py spill 3 6 for_loop lambda_param comprehension with_as except_as walrus reassign match_capture match_as +hides scope/py scope.py spill 3 3 type_param +proven scope/py callees scope.py:typed_param "fn spill scope.py:3" +hides scope/go scope.go Spill 8 12 RangeLoop ShortIf TypeSwitch +hides scope/go scope.go Spill 8 8 TypeParam TypeParamField +proven scope/go callees scope.go:BlockOutside "method Spill scope.go:8" +proven scope/go callees scope.go:TypedParam "method Spill scope.go:8" + +echo "=== (I) implicit receiver: a bare call never proves an unrelated class's method ===" +notproven java callees src/main/java/app/Logger.java:line "render src/main/java/app/Exporter.java" +notproven java callees src/main/java/app/Logger.java:close "flush src/main/java/app/Buffer.java" "flush src/main/java/app/Exporter.java" +notproven java callees src/main/java/app/FileBuffer.java:sync "flush src/main/java/app/Exporter.java" +notproven kt callees src/app/Plain.kt:finish "flush src/app/Exporter.kt" "flush src/app/Logger.kt" +notproven cs callees App/Logger.cs:Line "Render App/Exporter.cs" +notproven cs callees App/Logger.cs:Finish "Flush App/Logger.cs" "Flush App/Exporter.cs" # Exporter.cs: guard +notproven cpp callees src/plain.cpp:finish "flush src/exporter.cpp" "flush src/logger.cpp" +notproven swift callees Sources/App/Plain.swift:finish "flush Sources/App/Exporter.swift" "flush Sources/App/Logger.swift" +notproven rb callees lib/app/plain.rb:finish "render lib/app/exporter.rb" "flush lib/app/logger.rb" +echo "--- (I) near misses: own members, the in-repo superclass (cone, super.m()), free/top-level functions, mixins" +proven java callees src/main/java/app/Logger.java:close "method reset src/main/java/app/Logger.java" +proven java callees src/main/java/app/FileBuffer.java:sync "method flush src/main/java/app/Buffer.java" +exactproven java callees src/main/java/app/FileBuffer.java:drain "method flush src/main/java/app/Buffer.java" +exactproven java callees src/main/java/app/Buffer.java:write "method flush src/main/java/app/Buffer.java" +exactproven kt callees src/app/Logger.kt:line "fn render src/app/Logger.kt" +exactproven kt callees src/app/Logger.kt:close "fn flush src/app/Logger.kt;fn reset src/app/Logger.kt" +exactproven cs callees App/Logger.cs:Close "method Flush App/Logger.cs;method Reset App/Logger.cs" +exactproven cpp callees src/logger.cpp:line "fn render src/logger.cpp" +exactproven cpp callees src/logger.cpp:close "method flush src/logger.cpp;method reset src/logger.cpp" +exactproven swift callees Sources/App/Logger.swift:line "fn render Sources/App/Logger.swift" +exactproven swift callees Sources/App/Logger.swift:close "fn flush Sources/App/Logger.swift;fn reset Sources/App/Logger.swift" +# Ruby's own method lookup (mixins, typed and class receivers) is another lane's: these two arms state only the +# contract — a mixin's method is RESOLVED or HEDGED, never dropped and never beside a false proven row — so they hold +# whichever lands first +visible rb callees lib/app/logger.rb:line "stamp lib/app/helpers.rb" +notproven rb callees lib/app/logger.rb:line "render lib/app/exporter.rb" +# Ruby's own lookup (RubySelfReach, PR #373) is PROOF: what it proves comes out resolved, never via="name" — a mixin's +# method, a concern's call to its includer's method, a template method's call to a subclass's hook +exactproven rb callees lib/app/logger.rb:line "method stamp lib/app/helpers.rb" +exactproven rb callees lib/app/auditable.rb:log_audit "method audit_target lib/app/order.rb" +exactproven rb callees lib/app/order.rb:render_report "method header_line lib/app/order.rb" +exactproven rb callees lib/app/logger.rb:close "method flush lib/app/logger.rb;method reset lib/app/logger.rb;method top_level_note lib/app/notes.rb" + +echo "=== (O) graded false callees from an earlier binary (Go, TS, Python repros): red where still present, pinned clean otherwise ===" +notproven go callees src/gotool.go:runGo "Output src/gotool.go" +notproven go callees src/gotool.go:runGo "Errorf src/internal/log/log.go" "Split src/gotool.go" # guard: fmt.Errorf, strings.Split +exactproven go callees src/gotool.go:runTool "method Output src/gotool.go;method Split src/gotool.go" # guard +notproven ts callees src/http.ts:reply "getTime src/http.ts" +notproven ts callees src/http.ts:reply "Response src/response.ts" # guard: the global Response +notproven ts callees src/http.ts:stamped "getTime src/http.ts" +exactproven ts callees src/http.ts:local "cls Clock src/http.ts;method getTime src/http.ts" +exactproven ts callees src/http.ts:wrap "cls Response src/response.ts" # guard +notproven py callees src/tui/listing.py:gather "append src/tui/content.py" +exactproven py callees src/tui/listing.py:grow "cls Content src/tui/content.py;fn append src/tui/content.py" +hopnotproven go "how does runGo run the go command" runGo Output 14 +hopnotproven ts "how does reply build a Response" reply getTime 5 +hopnotproven ts "how does stamped get the time" stamped getTime 5 +hopnotproven py "how does gather append nodes" gather append 5 + +echo "=== (S) the two SIDES of a class lookup, and a fallback hook only after a miss (#373's Ruby class-object rule) ===" +# JS: Gauge.read() is the static (line 3) and never the instance read (line 4); new Gauge().read() the reverse; `this` inside +# a static member is the class. Plain.read() names an instance-only member on the CLASS: never proven (hedged or absent). +proven js callees lib/sides.js:onClass "method read lib/sides.js:3" +notproven js callees lib/sides.js:onClass "read lib/sides.js:4" +proven js callees lib/sides.js:onInstance "method read lib/sides.js:4" +notproven js callees lib/sides.js:onInstance "read lib/sides.js:3" +proven js callees lib/sides.js:make "method read lib/sides.js:3" +notproven js callees lib/sides.js:make "read lib/sides.js:4" +notproven js callees lib/sides.js:wrongSide "read lib/sides.js:8" "read lib/sides.js:4" +# TS: the same through a class-name receiver and a typed parameter +proven ts callees src/sides.ts:onClass "method read src/sides.ts:3" +notproven ts callees src/sides.ts:onClass "read src/sides.ts:4" +proven ts callees src/sides.ts:onTyped "method read src/sides.ts:4" +notproven ts callees src/sides.ts:onTyped "read src/sides.ts:3" +# Kotlin: Dial.read() is the companion's; Knob().read() never the companion's +exactproven kt callees src/app/Sides.kt:onClass "fn read src/app/Sides.kt" +notproven kt callees src/app/Sides.kt:onInstance "read src/app/Sides.kt:6" +# Python __getattr__ runs only after a miss: run()'s self.real() resolves to Proxyish.real (the hook changes nothing for a +# member the class defines); self.ghost() — a name the lookup misses — is never proven onto another class's ghost +proven py callees src/tui/ghost.py:run "fn real src/tui/ghost.py:7" +notproven py callees src/tui/ghost.py:run "real src/tui/ghost.py:19" "ghost src/tui/ghost.py:16" +echo "=== (H) propagation: one hedge, every surface ===" +# the name-only TRUE witness respondWith → reply.send(): kept and marked everywhere +marked js callers lib/reply.js:send "respondWith lib/reply.js" +# impact_d3 — the d=1 row respondWith is marked; viaModule (d=2, reached ONLY through respondWith → send) inherits +# the hedge; both (d=2, also reached through direct → new Reply().send, all proven) and direct (d=1) stay plain +impact_send(){ + local f; f="$( answer js impact lib/reply.js:send )" + ran_ok "(js) --impact=lib/reply.js:send" "$f" || return + rowsof "(H) --impact=lib/reply.js:send" "$f" || return + local d1 vm bo di + d1="$( printf '%s\n' "$GOT" | awk '$2 == "respondWith" && $6 == "1" { print $5 }' )" + if [ "$d1" = "name" ]; then ok "(H) --impact=lib/reply.js:send: the d=1 row respondWith is marked via=\"name\"" + else no "(H) --impact=lib/reply.js:send: the d=1 row respondWith is [${d1:-absent}], want via=\"name\": $( show )"; fi + vm="$( printf '%s\n' "$GOT" | awk '$2 == "viaModule" && $6 == "2" { print $5 }' )" + if [ "$vm" = "name" ]; then ok "(H) --impact=lib/reply.js:send: viaModule (d=2, only through the hedged edge) inherits via=\"name\"" + else no "(H) --impact=lib/reply.js:send: viaModule at d=2 is [${vm:-absent}], want via=\"name\" (D3): $( show )"; fi + bo="$( printf '%s\n' "$GOT" | awk '$2 == "both" && $6 == "2" { print $5 }' )" + if [ "$bo" = "-" ]; then ok "(H) --impact=lib/reply.js:send: both (d=2, an all-proven path through direct) stays plain" + else no "(H) --impact=lib/reply.js:send: both at d=2 is [${bo:-absent}], want plain (an all-proven path reaches it): $( show )"; fi + di="$( printf '%s\n' "$GOT" | awk '$2 == "direct" && $6 == "1" { print $5 }' )" + if [ "$di" = "-" ]; then ok "(H) --impact=lib/reply.js:send: direct (d=1, new Reply().send) stays plain" + else no "(H) --impact=lib/reply.js:send: direct at d=1 is [${di:-absent}], want plain: $( show )"; fi +} +impact_send +path_send(){ + local f last; f="$( answer js path lib/uses.js:viaModule,lib/reply.js:send )" + ran_ok "(js) --path=viaModule,send" "$f" || return + P rows "$f" || return + GOT="$PARSED" + last="$( printf '%s\n' "$GOT" | awk 'NF >= 5' | tail -1 )" + case "$last" in + "method send lib/reply.js 7 name"*) ok "(H) --path=viaModule,send: the hop into Reply.prototype.send is marked via=\"name\"";; + *) no "(H) --path=viaModule,send: last hop is [${last:-none}], want Reply.prototype.send marked via=\"name\": $( show )";; + esac +} +path_send +connect_send(){ + local f e; f="$( answer js connect lib/reply.js:respondWith,lib/reply.js:send )" + ran_ok "(js) --connect=respondWith,send" "$f" || return + P edges "$f" || return + e="$( printf '%s\n' "$PARSED" | awk '$1 == "respondWith" && $2 == "send" { print $3 }' )" + if [ "$e" = "name" ]; then ok "(H) --connect: the edge respondWith → send is marked via=\"name\"" + else no "(H) --connect: the edge respondWith → send is [${e:-absent}], want via=\"name\": $( printf '%s' "$PARSED" | tr '\n' ';' )"; fi +} +connect_send +expand_send(){ + local f c; f="$( answer js expand lib/reply.js:respondWith --top-k=0 --legend=full )" + ran_ok "(js) --expand=respondWith" "$f" || return + P ecalls "$f" || return + c="$( printf '%s\n' "$PARSED" | awk '$1 == "send" { print $3 }' | sort -u )" + if [ "$c" = "name" ]; then ok "(H) --expand=respondWith: row send is marked via=\"name\"" + else no "(H) --expand=respondWith: row send is [${c:-absent}], want via=\"name\": $( printf '%s' "$PARSED" | tr '\n' ';' )"; fi +} +expand_send +for_send(){ + local f c; f="$( answer js for "how does respondWith send the reply" --legend=full )" + ran_ok "(js) --for=respondWith" "$f" || return + P ecalls "$f" respondWith || return + if [ "$PARSED" = "NOROOT" ]; then no "(H) --for: no respondWith body with (the arm needs it)"; return; fi + c="$( printf '%s\n' "$PARSED" | awk '$1 == "send" { print $3 }' | sort -u )" + if [ "$c" = "name" ]; then ok "(H) --for: respondWith's body row send is marked via=\"name\"" + else no "(H) --for: respondWith's body row send is [${c:-absent}], want via=\"name\": $( printf '%s' "$PARSED" | tr '\n' ';' )"; fi +} +for_send +# the FALSE witness respond → ctx.onerror(): absent or marked on every surface +notproven js callers lib/application.js:onerror "respond lib/application.js" "respondSafely lib/application.js" +impact_onerror(){ + local f se rs; f="$( answer js impact lib/application.js:onerror )" + ran_ok "(js) --impact=lib/application.js:onerror" "$f" || return + rowsof "(H) --impact=lib/application.js:onerror" "$f" || return + if [ -n "$( printf '%s\n' "$GOT" | awk '$2 == "respond" && $6 == "1" && $5 != "name"' )" ]; then + no "(H) --impact=lib/application.js:onerror proves respond at d=1: $( show )" + else ok "(H) --impact=lib/application.js:onerror never proves respond at d=1"; fi + # serve reaches onerror only through respond: when respond is shown (hedged), serve is shown at d=2 marked + rs="$( printf '%s\n' "$GOT" | awk '$2 == "respond" { print $5 }' )" + se="$( printf '%s\n' "$GOT" | awk '$2 == "serve" { print $5 "@" $6 }' )" + if [ -n "$rs" ]; then + if [ "$se" = "name@2" ]; then ok "(H) --impact=lib/application.js:onerror: serve (d=2, only through respond's name-only call) inherits via=\"name\"" + else no "(H) --impact=lib/application.js:onerror: serve is [${se:-absent}], want via=\"name\" at d=2 (D3): $( show )"; fi + else + case "$se" in ""|name@*) ok "(H) --impact=lib/application.js:onerror: respond is gone, serve is absent or marked";; + *) no "(H) --impact=lib/application.js:onerror: serve is PROVEN [$se] with respond gone: $( show )";; esac + fi +} +impact_onerror +path_onerror(){ + local f last reach top; f="$( answer js path lib/application.js:respond,lib/application.js:onerror )" + ran_ok "(js) --path=respond,onerror" "$f" || return + P rows "$f" || return + GOT="$PARSED" + last="$( printf '%s\n' "$GOT" | awk 'NF >= 5' | tail -1 )" + top="$( grep -oE ']*>' "$f" | grep -oE ' to_p="[^"]*"' )" + if [ "$top" != ' to_p="lib/application.js:12"' ]; then no "(H) --path=respond,onerror bound the target to [${top:-nothing}], want to_p=\"lib/application.js:12\""; return; fi + reach="$( grep -oE ']*>' "$f" | grep -oE ' reachable="[^"]*"' | sed 's/.*="//; s/"$//' )" + case "$reach" in ''|*[!0-9]*) no "(H) --path=respond,onerror carries no numeric reachable= ([$reach])"; return;; esac + if [ "$reach" = "0" ]; then ok "(H) --path=respond,onerror: unreachable (the false edge is gone)" + else case "$last" in + *" name "*|*" name") ok "(H) --path=respond,onerror: the hop into Application.onerror is marked via=\"name\"";; + *) no "(H) --path=respond,onerror proves the hop into Application.onerror: [$last]";; + esac; fi +} +path_onerror +expand_onerror(){ + local f; f="$( answer js expand lib/application.js:respond --top-k=0 --legend=full )" + ran_ok "(js) --expand=respond" "$f" || return + P ecalls "$f" || return + if [ "$PARSED" = "NOROOT" ]; then no "(H) --expand=respond emitted no block" + elif [ -n "$( printf '%s\n' "$PARSED" | awk '$1 == "onerror" && $2 == "12" && $3 != "name"' )" ]; then + no "(H) --expand=respond: proves onerror at application.js:12: $( printf '%s' "$PARSED" | tr '\n' ';' )" + else ok "(H) --expand=respond: never proves Application.onerror"; fi +} +expand_onerror + +echo "--- (H) --for HOP blocks: the graded false rows were hop , not only body " +hopnotproven js "how does handle report an error" encode append 3 +hopnotproven js "how does respond report an error on ctx" respond onerror 12 +hopnotproven js "how does pipePayload handle payload end" pipePayload on 4 +hopnotproven js "how does the response go back to the referrer" back get 4 + +echo "--- (H) the legend defines via=\"name\" and says what it does NOT mean, on every surface" +legend_arm(){ + local label="$1" f="$2" + ran_ok "(H) legend $label" "$f" || return + P legend "$f" || return + if [ "$PARSED" = "DEFINED" ]; then ok "(H) legend $label defines via=\"name\" (by name alone) with a NOT sentence" + else no "(H) legend $label: no definition of via=\"name\" naming the mechanism (\"name alone\") and a \"does NOT mean\" sentence"; fi +} +legend_arm "--callees" "$( answer js callees lib/reply.js:respondWith --legend=full )" +legend_arm "--callers" "$( answer js callers lib/reply.js:send --legend=full )" +legend_arm "--impact" "$( answer js impact lib/reply.js:send --legend=full )" +legend_arm "--path" "$( answer js path lib/uses.js:viaModule,lib/reply.js:send --legend=full )" +legend_arm "--connect" "$( answer js connect lib/reply.js:respondWith,lib/reply.js:send --legend=full )" +legend_arm "--expand" "$( answer js expand lib/reply.js:respondWith --top-k=0 --legend=full )" +legend_arm "--for" "$( answer js for "how does respondWith send the reply" --legend=full )" + +echo "--- (H) the MAP's rows carry the same bit (the orient map is a surface too)" +# map_c FILE CALLER FILE CALLEE — the hedge column of that map row ("" when absent; NOROOT FAILs the arm) +map_c(){ + P map "$1" || return 1 + if [ "$PARSED" = "NOROOT" ]; then no "(H) map: no map document to read"; return 1; fi + MAPC="$( printf '%s\n' "$PARSED" | awk -v n="$2" -v f="$3" -v c="$4" '$1 == n && $2 == f && $3 == c { print $4 }' | sort -u | tr '\n' ' ' | sed 's/ $//' )" +} +map_arms(){ + local f="$TMP/js.map.xml" + rw js --top-k=500 --legend=full >"$f"; printf '%s' "$?" >"$f.rc" + ran_ok "(js) the default map" "$f" || return + map_c "$f" respondWith lib/reply.js send || return + if [ "$MAPC" = "name" ]; then ok "(H) map: respondWith's is marked via=\"name\"" + else no "(H) map: respondWith's is [${MAPC:-absent}], want via=\"name\""; fi + map_c "$f" direct lib/reply.js send || return + if [ "$MAPC" = "-" ]; then ok "(H) map: near miss — direct's (new Reply().send) stays plain" + else no "(H) map: direct's is [${MAPC:-absent}], want plain (a constructed receiver proves it)"; fi + map_c "$f" respond lib/application.js onerror || return + case "$MAPC" in *-*) no "(H) map: respond's is PROVEN (the false witness ctx.onerror())";; + *) ok "(H) map: respond's is absent or marked";; esac + legend_arm "map" "$f" + # near miss for the legend: a map whose rows carry no name-only pays nothing for it — a bare call to a function + # its own file defines is proven by scope, so its is plain and the comment must not ride + local g="$TMP/plainmap" gf="$TMP/plainmap.xml" + mkdir -p "$g" && printf 'function a() { return b(); }\nfunction b() { return 1; }\nmodule.exports = { a };\n' >"$g/m.js" + ( cd "$g" && "$BIN" . --no-cache --top-k=500 --legend=full >"$gf" 2>/dev/null ); printf '%s' "$?" >"$gf.rc" + ran_ok "(H) the plain-call map" "$gf" || return + map_c "$gf" a m.js b || return + if [ "$MAPC" != "-" ]; then no "(H) map near miss: a's is [${MAPC:-absent}], want a plain row (the premise of this arm)"; return; fi + P legend "$gf" || return + if [ "$PARSED" = "UNDEFINED" ]; then ok "(H) map near miss: a scope-proven stays plain and the via legend does not ride" + else no "(H) map near miss: the via legend rides a map whose rows carry no via=\"name\""; fi +} +map_arms + +# (H) the map's MERGED hedged rows (fix round 1): N byte-identical via="name" rows of one symbol print ONCE with x="N"; the +# full legend and the compact legend define x=; --callees on the caller lists every candidate (the cut is recoverable). +# Near misses: a lone hedged candidate carries no x=, a PROVEN row is never merged into a hedged one, and two different +# names stay two rows. +merge_arms(){ + local g="$TMP/mergemap" + mkdir -p "$g" + { printf 'class A { get() { return 1; } put() { return 1; } }\nclass B { get() { return 2; } }\nclass C { get() { return 3; } }\n' + printf 'function use(o) { return o.get() + o.put(); }\nfunction proven() { return new A().get(); }\nmodule.exports = { A, B, C, use, proven };\n'; } >"$g/u.js" + local full="$TMP/mergemap.full.xml" comp="$TMP/mergemap.compact.xml" cal="$TMP/mergemap.callees.xml" + ( cd "$g" && "$BIN" . --no-cache --top-k=500 --legend=full >"$full" 2>/dev/null ); printf '%s' "$?" >"$full.rc" + ( cd "$g" && "$BIN" . --no-cache --top-k=500 --legend=compact >"$comp" 2>/dev/null ); printf '%s' "$?" >"$comp.rc" + ( cd "$g" && "$BIN" . --no-cache --callees=u.js:use >"$cal" 2>/dev/null ); printf '%s' "$?" >"$cal.rc" + ran_ok "(H) merge: the full-legend map" "$full" || return + ran_ok "(H) merge: the compact-legend map" "$comp" || return + ran_ok "(H) merge: --callees=u.js:use" "$cal" || return + local use proven + use="$( sed 's/]*>' | wc -l | tr -d ' ' )" = 1 ] && printf '%s' "$use" | grep -qE '' \ + && ok "(H) merge: use's three same-named hedged get candidates print ONE row " \ + || no "(H) merge: use's get rows are not one x=\"3\" row: $use" + printf '%s' "$use" | grep -q ']* x="' \ + && no "(H) merge near miss: use's lone put candidate carries x=: $use" \ + || ok "(H) merge near miss: a lone hedged candidate (put) carries no x=" + printf '%s' "$proven" | grep -q 'x="' \ + && no "(H) merge near miss: proven's evidence-bound get row was merged: $proven" \ + || ok "(H) merge near miss: a proven row is never merged (proven's get carries no x=)" + [ "$( grep -o ']*n="get"' "$cal" | wc -l | tr -d ' ' )" = 3 ] \ + && ok "(H) merge: --callees=u.js:use lists all three get candidates (the merged row is recoverable)" \ + || no "(H) merge: --callees=u.js:use does not list three get candidates: $( head -c 600 "$cal" )" + grep -q 'x=N' "$full" && P legend "$full" && [ "$PARSED" = "DEFINED" ] \ + && ok "(H) merge: the full map legend defines via=\"name\" and x=" \ + || no "(H) merge: the full map legend does not define via=\"name\" (name alone + does NOT mean) and x=" + grep -q '' "$comp" && grep -qE '' "$comp" \ + && ok "(H) merge: the compact map defines and keeps the merged row" \ + || no "(H) merge: the compact map does not define beside the merged row" +} +merge_arms + +echo "--- (H) MCP twins (fresh TMPDIR: the MCP cache lives there)" +mkdir -p "$TMP/mcp" +# mcp ROOT TOOL SELECTOR OUT — one tools/call (then tools/list); the raw stream into OUT, the exit status into OUT.rc +mcp(){ + printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize"}' \ + "$( printf '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"%s","arguments":{"path":"%s","symbol":"%s"}}}' "$2" "$CORPUS/$1" "$3" )" \ + | TMPDIR="$TMP/mcp" "$BIN" --mcp >"$4" 2>/dev/null + printf '%s' "$?" >"$4.rc" +} +# mcp_hedge ROOT TOOL SEL FIELD NAME FILE WANT — WANT = name (marked) | notproven (absent or marked) +mcp_hedge(){ + local r="$1" tool="$2" sel="$3" field="$4" n="$5" file="$6" want="$7" out h + out="$TMP/mcp.$r.$tool.$( printf '%s' "$sel" | tr '/:.' '___' )" + mcp "$r" "$tool" "$sel" "$out" + ran_ok "(H) MCP $tool $sel" "$out" || return + P mcp "$out" "$field" || return + if [ "$PARSED" = "NOROOT" ]; then no "(H) MCP $tool $sel: no JSON answer with $field"; return; fi + h="$( printf '%s\n' "$PARSED" | awk -v n="$n" -v f="$file" '$1 == n && $2 == f { print $4 }' | sort -u | tr '\n' ' ' | sed 's/ $//' )" + if [ "$want" = "name" ]; then + if [ "$h" = "name" ]; then ok "(H) MCP $tool $sel: $field entry $n@$file is marked via=name" + else no "(H) MCP $tool $sel: $field entry $n@$file is [${h:-absent}], want via=name"; fi + else + case " $h " in *" - "*) no "(H) MCP $tool $sel: $field entry $n@$file is PROVEN (no via=name)";; + *) ok "(H) MCP $tool $sel: $field entry $n@$file is absent or marked";; esac + fi +} +mcp_hedge js find_symbol lib/reply.js:respondWith calls send lib/reply.js name +mcp_hedge js find_referencing_symbols lib/reply.js:send calledBy respondWith lib/reply.js name +mcp_hedge js find_symbol lib/application.js:respond calls onerror lib/application.js notproven +mcp_hedge js find_referencing_symbols lib/application.js:onerror calledBy respond lib/application.js notproven +mcp_hedge py find_symbol src/tui/callback.py:schedule calls call_later src/tui/message_pump.py name +# mcp_legend TOOL SEL — tools/call then tools/list: the tool's description or its answer defines "via":"name" +mcp_legend(){ + local tool="$1" sel="$2" out="$TMP/mcplegend.$1" + printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize"}' \ + "$( printf '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"%s","arguments":{"path":"%s","symbol":"%s"}}}' "$tool" "$CORPUS/js" "$sel" )" \ + '{"jsonrpc":"2.0","id":3,"method":"tools/list"}' \ + | TMPDIR="$TMP/mcp" "$BIN" --mcp >"$out" 2>/dev/null + printf '%s' "$?" >"$out.rc" + ran_ok "(H) MCP legend $tool" "$out" || return + P mcplegend "$out" "$tool" || return + if [ "$PARSED" = "DEFINED" ]; then ok "(H) MCP $tool: the tool description or answer defines \"via\":\"name\" with a NOT sentence" + elif [ "$PARSED" = "NOROOT" ]; then no "(H) MCP $tool: no tools/list or tools/call answer to read" + else no "(H) MCP $tool: neither the tool description nor the answer defines \"via\":\"name\" (\"name alone\" + \"does NOT mean\")"; fi +} +mcp_legend find_symbol lib/reply.js:respondWith +mcp_legend find_referencing_symbols lib/reply.js:send +# the MAP's MCP twin (analyze): the same rows, the same bit, the same legend +mcp_map(){ + local out="$TMP/mcp.js.analyze" c d + printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize"}' \ + "$( printf '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"analyze","arguments":{"path":"%s"}}}' "$CORPUS/js" )" \ + | TMPDIR="$TMP/mcp" "$BIN" --mcp >"$out" 2>/dev/null + printf '%s' "$?" >"$out.rc" + ran_ok "(H) MCP analyze" "$out" || return + P mcpmap "$out" || return + if [ "$PARSED" = "NOROOT" ]; then no "(H) MCP analyze: no map in the answer"; return; fi + c="$( printf '%s\n' "$PARSED" | awk '$1 == "respondWith" && $2 == "lib/reply.js" && $3 == "send" { print $4 }' | sort -u )" + d="$( printf '%s\n' "$PARSED" | awk '$1 == "direct" && $2 == "lib/reply.js" && $3 == "send" { print $4 }' | sort -u )" + if [ "$c" = "name" ]; then ok "(H) MCP analyze: respondWith's is marked via=\"name\" (the CLI map's bit)" + else no "(H) MCP analyze: respondWith's is [${c:-absent}], want via=\"name\""; fi + if [ "$d" = "-" ]; then ok "(H) MCP analyze: near miss — direct's stays plain" + else no "(H) MCP analyze: direct's is [${d:-absent}], want plain"; fi + P mcplegend "$out" analyze || return + if [ "$PARSED" = "DEFINED" ]; then ok "(H) MCP analyze: the answer defines via=\"name\" with a NOT sentence" + else no "(H) MCP analyze: the answer does not define via=\"name\" (\"name alone\" + \"does NOT mean\")"; fi +} +mcp_map + +echo "--- (H) parity: the hedge bit is value-equal across CLI and MCP, and across --callees and --expand" +# cli_set ROOT VERB SEL — "name@file#H" lines of the CLI answer; mcp_set ROOT TOOL SEL FIELD — the same from MCP +# (a run or reader failure prints RUNFAIL, which parity FAILs) +cli_set(){ local f rc rows; f="$( answer "$1" "$2" "$3" )"; rc="$( cat "$f.rc" 2>/dev/null )" + if [ "$rc" != "0" ]; then echo "RUNFAIL --$2=$3 rc=${rc:-unknown}"; return; fi + rows="$( python3 "$TMP/parse.py" rows "$f" 2>/dev/null )" || { echo "RUNFAIL reader on --$2=$3"; return; } + printf '%s\n' "$rows" | awk 'NF >= 5 { print $2 "@" $3 "#" $5 } $0 == "NOROOT" { print }' | sort -u; } +mcp_set(){ local out rc rows; out="$TMP/mcpset.$1.$2.$( printf '%s' "$3" | tr '/:.' '___' )"; mcp "$1" "$2" "$3" "$out" + rc="$( cat "$out.rc" 2>/dev/null )" + if [ "$rc" != "0" ]; then echo "RUNFAIL MCP $2 $3 rc=${rc:-unknown}"; return; fi + rows="$( python3 "$TMP/parse.py" mcp "$out" "$4" 2>/dev/null )" || { echo "RUNFAIL reader on MCP $2 $3"; return; } + printf '%s\n' "$rows" | awk 'NF >= 4 { print $1 "@" $2 "#" $4 } $0 == "NOROOT" { print }' | sort -u; } +parity(){ + local label="$1" a="$2" b="$3" + case "$a$b" in *RUNFAIL*) no "(H) parity $label: a run or reader failed ([$a] [$b])"; return;; esac + if [ -z "$a" ] || [ "$a" = "NOROOT" ] || [ "$b" = "NOROOT" ]; then no "(H) parity $label: an empty or rootless side ([$a] vs [$b])"; return; fi + if [ "$a" = "$b" ]; then ok "(H) parity $label: $( printf '%s' "$a" | tr '\n' ' ' )" + else no "(H) parity $label: CLI [$( printf '%s' "$a" | tr '\n' ' ' )] vs [$( printf '%s' "$b" | tr '\n' ' ' )]"; fi +} +for sel in lib/application.js:respond lib/reply.js:respondWith lib/reply.js:notFound lib/request.js:compile lib/uses.js:build lib/application.js:handle lib/delegate/response.js:back; do + parity "callees/find_symbol.calls $sel" "$( cli_set js callees "$sel" )" "$( mcp_set js find_symbol "$sel" calls )" +done +for sel in lib/reply.js:send lib/application.js:onerror lib/schemas.js:listSchemas; do + parity "callers/find_referencing_symbols.calledBy $sel" "$( cli_set js callers "$sel" )" "$( mcp_set js find_referencing_symbols "$sel" calledBy )" +done +# --callees vs --expand : compared by name + hedge (a row names no file) +for sel in lib/reply.js:respondWith lib/reply.js:notFound lib/request.js:compile lib/delegate/response.js:back; do + fc="$( answer js callees "$sel" )"; fe="$( answer js expand "$sel" --top-k=0 --legend=full )" + if ran_ok "(H) --callees=$sel" "$fc" && ran_ok "(H) --expand=$sel" "$fe"; then + if ra="$( python3 "$TMP/parse.py" rows "$fc" 2>/dev/null )" && rb="$( python3 "$TMP/parse.py" ecalls "$fe" 2>/dev/null )"; then + a="$( printf '%s\n' "$ra" | awk 'NF >= 5 { print $2 "#" $5 }' | sort -u )" + b="$( printf '%s\n' "$rb" | awk 'NF >= 3 { print $1 "#" $3 }' | sort -u )" + parity "callees/expand-calls $sel" "$a" "$b" + else no "(H) parity callees/expand-calls $sel: the reader failed"; fi + fi +done + +echo "=== (R) the base-class receiver is a KEYWORD, never a local spelled like one (CR 5469474915, receiverevidence.h:124) ===" +# superroot/: Panel extends Base; Base and Widget both define render. A local `base` (C#/Go: `super`) holding a +# Widget, or an untyped value, never proves Base's render through the bases-only lookup: the typed local resolves to its +# own class, the untyped one is hedged. The keyword twins (`super.render()`, Python `super().render()`, Go's promoted +# p.Render()) still prove Base's render. C# `base.Render()` is pinned as visible only: no C# tree reaches the base-class +# receiver today (the grammar's `base` node is not read as a receiver root — a disclosed floor, never a false row). +SR=superroot +notproven $SR/py callees pkg/panel.py:untyped "render pkg/shapes.py:2" +notproven $SR/py callees pkg/panel.py:shadowed "render pkg/shapes.py:2" +visible $SR/py callees pkg/panel.py:untyped "render pkg/shapes.py:2" "render pkg/shapes.py:7" +exactproven $SR/py callees pkg/panel.py:typed "cls Widget pkg/shapes.py;fn render pkg/shapes.py" # guard: Rule 2 types it first +proven $SR/py callees pkg/panel.py:typed "fn render pkg/shapes.py:7" # guard +proven $SR/py callees pkg/panel.py:real_super "fn render pkg/shapes.py:2" # guard: super() walks the bases +for l in js ts; do + f="$( [ "$l" = js ] && echo lib/panel.js || echo src/panel.ts )"; d="$( [ "$l" = js ] && echo lib/shapes.js || echo src/shapes.ts )" + notproven $SR/$l callees "$f:typed" "render $d:2" + proven $SR/$l callees "$f:typed" "method render $d:6" + notproven $SR/$l callees "$f:untyped" "render $d:2" + visible $SR/$l callees "$f:untyped" "render $d:2" "render $d:6" + proven $SR/$l callees "$f:realSuper" "method render $d:2" # guard: super.render() +done +notproven $SR/go callees src/panel.go:Typed "Render src/shapes.go:5" +proven $SR/go callees src/panel.go:Typed "method Render src/shapes.go:9" +notproven $SR/go callees src/panel.go:Untyped "Render src/shapes.go:5" +notproven $SR/go callees src/panel.go:Super "Render src/shapes.go:5" +visible $SR/go callees src/panel.go:Super "Render src/shapes.go:5" "Render src/shapes.go:9" +proven $SR/go callees src/panel.go:Promoted "method Render src/shapes.go:5" # guard: the embedded Base's promoted method +notproven $SR/java callees src/app/Panel.java:typed "render src/app/Base.java" +proven $SR/java callees src/app/Panel.java:typed "method render src/app/Widget.java" +notproven $SR/java callees src/app/Panel.java:untyped "render src/app/Base.java" +proven $SR/java callees src/app/Panel.java:realSuper "method render src/app/Base.java" # guard +notproven $SR/kt callees src/app/Panel.kt:typed "render src/app/Shapes.kt:4" +proven $SR/kt callees src/app/Panel.kt:typed "fn render src/app/Shapes.kt:8" +notproven $SR/kt callees src/app/Panel.kt:untyped "render src/app/Shapes.kt:4" +proven $SR/kt callees src/app/Panel.kt:realSuper "fn render src/app/Shapes.kt:4" # guard +notproven $SR/swift callees Sources/App/Panel.swift:typed "render Sources/App/Shapes.swift:2" +proven $SR/swift callees Sources/App/Panel.swift:typed "fn render Sources/App/Shapes.swift:6" +notproven $SR/swift callees Sources/App/Panel.swift:untyped "render Sources/App/Shapes.swift:2" +proven $SR/swift callees Sources/App/Panel.swift:realSuper "fn render Sources/App/Shapes.swift:2" # guard +notproven $SR/cs callees App/Panel.cs:Typed "Render App/Shapes.cs:5" +proven $SR/cs callees App/Panel.cs:Typed "method Render App/Shapes.cs:10" +notproven $SR/cs callees App/Panel.cs:Untyped "Render App/Shapes.cs:5" +visible $SR/cs callees App/Panel.cs:RealBase "Render App/Shapes.cs:5" # guard (floor above) +notproven $SR/cpp callees src/panel.cpp:untyped "render src/shapes.h:6" +proven $SR/cpp callees src/panel.cpp:typed "method render src/shapes.h:11" # guard: Rule 2 types it first +notproven $SR/rb callees lib/panel.rb:untyped "render lib/shapes.rb:2" +proven $SR/rb callees lib/panel.rb:typed "method render lib/shapes.rb:8" # guard + +echo "=== (C) conservation: no call silently disappears ===" +ROOTS="js ts py go java kt cs cpp swift rb" +for r in $ROOTS; do + if ! rw "$r" --pin-census="$TMP/$r.tsv" >"$TMP/$r.map.xml"; then no "(C) ($r) the census run exited non-zero"; continue; fi + DL="$( grep -m1 '^# dispositions ' "$TMP/$r.tsv" 2>/dev/null )" + if [ -z "$DL" ]; then no "(C) ($r) the census carries no '# dispositions' line"; continue; fi + if python3 - "$DL" <<'PY' +import re, sys +kv = dict( ( k, int( v ) ) for k, v in re.findall( r"(\w+)=(\d+)", sys.argv[ 1 ] ) ) +if "calls" not in kv or "unaccounted" not in kv or kv[ "calls" ] < 1: + sys.exit( 1 ) +total = kv.pop( "calls" ) +sys.exit( 0 if kv[ "unaccounted" ] == 0 and sum( kv.values() ) == total else 1 ) +PY + then ok "(C) ($r) dispositions sum to calls= with unaccounted=0" + else no "(C) ($r) conservation broken: $DL"; fi +done + +echo "=== (K) the predicates can fail ===" +kread(){ P rows "$1" && GOT="$PARSED"; } +printf '' >"$TMP/k1.xml"; kread "$TMP/k1.xml" +if [ -n "$( specmatch "onerror lib/application.js" | awk '$5 != "name"' )" ]; then ok "(K) an unhedged planted row reads as PROVEN (notproven would fail it)" +else no "(K) the reader missed an unhedged planted row: [$GOT]"; fi +printf '' >"$TMP/k2.xml"; kread "$TMP/k2.xml" +if [ -n "$( specmatch "onerror lib/application.js" | awk '$5 == "name"' )" ] && [ -z "$( specmatch "onerror lib/application.js" | awk '$5 != "name"' )" ]; then + ok "(K) a hedged planted row reads as marked (notproven passes it, proven fails it)" +else no "(K) the reader did not read via=\"name\": [$GOT]"; fi +printf '' >"$TMP/k3.xml"; kread "$TMP/k3.xml" +if [ -n "$( specmatch "onerror lib/application.js" | awk '$5 != "name"' )" ]; then ok "(K) via=\"import\" is not the hedge" +else no "(K) the reader took via=\"import\" for the hedge: [$GOT]"; fi +printf 'nothing here' >"$TMP/k4.xml" +if P rows "$TMP/k4.xml" && [ "$PARSED" = "NOROOT" ]; then ok "(K) an answer with no root element is NOROOT" +else no "(K) a rootless document read as an answer"; fi +printf '' >"$TMP/k5.xml" +if P rows "$TMP/k5.xml" && [ "$PARSED" = "NOROOT" ]; then ok "(K) an answer about no symbol (no defs=) is NOROOT" +else no "(K) a not-found answer read as an empty answer"; fi +printf 'sig' >"$TMP/k6.xml" +if P ecalls "$TMP/k6.xml" respondWith && [ "$PARSED" = "NOROOT" ]; then ok "(K) a --for answer without the named body is NOROOT, never an empty pass" +else no "(K) ecalls read another body's as respondWith's"; fi +printf 'sig' >"$TMP/k7.xml" +if P ecalls "$TMP/k7.xml" respondWith && [ "$PARSED" = "send 7 name" ]; then ok "(K) ecalls reads a marked row" +else no "(K) ecalls missed a marked row: [$PARSED]"; fi +printf '%s\n' '{"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"{\"calls\":[{\"name\":\"send\",\"file\":\"lib/reply.js\",\"line\":7,\"via\":\"name\"},{\"name\":\"code\",\"file\":\"lib/reply.js\",\"line\":12}]}"}]}}' >"$TMP/k8.json" +if P mcp "$TMP/k8.json" calls && [ "$( printf '%s' "$PARSED" | tr '\n' ';' )" = "send lib/reply.js 7 name;code lib/reply.js 12 -" ]; then ok "(K) the MCP reader reads the hedge bit per entry" +else no "(K) the MCP reader misread: [$( printf '%s' "$PARSED" | tr '\n' ';' )]"; fi +if P mcp "$TMP/k8.json" calledBy && [ "$PARSED" = "NOROOT" ]; then ok "(K) an MCP answer without the field is NOROOT" +else no "(K) the MCP reader invented a missing field"; fi +printf '1' >"$TMP/k9.rc" +if ( ran_ok k "$TMP/k9" ) >/dev/null; then no "(K) ran_ok accepted a run that exited 1" +else ok "(K) ran_ok fails a run that exited non-zero"; fi +rm -f "$TMP/k10.rc" +if ( ran_ok k "$TMP/k10" ) >/dev/null; then no "(K) ran_ok accepted a run with no recorded exit status" +else ok "(K) ran_ok fails a run with no recorded exit status"; fi +if ( parity k "a#-" "a#name" ) | grep -q FAIL; then ok "(K) parity fails when the hedge bit differs" +else no "(K) parity accepted differing hedge bits"; fi +if ( parity k "" "" ) | grep -q FAIL; then ok "(K) parity fails on two empty sides (never a vacuous pass)" +else no "(K) parity accepted two empty sides"; fi +if ( parity k "RUNFAIL --callees=x rc=1" "RUNFAIL --callees=x rc=1" ) | grep -q FAIL; then ok "(K) parity fails when a run exited non-zero" +else no "(K) parity accepted a failed run"; fi +# change 1: a reader crash or malformed defs= FAILs the arm; it never reads as "no rows" (the old fall-through) +printf '' >"$TMP/k11.xml" +if python3 "$TMP/parse.py" rows "$TMP/k11.xml" >/dev/null 2>&1; then no "(K) the reader accepted a non-numeric defs=" +else ok "(K) the reader exits non-zero on a non-numeric defs="; fi +if ( rowsof k "$TMP/k11.xml" ) | grep -q FAIL; then ok "(K) a reader failure FAILs the arm (never an empty pass)" +else no "(K) a reader failure fell through to PASS"; fi +if ( fail=0; rowsof k "$TMP/k11.xml" >/dev/null; [ "$fail" -eq 1 ] ); then ok "(K) a reader failure sets the gate's failure flag" +else no "(K) a reader failure left the failure flag clear"; fi +printf '' >"$TMP/k12.xml" +if P hcalls "$TMP/k12.xml" encode && [ "$PARSED" = "append 3 name" ] && P hcalls "$TMP/k12.xml" respond && [ "$PARSED" = "onerror 12 -" ]; then + ok "(K) a hop reader reads only its own hop's " +else no "(K) the hop reader crossed hops: [$PARSED]"; fi +if P hcalls "$TMP/k12.xml" pipePayload && [ "$PARSED" = "NOROOT" ]; then ok "(K) a hop that is not shown is NOROOT, never an empty pass" +else no "(K) the hop reader invented a hop: [$PARSED]"; fi +printf '' >"$TMP/k13.xml" +if P ecalls "$TMP/k13.xml" && [ "$( printf '%s' "$PARSED" | tr '\n' ';' )" = "get 4 -;get 8 -" ]; then ok "(K) a joined row reads as one row per line" +else no "(K) a joined row misread: [$PARSED]"; fi +printf '' >"$TMP/k14.xml" +printf '' >"$TMP/k15.xml" +if P legend "$TMP/k14.xml" && [ "$PARSED" = "DEFINED" ] && P legend "$TMP/k15.xml" && [ "$PARSED" = "UNDEFINED" ]; then + ok "(K) the legend reader needs the mechanism and the NOT sentence AFTER via=\"name\"" +else no "(K) the legend reader misread a planted legend"; fi + +if [ "$fail" -eq 0 ]; then echo "ALL PASS"; else echo "FAILURES ABOVE"; exit 1; fi diff --git a/test/receiverevidencefix/README.md b/test/receiverevidencefix/README.md new file mode 100644 index 000000000..d9a855b89 --- /dev/null +++ b/test/receiverevidencefix/README.md @@ -0,0 +1,59 @@ +# receiverevidencefix — calls bound by name alone (test/receiverevidencecheck.sh) + +Each directory is its own root (the gate indexes one at a time). Every root holds member calls (or, in an +implicit-receiver language, bare calls) whose receiver nothing proves to be the class of the same-named +definition beside them, plus near-miss calls whose receiver IS proven and whose edges must stay plain. + +- `js/` — `ctx.onerror()` (plain and optional-chained) in the file that defines `Application.onerror`; a + constructor-assigned `this.bucket` (a `Schemas`) beside the caller's own `listSchemas`; WeakMap `.set/.get` + beside same-file accessors; Promise `.then` beside `Reply.prototype.then`; a stream's `.on` beside a test + double; an outside router's `.all` and a Set's `.delete` beside instance shorthands; URLSearchParams `.append` + beside `Response.append`; a `done` parameter beside another file's closure; `context.handler()` beside a free + `handler`; an object-literal module's `this.ctx.get()` beside its own `get` (the true target is the request + delegate's `get`); an alias `const onerror = ctx.onerror` and a destructured `const { onerror } = ctx`; the + globals `Reflect.get` / `Atomics.add` beside in-repo `get` / `add`. Kept: `this.onerror()`, a same-file bare call, a relative module receiver, a closure called in its + own function, `new Application()` / `new Schemas()` deciding between same-named methods, `super.listSchemas()`, + a class-name receiver `Application.create()`, a relative module bound to the name `Reflect`, `new Reply().send()`; + `reply.send()` / `reply.code()` on a parameter are the name-only true edges the gate wants kept AND marked. + `serve` (via respond) and `both` (via direct and respondWith) give `--impact` its d=2 rows. +- `ts/` — a loop variable over `Router[]` beside `SmartRouter.add`; WHATWG `headers.get()` beside + `Context.get` / `Cache.get`; the global `Atomics.add`; `Date#getTime` beside an in-repo `Clock.getTime`, and the + global `Response` beside an in-repo `Response` class. Kept: an annotated parameter, construction through an import alias, a + constructed local. +- `py/` (src layout) — dict/set/list/str receivers in files that import `Styles` / `Content`; an argument's + `.animate`; an asyncio handle's `.cancel`; an outside package's parser `.write`; `self._send` holding a passed + callable beside a nested `_send`; non-self receivers in `App`'s own file (`screen` is a property); + `from json import dumps as stringify` beside in-repo `stringify` / `dumps`; `append` on a list literal; `read = os.read` and + `feed = parser.feed` called from a nested def; `add_widget = widgets.append` beside a nested `add_widget`. + Kept: `self.update()`, a constructor-assigned field through the class cone, a typed parameter, construction, + an import alias, an imported function beside same-named methods, `cls.default_rules()`, `Styles.copy(x)`, a + constructed `Content`'s `append`, the + `XTermParser.feed` the alias really reaches; `pump.call_later()` on a parameter is kept and marked. +- `go/` — an item's typed `text util.Chars` field, a typed parameter, a typed local through an aliased import + and an embedded field, all beside `Merger.Get/Length` in the caller's package; an outside `tcell.Screen`'s + `Size` and an outside value's `Runes` beside a renderer's methods; a field / local named `log` of an outside type + beside the in-repo `log.Errorf`; exec's `Cmd.Output` beside an in-repo `Output` (with `fmt.Errorf` and + `strings.Split`). Kept: `m := &Merger{}`, the method receiver itself, a real import of the in-repo log package, + a constructed in-repo `Tool`. +- `java/ kt/ cs/ cpp/ swift/ rb/` — a bare call inside a class whose base is outside the tree (or whose name + comes from an outside import) beside an unrelated class's method of that name. Kept: own members (private + too), an in-repo superclass's member (and Java `super.flush()`), a free / top-level function, a Ruby top-level + def; a Ruby included module's method is resolved or hedged (the contract only: Ruby's own lookup is separate work). + +Sides of a class lookup (js/lib/sides.js, ts/src/sides.ts, kt/src/app/Sides.kt): a call on the class object reaches +its static (companion) members, a call on an instance the others. A fallback hook only after a miss +(py/src/tui/ghost.py): `__getattr__` leaves a defined member's call resolved. + +The names are paraphrases of graded false rows; the code is minimal and is never built. +- `scope/{java,cs,kt,swift,ts,js,py,go}` — review B4: Tank and Barrel both define `spill`, and the class's field (or the + function's outer parameter / local) named `tank` is a Tank. Each method binds `tank` again — a loop, lambda, catch, + resource, pattern, `case`, `out var`, destructuring, `if`/`guard`/`while let`, `with`/`except`/walrus/`match`, a Go + range / if / type-switch variable, a reassignment — and calls `tank.spill()`, which must never prove Tank's spill; + `nestedBlock` / `lambdaOutside` / `blockOutside` call it past the block that hid it, which must never prove Barrel's. A + generic whose type parameter is spelled `Tank` (method and class, a field of that type) must never prove class Tank. + Kept: the field itself, a typed local, a typed loop / lambda variable's own class, a typed parameter. +- `superroot/{py,js,ts,go,java,kt,swift,cs,cpp,rb}` — CR 5469474915: `Panel` extends `Base`; `Base` and `Widget` both + define `render`. `typed` binds a local spelled `base` (C#: `super`) to a `Widget`, `untyped` to a value nothing types + (Python and Go also a local `super`), then calls `render` on it: never Base's render through the bases-only lookup. + Kept: `super.render()`, Python `super().render()`, Go's promoted `p.Render()`, the typed local's own class. C# + `base.Render()` is visible only (no C# tree reads `base` as a receiver root yet). diff --git a/test/receiverevidencefix/cpp/src/exporter.cpp b/test/receiverevidencefix/cpp/src/exporter.cpp new file mode 100644 index 000000000..8955d6a81 --- /dev/null +++ b/test/receiverevidencefix/cpp/src/exporter.cpp @@ -0,0 +1,5 @@ +#include "exporter.hpp" +#include + +void Exporter::flush() { std::puts("exporter flush"); } +std::string Exporter::render(const std::string& s) { return "<" + s + ">"; } diff --git a/test/receiverevidencefix/cpp/src/exporter.hpp b/test/receiverevidencefix/cpp/src/exporter.hpp new file mode 100644 index 000000000..18f0d9837 --- /dev/null +++ b/test/receiverevidencefix/cpp/src/exporter.hpp @@ -0,0 +1,8 @@ +#pragma once +#include + +class Exporter { +public: + void flush(); + std::string render(const std::string& s); +}; diff --git a/test/receiverevidencefix/cpp/src/logger.cpp b/test/receiverevidencefix/cpp/src/logger.cpp new file mode 100644 index 000000000..456f04f62 --- /dev/null +++ b/test/receiverevidencefix/cpp/src/logger.cpp @@ -0,0 +1,10 @@ +#include "logger.hpp" +#include + +std::string render(const std::string& s) { return "[" + s + "]"; } +void Base::flush() { std::puts("base flush"); } + +// A bare call inside a member function: an own/inherited member or a free function, never an unrelated class's member. +std::string Logger::line(const std::string& s) { return render(s); } +void Logger::close() { flush(); reset(); } +void Logger::reset() { std::puts("reset"); } diff --git a/test/receiverevidencefix/cpp/src/logger.hpp b/test/receiverevidencefix/cpp/src/logger.hpp new file mode 100644 index 000000000..29ce5c9c9 --- /dev/null +++ b/test/receiverevidencefix/cpp/src/logger.hpp @@ -0,0 +1,17 @@ +#pragma once +#include + +std::string render(const std::string& s); + +class Base { +public: + void flush(); +}; + +class Logger : public Base { +public: + std::string line(const std::string& s); + void close(); +private: + void reset(); +}; diff --git a/test/receiverevidencefix/cpp/src/plain.cpp b/test/receiverevidencefix/cpp/src/plain.cpp new file mode 100644 index 000000000..a39fb560b --- /dev/null +++ b/test/receiverevidencefix/cpp/src/plain.cpp @@ -0,0 +1,4 @@ +#include "plain.hpp" + +// A bare flush() inside a member: the outside base's member, never Base::flush or Exporter::flush. +void Plain::finish() { flush(); } diff --git a/test/receiverevidencefix/cpp/src/plain.hpp b/test/receiverevidencefix/cpp/src/plain.hpp new file mode 100644 index 000000000..3ef02f9c9 --- /dev/null +++ b/test/receiverevidencefix/cpp/src/plain.hpp @@ -0,0 +1,8 @@ +#pragma once +#include + +// Plain inherits flush() from an OUTSIDE class. +class Plain : public std::ostream { +public: + void finish(); +}; diff --git a/test/receiverevidencefix/cs/App/Exporter.cs b/test/receiverevidencefix/cs/App/Exporter.cs new file mode 100644 index 000000000..97e29b413 --- /dev/null +++ b/test/receiverevidencefix/cs/App/Exporter.cs @@ -0,0 +1,8 @@ +namespace App +{ + public class Exporter + { + public void Flush() { System.Console.WriteLine("exporter flush"); } + public string Render(string s) { return "<" + s + ">"; } + } +} diff --git a/test/receiverevidencefix/cs/App/Logger.cs b/test/receiverevidencefix/cs/App/Logger.cs new file mode 100644 index 000000000..99b710f7d --- /dev/null +++ b/test/receiverevidencefix/cs/App/Logger.cs @@ -0,0 +1,24 @@ +using static Markup.Html; + +namespace App +{ + public class Base + { + public void Flush() { System.Console.WriteLine("base flush"); } + } + + // Inherited: a bare Flush() reaches Base.Flush through the class cone; Reset is its own. + public class Child : Base + { + public void Close() { Flush(); Reset(); } + private void Reset() { System.Console.WriteLine("reset"); } + } + + // A bare call is this.Method() or a using-static import: Logger inherits Flush() from an OUTSIDE class + // and imports Render from an outside type; Exporter and Base are unrelated. + public class Logger : System.IO.StringWriter + { + public string Line(string s) { return Render(s); } + public void Finish() { Flush(); } + } +} diff --git a/test/receiverevidencefix/cs/App/Pump.cs b/test/receiverevidencefix/cs/App/Pump.cs new file mode 100644 index 000000000..7f12ef22c --- /dev/null +++ b/test/receiverevidencefix/cs/App/Pump.cs @@ -0,0 +1,26 @@ +// Review B3: C# receivers the source TYPES resolve (a typed parameter, a typed / constructed / `var` local, a typed field +// read bare or through `this`, a typed property), against two classes that both define Spill(). Near misses stay hedged: +// an untyped local, a local that hides the field, an interface-typed receiver with two implementors. +namespace App +{ + interface ISpiller { void Spill(); } + class Tank : ISpiller { public void Spill() {} } + class Barrel : ISpiller { public void Spill() {} } + + public class Pump + { + private Tank tank = new Tank(); + public Barrel Keg { get; } = new Barrel(); + void Param(Tank t) { t.Spill(); } + void TypedLocal() { Tank t = MakeTank(); t.Spill(); } + void CtorLocal() { var t = new Tank(); t.Spill(); } + void ImplicitField() { tank.Spill(); } + void ThisField() { this.tank.Spill(); } + void ViaProperty() { Keg.Spill(); } + void Untyped() { var x = MakeAny(); x.Spill(); } + void Shadow() { var tank = MakeAny(); tank.Spill(); } + void ViaIface(ISpiller s) { s.Spill(); } + Tank MakeTank() { return null; } + dynamic MakeAny() { return null; } + } +} diff --git a/test/receiverevidencefix/go/go.mod b/test/receiverevidencefix/go/go.mod new file mode 100644 index 000000000..8854a7dbf --- /dev/null +++ b/test/receiverevidencefix/go/go.mod @@ -0,0 +1,7 @@ +module example.com/fzfish + +go 1.22 + +require github.com/gdamore/tcell/v2 v2.7.0 +require github.com/rivo/uniseg v0.4.7 +require go.uber.org/zap v1.27.0 diff --git a/test/receiverevidencefix/go/src/cmd/run.go b/test/receiverevidencefix/go/src/cmd/run.go new file mode 100644 index 000000000..0c9b2664d --- /dev/null +++ b/test/receiverevidencefix/go/src/cmd/run.go @@ -0,0 +1,8 @@ +package cmd + +import "example.com/fzfish/src/internal/log" + +// Run: a real import of the in-repo log package, so log.Errorf is that package's function. +func Run() error { + return log.Errorf("run") +} diff --git a/test/receiverevidencefix/go/src/embed.go b/test/receiverevidencefix/go/src/embed.go new file mode 100644 index 000000000..0d2bdcdcc --- /dev/null +++ b/test/receiverevidencefix/go/src/embed.go @@ -0,0 +1,20 @@ +package fzf + +import u "example.com/fzfish/src/util" + +// FileItem embeds Chars: its promoted Get is Chars.Get. +type FileItem struct { + u.Chars + path string +} + +// firstRune: a promoted method through an embedded field. +func firstRune(fi *FileItem) rune { + return fi.Get(0) +} + +// aliased: a local typed through an aliased import. +func aliased() int { + var c u.Chars + return c.Length() +} diff --git a/test/receiverevidencefix/go/src/gotool.go b/test/receiverevidencefix/go/src/gotool.go new file mode 100644 index 000000000..2cc5d91e9 --- /dev/null +++ b/test/receiverevidencefix/go/src/gotool.go @@ -0,0 +1,37 @@ +package fzf + +import ( + "fmt" + "os/exec" + "strings" +) + +// Tool has methods named like stdlib functions and methods it never reaches. +type Tool struct { + out []byte +} + +func (t *Tool) Output() ([]byte, error) { + return t.out, nil +} + +func (t *Tool) Split(s string) []string { + return []string{s} +} + +// runGo: fmt.Errorf, exec's Cmd.Output and strings.Split are stdlib, never Tool's methods or the log package. +func runGo(args string) ([]string, error) { + cmd := exec.Command("go", args) + out, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("go: %w", err) + } + return strings.Split(string(out), "\n"), nil +} + +// runTool: a local of the in-repo type keeps its edges. +func runTool(s string) []string { + t := &Tool{} + t.Output() + return t.Split(s) +} diff --git a/test/receiverevidencefix/go/src/internal/log/log.go b/test/receiverevidencefix/go/src/internal/log/log.go new file mode 100644 index 000000000..729567025 --- /dev/null +++ b/test/receiverevidencefix/go/src/internal/log/log.go @@ -0,0 +1,8 @@ +package log + +import "fmt" + +// Errorf is the in-repo log package's function. +func Errorf(format string, a ...any) error { + return fmt.Errorf(format, a...) +} diff --git a/test/receiverevidencefix/go/src/item.go b/test/receiverevidencefix/go/src/item.go new file mode 100644 index 000000000..657d9f0e9 --- /dev/null +++ b/test/receiverevidencefix/go/src/item.go @@ -0,0 +1,7 @@ +package fzf + +import "example.com/fzfish/src/util" + +type Item struct { + text util.Chars +} diff --git a/test/receiverevidencefix/go/src/merger.go b/test/receiverevidencefix/go/src/merger.go new file mode 100644 index 000000000..cfc332a98 --- /dev/null +++ b/test/receiverevidencefix/go/src/merger.go @@ -0,0 +1,19 @@ +package fzf + +// Merger has Get and Length too; an item's text field is never a Merger. +type Merger struct { + lists [][]int + count int +} + +func (mg *Merger) Get(idx int) int { + return mg.lists[0][idx] +} + +func (mg *Merger) Length() int { + return mg.count +} + +func (mg *Merger) First() int { + return mg.Get(0) +} diff --git a/test/receiverevidencefix/go/src/result.go b/test/receiverevidencefix/go/src/result.go new file mode 100644 index 000000000..410b26298 --- /dev/null +++ b/test/receiverevidencefix/go/src/result.go @@ -0,0 +1,21 @@ +package fzf + +import "example.com/fzfish/src/util" + +// buildResult reads an item's text through its typed field. +func buildResult(item *Item, b int) int { + numChars := item.text.Length() + last := item.text.Get(b - 1) + return numChars + int(last) +} + +// merged: a local whose type is spelled at its declaration. +func merged(n int) int { + m := &Merger{count: n} + return m.Length() +} + +// fromValue: a local declared with an in-repo type from another package. +func fromValue(c util.Chars) rune { + return c.Get(0) +} diff --git a/test/receiverevidencefix/go/src/service.go b/test/receiverevidencefix/go/src/service.go new file mode 100644 index 000000000..120c425c2 --- /dev/null +++ b/test/receiverevidencefix/go/src/service.go @@ -0,0 +1,19 @@ +package fzf + +import "go.uber.org/zap" + +// Service logs through an outside logger held in a field named like the in-repo log package. +type Service struct { + log *zap.SugaredLogger +} + +// Fail: s.log is a field of an outside type, never the in-repo log package. +func (s *Service) Fail(msg string) { + s.log.Errorf("failed: %s", msg) +} + +// Report: a local named log shadows nothing in-repo here; this file never imports the log package. +func Report(l *zap.SugaredLogger) { + log := l + log.Errorf("report") +} diff --git a/test/receiverevidencefix/go/src/tui/tcell.go b/test/receiverevidencefix/go/src/tui/tcell.go new file mode 100644 index 000000000..573ab66eb --- /dev/null +++ b/test/receiverevidencefix/go/src/tui/tcell.go @@ -0,0 +1,37 @@ +package tui + +import ( + "github.com/gdamore/tcell/v2" + "github.com/rivo/uniseg" +) + +var _screen tcell.Screen + +type FullscreenRenderer struct { + height int +} + +func (r *FullscreenRenderer) Size() int { + return r.height +} + +func (r *FullscreenRenderer) Runes() []rune { + return nil +} + +// MaxY asks the outside screen, not this renderer. +func (r *FullscreenRenderer) MaxY() int { + _, nlines := _screen.Size() + return nlines +} + +// Lines asks the renderer itself: the method receiver is the type. +func (r *FullscreenRenderer) Lines() int { + return r.Size() +} + +// Graphemes: a value returned by an outside package. +func Graphemes(input string) int { + gr := uniseg.NewGraphemes(input) + return len(gr.Runes()) +} diff --git a/test/receiverevidencefix/go/src/util/chars.go b/test/receiverevidencefix/go/src/util/chars.go new file mode 100644 index 000000000..c453c1461 --- /dev/null +++ b/test/receiverevidencefix/go/src/util/chars.go @@ -0,0 +1,18 @@ +package util + +// Chars holds an item's text. +type Chars struct { + runes []rune +} + +func (c *Chars) Get(i int) rune { + return c.runes[i] +} + +func (c *Chars) Length() int { + return len(c.runes) +} + +func (c *Chars) Runes() []rune { + return c.runes +} diff --git a/test/receiverevidencefix/gomod/README.md b/test/receiverevidencefix/gomod/README.md new file mode 100644 index 000000000..e5057ce42 --- /dev/null +++ b/test/receiverevidencefix/gomod/README.md @@ -0,0 +1,12 @@ +# gomod — Go module-alias proof (receiverevidencecheck.sh section (G2)) + +Each directory is its own tree (its own `go.mod`), indexed alone. A module-alias call `lib.Mark()` is proven onto a +candidate only when the import path EXACTLY names the candidate's package: the nearest go.mod's module path plus the +directory below it, or the path a local `replace L => ./D` gives a file under D. Never built. + +- fork2: `replace github.com/up/lib => ./third/lib` beside an in-repo `lib/` (another package): only `third/lib` is proven. +- tail: `example.com/t/pkg/lib` — `pkg/lib` is the package; `lib/` merely ends the path. +- fork: `replace github.com/up/lib => ./lib`. rep / rep2: a replaced `legacy/` with and without its own go.mod. +- nest: a nested module `other/`. pfx: `example.com/a` vs `example.com/ab`. vend: a vendored copy. dot: a dot import. +- mixed: no go.mod at the root; the nested module `m/` carries `example.com/x/lib`, and the root's `lib/` (no import + path at all) merely ends it — the directory-tail rule never applies to a path an in-tree module carries. diff --git a/test/receiverevidencefix/gomod/dot/app/app.go b/test/receiverevidencefix/gomod/dot/app/app.go new file mode 100644 index 000000000..ece5831ba --- /dev/null +++ b/test/receiverevidencefix/gomod/dot/app/app.go @@ -0,0 +1,5 @@ +package app + +import . "example.com/d/lib" + +func Use(n int) int { return Mark(n) } diff --git a/test/receiverevidencefix/gomod/dot/decoy/d.go b/test/receiverevidencefix/gomod/dot/decoy/d.go new file mode 100644 index 000000000..5721f1295 --- /dev/null +++ b/test/receiverevidencefix/gomod/dot/decoy/d.go @@ -0,0 +1,3 @@ +package decoy + +func Mark(n int) int { return n + 9 } diff --git a/test/receiverevidencefix/gomod/dot/go.mod b/test/receiverevidencefix/gomod/dot/go.mod new file mode 100644 index 000000000..9edd4200f --- /dev/null +++ b/test/receiverevidencefix/gomod/dot/go.mod @@ -0,0 +1,3 @@ +module example.com/d + +go 1.22 diff --git a/test/receiverevidencefix/gomod/dot/lib/l.go b/test/receiverevidencefix/gomod/dot/lib/l.go new file mode 100644 index 000000000..d0bcfe79f --- /dev/null +++ b/test/receiverevidencefix/gomod/dot/lib/l.go @@ -0,0 +1,3 @@ +package lib + +func Mark(n int) int { return n + 1 } diff --git a/test/receiverevidencefix/gomod/fork/app/app.go b/test/receiverevidencefix/gomod/fork/app/app.go new file mode 100644 index 000000000..2f7675767 --- /dev/null +++ b/test/receiverevidencefix/gomod/fork/app/app.go @@ -0,0 +1,5 @@ +package app + +import "github.com/up/lib" + +func Use(n int) int { return lib.Mark(n) } diff --git a/test/receiverevidencefix/gomod/fork/go.mod b/test/receiverevidencefix/gomod/fork/go.mod new file mode 100644 index 000000000..c92da86ae --- /dev/null +++ b/test/receiverevidencefix/gomod/fork/go.mod @@ -0,0 +1,5 @@ +module example.com/f + +go 1.22 + +replace github.com/up/lib => ./lib diff --git a/test/receiverevidencefix/gomod/fork/lib/l.go b/test/receiverevidencefix/gomod/fork/lib/l.go new file mode 100644 index 000000000..d0bcfe79f --- /dev/null +++ b/test/receiverevidencefix/gomod/fork/lib/l.go @@ -0,0 +1,3 @@ +package lib + +func Mark(n int) int { return n + 1 } diff --git a/test/receiverevidencefix/gomod/fork2/app/app.go b/test/receiverevidencefix/gomod/fork2/app/app.go new file mode 100644 index 000000000..2f7675767 --- /dev/null +++ b/test/receiverevidencefix/gomod/fork2/app/app.go @@ -0,0 +1,5 @@ +package app + +import "github.com/up/lib" + +func Use(n int) int { return lib.Mark(n) } diff --git a/test/receiverevidencefix/gomod/fork2/go.mod b/test/receiverevidencefix/gomod/fork2/go.mod new file mode 100644 index 000000000..31baa03bf --- /dev/null +++ b/test/receiverevidencefix/gomod/fork2/go.mod @@ -0,0 +1,5 @@ +module example.com/f + +go 1.22 + +replace github.com/up/lib => ./third/lib diff --git a/test/receiverevidencefix/gomod/fork2/lib/l.go b/test/receiverevidencefix/gomod/fork2/lib/l.go new file mode 100644 index 000000000..d0bcfe79f --- /dev/null +++ b/test/receiverevidencefix/gomod/fork2/lib/l.go @@ -0,0 +1,3 @@ +package lib + +func Mark(n int) int { return n + 1 } diff --git a/test/receiverevidencefix/gomod/fork2/third/lib/l.go b/test/receiverevidencefix/gomod/fork2/third/lib/l.go new file mode 100644 index 000000000..2e68b9c51 --- /dev/null +++ b/test/receiverevidencefix/gomod/fork2/third/lib/l.go @@ -0,0 +1,3 @@ +package lib + +func Mark(n int) int { return n + 3 } diff --git a/test/receiverevidencefix/gomod/mixed/lib/l.go b/test/receiverevidencefix/gomod/mixed/lib/l.go new file mode 100644 index 000000000..ac5d0cb23 --- /dev/null +++ b/test/receiverevidencefix/gomod/mixed/lib/l.go @@ -0,0 +1,4 @@ +package lib + +// no go.mod at or above this file: its import path is unknown, and an import path an in-tree module carries never names it +func Mark(n int) int { return n + 7 } diff --git a/test/receiverevidencefix/gomod/mixed/m/app/app.go b/test/receiverevidencefix/gomod/mixed/m/app/app.go new file mode 100644 index 000000000..fa4de2940 --- /dev/null +++ b/test/receiverevidencefix/gomod/mixed/m/app/app.go @@ -0,0 +1,5 @@ +package app + +import "example.com/x/lib" + +func Use(n int) int { return lib.Mark(n) } diff --git a/test/receiverevidencefix/gomod/mixed/m/go.mod b/test/receiverevidencefix/gomod/mixed/m/go.mod new file mode 100644 index 000000000..ff963b68b --- /dev/null +++ b/test/receiverevidencefix/gomod/mixed/m/go.mod @@ -0,0 +1,3 @@ +module example.com/x + +go 1.22 diff --git a/test/receiverevidencefix/gomod/mixed/m/lib/l.go b/test/receiverevidencefix/gomod/mixed/m/lib/l.go new file mode 100644 index 000000000..d0bcfe79f --- /dev/null +++ b/test/receiverevidencefix/gomod/mixed/m/lib/l.go @@ -0,0 +1,3 @@ +package lib + +func Mark(n int) int { return n + 1 } diff --git a/test/receiverevidencefix/gomod/nest/app/app.go b/test/receiverevidencefix/gomod/nest/app/app.go new file mode 100644 index 000000000..5ed7b8586 --- /dev/null +++ b/test/receiverevidencefix/gomod/nest/app/app.go @@ -0,0 +1,5 @@ +package app + +import "example.com/other/util" + +func Use(n int) int { return util.Mark(n) } diff --git a/test/receiverevidencefix/gomod/nest/go.mod b/test/receiverevidencefix/gomod/nest/go.mod new file mode 100644 index 000000000..1e9be762e --- /dev/null +++ b/test/receiverevidencefix/gomod/nest/go.mod @@ -0,0 +1,3 @@ +module example.com/root + +go 1.22 diff --git a/test/receiverevidencefix/gomod/nest/other/go.mod b/test/receiverevidencefix/gomod/nest/other/go.mod new file mode 100644 index 000000000..8fce098ba --- /dev/null +++ b/test/receiverevidencefix/gomod/nest/other/go.mod @@ -0,0 +1,3 @@ +module example.com/other + +go 1.22 diff --git a/test/receiverevidencefix/gomod/nest/other/util/u.go b/test/receiverevidencefix/gomod/nest/other/util/u.go new file mode 100644 index 000000000..8c3fd2f78 --- /dev/null +++ b/test/receiverevidencefix/gomod/nest/other/util/u.go @@ -0,0 +1,3 @@ +package util + +func Mark(n int) int { return n + 2 } diff --git a/test/receiverevidencefix/gomod/nest/util/u.go b/test/receiverevidencefix/gomod/nest/util/u.go new file mode 100644 index 000000000..1b26ae9ba --- /dev/null +++ b/test/receiverevidencefix/gomod/nest/util/u.go @@ -0,0 +1,3 @@ +package util + +func Mark(n int) int { return n + 1 } diff --git a/test/receiverevidencefix/gomod/pfx/ab/go.mod b/test/receiverevidencefix/gomod/pfx/ab/go.mod new file mode 100644 index 000000000..d6b4e7647 --- /dev/null +++ b/test/receiverevidencefix/gomod/pfx/ab/go.mod @@ -0,0 +1,3 @@ +module example.com/ab + +go 1.22 diff --git a/test/receiverevidencefix/gomod/pfx/ab/lib/l.go b/test/receiverevidencefix/gomod/pfx/ab/lib/l.go new file mode 100644 index 000000000..d42bdd639 --- /dev/null +++ b/test/receiverevidencefix/gomod/pfx/ab/lib/l.go @@ -0,0 +1,3 @@ +package lib + +func Mark(n int) int { return n + 2 } diff --git a/test/receiverevidencefix/gomod/pfx/app/app.go b/test/receiverevidencefix/gomod/pfx/app/app.go new file mode 100644 index 000000000..3f31895d7 --- /dev/null +++ b/test/receiverevidencefix/gomod/pfx/app/app.go @@ -0,0 +1,5 @@ +package app + +import "example.com/ab/lib" + +func Use(n int) int { return lib.Mark(n) } diff --git a/test/receiverevidencefix/gomod/pfx/app/near.go b/test/receiverevidencefix/gomod/pfx/app/near.go new file mode 100644 index 000000000..d12914ac1 --- /dev/null +++ b/test/receiverevidencefix/gomod/pfx/app/near.go @@ -0,0 +1,5 @@ +package app + +import "example.com/a/lib" + +func Near(n int) int { return lib.Mark(n) } diff --git a/test/receiverevidencefix/gomod/pfx/go.mod b/test/receiverevidencefix/gomod/pfx/go.mod new file mode 100644 index 000000000..df2941f19 --- /dev/null +++ b/test/receiverevidencefix/gomod/pfx/go.mod @@ -0,0 +1,3 @@ +module example.com/a + +go 1.22 diff --git a/test/receiverevidencefix/gomod/pfx/lib/l.go b/test/receiverevidencefix/gomod/pfx/lib/l.go new file mode 100644 index 000000000..d0bcfe79f --- /dev/null +++ b/test/receiverevidencefix/gomod/pfx/lib/l.go @@ -0,0 +1,3 @@ +package lib + +func Mark(n int) int { return n + 1 } diff --git a/test/receiverevidencefix/gomod/rep/app/app.go b/test/receiverevidencefix/gomod/rep/app/app.go new file mode 100644 index 000000000..bf8143e71 --- /dev/null +++ b/test/receiverevidencefix/gomod/rep/app/app.go @@ -0,0 +1,5 @@ +package app + +import "example.com/legacy/lib" + +func Use(n int) int { return lib.Mark(n) } diff --git a/test/receiverevidencefix/gomod/rep/app/near.go b/test/receiverevidencefix/gomod/rep/app/near.go new file mode 100644 index 000000000..60a91086f --- /dev/null +++ b/test/receiverevidencefix/gomod/rep/app/near.go @@ -0,0 +1,5 @@ +package app + +import "example.com/r/lib" + +func Near(n int) int { return lib.Mark(n) } diff --git a/test/receiverevidencefix/gomod/rep/go.mod b/test/receiverevidencefix/gomod/rep/go.mod new file mode 100644 index 000000000..0bb14a6b8 --- /dev/null +++ b/test/receiverevidencefix/gomod/rep/go.mod @@ -0,0 +1,5 @@ +module example.com/r + +go 1.22 + +replace example.com/legacy => ./legacy diff --git a/test/receiverevidencefix/gomod/rep/legacy/go.mod b/test/receiverevidencefix/gomod/rep/legacy/go.mod new file mode 100644 index 000000000..85e10f5a8 --- /dev/null +++ b/test/receiverevidencefix/gomod/rep/legacy/go.mod @@ -0,0 +1,3 @@ +module example.com/legacy + +go 1.22 diff --git a/test/receiverevidencefix/gomod/rep/legacy/lib/l.go b/test/receiverevidencefix/gomod/rep/legacy/lib/l.go new file mode 100644 index 000000000..d42bdd639 --- /dev/null +++ b/test/receiverevidencefix/gomod/rep/legacy/lib/l.go @@ -0,0 +1,3 @@ +package lib + +func Mark(n int) int { return n + 2 } diff --git a/test/receiverevidencefix/gomod/rep/lib/l.go b/test/receiverevidencefix/gomod/rep/lib/l.go new file mode 100644 index 000000000..d0bcfe79f --- /dev/null +++ b/test/receiverevidencefix/gomod/rep/lib/l.go @@ -0,0 +1,3 @@ +package lib + +func Mark(n int) int { return n + 1 } diff --git a/test/receiverevidencefix/gomod/rep2/app/app.go b/test/receiverevidencefix/gomod/rep2/app/app.go new file mode 100644 index 000000000..bf8143e71 --- /dev/null +++ b/test/receiverevidencefix/gomod/rep2/app/app.go @@ -0,0 +1,5 @@ +package app + +import "example.com/legacy/lib" + +func Use(n int) int { return lib.Mark(n) } diff --git a/test/receiverevidencefix/gomod/rep2/app/near.go b/test/receiverevidencefix/gomod/rep2/app/near.go new file mode 100644 index 000000000..60a91086f --- /dev/null +++ b/test/receiverevidencefix/gomod/rep2/app/near.go @@ -0,0 +1,5 @@ +package app + +import "example.com/r/lib" + +func Near(n int) int { return lib.Mark(n) } diff --git a/test/receiverevidencefix/gomod/rep2/go.mod b/test/receiverevidencefix/gomod/rep2/go.mod new file mode 100644 index 000000000..0bb14a6b8 --- /dev/null +++ b/test/receiverevidencefix/gomod/rep2/go.mod @@ -0,0 +1,5 @@ +module example.com/r + +go 1.22 + +replace example.com/legacy => ./legacy diff --git a/test/receiverevidencefix/gomod/rep2/legacy/lib/l.go b/test/receiverevidencefix/gomod/rep2/legacy/lib/l.go new file mode 100644 index 000000000..d42bdd639 --- /dev/null +++ b/test/receiverevidencefix/gomod/rep2/legacy/lib/l.go @@ -0,0 +1,3 @@ +package lib + +func Mark(n int) int { return n + 2 } diff --git a/test/receiverevidencefix/gomod/rep2/lib/l.go b/test/receiverevidencefix/gomod/rep2/lib/l.go new file mode 100644 index 000000000..d0bcfe79f --- /dev/null +++ b/test/receiverevidencefix/gomod/rep2/lib/l.go @@ -0,0 +1,3 @@ +package lib + +func Mark(n int) int { return n + 1 } diff --git a/test/receiverevidencefix/gomod/tail/app/app.go b/test/receiverevidencefix/gomod/tail/app/app.go new file mode 100644 index 000000000..2180cd014 --- /dev/null +++ b/test/receiverevidencefix/gomod/tail/app/app.go @@ -0,0 +1,5 @@ +package app + +import "example.com/t/pkg/lib" + +func Use(n int) int { return lib.Mark(n) } diff --git a/test/receiverevidencefix/gomod/tail/app/near.go b/test/receiverevidencefix/gomod/tail/app/near.go new file mode 100644 index 000000000..38ad013e5 --- /dev/null +++ b/test/receiverevidencefix/gomod/tail/app/near.go @@ -0,0 +1,5 @@ +package app + +import "example.com/t/lib" + +func Near(n int) int { return lib.Mark(n) } diff --git a/test/receiverevidencefix/gomod/tail/go.mod b/test/receiverevidencefix/gomod/tail/go.mod new file mode 100644 index 000000000..3d7deb44d --- /dev/null +++ b/test/receiverevidencefix/gomod/tail/go.mod @@ -0,0 +1,3 @@ +module example.com/t + +go 1.22 diff --git a/test/receiverevidencefix/gomod/tail/lib/l.go b/test/receiverevidencefix/gomod/tail/lib/l.go new file mode 100644 index 000000000..d0bcfe79f --- /dev/null +++ b/test/receiverevidencefix/gomod/tail/lib/l.go @@ -0,0 +1,3 @@ +package lib + +func Mark(n int) int { return n + 1 } diff --git a/test/receiverevidencefix/gomod/tail/pkg/lib/l.go b/test/receiverevidencefix/gomod/tail/pkg/lib/l.go new file mode 100644 index 000000000..2e68b9c51 --- /dev/null +++ b/test/receiverevidencefix/gomod/tail/pkg/lib/l.go @@ -0,0 +1,3 @@ +package lib + +func Mark(n int) int { return n + 3 } diff --git a/test/receiverevidencefix/gomod/vend/app/app.go b/test/receiverevidencefix/gomod/vend/app/app.go new file mode 100644 index 000000000..5487ce1a0 --- /dev/null +++ b/test/receiverevidencefix/gomod/vend/app/app.go @@ -0,0 +1,5 @@ +package app + +import "github.com/x/lib" + +func Use(n int) int { return lib.Mark(n) } diff --git a/test/receiverevidencefix/gomod/vend/go.mod b/test/receiverevidencefix/gomod/vend/go.mod new file mode 100644 index 000000000..10ef5515f --- /dev/null +++ b/test/receiverevidencefix/gomod/vend/go.mod @@ -0,0 +1,5 @@ +module example.com/v + +go 1.22 + +require github.com/x/lib v1.0.0 diff --git a/test/receiverevidencefix/gomod/vend/lib/l.go b/test/receiverevidencefix/gomod/vend/lib/l.go new file mode 100644 index 000000000..d0bcfe79f --- /dev/null +++ b/test/receiverevidencefix/gomod/vend/lib/l.go @@ -0,0 +1,3 @@ +package lib + +func Mark(n int) int { return n + 1 } diff --git a/test/receiverevidencefix/gomod/vend/vendor/github.com/x/lib/l.go b/test/receiverevidencefix/gomod/vend/vendor/github.com/x/lib/l.go new file mode 100644 index 000000000..cac212b3f --- /dev/null +++ b/test/receiverevidencefix/gomod/vend/vendor/github.com/x/lib/l.go @@ -0,0 +1,3 @@ +package lib + +func Mark(n int) int { return n + 5 } diff --git a/test/receiverevidencefix/gomod/vend/vendor/modules.txt b/test/receiverevidencefix/gomod/vend/vendor/modules.txt new file mode 100644 index 000000000..23777cf9f --- /dev/null +++ b/test/receiverevidencefix/gomod/vend/vendor/modules.txt @@ -0,0 +1,3 @@ +# github.com/x/lib v1.0.0 +## explicit +github.com/x/lib diff --git a/test/receiverevidencefix/java/src/main/java/app/Buffer.java b/test/receiverevidencefix/java/src/main/java/app/Buffer.java new file mode 100644 index 000000000..a62ac43d8 --- /dev/null +++ b/test/receiverevidencefix/java/src/main/java/app/Buffer.java @@ -0,0 +1,14 @@ +package app; + +public class Buffer { + protected StringBuilder data = new StringBuilder(); + + public void flush() { + data.setLength(0); + } + + public void write(String s) { + data.append(s); + flush(); + } +} diff --git a/test/receiverevidencefix/java/src/main/java/app/Exporter.java b/test/receiverevidencefix/java/src/main/java/app/Exporter.java new file mode 100644 index 000000000..f9f328848 --- /dev/null +++ b/test/receiverevidencefix/java/src/main/java/app/Exporter.java @@ -0,0 +1,12 @@ +package app; + +// An unrelated class that happens to define flush() and render(). +public class Exporter { + public void flush() { + System.out.println("exporter flush"); + } + + public String render(String s) { + return "<" + s + ">"; + } +} diff --git a/test/receiverevidencefix/java/src/main/java/app/FileBuffer.java b/test/receiverevidencefix/java/src/main/java/app/FileBuffer.java new file mode 100644 index 000000000..2ffddbf9c --- /dev/null +++ b/test/receiverevidencefix/java/src/main/java/app/FileBuffer.java @@ -0,0 +1,12 @@ +package app; + +// Inherited: a bare flush() reaches Buffer.flush through the class cone. +public class FileBuffer extends Buffer { + public void sync() { + flush(); + } + + public void drain() { + super.flush(); + } +} diff --git a/test/receiverevidencefix/java/src/main/java/app/Logger.java b/test/receiverevidencefix/java/src/main/java/app/Logger.java new file mode 100644 index 000000000..ffb5024f0 --- /dev/null +++ b/test/receiverevidencefix/java/src/main/java/app/Logger.java @@ -0,0 +1,20 @@ +package app; + +import static org.markup.Html.render; + +// A bare call is this.method() or a static import: Logger inherits flush() from an OUTSIDE class and +// imports render() from an outside package; Exporter and Buffer are unrelated. +public class Logger extends java.io.Writer { + public String line(String s) { + return render(s); + } + + public void close() { + flush(); + reset(); + } + + private void reset() { + System.out.println("logger reset"); + } +} diff --git a/test/receiverevidencefix/java/src/main/java/app/Pump.java b/test/receiverevidencefix/java/src/main/java/app/Pump.java new file mode 100644 index 000000000..a539fbfbf --- /dev/null +++ b/test/receiverevidencefix/java/src/main/java/app/Pump.java @@ -0,0 +1,23 @@ +package app; + +// Review B3: Java receivers the source TYPES resolve (a typed parameter, a typed / constructed / `var` local, a typed field +// read bare or through `this`), against two classes that both define spill(). Near misses stay hedged: an untyped local, +// a local that hides the field, an interface-typed receiver with two implementors. +interface Spiller { void spill(); } +class Tank implements Spiller { public void spill() {} } +class Barrel implements Spiller { public void spill() {} } + +public class Pump { + private Tank tank = new Tank(); + void param(Tank t) { t.spill(); } + void typedLocal() { Tank t = makeTank(); t.spill(); } + void ctorLocal() { Tank t = new Tank(); t.spill(); } + void varLocal() { var t = new Tank(); t.spill(); } + void implicitField() { tank.spill(); } + void thisField() { this.tank.spill(); } + void untyped() { var x = makeAny(); x.spill(); } + void shadow() { var tank = makeAny(); tank.spill(); } + void viaIface(Spiller s) { s.spill(); } + Tank makeTank() { return null; } + Object makeAny() { return null; } +} diff --git a/test/receiverevidencefix/js/lib/application.js b/test/receiverevidencefix/js/lib/application.js new file mode 100644 index 000000000..797bd3983 --- /dev/null +++ b/test/receiverevidencefix/js/lib/application.js @@ -0,0 +1,64 @@ +'use strict' +// Bm: `ctx` is a request context, never an Application; this file defining Application is no evidence. +const proto = require('./context') +const Schemas = require('./schemas') + +class Application { + constructor () { + this.context = Object.create(proto) + this.bucket = new Schemas() + } + + onerror (err) { + console.error(err) + } + + handle (ctx) { + this.onerror(new Error('local')) + return respond(ctx) + } + + schemas () { + return this.bucket.listSchemas() + } + + listSchemas () { + return [] + } + + static create () { + return new Application() + } +} + +function respond (ctx) { + if (!ctx.writable) ctx.onerror(new Error('closed')) +} + +// Siblings of the receiver shape: optional chaining and a computed member, on the same context object. +function respondSafely (ctx) { + ctx?.onerror?.(new Error('maybe')) +} + +function respondComputed (ctx) { + ctx['onerror'](new Error('computed')) +} + +// An alias and a destructured binding of the same context member: still no evidence about `ctx`. +function respondAlias (ctx) { + const onerror = ctx.onerror + onerror(new Error('alias')) +} + +function respondDestructured (ctx) { + const { onerror } = ctx + onerror(new Error('destructured')) +} + +// A caller of respond: --impact=onerror reaches it at d=2 only through respond's name-only call. +function serve (ctx) { + return respond(ctx) +} + +module.exports = Application +module.exports.serve = serve diff --git a/test/receiverevidencefix/js/lib/context.js b/test/receiverevidencefix/js/lib/context.js new file mode 100644 index 000000000..6300780d6 --- /dev/null +++ b/test/receiverevidencefix/js/lib/context.js @@ -0,0 +1,6 @@ +'use strict' +module.exports = { + onerror (err) { + this.app.emit('error', err, this) + } +} diff --git a/test/receiverevidencefix/js/lib/delegate/request.js b/test/receiverevidencefix/js/lib/delegate/request.js new file mode 100644 index 000000000..cc371ffdf --- /dev/null +++ b/test/receiverevidencefix/js/lib/delegate/request.js @@ -0,0 +1,8 @@ +'use strict' +// A request delegate: ctx.get(field) reaches this get through delegation, never by a static edge. +module.exports = { + get (field) { + const header = this.header + return header[field] || '' + } +} diff --git a/test/receiverevidencefix/js/lib/delegate/response.js b/test/receiverevidencefix/js/lib/delegate/response.js new file mode 100644 index 000000000..b91aa0f63 --- /dev/null +++ b/test/receiverevidencefix/js/lib/delegate/response.js @@ -0,0 +1,11 @@ +'use strict' +// An object-literal module: `this.ctx` is a request context, so this.ctx.get() is never this module's own get. +module.exports = { + get (field) { + return this.header[field] + }, + + back () { + return this.ctx.get('Referrer') + } +} diff --git a/test/receiverevidencefix/js/lib/handle.js b/test/receiverevidencefix/js/lib/handle.js new file mode 100644 index 000000000..779e5385e --- /dev/null +++ b/test/receiverevidencefix/js/lib/handle.js @@ -0,0 +1,15 @@ +'use strict' +// A member call on a route context reaches the user's handler, not this module's own function of that name. +function handler (request, reply) { + return reply +} + +function run (context, request, reply) { + return context.handler(request, reply) +} + +function wrapped (request, reply) { + return handler(request, reply) +} + +module.exports = { handler, run, wrapped } diff --git a/test/receiverevidencefix/js/lib/listing.js b/test/receiverevidencefix/js/lib/listing.js new file mode 100644 index 000000000..43eb92ed6 --- /dev/null +++ b/test/receiverevidencefix/js/lib/listing.js @@ -0,0 +1,10 @@ +'use strict' +// Near miss: super.listSchemas() reaches the superclass's method. +const Schemas = require('./schemas') + +class Listing extends Schemas { + list () { + return super.listSchemas() + } +} +module.exports = Listing diff --git a/test/receiverevidencefix/js/lib/meta.js b/test/receiverevidencefix/js/lib/meta.js new file mode 100644 index 000000000..493845506 --- /dev/null +++ b/test/receiverevidencefix/js/lib/meta.js @@ -0,0 +1,19 @@ +'use strict' +// Reflect and Atomics are globals: their get/add are never this file's functions of those names. +function get (obj, key) { + return obj[key] +} + +function add (a, b) { + return a + b +} + +function peek (obj) { + return Reflect.get(obj, 'x') +} + +function bump (arr) { + return Atomics.add(arr, 0, 1) +} + +module.exports = { get, add, peek, bump } diff --git a/test/receiverevidencefix/js/lib/mirror.js b/test/receiverevidencefix/js/lib/mirror.js new file mode 100644 index 000000000..38c805f51 --- /dev/null +++ b/test/receiverevidencefix/js/lib/mirror.js @@ -0,0 +1,8 @@ +'use strict' +// Near miss: a file-local binding named Reflect IS the relative module, so its get is a real edge. +const Reflect = require('./reflect') + +function look (obj) { + return Reflect.get(obj, 'k') +} +module.exports = { look } diff --git a/test/receiverevidencefix/js/lib/parser.js b/test/receiverevidencefix/js/lib/parser.js new file mode 100644 index 000000000..0e3964778 --- /dev/null +++ b/test/receiverevidencefix/js/lib/parser.js @@ -0,0 +1,8 @@ +'use strict' +function runParser (req, body) { + function done (error, parsed) { + req.body = error ? null : parsed + } + done(null, body) +} +module.exports = { runParser } diff --git a/test/receiverevidencefix/js/lib/reflect.js b/test/receiverevidencefix/js/lib/reflect.js new file mode 100644 index 000000000..c6bddb6c1 --- /dev/null +++ b/test/receiverevidencefix/js/lib/reflect.js @@ -0,0 +1,6 @@ +'use strict' +module.exports = { + get (obj, key) { + return obj[key] + } +} diff --git a/test/receiverevidencefix/js/lib/reply.js b/test/receiverevidencefix/js/lib/reply.js new file mode 100644 index 000000000..09e4f7990 --- /dev/null +++ b/test/receiverevidencefix/js/lib/reply.js @@ -0,0 +1,54 @@ +'use strict' +// Promise .then, stream .on, a parameter callback and a chained send, beside in-repo names they never reach. +function Reply (res) { + this.res = res +} + +Reply.prototype.send = function (payload) { + this.res.end(payload) + return this +} + +Reply.prototype.code = function (n) { + this.res.statusCode = n + return this +} + +Reply.prototype.then = function (fulfilled, rejected) { + return Promise.resolve(this).then(fulfilled, rejected) +} + +function awaitResult (result, cb) { + return result.then((v) => cb(null, v), cb) +} + +function pipePayload (payload, onEnd) { + payload.on('error', onEnd) + payload.on('end', onEnd) +} + +function writePayload (payload, done) { + payload.write('x') + done() +} + +function notFound (reply) { + reply.code(404).send('missing') +} + +function respondWith (reply, body) { + reply.send(body) +} + +// Near miss for --impact inheritance: `direct` reaches send through a construction (proven), so `both` +// is reached at d=2 by a proven path as well as through respondWith's name-only edge. +function direct (r) { + return new Reply(r).send('x') +} + +function both (r) { + direct(r) + respondWith(r, 'y') +} + +module.exports = { Reply, awaitResult, pipePayload, writePayload, notFound, respondWith, direct, both } diff --git a/test/receiverevidencefix/js/lib/request.js b/test/receiverevidencefix/js/lib/request.js new file mode 100644 index 000000000..6eb4531ee --- /dev/null +++ b/test/receiverevidencefix/js/lib/request.js @@ -0,0 +1,26 @@ +'use strict' +// A dynamic builtin receiver in the defining file: WeakMap .set/.get never reach the accessors below. +const kCache = Symbol('cache') + +function Request (headers) { + this.headersStore = headers + this[kCache] = new WeakMap() +} + +Object.defineProperties(Request.prototype, { + headers: { + get () { + return this.headersStore + }, + set (headers) { + this.headersStore = headers + } + } +}) + +Request.prototype.compile = function (schema, fn) { + this[kCache].set(schema, fn) + return this[kCache].get(schema) +} + +module.exports = Request diff --git a/test/receiverevidencefix/js/lib/response.js b/test/receiverevidencefix/js/lib/response.js new file mode 100644 index 000000000..43badf5e6 --- /dev/null +++ b/test/receiverevidencefix/js/lib/response.js @@ -0,0 +1,8 @@ +'use strict' +class Response { + append (field, val) { + this.headers = this.headers || {} + this.headers[field] = val + } +} +module.exports = Response diff --git a/test/receiverevidencefix/js/lib/routes.js b/test/receiverevidencefix/js/lib/routes.js new file mode 100644 index 000000000..7df0efc3d --- /dev/null +++ b/test/receiverevidencefix/js/lib/routes.js @@ -0,0 +1,19 @@ +'use strict' +// A router from an outside package and a builtin Set, beside the instance shorthands of those names. +const FindMyWay = require('find-my-way') + +const instance = { + all: function _all (url, fn) { return fn }, + delete: function _delete (url, fn) { return fn } +} + +function fallback (handler) { + const router = FindMyWay({ defaultRoute: handler }) + router.all('/*', handler) +} + +function forget (connections, conn) { + connections.delete(conn) +} + +module.exports = { instance, fallback, forget } diff --git a/test/receiverevidencefix/js/lib/schemas.js b/test/receiverevidencefix/js/lib/schemas.js new file mode 100644 index 000000000..fdec5ddbc --- /dev/null +++ b/test/receiverevidencefix/js/lib/schemas.js @@ -0,0 +1,11 @@ +'use strict' +class Schemas { + constructor () { + this.store = {} + } + + listSchemas () { + return Object.keys(this.store) + } +} +module.exports = Schemas diff --git a/test/receiverevidencefix/js/lib/search.js b/test/receiverevidencefix/js/lib/search.js new file mode 100644 index 000000000..f5e9dda64 --- /dev/null +++ b/test/receiverevidencefix/js/lib/search.js @@ -0,0 +1,8 @@ +'use strict' +// URLSearchParams.append beside an in-repo append method. +function encode (obj) { + const params = new URLSearchParams() + for (const k of Object.keys(obj)) params.append(k, obj[k]) + return params.toString() +} +module.exports = { encode } diff --git a/test/receiverevidencefix/js/lib/sides.js b/test/receiverevidencefix/js/lib/sides.js new file mode 100644 index 000000000..92019aa94 --- /dev/null +++ b/test/receiverevidencefix/js/lib/sides.js @@ -0,0 +1,13 @@ +// The two SIDES of a class lookup: a call on the CLASS object reaches its static members, a call on an INSTANCE the others. +class Gauge { + static read () { return 1 } + read () { return 2 } + static make () { return this.read() } +} +class Plain { + read () { return 3 } +} +function onClass () { return Gauge.read() } +function onInstance () { return new Gauge().read() } +function wrongSide () { return Plain.read() } +module.exports = { Gauge, Plain, onClass, onInstance, wrongSide } diff --git a/test/receiverevidencefix/js/lib/trailer.js b/test/receiverevidencefix/js/lib/trailer.js new file mode 100644 index 000000000..a91c73c10 --- /dev/null +++ b/test/receiverevidencefix/js/lib/trailer.js @@ -0,0 +1,9 @@ +'use strict' +// A closure named like Reply.prototype.send: only its own enclosing function can call it. +function sendTrailer (reply, value) { + function send (v) { + reply.res.addTrailers(v) + } + send(value) +} +module.exports = { sendTrailer } diff --git a/test/receiverevidencefix/js/lib/uses.js b/test/receiverevidencefix/js/lib/uses.js new file mode 100644 index 000000000..098d29bac --- /dev/null +++ b/test/receiverevidencefix/js/lib/uses.js @@ -0,0 +1,23 @@ +'use strict' +// Near misses: receivers proven by construction, an import alias, and a relative module object. +const { Schemas: Store } = { Schemas: require('./schemas') } +const Schemas = require('./schemas') +const Application = require('./application') +const replies = require('./reply') + +function build () { + const app = new Application() + app.onerror(new Error('built')) + const s = new Schemas() + return s.listSchemas() +} + +function viaModule (reply) { + return replies.respondWith(reply, 'ok') +} + +function make () { + return Application.create() +} + +module.exports = { build, viaModule, make, Store } diff --git a/test/receiverevidencefix/js/package.json b/test/receiverevidencefix/js/package.json new file mode 100644 index 000000000..f2793e83b --- /dev/null +++ b/test/receiverevidencefix/js/package.json @@ -0,0 +1 @@ +{ "name": "rxjs-fixture", "private": true } diff --git a/test/receiverevidencefix/js/test/server.test.js b/test/receiverevidencefix/js/test/server.test.js new file mode 100644 index 000000000..af0c6e9ff --- /dev/null +++ b/test/receiverevidencefix/js/test/server.test.js @@ -0,0 +1,7 @@ +'use strict' +// A test double whose `on` no production file can reach. +const fakeServer = { + on () {}, + listen () {} +} +module.exports = fakeServer diff --git a/test/receiverevidencefix/kt/src/app/Exporter.kt b/test/receiverevidencefix/kt/src/app/Exporter.kt new file mode 100644 index 000000000..497cd9bcd --- /dev/null +++ b/test/receiverevidencefix/kt/src/app/Exporter.kt @@ -0,0 +1,9 @@ +package app + +class Exporter { + fun flush() { + println("exporter flush") + } + + fun render(s: String): String = "<$s>" +} diff --git a/test/receiverevidencefix/kt/src/app/Logger.kt b/test/receiverevidencefix/kt/src/app/Logger.kt new file mode 100644 index 000000000..09d27df7a --- /dev/null +++ b/test/receiverevidencefix/kt/src/app/Logger.kt @@ -0,0 +1,23 @@ +package app + +fun render(s: String): String = "[$s]" + +open class Base { + fun flush() { + println("base flush") + } +} + +// A bare call is this.method() or a top-level function; never an unrelated class's method. +class Logger : Base() { + fun line(s: String): String = render(s) + + fun close() { + flush() + reset() + } + + private fun reset() { + println("reset") + } +} diff --git a/test/receiverevidencefix/kt/src/app/Plain.kt b/test/receiverevidencefix/kt/src/app/Plain.kt new file mode 100644 index 000000000..8e08b4399 --- /dev/null +++ b/test/receiverevidencefix/kt/src/app/Plain.kt @@ -0,0 +1,8 @@ +package app + +// Plain inherits flush() from an OUTSIDE class: Base and Exporter are unrelated. +class Plain : java.io.Writer() { + fun finish() { + flush() + } +} diff --git a/test/receiverevidencefix/kt/src/app/Pump.kt b/test/receiverevidencefix/kt/src/app/Pump.kt new file mode 100644 index 000000000..b416119ab --- /dev/null +++ b/test/receiverevidencefix/kt/src/app/Pump.kt @@ -0,0 +1,25 @@ +package app + +// Review B3: Kotlin receivers the source TYPES resolve (a typed parameter, a typed / constructed local, a typed or +// constructed property, a `val` constructor property), against two classes that both define spill(). Near misses stay +// hedged: an untyped local from a lower-case factory, a local that hides the property, an interface-typed receiver. +interface Spiller { fun spill() } +class Tank : Spiller { override fun spill() {} } +class Barrel : Spiller { override fun spill() {} } + +class Pump(val keg: Barrel) { + private val tank: Tank = Tank() + var spare = Tank() + fun param(t: Tank) { t.spill() } + fun typedLocal() { val t: Tank = makeTank(); t.spill() } + fun ctorLocal() { val t = Tank(); t.spill() } + fun implicitField() { tank.spill() } + fun thisField() { this.tank.spill() } + fun ctorProperty() { keg.spill() } + fun constructedProperty() { spare.spill() } + fun untyped() { val x = makeAny(); x.spill() } + fun shadow() { val tank = makeAny(); tank.spill() } + fun viaIface(s: Spiller) { s.spill() } + fun makeTank(): Tank = Tank() + fun makeAny(): Any = Tank() +} diff --git a/test/receiverevidencefix/kt/src/app/Sides.kt b/test/receiverevidencefix/kt/src/app/Sides.kt new file mode 100644 index 000000000..aea3c7234 --- /dev/null +++ b/test/receiverevidencefix/kt/src/app/Sides.kt @@ -0,0 +1,16 @@ +package app + +// The two SIDES of a class lookup: a companion member is the CLASS side, an instance member the other. +class Dial { + companion object { + fun read(): Int = 1 + } + fun turn(): Int = 2 +} + +class Knob { + fun read(): Int = 3 +} + +fun onClass(): Int = Dial.read() +fun onInstance(): Int = Knob().read() diff --git a/test/receiverevidencefix/py/src/tui/__init__.py b/test/receiverevidencefix/py/src/tui/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/test/receiverevidencefix/py/src/tui/app.py b/test/receiverevidencefix/py/src/tui/app.py new file mode 100644 index 000000000..a3b56f868 --- /dev/null +++ b/test/receiverevidencefix/py/src/tui/app.py @@ -0,0 +1,30 @@ +from .screen import Screen + + +class App: + """A non-self receiver in the class's own file: the file defining App is no evidence.""" + + def __init__(self): + self._screen_stack = [Screen()] + + @property + def screen(self): + """The top of the screen stack: a property, so `self.screen` carries no receiver evidence.""" + return self._screen_stack[-1] + + def refresh(self): + return False + + def get_screen(self, name): + return Screen() + + def switch_mode(self, name): + mode_screen = self.get_screen(name) + mode_screen.refresh() + + def repaint(self): + self.refresh() + self.screen.refresh() + + def repaint_screen(self): + self.screen.refresh() diff --git a/test/receiverevidencefix/py/src/tui/byte_stream.py b/test/receiverevidencefix/py/src/tui/byte_stream.py new file mode 100644 index 000000000..d28f94746 --- /dev/null +++ b/test/receiverevidencefix/py/src/tui/byte_stream.py @@ -0,0 +1,3 @@ +class ByteStreamParser: + def feed(self, data): + return list(data) diff --git a/test/receiverevidencefix/py/src/tui/callback.py b/test/receiverevidencefix/py/src/tui/callback.py new file mode 100644 index 000000000..37475f032 --- /dev/null +++ b/test/receiverevidencefix/py/src/tui/callback.py @@ -0,0 +1,15 @@ +import asyncio + + +def invoke(callback, log_slow): + """An asyncio loop's call_later and its TimerHandle's cancel, beside in-repo methods of those names.""" + handle = asyncio.get_running_loop().call_later(5.0, log_slow) + try: + return callback() + finally: + handle.cancel() + + +def schedule(pump, callback): + """A parameter receiver with one in-repo candidate: name-only, true here, and must stay visible.""" + return pump.call_later(1.0, callback) diff --git a/test/receiverevidencefix/py/src/tui/compositor.py b/test/receiverevidencefix/py/src/tui/compositor.py new file mode 100644 index 000000000..28e8d0b95 --- /dev/null +++ b/test/receiverevidencefix/py/src/tui/compositor.py @@ -0,0 +1,10 @@ +class Compositor: + def arrange_root(self, root): + placed = [] + + def add_widget(widget): + placed.append(widget) + + for child in root: + add_widget(child) + return placed diff --git a/test/receiverevidencefix/py/src/tui/content.py b/test/receiverevidencefix/py/src/tui/content.py new file mode 100644 index 000000000..d9f6f53cd --- /dev/null +++ b/test/receiverevidencefix/py/src/tui/content.py @@ -0,0 +1,12 @@ +class Content: + def __init__(self, text=""): + self.text = text + + def append(self, other): + return Content(self.text + str(other)) + + def split(self, sep=" "): + return [Content(t) for t in self.text.split(sep)] + + def join(self, parts): + return Content(self.text.join(str(p) for p in parts)) diff --git a/test/receiverevidencefix/py/src/tui/css/__init__.py b/test/receiverevidencefix/py/src/tui/css/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/test/receiverevidencefix/py/src/tui/css/parse.py b/test/receiverevidencefix/py/src/tui/css/parse.py new file mode 100644 index 000000000..0cccc679b --- /dev/null +++ b/test/receiverevidencefix/py/src/tui/css/parse.py @@ -0,0 +1,3 @@ +def parse(css): + """The module function the stylesheet imports.""" + return [rule.strip() for rule in css.split(";") if rule.strip()] diff --git a/test/receiverevidencefix/py/src/tui/css/styles.py b/test/receiverevidencefix/py/src/tui/css/styles.py new file mode 100644 index 000000000..7fa133151 --- /dev/null +++ b/test/receiverevidencefix/py/src/tui/css/styles.py @@ -0,0 +1,20 @@ +class StylesBase: + def __init__(self): + self._rules = {} + + def get(self, key, default=None): + return self._rules.get(key, default) + + def keys(self): + return list(self._rules) + + def items(self): + return list(self._rules.items()) + + def animate(self, attribute, value): + self._rules[attribute] = value + + +class Styles(StylesBase): + def copy(self): + return Styles() diff --git a/test/receiverevidencefix/py/src/tui/css/stylesheet.py b/test/receiverevidencefix/py/src/tui/css/stylesheet.py new file mode 100644 index 000000000..70d068fd4 --- /dev/null +++ b/test/receiverevidencefix/py/src/tui/css/stylesheet.py @@ -0,0 +1,44 @@ +from .parse import parse +from .styles import Styles + + +class Stylesheet: + """Builtin dict/set receivers in a file that imports Styles: the import is no evidence about them.""" + + def __init__(self): + self.source = {} + self._invalid_css = set() + self.styles = Styles() + + def update(self, other): + self.source.update(other) + + def read(self, path): + return open(path).read() + + def reparse(self, other): + cache = {} + for key, value in self.source.items(): + cache[key] = value + self._invalid_css.update(other._invalid_css) + rules_map = {"a": 1} + names = sorted(rules_map.keys()) + return cache.get(names[0]) + + def refresh_rules(self, animator): + animator.animate(self, "opacity") + + def own(self, other): + self.update(other) + return self.styles.get("color") + + def parse_rules(self, css): + return list(parse(css)) + + @classmethod + def blank(cls): + return cls.default_rules() + + @classmethod + def default_rules(cls): + return [] diff --git a/test/receiverevidencefix/py/src/tui/drivers/__init__.py b/test/receiverevidencefix/py/src/tui/drivers/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/test/receiverevidencefix/py/src/tui/drivers/linux.py b/test/receiverevidencefix/py/src/tui/drivers/linux.py new file mode 100644 index 000000000..5d4e9b411 --- /dev/null +++ b/test/receiverevidencefix/py/src/tui/drivers/linux.py @@ -0,0 +1,20 @@ +import os + +from ..xterm_parser import XTermParser + + +class Driver: + def run_input_thread(self, fd): + """Aliases bound in the method and called from a nested def: an outside function and a constructed + object's bound method.""" + parser = XTermParser() + feed = parser.feed + read = os.read + + def process_events(ready): + for _ in ready: + data = read(fd, 1024) + for event in feed(data): + print(event) + + process_events([fd]) diff --git a/test/receiverevidencefix/py/src/tui/geometry.py b/test/receiverevidencefix/py/src/tui/geometry.py new file mode 100644 index 000000000..a6becefaf --- /dev/null +++ b/test/receiverevidencefix/py/src/tui/geometry.py @@ -0,0 +1,7 @@ +class Region: + def __init__(self, x, y): + self.x = x + self.y = y + + def split(self, cut): + return Region(self.x, cut), Region(cut, self.y) diff --git a/test/receiverevidencefix/py/src/tui/ghost.py b/test/receiverevidencefix/py/src/tui/ghost.py new file mode 100644 index 000000000..3ec136a73 --- /dev/null +++ b/test/receiverevidencefix/py/src/tui/ghost.py @@ -0,0 +1,20 @@ +# A class with __getattr__: Python calls the hook only after normal lookup MISSES, so a method the class defines +# resolves as usual and only a name the lookup misses is left to the name ladder. +class Proxyish: + def __getattr__(self, name): + return lambda: 0 + + def real(self): + return 1 + + def run(self): + self.real() + return self.ghost() + + +class Haunted: + def ghost(self): + return 2 + + def real(self): + return 3 diff --git a/test/receiverevidencefix/py/src/tui/listing.py b/test/receiverevidencefix/py/src/tui/listing.py new file mode 100644 index 000000000..f39b0dd92 --- /dev/null +++ b/test/receiverevidencefix/py/src/tui/listing.py @@ -0,0 +1,15 @@ +from .content import Content + + +def gather(nodes): + """list.append on a list literal local, in a file that imports Content (which defines append).""" + out = [] + for node in nodes: + out.append(node) + return out + + +def grow(text): + """Near miss: a constructed Content's append is that class's method.""" + c = Content(text) + return c.append("!") diff --git a/test/receiverevidencefix/py/src/tui/markup.py b/test/receiverevidencefix/py/src/tui/markup.py new file mode 100644 index 000000000..2f56f22eb --- /dev/null +++ b/test/receiverevidencefix/py/src/tui/markup.py @@ -0,0 +1,8 @@ +class MarkupParser: + def parse(self, text): + return [text] + + +class TokenParser: + def parse(self, text): + return text.split() diff --git a/test/receiverevidencefix/py/src/tui/message_pump.py b/test/receiverevidencefix/py/src/tui/message_pump.py new file mode 100644 index 000000000..c3b7cbe35 --- /dev/null +++ b/test/receiverevidencefix/py/src/tui/message_pump.py @@ -0,0 +1,3 @@ +class MessagePump: + def call_later(self, delay, callback): + return (delay, callback) diff --git a/test/receiverevidencefix/py/src/tui/reactive.py b/test/receiverevidencefix/py/src/tui/reactive.py new file mode 100644 index 000000000..5728013e7 --- /dev/null +++ b/test/receiverevidencefix/py/src/tui/reactive.py @@ -0,0 +1,15 @@ +from .content import Content + + +def watch(watchers, name, node, callback): + """list.append / str.split on plain values, in a file that imports Content.""" + watcher_list = watchers.setdefault(name, []) + watcher_list.append((node, callback)) + + +def toggle(obj, toggle_class: str): + obj.set_class(True, *toggle_class.split()) + + +def label() -> Content: + return Content("label") diff --git a/test/receiverevidencefix/py/src/tui/screen.py b/test/receiverevidencefix/py/src/tui/screen.py new file mode 100644 index 000000000..82cbe7785 --- /dev/null +++ b/test/receiverevidencefix/py/src/tui/screen.py @@ -0,0 +1,11 @@ +class Screen: + def refresh(self): + return True + + def collect(self, nodes): + """A local alias of a list's bound append beside another class's nested add_widget.""" + widgets = [] + add_widget = widgets.append + for node in nodes: + add_widget(node) + return widgets diff --git a/test/receiverevidencefix/py/src/tui/uses.py b/test/receiverevidencefix/py/src/tui/uses.py new file mode 100644 index 000000000..b6b8b93d7 --- /dev/null +++ b/test/receiverevidencefix/py/src/tui/uses.py @@ -0,0 +1,29 @@ +from .css.styles import Styles +from .css.stylesheet import Stylesheet as Sheet +from .css.stylesheet import Stylesheet + + +class Holder: + """Near misses: constructor-assigned field, typed parameter, construction, import alias.""" + + def __init__(self): + self.sheet = Stylesheet() + + def sync(self, other): + self.sheet.update(other) + + +def apply(styles: Styles): + return styles.animate("color", 1) + + +def build(path): + sheet = Stylesheet() + sheet.update({}) + alias = Sheet() + return alias.read(path) + + +def clone(styles): + """Near miss: a class-name receiver is evidence.""" + return Styles.copy(styles) diff --git a/test/receiverevidencefix/py/src/tui/worker.py b/test/receiverevidencefix/py/src/tui/worker.py new file mode 100644 index 000000000..5eb05641d --- /dev/null +++ b/test/receiverevidencefix/py/src/tui/worker.py @@ -0,0 +1,3 @@ +class Worker: + def cancel(self): + return True diff --git a/test/receiverevidencefix/py/src/tui/xterm_parser.py b/test/receiverevidencefix/py/src/tui/xterm_parser.py new file mode 100644 index 000000000..497f0f226 --- /dev/null +++ b/test/receiverevidencefix/py/src/tui/xterm_parser.py @@ -0,0 +1,8 @@ +class Parser: + def feed(self, data): + return list(data) + + +class XTermParser(Parser): + def feed(self, data): + return [chr(b) for b in data] diff --git a/test/receiverevidencefix/py/src/web/__init__.py b/test/receiverevidencefix/py/src/web/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/test/receiverevidencefix/py/src/web/datastructures.py b/test/receiverevidencefix/py/src/web/datastructures.py new file mode 100644 index 000000000..617f92cf7 --- /dev/null +++ b/test/receiverevidencefix/py/src/web/datastructures.py @@ -0,0 +1,3 @@ +class UploadFile: + def write(self, data): + return len(data) diff --git a/test/receiverevidencefix/py/src/web/errors.py b/test/receiverevidencefix/py/src/web/errors.py new file mode 100644 index 000000000..d1ea032bd --- /dev/null +++ b/test/receiverevidencefix/py/src/web/errors.py @@ -0,0 +1,9 @@ +class ServerErrorMiddleware: + def __init__(self, app): + self.app = app + + async def __call__(self, scope, receive, send): + async def _send(message): + await send(message) + + await self.app(scope, receive, _send) diff --git a/test/receiverevidencefix/py/src/web/formparsers.py b/test/receiverevidencefix/py/src/web/formparsers.py new file mode 100644 index 000000000..fcc93c710 --- /dev/null +++ b/test/receiverevidencefix/py/src/web/formparsers.py @@ -0,0 +1,8 @@ +import multipart + + +def parse_form(callbacks, chunks): + """A parser object built by an outside package beside UploadFile.write.""" + parser = multipart.QuerystringParser(callbacks) + for chunk in chunks: + parser.write(chunk) diff --git a/test/receiverevidencefix/py/src/web/jsonutil.py b/test/receiverevidencefix/py/src/web/jsonutil.py new file mode 100644 index 000000000..0e65e198a --- /dev/null +++ b/test/receiverevidencefix/py/src/web/jsonutil.py @@ -0,0 +1,7 @@ +def stringify(obj): + """In-repo names that an outside from-import shadows in another file.""" + return str(obj) + + +def dumps(obj): + return repr(obj) diff --git a/test/receiverevidencefix/py/src/web/render.py b/test/receiverevidencefix/py/src/web/render.py new file mode 100644 index 000000000..44165fdf6 --- /dev/null +++ b/test/receiverevidencefix/py/src/web/render.py @@ -0,0 +1,7 @@ +from json import dumps as stringify +from json import dumps + + +def render(obj): + """`stringify` and `dumps` here are json.dumps (an outside from-import), never jsonutil's functions.""" + return stringify(obj) + dumps(obj) diff --git a/test/receiverevidencefix/py/src/web/requests.py b/test/receiverevidencefix/py/src/web/requests.py new file mode 100644 index 000000000..804e2f563 --- /dev/null +++ b/test/receiverevidencefix/py/src/web/requests.py @@ -0,0 +1,10 @@ +class HTTPConnection: + """self._send holds the ASGI send callable passed in; the nested _send elsewhere is never it.""" + + def __init__(self, scope, receive, send): + self.scope = scope + self._receive = receive + self._send = send + + async def send_json(self, message): + await self._send(message) diff --git a/test/receiverevidencefix/rb/lib/app/auditable.rb b/test/receiverevidencefix/rb/lib/app/auditable.rb new file mode 100644 index 000000000..5b96af683 --- /dev/null +++ b/test/receiverevidencefix/rb/lib/app/auditable.rb @@ -0,0 +1,8 @@ +module App + # A concern calls a method its INCLUDER defines: Ruby's lookup proves it (self is an includer instance). + module Auditable + def log_audit + audit_target + end + end +end diff --git a/test/receiverevidencefix/rb/lib/app/exporter.rb b/test/receiverevidencefix/rb/lib/app/exporter.rb new file mode 100644 index 000000000..7a2ac6a86 --- /dev/null +++ b/test/receiverevidencefix/rb/lib/app/exporter.rb @@ -0,0 +1,11 @@ +module App + class Exporter + def flush + puts "exporter flush" + end + + def render(s) + "<#{s}>" + end + end +end diff --git a/test/receiverevidencefix/rb/lib/app/helpers.rb b/test/receiverevidencefix/rb/lib/app/helpers.rb new file mode 100644 index 000000000..df0b884bc --- /dev/null +++ b/test/receiverevidencefix/rb/lib/app/helpers.rb @@ -0,0 +1,7 @@ +module App + module Helpers + def stamp(s) + "#{Time.now} #{s}" + end + end +end diff --git a/test/receiverevidencefix/rb/lib/app/logger.rb b/test/receiverevidencefix/rb/lib/app/logger.rb new file mode 100644 index 000000000..821607f4e --- /dev/null +++ b/test/receiverevidencefix/rb/lib/app/logger.rb @@ -0,0 +1,30 @@ +require_relative "helpers" + +module App + class Base + def flush() + puts "base flush" + end + end + + # A bare call is self.method: its own class, a superclass, an included module. Never an unrelated class. + class Logger < Base + include Helpers + + def line(s) + stamp(s) + end + + def close + flush() + reset() + top_level_note("closed") + end + + private + + def reset() + puts "reset" + end + end +end diff --git a/test/receiverevidencefix/rb/lib/app/notes.rb b/test/receiverevidencefix/rb/lib/app/notes.rb new file mode 100644 index 000000000..ea33adc8a --- /dev/null +++ b/test/receiverevidencefix/rb/lib/app/notes.rb @@ -0,0 +1,4 @@ +# A top-level def is a private method of Object: every bare call can reach it. +def top_level_note(s) + warn s +end diff --git a/test/receiverevidencefix/rb/lib/app/order.rb b/test/receiverevidencefix/rb/lib/app/order.rb new file mode 100644 index 000000000..6ed4df299 --- /dev/null +++ b/test/receiverevidencefix/rb/lib/app/order.rb @@ -0,0 +1,22 @@ +module App + class Order + include Auditable + + def audit_target + "order" + end + end + + # A template method: the base calls a hook only its subclass defines. + class Report + def render_report + header_line + end + end + + class PdfReport < Report + def header_line + "pdf" + end + end +end diff --git a/test/receiverevidencefix/rb/lib/app/plain.rb b/test/receiverevidencefix/rb/lib/app/plain.rb new file mode 100644 index 000000000..4ee559113 --- /dev/null +++ b/test/receiverevidencefix/rb/lib/app/plain.rb @@ -0,0 +1,11 @@ +require "logger" + +module App + # Plain inherits from an OUTSIDE class (the stdlib Logger): a bare render or flush never reaches Exporter or Base. + class Plain < ::Logger + def finish(s) + flush() + render(s) + end + end +end diff --git a/test/receiverevidencefix/scope/cs/App/Scope.cs b/test/receiverevidencefix/scope/cs/App/Scope.cs new file mode 100644 index 000000000..160534711 --- /dev/null +++ b/test/receiverevidencefix/scope/cs/App/Scope.cs @@ -0,0 +1,34 @@ +using System; +using System.Linq; +using System.Collections.Generic; +namespace App { + // Review B4: Tank and Barrel both define Spill(), and the class's field `tank` is a Tank. + public interface ISpiller { void Spill(); } + public class Tank : ISpiller { public void Spill() {} } + public class Barrel : ISpiller { public void Spill() {} } + + // (a) a binding that hides the field, and a local seen outside its own block; (b) a type parameter named like a class + public class Scope { + private Tank tank = new Tank(); + public void ForEachLoop(List bs) { foreach (Barrel tank in bs) { tank.Spill(); } } + public void ForEachVar(List bs) { foreach (var tank in bs) { tank.Spill(); } } + public void LambdaParam(List bs) { bs.ForEach(tank => tank.Spill()); } + public void PatternVar(object o) { if (o is Barrel tank) { tank.Spill(); } } + public void OutVar(Dictionary d) { var found = d.TryGetValue(1, out var tank); if (found) tank.Spill(); } + public void SwitchCase(object o) { switch (o) { case Barrel tank: tank.Spill(); break; } } + public void Deconstruct((Barrel, Barrel) p) { var (tank, other) = p; tank.Spill(); } + public void CatchUse() { try { } catch (Exception tank) { tank.Spill(); } } + public void Linq(List bs) { var q = from tank in bs select tank.Spill(); } + public void NestedBlock(bool b) { if (b) { Barrel tank = new Barrel(); } tank.Spill(); } + public void LambdaOutside(List bs) { bs.ForEach((Barrel tank) => { }); tank.Spill(); } + public void FieldUse() { tank.Spill(); } + public void TypedLocal() { Barrel tank = new Barrel(); tank.Spill(); } + public void MethodTypeParam(Tank t) where Tank : ISpiller { t.Spill(); } + } + + public class Box where Tank : ISpiller { + private Tank item; + public void ClassTypeParam(Tank t) { t.Spill(); } + public void TypeParamField() { item.Spill(); } + } +} diff --git a/test/receiverevidencefix/scope/go/go.mod b/test/receiverevidencefix/scope/go/go.mod new file mode 100644 index 000000000..82330bddf --- /dev/null +++ b/test/receiverevidencefix/scope/go/go.mod @@ -0,0 +1,3 @@ +module example.com/scope + +go 1.22 diff --git a/test/receiverevidencefix/scope/go/scope.go b/test/receiverevidencefix/scope/go/scope.go new file mode 100644 index 000000000..af323090e --- /dev/null +++ b/test/receiverevidencefix/scope/go/scope.go @@ -0,0 +1,42 @@ +// Review B4 (language neutrality): a binding that hides a typed parameter of its name, and a type parameter named like +// a type. Tank and Barrel both define Spill. +package scope + +type Spiller interface{ Spill() } +type Tank struct{} + +func (Tank) Spill() {} + +type Barrel struct{} + +func (Barrel) Spill() {} + +func RangeLoop(tank Tank, bs []Barrel) { + for _, tank := range bs { + tank.Spill() + } +} +func ShortIf(tank Tank, b Barrel) { + if tank := b; true { + tank.Spill() + } +} +func TypeSwitch(tank Tank, o any) { + switch tank := o.(type) { + case Barrel: + tank.Spill() + } +} +func BlockOutside(tank Tank, b Barrel) { + { + tank := b + _ = tank + } + tank.Spill() +} +func TypeParam[Tank Spiller](t Tank) { t.Spill() } + +type Box[Tank Spiller] struct{ item Tank } + +func (g Box[Tank]) TypeParamField() { g.item.Spill() } +func TypedParam(t Tank) { t.Spill() } diff --git a/test/receiverevidencefix/scope/java/src/app/Scope.java b/test/receiverevidencefix/scope/java/src/app/Scope.java new file mode 100644 index 000000000..33a67463c --- /dev/null +++ b/test/receiverevidencefix/scope/java/src/app/Scope.java @@ -0,0 +1,35 @@ +package app; + +import java.util.List; + +// Review B4: Tank and Barrel both define spill(), and the class's field `tank` is a Tank. +interface Spiller { void spill(); } +class Tank implements Spiller { public void spill() {} } +class Barrel implements Spiller, AutoCloseable { public void spill() {} public void close() {} } +record P(Barrel b) {} + +// (a) a binding that hides the field — a loop, lambda, catch or resource variable, a pattern variable — and a local seen +// outside its own block; (b) a generic's type parameter named like a class. Kept: the field, a typed local. +class Scope { + private Tank tank = new Tank(); + void forEachLoop(List bs) { for (Barrel tank : bs) { tank.spill(); } } + void lambdaParam(List bs) { bs.forEach(tank -> tank.spill()); } + void typedLambda(List bs) { bs.forEach((Barrel tank) -> tank.spill()); } + void catchUse() { try { } catch (RuntimeException tank) { tank.spill(); } } + void tryRes() throws Exception { try (Barrel tank = new Barrel()) { tank.spill(); tank.close(); } } + void patternVar(Object o) { if (o instanceof Barrel tank) { tank.spill(); } } + void negatedPattern(Object o) { if (!(o instanceof Barrel tank)) return; tank.spill(); } + void switchPattern(Object o) { switch (o) { case Barrel tank -> tank.spill(); default -> {} } } + void recordPattern(Object o) { if (o instanceof P(Barrel tank)) { tank.spill(); } } + void nestedBlock(boolean b) { if (b) { Barrel tank = new Barrel(); } tank.spill(); } + void lambdaOutside(List bs) { bs.forEach((Barrel tank) -> { }); tank.spill(); } + void fieldUse() { tank.spill(); } + void typedLocal() { Barrel tank = new Barrel(); tank.spill(); } + void methodTypeParam(Tank t) { t.spill(); } +} + +class Box { + Tank item; + void classTypeParam(Tank t) { t.spill(); } + void typeParamField() { item.spill(); } +} diff --git a/test/receiverevidencefix/scope/js/scope.js b/test/receiverevidencefix/scope/js/scope.js new file mode 100644 index 000000000..2ade49a01 --- /dev/null +++ b/test/receiverevidencefix/scope/js/scope.js @@ -0,0 +1,9 @@ +// Review B4 (language neutrality): a binding that hides an outer constructed local of its name. +export class Tank { spill() {} } +export class Barrel { spill() {} } +export function arrowParam(bs) { const tank = new Tank(); bs.forEach((tank) => tank.spill()); } +export function forOf(bs) { const tank = new Tank(); for (const tank of bs) { tank.spill(); } } +export function catchParam() { const tank = new Tank(); try { } catch (tank) { tank.spill(); } } +export function blockOutside(b) { const tank = new Tank(); { const tank = b; } tank.spill(); } +export function reassign(b) { let tank = new Tank(); tank = b; tank.spill(); } +export function constructed() { const t = new Tank(); t.spill(); } diff --git a/test/receiverevidencefix/scope/kt/src/app/Scope.kt b/test/receiverevidencefix/scope/kt/src/app/Scope.kt new file mode 100644 index 000000000..79c39d70a --- /dev/null +++ b/test/receiverevidencefix/scope/kt/src/app/Scope.kt @@ -0,0 +1,28 @@ +package app + +// Review B4: Tank and Barrel both define spill(), and the class's field `tank` is a Tank. +interface Spiller { fun spill() } +class Tank : Spiller { override fun spill() {} } +class Barrel : Spiller { override fun spill() {} } + +// (a) a binding that hides the field, and a local seen outside its own block; (b) a type parameter named like a class +class Scope { + private val tank: Tank = Tank() + fun forLoop(bs: List) { for (tank in bs) { tank.spill() } } + fun forDestructure(ps: List>) { for ((tank, o) in ps) { tank.spill() } } + fun lambdaParam(bs: List) { bs.forEach { tank -> tank.spill() } } + fun letParam(b: Barrel?) { b?.let { tank -> tank.spill() } } + fun typedLambda(bs: List) { bs.forEach { tank: Barrel -> tank.spill() } } + fun destructure(p: Pair) { val (tank, other) = p; tank.spill() } + fun whenSubject(o: Any) { when (val tank = o) { is Barrel -> tank.spill() } } + fun catchUse() { try { } catch (tank: Exception) { tank.spill() } } + fun nestedBlock(b: Boolean) { if (b) { val tank = Barrel() }; tank.spill() } + fun lambdaOutside(bs: List) { bs.forEach { tank: Barrel -> }; tank.spill() } + fun fieldUse() { tank.spill() } + fun typedLocal() { val tank: Barrel = Barrel(); tank.spill() } +} + +class Box(val item: Tank) { + fun classTypeParam(t: Tank) { t.spill() } + fun typeParamField() { item.spill() } +} diff --git a/test/receiverevidencefix/scope/py/scope.py b/test/receiverevidencefix/scope/py/scope.py new file mode 100644 index 000000000..757e9f502 --- /dev/null +++ b/test/receiverevidencefix/scope/py/scope.py @@ -0,0 +1,50 @@ +# Review B4 (language neutrality): a rebinding of a typed parameter's name, and a type parameter named like a class. +class Tank: + def spill(self): pass + +class Barrel: + def spill(self): pass + +def for_loop(tank: Tank, bs: list): + for tank in bs: + tank.spill() + +def lambda_param(tank: Tank, bs): + return list(map(lambda tank: tank.spill(), bs)) + +def comprehension(tank: Tank, bs): + return [tank.spill() for tank in bs] + +def with_as(tank: Tank, cm): + with cm as tank: + tank.spill() + +def except_as(tank: Tank): + try: + pass + except Exception as tank: + tank.spill() + +def walrus(tank: Tank, f): + if (tank := f()): + tank.spill() + +def reassign(tank: Tank, b): + tank = b + tank.spill() + +def type_param[Tank](t: Tank): + t.spill() + +def typed_param(t: Tank): + t.spill() + +def match_capture(tank: Tank, o): + match o: + case [tank, *rest]: + tank.spill() + +def match_as(tank: Tank, o): + match o: + case Barrel() as tank: + tank.spill() diff --git a/test/receiverevidencefix/scope/swift/Sources/App/Scope.swift b/test/receiverevidencefix/scope/swift/Sources/App/Scope.swift new file mode 100644 index 000000000..7f442aba0 --- /dev/null +++ b/test/receiverevidencefix/scope/swift/Sources/App/Scope.swift @@ -0,0 +1,31 @@ +// Review B4: Tank and Barrel both define spill(), and the class's field `tank` is a Tank. +protocol Spiller { func spill() } +class Tank: Spiller { func spill() {} } +class Barrel: Spiller { func spill() {} } +enum Cask { case b(Barrel) } + +// (a) a binding that hides the field, and a local seen outside its own block; (b) a type parameter named like a class +class Scope { + var tank: Tank = Tank() + func forLoop(_ bs: [Barrel]) { for tank in bs { tank.spill() } } + func forCaseLet(_ bs: [Barrel?]) { for case let tank? in bs { tank.spill() } } + func forTuple(_ ps: [(Barrel, Barrel)]) { for (tank, _) in ps { tank.spill() } } + func closureParam(_ bs: [Barrel]) { bs.forEach { tank in tank.spill() } } + func typedClosure(_ bs: [Barrel]) { bs.forEach { (tank: Barrel) in tank.spill() } } + func ifLet(_ b: Barrel?) { if let tank = b { tank.spill() } } + func guardLet(_ b: Barrel?) { guard let tank = b else { return }; tank.spill() } + func whileLet(_ it: inout IndexingIterator<[Barrel]>) { while let tank = it.next() { tank.spill() } } + func caseLet(_ x: Cask) { switch x { case .b(let tank): tank.spill() } } + func ifCaseLet(_ x: Cask) { if case .b(let tank) = x { tank.spill() } } + func catchLet() { do { } catch let tank { tank.spill() } } + func nestedBlock(_ b: Bool) { if b { let tank = Barrel(); _ = tank }; tank.spill() } + func fieldUse() { tank.spill() } + func typedLocal() { let tank: Barrel = Barrel(); tank.spill() } +} + +class Box { + var item: Tank + init(item: Tank) { self.item = item } + func classTypeParam(_ t: Tank) { t.spill() } + func typeParamField() { item.spill() } +} diff --git a/test/receiverevidencefix/scope/ts/scope.ts b/test/receiverevidencefix/scope/ts/scope.ts new file mode 100644 index 000000000..29ac09d81 --- /dev/null +++ b/test/receiverevidencefix/scope/ts/scope.ts @@ -0,0 +1,18 @@ +// Review B4 (language neutrality): a binding that hides an outer typed binding of its name, and a type parameter named +// like a class. Tank and Barrel both define spill(). +interface Spiller { spill(): void } +export class Tank implements Spiller { spill() {} } +export class Barrel implements Spiller { spill() {} } +export function arrowParam(tank: Tank, bs: Barrel[]) { bs.forEach((tank) => tank.spill()); } +export function forOf(tank: Tank, bs: Barrel[]) { for (const tank of bs) { tank.spill(); } } +export function blockLocal(tank: Tank, b: Barrel) { { const tank = b; tank.spill(); } } +export function catchParam(tank: Tank) { try { } catch (tank) { tank.spill(); } } +export function destructure(tank: Tank, p: [Barrel, Barrel]) { { const [tank] = p; tank.spill(); } } +export function typeParam(t: Tank) { t.spill(); } +export class Box { + item: Tank; + constructor(item: Tank) { this.item = item; } + classTypeParam(t: Tank) { t.spill(); } + typeParamField() { this.item.spill(); } +} +export function typedParam(t: Tank) { t.spill(); } diff --git a/test/receiverevidencefix/superroot/cpp/src/panel.cpp b/test/receiverevidencefix/superroot/cpp/src/panel.cpp new file mode 100644 index 000000000..3163695a1 --- /dev/null +++ b/test/receiverevidencefix/superroot/cpp/src/panel.cpp @@ -0,0 +1,16 @@ +#include "shapes.h" + +struct Panel : Base +{ + const char* typed() + { + Widget base; + return base.render(); + } + + const char* untyped() + { + auto base = outside::make(); + return base.render(); + } +}; diff --git a/test/receiverevidencefix/superroot/cpp/src/shapes.h b/test/receiverevidencefix/superroot/cpp/src/shapes.h new file mode 100644 index 000000000..c7df0aa86 --- /dev/null +++ b/test/receiverevidencefix/superroot/cpp/src/shapes.h @@ -0,0 +1,12 @@ +#pragma once +#include + +struct Base +{ + virtual const char* render() { return "base"; } +}; + +struct Widget +{ + const char* render() { return "widget"; } +}; diff --git a/test/receiverevidencefix/superroot/cs/App/Panel.cs b/test/receiverevidencefix/superroot/cs/App/Panel.cs new file mode 100644 index 000000000..fec3817ec --- /dev/null +++ b/test/receiverevidencefix/superroot/cs/App/Panel.cs @@ -0,0 +1,24 @@ +using Outside; + +namespace App +{ + public class Panel : Base + { + public string Typed() + { + var super = new Widget(); + return super.Render(); + } + + public string Untyped() + { + var super = Factory.Make(); + return super.Render(); + } + + public string RealBase() + { + return base.Render(); + } + } +} diff --git a/test/receiverevidencefix/superroot/cs/App/Shapes.cs b/test/receiverevidencefix/superroot/cs/App/Shapes.cs new file mode 100644 index 000000000..8090d1679 --- /dev/null +++ b/test/receiverevidencefix/superroot/cs/App/Shapes.cs @@ -0,0 +1,12 @@ +namespace App +{ + public class Base + { + public virtual string Render() { return "base"; } + } + + public class Widget + { + public string Render() { return "widget"; } + } +} diff --git a/test/receiverevidencefix/superroot/go/go.mod b/test/receiverevidencefix/superroot/go/go.mod new file mode 100644 index 000000000..8ade2c593 --- /dev/null +++ b/test/receiverevidencefix/superroot/go/go.mod @@ -0,0 +1,3 @@ +module example.com/superroot + +go 1.21 diff --git a/test/receiverevidencefix/superroot/go/src/panel.go b/test/receiverevidencefix/superroot/go/src/panel.go new file mode 100644 index 000000000..c1868d90d --- /dev/null +++ b/test/receiverevidencefix/superroot/go/src/panel.go @@ -0,0 +1,24 @@ +package src + +type Panel struct { + Base +} + +func (p *Panel) Typed() string { + base := &Widget{} + return base.Render() +} + +func (p *Panel) Untyped() string { + base := makeAny() + return base.Render() +} + +func (p *Panel) Super() string { + super := makeAny() + return super.Render() +} + +func (p *Panel) Promoted() string { + return p.Render() +} diff --git a/test/receiverevidencefix/superroot/go/src/shapes.go b/test/receiverevidencefix/superroot/go/src/shapes.go new file mode 100644 index 000000000..22cd90855 --- /dev/null +++ b/test/receiverevidencefix/superroot/go/src/shapes.go @@ -0,0 +1,13 @@ +package src + +type Base struct{} + +func (b *Base) Render() string { return "base" } + +type Widget struct{} + +func (w *Widget) Render() string { return "widget" } + +func NewWidget() *Widget { return &Widget{} } + +func makeAny() interface{ Render() string } { return nil } diff --git a/test/receiverevidencefix/superroot/java/src/app/Base.java b/test/receiverevidencefix/superroot/java/src/app/Base.java new file mode 100644 index 000000000..c89e3fe1d --- /dev/null +++ b/test/receiverevidencefix/superroot/java/src/app/Base.java @@ -0,0 +1,5 @@ +package app; + +public class Base { + public String render() { return "base"; } +} diff --git a/test/receiverevidencefix/superroot/java/src/app/Panel.java b/test/receiverevidencefix/superroot/java/src/app/Panel.java new file mode 100644 index 000000000..5032d3b45 --- /dev/null +++ b/test/receiverevidencefix/superroot/java/src/app/Panel.java @@ -0,0 +1,19 @@ +package app; + +import outside.Factory; + +public class Panel extends Base { + public String typed() { + Widget base = new Widget(); + return base.render(); + } + + public String untyped() { + var base = Factory.make(); + return base.render(); + } + + public String realSuper() { + return super.render(); + } +} diff --git a/test/receiverevidencefix/superroot/java/src/app/Widget.java b/test/receiverevidencefix/superroot/java/src/app/Widget.java new file mode 100644 index 000000000..b8480fa12 --- /dev/null +++ b/test/receiverevidencefix/superroot/java/src/app/Widget.java @@ -0,0 +1,5 @@ +package app; + +public class Widget { + public String render() { return "widget"; } +} diff --git a/test/receiverevidencefix/superroot/js/lib/panel.js b/test/receiverevidencefix/superroot/js/lib/panel.js new file mode 100644 index 000000000..4ecf586c5 --- /dev/null +++ b/test/receiverevidencefix/superroot/js/lib/panel.js @@ -0,0 +1,19 @@ +const { Base, Widget, makeBase } = require('./shapes'); + +class Panel extends Base { + typed() { + const base = new Widget(); + return base.render(); + } + + untyped() { + const base = makeBase(); + return base.render(); + } + + realSuper() { + return super.render(); + } +} + +module.exports = { Panel }; diff --git a/test/receiverevidencefix/superroot/js/lib/shapes.js b/test/receiverevidencefix/superroot/js/lib/shapes.js new file mode 100644 index 000000000..e5f60a717 --- /dev/null +++ b/test/receiverevidencefix/superroot/js/lib/shapes.js @@ -0,0 +1,11 @@ +class Base { + render() { return 'base'; } +} + +class Widget { + render() { return 'widget'; } +} + +function makeBase() { return {}; } + +module.exports = { Base, Widget, makeBase }; diff --git a/test/receiverevidencefix/superroot/js/package.json b/test/receiverevidencefix/superroot/js/package.json new file mode 100644 index 000000000..728d11064 --- /dev/null +++ b/test/receiverevidencefix/superroot/js/package.json @@ -0,0 +1 @@ +{ "name": "superroot-js", "version": "0.0.0" } diff --git a/test/receiverevidencefix/superroot/kt/src/app/Panel.kt b/test/receiverevidencefix/superroot/kt/src/app/Panel.kt new file mode 100644 index 000000000..69ff5c01f --- /dev/null +++ b/test/receiverevidencefix/superroot/kt/src/app/Panel.kt @@ -0,0 +1,19 @@ +package app + +import outside.makeAny + +class Panel : Base() { + fun typed(): String { + val base = Widget() + return base.render() + } + + fun untyped(): String { + val base = makeAny() + return base.render() + } + + fun realSuper(): String { + return super.render() + } +} diff --git a/test/receiverevidencefix/superroot/kt/src/app/Shapes.kt b/test/receiverevidencefix/superroot/kt/src/app/Shapes.kt new file mode 100644 index 000000000..07d04d615 --- /dev/null +++ b/test/receiverevidencefix/superroot/kt/src/app/Shapes.kt @@ -0,0 +1,9 @@ +package app + +open class Base { + open fun render(): String = "base" +} + +class Widget { + fun render(): String = "widget" +} diff --git a/test/receiverevidencefix/superroot/py/pkg/__init__.py b/test/receiverevidencefix/superroot/py/pkg/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/test/receiverevidencefix/superroot/py/pkg/panel.py b/test/receiverevidencefix/superroot/py/pkg/panel.py new file mode 100644 index 000000000..59e41ae54 --- /dev/null +++ b/test/receiverevidencefix/superroot/py/pkg/panel.py @@ -0,0 +1,18 @@ +from pkg.shapes import Base, Widget, make_base + + +class Panel(Base): + def typed(self): + base = Widget() + return base.render() + + def untyped(self): + base = make_base() + return base.render() + + def shadowed(self): + super = make_base() + return super.render() + + def real_super(self): + return super().render() diff --git a/test/receiverevidencefix/superroot/py/pkg/shapes.py b/test/receiverevidencefix/superroot/py/pkg/shapes.py new file mode 100644 index 000000000..1cc3e732f --- /dev/null +++ b/test/receiverevidencefix/superroot/py/pkg/shapes.py @@ -0,0 +1,12 @@ +class Base: + def render(self): + return "base" + + +class Widget: + def render(self): + return "widget" + + +def make_base(): + return object() diff --git a/test/receiverevidencefix/superroot/rb/lib/panel.rb b/test/receiverevidencefix/superroot/rb/lib/panel.rb new file mode 100644 index 000000000..6d7fbde6b --- /dev/null +++ b/test/receiverevidencefix/superroot/rb/lib/panel.rb @@ -0,0 +1,13 @@ +require_relative "shapes" + +class Panel < Base + def typed + base = Widget.new + base.render + end + + def untyped + base = Outside.make + base.render + end +end diff --git a/test/receiverevidencefix/superroot/rb/lib/shapes.rb b/test/receiverevidencefix/superroot/rb/lib/shapes.rb new file mode 100644 index 000000000..38111d963 --- /dev/null +++ b/test/receiverevidencefix/superroot/rb/lib/shapes.rb @@ -0,0 +1,11 @@ +class Base + def render + "base" + end +end + +class Widget + def render + "widget" + end +end diff --git a/test/receiverevidencefix/superroot/swift/Sources/App/Panel.swift b/test/receiverevidencefix/superroot/swift/Sources/App/Panel.swift new file mode 100644 index 000000000..4b4e71c5f --- /dev/null +++ b/test/receiverevidencefix/superroot/swift/Sources/App/Panel.swift @@ -0,0 +1,17 @@ +import Outside + +class Panel: Base { + func typed() -> String { + let base = Widget() + return base.render() + } + + func untyped() -> String { + let base = makeAny() + return base.render() + } + + func realSuper() -> String { + return super.render() + } +} diff --git a/test/receiverevidencefix/superroot/swift/Sources/App/Shapes.swift b/test/receiverevidencefix/superroot/swift/Sources/App/Shapes.swift new file mode 100644 index 000000000..42fe7167b --- /dev/null +++ b/test/receiverevidencefix/superroot/swift/Sources/App/Shapes.swift @@ -0,0 +1,7 @@ +class Base { + func render() -> String { return "base" } +} + +class Widget { + func render() -> String { return "widget" } +} diff --git a/test/receiverevidencefix/superroot/ts/package.json b/test/receiverevidencefix/superroot/ts/package.json new file mode 100644 index 000000000..2aa201d1e --- /dev/null +++ b/test/receiverevidencefix/superroot/ts/package.json @@ -0,0 +1 @@ +{ "name": "superroot-ts", "version": "0.0.0" } diff --git a/test/receiverevidencefix/superroot/ts/src/panel.ts b/test/receiverevidencefix/superroot/ts/src/panel.ts new file mode 100644 index 000000000..ebf1a2f30 --- /dev/null +++ b/test/receiverevidencefix/superroot/ts/src/panel.ts @@ -0,0 +1,17 @@ +import { Base, Widget, makeBase } from './shapes'; + +export class Panel extends Base { + typed(): string { + const base: Widget = new Widget(); + return base.render(); + } + + untyped(): string { + const base = makeBase(); + return base.render(); + } + + realSuper(): string { + return super.render(); + } +} diff --git a/test/receiverevidencefix/superroot/ts/src/shapes.ts b/test/receiverevidencefix/superroot/ts/src/shapes.ts new file mode 100644 index 000000000..ba98f5ed6 --- /dev/null +++ b/test/receiverevidencefix/superroot/ts/src/shapes.ts @@ -0,0 +1,9 @@ +export class Base { + render(): string { return 'base'; } +} + +export class Widget { + render(): string { return 'widget'; } +} + +export function makeBase(): any { return {}; } diff --git a/test/receiverevidencefix/swift/Sources/App/Exporter.swift b/test/receiverevidencefix/swift/Sources/App/Exporter.swift new file mode 100644 index 000000000..f10887387 --- /dev/null +++ b/test/receiverevidencefix/swift/Sources/App/Exporter.swift @@ -0,0 +1,9 @@ +class Exporter { + func flush() { + print("exporter flush") + } + + func render(_ s: String) -> String { + return "<" + s + ">" + } +} diff --git a/test/receiverevidencefix/swift/Sources/App/Logger.swift b/test/receiverevidencefix/swift/Sources/App/Logger.swift new file mode 100644 index 000000000..86aad001c --- /dev/null +++ b/test/receiverevidencefix/swift/Sources/App/Logger.swift @@ -0,0 +1,25 @@ +func render(_ s: String) -> String { + return "[" + s + "]" +} + +class Base { + func flush() { + print("base flush") + } +} + +// A bare call is self.method() or a free function; never an unrelated class's method. +class Logger: Base { + func line(_ s: String) -> String { + return render(s) + } + + func close() { + flush() + reset() + } + + private func reset() { + print("reset") + } +} diff --git a/test/receiverevidencefix/swift/Sources/App/Plain.swift b/test/receiverevidencefix/swift/Sources/App/Plain.swift new file mode 100644 index 000000000..dbe844a89 --- /dev/null +++ b/test/receiverevidencefix/swift/Sources/App/Plain.swift @@ -0,0 +1,8 @@ +import Foundation + +// Plain inherits from an OUTSIDE class: a bare flush() never reaches Base or Exporter. +class Plain: FileHandle { + func finish() { + flush() + } +} diff --git a/test/receiverevidencefix/swift/Sources/App/Pump.swift b/test/receiverevidencefix/swift/Sources/App/Pump.swift new file mode 100644 index 000000000..1b8700161 --- /dev/null +++ b/test/receiverevidencefix/swift/Sources/App/Pump.swift @@ -0,0 +1,22 @@ +// Review B3: Swift receivers the source TYPES resolve (a typed parameter, a typed / constructed local, a typed or +// constructed property read bare or through `self`), against two classes that both define spill(). Near misses stay +// hedged: an untyped local from a lower-case factory, a local that hides the property, a protocol-typed receiver. +protocol Spiller { func spill() } +class Tank: Spiller { func spill() {} } +class Barrel: Spiller { func spill() {} } + +class Pump { + var tank: Tank = Tank() + let keg = Barrel() + func param(t: Tank) { t.spill() } + func typedLocal() { let t: Tank = makeTank(); t.spill() } + func ctorLocal() { let t = Tank(); t.spill() } + func implicitField() { tank.spill() } + func selfField() { self.tank.spill() } + func constructedProperty() { keg.spill() } + func untyped() { let x = makeAny(); x.spill() } + func shadow() { let tank = makeAny(); tank.spill() } + func viaIface(s: Spiller) { s.spill() } + func makeTank() -> Tank { return Tank() } + func makeAny() -> Any { return Tank() } +} diff --git a/test/receiverevidencefix/ts/package.json b/test/receiverevidencefix/ts/package.json new file mode 100644 index 000000000..06bbcf967 --- /dev/null +++ b/test/receiverevidencefix/ts/package.json @@ -0,0 +1 @@ +{ "name": "rxts-fixture", "private": true } diff --git a/test/receiverevidencefix/ts/src/atomics.ts b/test/receiverevidencefix/ts/src/atomics.ts new file mode 100644 index 000000000..38cf9d632 --- /dev/null +++ b/test/receiverevidencefix/ts/src/atomics.ts @@ -0,0 +1,8 @@ +// Atomics is a global: its add is never this file's add, nor a router's. +export function add(a: number, b: number): number { + return a + b +} + +export function bump(arr: Int32Array): number { + return Atomics.add(arr, 0, 1) +} diff --git a/test/receiverevidencefix/ts/src/cache.ts b/test/receiverevidencefix/ts/src/cache.ts new file mode 100644 index 000000000..5a1a01d31 --- /dev/null +++ b/test/receiverevidencefix/ts/src/cache.ts @@ -0,0 +1,11 @@ +export class Cache { + private entries: Record = {} + + get(key: string): unknown { + return this.entries[key] + } + + set(key: string, value: unknown): void { + this.entries[key] = value + } +} diff --git a/test/receiverevidencefix/ts/src/context.ts b/test/receiverevidencefix/ts/src/context.ts new file mode 100644 index 000000000..35474c3d1 --- /dev/null +++ b/test/receiverevidencefix/ts/src/context.ts @@ -0,0 +1,16 @@ +export class Context { + private vars = new Map() + req: { raw: Request } + + constructor(raw: Request) { + this.req = { raw } + } + + get(key: string): unknown { + return this.vars.get(key) + } + + set(key: string, value: unknown): void { + this.vars.set(key, value) + } +} diff --git a/test/receiverevidencefix/ts/src/http.ts b/test/receiverevidencefix/ts/src/http.ts new file mode 100644 index 000000000..4ca2004be --- /dev/null +++ b/test/receiverevidencefix/ts/src/http.ts @@ -0,0 +1,30 @@ +import { Response as AppResponse } from './response' + +// An in-repo method named like Date#getTime. +export class Clock { + getTime(): number { + return 0 + } +} + +// reply: the global Response (this file never imports one under that name) and Date#getTime. +export function reply(body: string): Response { + const stamp = new Date().getTime() + return new Response(body + stamp) +} + +// stamped: a local Date's getTime. +export function stamped(): number { + const d = new Date() + return d.getTime() +} + +// Near misses: a constructed local of the in-repo class, and the in-repo Response through its import alias. +export function local(): number { + const c = new Clock() + return c.getTime() +} + +export function wrap(body: string): AppResponse { + return new AppResponse(body) +} diff --git a/test/receiverevidencefix/ts/src/middleware/alias.ts b/test/receiverevidencefix/ts/src/middleware/alias.ts new file mode 100644 index 000000000..133969693 --- /dev/null +++ b/test/receiverevidencefix/ts/src/middleware/alias.ts @@ -0,0 +1,14 @@ +import { Context as Ctx } from '../context' +import { TrieRouter } from '../router/trie' + +// Near misses: construction through an import alias, and a constructed local. +export function remember(raw: Request): void { + const c = new Ctx(raw) + c.set('seen', true) +} + +export function single(): string[] { + const r = new TrieRouter() + r.add('GET', '/', 'root') + return r.match('GET', '/') +} diff --git a/test/receiverevidencefix/ts/src/middleware/auth.ts b/test/receiverevidencefix/ts/src/middleware/auth.ts new file mode 100644 index 000000000..c2cb27873 --- /dev/null +++ b/test/receiverevidencefix/ts/src/middleware/auth.ts @@ -0,0 +1,12 @@ +import { Context } from '../context' + +// Bm: a WHATWG Headers .get on the raw request, beside Context.get in the imported class. +export function bearer(ctx: Context, headerName: string): string | null { + const credentials = ctx.req.raw.headers.get(headerName) + return typeof credentials === 'string' ? credentials : null +} + +// Near miss: an annotated parameter IS evidence. +export function readUser(c: Context): unknown { + return c.get('user') +} diff --git a/test/receiverevidencefix/ts/src/response.ts b/test/receiverevidencefix/ts/src/response.ts new file mode 100644 index 000000000..360133dfe --- /dev/null +++ b/test/receiverevidencefix/ts/src/response.ts @@ -0,0 +1,4 @@ +// An in-repo class named like the WHATWG global Response. +export class Response { + constructor(public body: string) {} +} diff --git a/test/receiverevidencefix/ts/src/router/router.ts b/test/receiverevidencefix/ts/src/router/router.ts new file mode 100644 index 000000000..1b92ea6e1 --- /dev/null +++ b/test/receiverevidencefix/ts/src/router/router.ts @@ -0,0 +1,4 @@ +export interface Router { + add(method: string, path: string, handler: T): void + match(method: string, path: string): T[] +} diff --git a/test/receiverevidencefix/ts/src/router/smart.ts b/test/receiverevidencefix/ts/src/router/smart.ts new file mode 100644 index 000000000..cfff1aad4 --- /dev/null +++ b/test/receiverevidencefix/ts/src/router/smart.ts @@ -0,0 +1,25 @@ +import type { Router } from './router' + +// Bm: `router` walks the inner routers; SmartRouter's own add is never what it calls. +export class SmartRouter implements Router { + private routers: Router[] = [] + private pending: [string, string, T][] = [] + + constructor(init: { routers: Router[] }) { + this.routers = init.routers + } + + add(method: string, path: string, handler: T): void { + this.pending.push([method, path, handler]) + } + + match(method: string, path: string): T[] { + for (const router of this.routers) { + for (const p of this.pending) { + router.add(...p) + } + return router.match(method, path) + } + return [] + } +} diff --git a/test/receiverevidencefix/ts/src/router/trie.ts b/test/receiverevidencefix/ts/src/router/trie.ts new file mode 100644 index 000000000..f01af971e --- /dev/null +++ b/test/receiverevidencefix/ts/src/router/trie.ts @@ -0,0 +1,13 @@ +import type { Router } from './router' + +export class TrieRouter implements Router { + private routes: [string, string, T][] = [] + + add(method: string, path: string, handler: T): void { + this.routes.push([method, path, handler]) + } + + match(method: string, path: string): T[] { + return this.routes.filter((r) => r[0] === method && r[1] === path).map((r) => r[2]) + } +} diff --git a/test/receiverevidencefix/ts/src/sides.ts b/test/receiverevidencefix/ts/src/sides.ts new file mode 100644 index 000000000..3ef99360f --- /dev/null +++ b/test/receiverevidencefix/ts/src/sides.ts @@ -0,0 +1,7 @@ +// The two SIDES of a class lookup, typed: the class object reaches its static members, an instance the others. +export class Meter { + static read(): number { return 1 } + read(): number { return 2 } +} +export function onClass(): number { return Meter.read() } +export function onTyped(m: Meter): number { return m.read() } diff --git a/test/regression.sh b/test/regression.sh index 9435c5687..babf92104 100755 --- a/test/regression.sh +++ b/test/regression.sh @@ -276,8 +276,9 @@ else no "Codex active-surface doctor gate (codexdoctorcheck.sh failed)" RIPWIRE_BIN="$BIN" bash "$ROOT/test/codexdoctorcheck.sh" 2>&1 | sed 's/^/ | /' fi +# retired: forspinecheck — the --for caller spine (Change A) was dropped by its own offline kill rule: a spine of up to 2 caller hops from the top 3 seeds named a missed rubric item on 1 of 7 in-sample rows, so the gate that pinned it went with it # retired: cacheexclkeycheck — the per-configuration auto-cache key it pinned is a registered NEGATIVE (docs/EVALS.md, "The auto-cache key ignores --exclude", RUN 2026-09-03: a 158K-file root with >= 12 gate configurations thrashed the 2 GiB sweep); the retry design keeps ONE superset blob per root and will bring its own gate -for _g in a9disclosurecheck abicheck accessshapecheck ackonlycheck adaptivecheck adaptivecutshapecheck affectedcheck agentloopclaudecheck agentloopcodexcheck agentloopeditsuitecheck agentloopfollowupcheck agentloopgradercheck agentlooplockcheck agentloopopencodecheck agentsurfacecheck agenttablecheck aiderbytescheck anchorbodycheck anchorcheck archcheck archmetricscheck argvdiffcheck arisefollowupcheck ariseshimcheck aritycheck artifactcheck astqueryregexcheck astrocheck atcheck atomscheck attrvocabcheck baselinecheck baselinedirtycheck baselineportcheck bashsourcecheck batchcheck binoverridecheck blindspotcheck bm25boundcheck bm25check bodiesshowncheck bodydialectcheck budgetpolicycheck buildtypestampcheck builtinbindcheck bundleidcheck cachefuzzcheck cachehashcheck cacheidentitycheck cacheisolationcheck cachelintcheck cacheoffsetcheck cachereservecheck cachesplitcheck callerscheck callformcheck callsrankordercheck candheadcheck candidatescheck canoncheck capdisclosurecheck capsweepcheck ccheck ccjsoncheck ceilingverdictcheck chacheck chaconecheck chainguardcheck chainidcheck childwalkscalecheck churndecaycheck churnjoincheck churnjsonstampcheck claudeconfigdircheck clicheck clonebandcheck clonecachecheck clonededupcheck cloneidiomcheck clonelexcheck clsrecvcheck cochangeboostcheck cochangecliocheck cochangesurprisecheck codexinstallhonestycheck codexplugincheck codexwrapcheck collectioncapcheck columnarattrcheck columnarcheck columnarcommacheck commentcoherencecheck commenttokencheck communitydrillcheck communitylabelcheck compactlegendcheck compactroutecheck completecheck composelangcheck connectcheck connectcorecheck connectjoincheck constcheck contextratiocheck coplintcheck cppbenchcheck cppoperatorcheck cppqualcheck cpptmplscopecheck crashsweepcheck crawlescapecheck crossdirincludecheck crossrefcheck crossrefdegradecheck csharpcheck csharpcondcheck cudacheck cyclecutcheck dartcheck deadcheck deadfiltercheck deadprecisioncheck deckcheck deckclaimcheck declinecheck declinedlistcheck decltodefcheck deeptailcheck defaultceilingcheck defoverdeclcheck degradedhintcheck dependencypincheck deplangscheck depsprecisecheck detailcheck diagnoticecheck didyoumeancheck dispatchordercheck dmmcheck docanchorcheck docdemotecheck docdriftcheck docdriftcommentcheck docmdcachecheck docmentioncheck docscommandscheck doctorcheck donelegendcheck droppedpositivecheck duprowcheck dynmapsimdcheck editcheckanswercheck editcheckcheck editcheckdeclcheck editchecknotecheck edithandlehintcheck editpayloadbinarycheck editplancheck editplanpayloadconfinecheck editplanrecheckcheck editplanrollbackmsgcheck editpreviewcheck editroundtripcheck edittargetfileabscheck eliximportcheck elixircheck elixirnamearitycheck elixirsemanticcheck emitescapecheck emittertruthcheck emptycorpuscheck emptyvaluerefusecheck ensembleavailcheck ensemblecheck enumtablecheck essentialcxcheck estchargecheck evalcheck evictioncheck exemplarcheck exemplarconfcheck exercisescheck expandbodyfirstcheck expandcallscheck expandmodecheck expandrangecheck expandsibscheck expandtokencheck expandtopk0check extentcheck externalvetocheck falseedgecheck fficheck fieldaffinitycheck fieldidcheck fieldnarrowcheck fieldusescheck filerootcheck fileselectorrefusecheck fillordercheck fixedbufsweep flagscheck flagsnoisecheck flagsurfacecheck flagtablecheck flipcheck floormarkcheck fnliteralcheck fnptrcheck forautobodycheck forblowupcheck forbudgetmonotoncheck forcalibfactscheck forcompresscheck fordisclosurecheck forhdrshapecheck forlenscheck formatgatecheck formaxtokenscheck fornotesbudgetcheck fornotesjsoncheck forrankordercheck forrootlegendcheck forsectioncollapsecheck forwidencheck freshclonecheck freshnesscheck g1configcheck gateabilitycheck gatecountcheck gateexitcheck gdscriptcheck genrecallcheck gitenvhermeticcheck githardencheck gitignorecheck gitquotepathcheck gitstampcheck goinstcheck gointerfacecheck graphlegendbudgetcheck graphqueryrefusecheck grepanchorcheck grepandcheck grepbytescheck grepcheck grepcontextcheck grepcorpuscheck grepfastcheck grepfollowupcheck grepignorecheck grepscancheck grepseamcheck greptiercheck guardmsgcheck hasacheck hazardpatterncheck headbinstagecheck headsnapcachecheck helpbudgetcheck hermesinstallcheck historyoraclecheck hookcheck hostilecheck hotspotsincecheck htmlcolorcheck htmlhostcheck htmlrendercheck identitycheck impactdepthcheck impactimportcheck impactpartitioncheck importcapcheck importnarrowcheck includeanglecheck includeprecisecheck indexoutcheck infraportcheck isolateprovenancecheck javamethodrefcheck javarubycheck jslangcheck jsmetricscheck jsnestedcheck jsoncheck jsonlangcheck jsonparitycheck jsonredactcheck jsonrefusallegendcheck jsonwalkcheck jsshapecheck jsverbscheck jsxcallcheck knownitemcheck kotlincheck landingcheck langcensuscheck langcheck layerquerycheck layoutcheck lb3namecheck legendcostcheck legendcoveragecheck legenddriftcheck legendrefcheck legobundlecheck legocheck liftdisclosurecheck limitstablecheck lintbudgetcheck lintcatalogcheck lintcheck lintdedupcheck lintpayloadcapcheck lintprecisioncheck lintrulescheck lintscopecheck lintselectcheck listingpagingcheck localitycheck localscountcheck loopconservationcheck lpincheck luacheck luarequirecheck macroedgecheck macroreparsecheck manifestcheck mapdatasectioncheck mapdiffcheck matchcapturecheck matchgrammarcheck maxfilesizecheck mcpattrparitycheck mcpaudit4hardencheck mcpclidiffcheck mcpcodexmetacheck mcpcontractcheck mcpdegradedhintcheck mcpeditcheck mcpeditkindcheck mcpeditmodecheck mcpeditpresencecheck mcpeditracecheck mcpflagshipcheck mcpforparitycheck mcpframehonestycheck mcpgrepdegradedcheck mcphandlecheck mcpincrementalcheck mcpmanifestcheck mcprangeedgecheck mcpreadloopcheck mcpredactcheck mcpreloadcheck mcpremotecheck mcprobustcheck mcpslicecheck mcpstalecheck mcpstdiolinecapcheck mcpstrictschemacheck mcptoolprunecheck mcptoolsubsetcheck mcptranchecheck mcptwinclaimscheck mcpverbscheck mcpw2fixcheck mcpw3fixcheck mcpwatchercheck mdembedcheck mdsectioncheck memguardcheck mentioncapcheck mentioncheck mentionsverbcheck mergechurncheck mergescoutcheck mergescoutlonglinecheck metalcheck meterdisclosurecheck metricscheck modifierguardcheck moduleconstcheck morecontractcheck mrowalkcheck multirootcheck multiswecheck namedfileinputcheck nameinfocheck namingcalibrationcheck namingconsistencycheck naminglenscheck naminglocalscheck narrowcheck narrowlangcheck neighbourcapcheck nestedimportcheck nestedqualcheck nestprofilecheck nextverbcheck noaliascheck nodekindcheck nongitqmetricscheck nonlocalstatecheck notecanoncheck notescheck notesdegradecheck nsfiltercheck nulbytecheck numericrefusecheck objcfieldcheck objcsniffcheck opencodewrapcheck optremarkscheck optremarkshotcheck ordercheck osswitchcheck oswin32logiccheck outlinecheck overbudgetcommentcheck ownerscheck packcallersharecheck packtaskcheck packtaskmonotoncheck packtaskquotacheck padscalecheck paginationcheck pagingsweepcheck panellegendcheck pargatescheck parsehealthcheck partitioncheck patterncheck perfharnesscheck phpcheck pincensuscheck planlanescheck planlintcheck pmccheck portablebuildcheck portablecachecheck postingscheck ppaltcheck ppdeadrolescheck pranchorcheck prbudgetcheck prcheck prcontextcheck prconvergecheck precedencecheck preproccondcheck preprocdeadscalecheck prmaskanchorcheck prnestedcapcheck probecheck propcostcheck prrefsafecheck prrenamecheck pyimportprecisecheck pymodulealiascheck pyshapecheck qackconcurrencycheck qackorigincheck qbaselineproducercheck qchurncheck qchurnmemocheck qddialscheck qdrefpaircheck qextractionkeycheck qoriginoraclecheck qrevtokencheck qrowlocatorcheck qschemetripcheck qsnapcachecheck qsnapprefetchcheck qsnapproducercheck qualifiedresolvecheck qualitycheck qualitycrosslangcheck qualityexcludecheck qualitykeycheck qualitykindscheck qualityorigincheck qualitypanelcheck qualityscopecheck qualitysignalcheck qualitystalecheck qualitysymcheck qualnewcheck querycheck queryfilescancheck racymtimecheck radixsimdcheck rangecomposecheck rankbycheck reachcheck readabilitycheck readmedriftcheck readmeexamplecheck recallanchorcheck recallboundarycheck recallbudgetcheck recallbufcheck recallevalcheck recallparitycheck recallpassagecheck recallrankdepthcheck recallrelcheck recallshapecheck recalltablecheck recalltotalcheck receiptpostcheck recentscopecheck redactcheck redactfixcheck refusaltailcheck regexbombcheck regexcheck regexguardcheck regexrefusecheck registermacrocheck relevancefloorcheck relinkcheck reportcheck resolvecheck resolverhonestycheck retrievalqualitycheck reusefirstworkflowcheck ripwirepubliccheck rootrelcheck rootrelemitcheck rootspellingcheck routecheck routeedgecheck routehookcheck routeoncecheck routingreportcheck rubyargcheck rubyattrscheck rubybarecallcheck rubyclassrecvcheck rubyconstcheck rubydeclrefcheck rubydescribedclasscheck rubyinheritcheck rubymetricscheck rubyrakejbuildercheck rubyreachcheck rubyrecvcheck rubyrecvnarrowcheck rubyrequirecheck rubyrspectargetcheck rubyscopecheck rubysettercheck rubytypedrecvcheck runhintcheck runtracecheck rustanccheck rustimportprecisecheck rustqualcheck safedeletecheck sarifcheck savecachecheck scipcheck scipjoincheck scorecardcheck scoutheadconflictcheck scoutkeycheck scroundtripcheck seedboundscheck selectorchaincheck selectorhonestycheck selectorrefusecheck selectorscopecheck selfcheckcheck selfcontainedcheck shadowcheck shallowhistorycheck shapingflagcheck shellgateindexcheck showcasecapturecheck sibliftcheck sidecarsymlinkcheck sigredactcheck sincecheck sincecochangecheck sincewindowcheck singledefcheck situdiffcheck situshapecheck skilldescbudgetcheck skillevalcheck skillevalsplitcheck skillinstallcheck skillroutingjudgedcheck skillscanreadcheck skilltruthcheck skipclassifycheck skippedcheck skipreasoncheck slicecheck slicediffcheck sliceflowcheck sliceflowsenscheck spectimingcheck staleackcheck statgatecheck stdqualcheck strkerncheck structlayoutcheck sublistcountcheck substrfiltercheck subtokencheck svectorcheck swiftcheck swiftmemberscheck swiftshapecheck taskechocheck tempfilesymlinkcheck termmargincheck testedreachcheck testgatecheck testgatelegendbudgetcheck testgatepagecheck testgaterefusecheck testmacrocheck testrowruncheck testscopecheck textdocscheck timsortcheck tokenbudgetcheck tomllangcheck toolcallroutecheck tornreadcheck traceasanlinearcheck tracecheck tracehandoffcapcheck tracehopcheck traceminecheck treecheck truncvocabcheck tsimportprecisecheck tsshapecheck type3check type3clonecheck typerefcheck unreachablecheck unresolvedcheck usescheck usesselectorcheck usingdeclcheck utf8scrubcheck vendoredassetcheck vendoredbundlecheck vendorpatchcheck verifycheck versioncheck w2verbscheck w3fixbudgetcheck w3fixlegendcheck weaksignalcheck withgraphcheck withprofilecheck worktreeleakcheck wrapverbscheck writetargetcheck xmlwellformed yamllangcheck zonecheck zoneconsistencycheck zoomcheck; do +for _g in a9disclosurecheck abicheck accessshapecheck ackonlycheck adaptivecheck adaptivecutshapecheck affectedcheck agentloopclaudecheck agentloopcodexcheck agentloopeditsuitecheck agentloopfollowupcheck agentloopgradercheck agentlooplockcheck agentloopopencodecheck agentsurfacecheck agenttablecheck aiderbytescheck anchorbodycheck anchorcheck archcheck archmetricscheck argvdiffcheck arisefollowupcheck ariseshimcheck aritycheck artifactcheck astqueryregexcheck astrocheck atcheck atomscheck attrvocabcheck baselinecheck baselinedirtycheck baselineportcheck bashsourcecheck batchcheck binoverridecheck blindspotcheck bm25boundcheck bm25check bodiesshowncheck bodydialectcheck budgetpolicycheck buildtypestampcheck builtinbindcheck bundleidcheck cachefuzzcheck cachehashcheck cacheidentitycheck cacheisolationcheck cachelintcheck cacheoffsetcheck cachereservecheck cachesplitcheck callerscheck callformcheck callsrankordercheck candheadcheck candidatescheck canoncheck capdisclosurecheck capsweepcheck ccheck ccjsoncheck ceilingverdictcheck chacheck chaconecheck chainguardcheck chainidcheck childwalkscalecheck churndecaycheck churnjoincheck churnjsonstampcheck claudeconfigdircheck clicheck clonebandcheck clonecachecheck clonededupcheck cloneidiomcheck clonelexcheck clsrecvcheck cochangeboostcheck cochangecliocheck cochangesurprisecheck codexinstallhonestycheck codexplugincheck codexwrapcheck collectioncapcheck columnarattrcheck columnarcheck columnarcommacheck commentcoherencecheck commenttokencheck communitydrillcheck communitylabelcheck compactlegendcheck compactroutecheck completecheck composelangcheck connectcheck connectcorecheck connectjoincheck constcheck contextratiocheck coplintcheck countfloorcheck cppbenchcheck cppoperatorcheck cppqualcheck cpptmplscopecheck crashsweepcheck crawlescapecheck crossdirincludecheck crossrefcheck crossrefdegradecheck csharpcheck csharpcondcheck cudacheck cyclecutcheck dartcheck deadcheck deadfiltercheck deadprecisioncheck deckcheck deckclaimcheck declinecheck declinedlistcheck decltodefcheck deeptailcheck defaultceilingcheck defectshapecheck defoverdeclcheck degradedhintcheck dependencypincheck deplangscheck depsprecisecheck detailcheck diagnoticecheck didyoumeancheck dispatchordercheck dmmcheck docanchorcheck docdemotecheck docdriftcheck docdriftcommentcheck docmdcachecheck docmentioncheck docscommandscheck doctorcheck donelegendcheck droppedpositivecheck duprowcheck dynmapsimdcheck editcheckanswercheck editcheckcheck editcheckdeclcheck editchecknotecheck edithandlehintcheck editpayloadbinarycheck editplancheck editplanpayloadconfinecheck editplanrecheckcheck editplanrollbackmsgcheck editpreviewcheck editroundtripcheck edittargetfileabscheck eliximportcheck elixircheck elixirnamearitycheck elixirsemanticcheck emitescapecheck emittertruthcheck emptycorpuscheck emptyvaluerefusecheck ensembleavailcheck ensemblecheck enumtablecheck essentialcxcheck estchargecheck evalcheck evictioncheck exemplarcheck exemplarconfcheck exercisescheck expandbodyfirstcheck expandcallscheck expandmodecheck expandrangecheck expandsibscheck expandtokencheck expandtopk0check extentcheck externalvetocheck falseedgecheck fficheck fieldaffinitycheck fieldidcheck fieldnarrowcheck fieldusescheck filerootcheck fileselectorrefusecheck fillordercheck fixedbufsweep flagscheck flagsnoisecheck flagsurfacecheck flagtablecheck flipcheck floormarkcheck fnliteralcheck fnptrcheck forautobodycheck forblowupcheck forbudgetmonotoncheck forcalibfactscheck forcompresscheck fordisclosurecheck forhdrshapecheck forlenscheck formatgatecheck formaxtokenscheck fornotesbudgetcheck fornotesjsoncheck forrankordercheck forrootlegendcheck forsectioncollapsecheck forsigspancheck forwidencheck freshclonecheck freshnesscheck g1configcheck gateabilitycheck gatecountcheck gateexitcheck gdscriptcheck genrecallcheck gitenvhermeticcheck githardencheck gitignorecheck gitquotepathcheck gitstampcheck goinstcheck gointerfacecheck gokindcheck graphlegendbudgetcheck graphqueryrefusecheck grepanchorcheck grepandcheck grepbytescheck grepcheck grepcontextcheck grepcorpuscheck grepfastcheck grepfollowupcheck grepignorecheck grepscancheck grepseamcheck greptiercheck guardmsgcheck hasacheck hazardpatterncheck headbinstagecheck headsnapcachecheck helpbudgetcheck hermesinstallcheck historyoraclecheck hookcheck hostilecheck hotspotsincecheck htmlcolorcheck htmlhostcheck htmlrendercheck identitycheck impactdepthcheck impactimportcheck impactpartitioncheck importcapcheck importnarrowcheck includeanglecheck includeprecisecheck indexoutcheck infraportcheck isolateprovenancecheck javamethodrefcheck javarubycheck jslangcheck jsmetricscheck jsnestedcheck jsoncheck jsonlangcheck jsonparitycheck jsonredactcheck jsonrefusallegendcheck jsonwalkcheck jsshapecheck jsverbscheck jsxcallcheck knownitemcheck kotlincheck landingcheck langcensuscheck langcheck layerquerycheck layoutcheck lb3namecheck legendcostcheck legendcoveragecheck legenddriftcheck legendrefcheck legobundlecheck legocheck liftdisclosurecheck limitstablecheck lintbudgetcheck lintcatalogcheck lintcheck lintdedupcheck lintpayloadcapcheck lintprecisioncheck lintrulescheck lintscopecheck lintselectcheck listingpagingcheck localitycheck localscountcheck loopconservationcheck lpincheck luacheck luarequirecheck macroedgecheck macroreparsecheck manifestcheck mapdatasectioncheck mapdiffcheck matchcapturecheck matchgrammarcheck maxfilesizecheck mcpattrparitycheck mcpaudit4hardencheck mcpclidiffcheck mcpcodexmetacheck mcpcontractcheck mcpdegradedhintcheck mcpeditcheck mcpeditkindcheck mcpeditmodecheck mcpeditpresencecheck mcpeditracecheck mcpflagshipcheck mcpforparitycheck mcpframehonestycheck mcpgrepdegradedcheck mcphandlecheck mcpincrementalcheck mcpmanifestcheck mcprangeedgecheck mcpreadloopcheck mcpredactcheck mcpreloadasancheck mcpreloadcheck mcpremotecheck mcprobustcheck mcpslicecheck mcpstalecheck mcpstdiolinecapcheck mcpstrictschemacheck mcptoolprunecheck mcptoolsubsetcheck mcptranchecheck mcptwinclaimscheck mcpverbscheck mcpw2fixcheck mcpw3fixcheck mcpwatchercheck mdembedcheck mdsectioncheck memguardcheck mentioncapcheck mentioncheck mentionsverbcheck mergechurncheck mergescoutcheck mergescoutlonglinecheck metalcheck meterdisclosurecheck metricscheck modifierguardcheck moduleconstcheck morecontractcheck mrowalkcheck multirootcheck multiswecheck namedfileinputcheck nameinfocheck namingcalibrationcheck namingconsistencycheck naminglenscheck naminglocalscheck narrowcheck narrowlangcheck neighbourcapcheck nestedimportcheck nestedqualcheck nestprofilecheck nextverbcheck noaliascheck nodekindcheck nongitqmetricscheck nonlocalstatecheck notecanoncheck notescheck notesdegradecheck nsfiltercheck nulbytecheck numericrefusecheck objcfieldcheck objcsniffcheck opencodewrapcheck optremarkscheck optremarkshotcheck ordercheck osswitchcheck oswin32logiccheck outlinecheck overbudgetcommentcheck ownerscheck packcallersharecheck packtaskcheck packtaskmonotoncheck packtaskquotacheck padscalecheck paginationcheck pagingsweepcheck panellegendcheck pargatescheck parsehealthcheck partitioncheck pathgapcheck patterncheck perfharnesscheck phpcheck pincensuscheck planlanescheck planlintcheck pmccheck portablebuildcheck portablecachecheck postingscheck ppaltcheck ppdeadrolescheck pranchorcheck prbudgetcheck prcheck prcontextcheck prconvergecheck precedencecheck preproccondcheck preprocdeadscalecheck prmaskanchorcheck prnestedcapcheck probecheck propcostcheck prrefsafecheck prrenamecheck pyimportprecisecheck pymodulealiascheck pyshapecheck qackconcurrencycheck qackorigincheck qbaselineproducercheck qchurncheck qchurnmemocheck qddialscheck qdrefpaircheck qextractionkeycheck qoriginoraclecheck qrevtokencheck qrowlocatorcheck qschemetripcheck qsnapcachecheck qsnapprefetchcheck qsnapproducercheck qualifiedresolvecheck qualitycheck qualitycrosslangcheck qualityexcludecheck qualitykeycheck qualitykindscheck qualityorigincheck qualitypanelcheck qualityscopecheck qualitysignalcheck qualitystalecheck qualitysymcheck qualnewcheck querycheck queryfilescancheck racymtimecheck radixsimdcheck rangecomposecheck rankbycheck reachcheck readabilitycheck readmedriftcheck readmeexamplecheck recallanchorcheck recallboundarycheck recallbudgetcheck recallbufcheck recallevalcheck recallparitycheck recallpassagecheck recallrankdepthcheck recallrelcheck recallshapecheck recalltablecheck recalltotalcheck receiptpostcheck receiverevidencecheck recentscopecheck redactcheck redactfixcheck refusaltailcheck regexbombcheck regexcheck regexguardcheck regexrefusecheck registermacrocheck relevancefloorcheck relinkcheck reportcheck resolvecheck resolverhonestycheck retrievalqualitycheck reusefirstworkflowcheck ripwirepubliccheck rootrelcheck rootrelemitcheck rootspellingcheck routecheck routeedgecheck routehookcheck routeoncecheck routingreportcheck rubyargcheck rubyattrscheck rubybarecallcheck rubyclassrecvcheck rubyconstcheck rubydeclrefcheck rubydescribedclasscheck rubyinheritcheck rubymetricscheck rubyrakejbuildercheck rubyreachcheck rubyrecvcheck rubyrecvnarrowcheck rubyrequirecheck rubyrspectargetcheck rubyscopecheck rubysettercheck rubytypedrecvcheck runhintcheck runtracecheck rustanccheck rustimportprecisecheck rustqualcheck safedeletecheck sarifcheck savecachecheck scipcheck scipjoincheck scorecardcheck scoutheadconflictcheck scoutkeycheck scroundtripcheck seedboundscheck selectorchaincheck selectorhonestycheck selectorrefusecheck selectorscopecheck selfcheckcheck selfcontainedcheck shadowcheck shallowhistorycheck shapingflagcheck shellgateindexcheck showcasecapturecheck sibliftcheck sidecarsymlinkcheck sigredactcheck sincecheck sincecochangecheck sincewindowcheck singledefcheck situdiffcheck situshapecheck skilldescbudgetcheck skillevalcheck skillevalsplitcheck skillinstallcheck skillroutingjudgedcheck skillscanreadcheck skilltruthcheck skipclassifycheck skippedcheck skipreasoncheck slicecheck slicediffcheck sliceflowcheck sliceflowsenscheck spectimingcheck staleackcheck statgatecheck stdqualcheck strkerncheck structlayoutcheck sublistcountcheck substrfiltercheck subtokencheck svectorcheck swiftcheck swiftmemberscheck swiftshapecheck taskechocheck tempfilesymlinkcheck termmargincheck testedreachcheck testgatecheck testgatelegendbudgetcheck testgatepagecheck testgaterefusecheck testmacrocheck testrowruncheck testscopecheck textdocscheck timsortcheck tokenbudgetcheck tomllangcheck toolcallroutecheck tornreadcheck traceasanlinearcheck tracecheck tracehandoffcapcheck tracehopcheck traceminecheck treecheck truncvocabcheck tsimportprecisecheck tsshapecheck type3check type3clonecheck typerefcheck unreachablecheck unresolvedcheck usescheck usesselectorcheck usingdeclcheck utf8scrubcheck vendoredassetcheck vendoredbundlecheck vendorpatchcheck verifycheck versioncheck w2verbscheck w3fixbudgetcheck w3fixlegendcheck weaksignalcheck withgraphcheck withprofilecheck worktreeleakcheck wrapverbscheck writetargetcheck xmlwellformed yamllangcheck zonecheck zoneconsistencycheck zoomcheck; do [ -f "$ROOT/test/$_g.sh" ] || continue if RIPWIRE_BIN="$BIN" bash "$ROOT/test/$_g.sh" >/dev/null 2>&1; then ok "absorb gate ($_g.sh)" diff --git a/test/relevancefloorcheck.sh b/test/relevancefloorcheck.sh index 6942b8004..efab47e7a 100755 --- a/test/relevancefloorcheck.sh +++ b/test/relevancefloorcheck.sh @@ -172,9 +172,24 @@ echo "=== (4) byte ceiling on the fixture ===" # Pinned, not relative: the pre-fix binary answers this exact fixture in 5,307 B (recorded in the header). # 3000 leaves room for the shared legends (which are most of what is left) while staying far below any # padded answer. If a legend legitimately grows past this, re-pin in the same commit and say why. +# RE-PIN 2026-10-07 (lane for-spine-span-068, by design): the one floored row now carries e="N" (its end line) +# and the full legend gains the ~225 B clause defining e= — 2871 -> 3107 B. The 3000 B assertion is KEPT on the +# answer with exactly those two additions normalised out (4), and the whole answer gets its own ceiling of +# 3300 B (4b) — still far below the 5307 B padded answer this arm exists to keep out. a_bytes="$( printf '%s' "$A_OUT" | wc -c | tr -d ' ' )" -[ "$a_bytes" -lt 3000 ] && ok "(4) the floored bundle is $a_bytes B (< 3000; pre-fix was 5307 B on this fixture)" \ - || no "(4) the floored bundle is $a_bytes B, over the 3000 B ceiling" +a_norm_bytes="$( printf '%s' "$A_OUT" | sed -E 's/ e="[0-9]+"//g; s/; e= on a d row: the 1-based line where that definition ends, body-inclusive; absent when the extent is not known \(extent_suspect, docs, config\), never 0; l= is the line of the definition.s name, so a definition can start above l=//' | wc -c | tr -d ' ' )" +case "$a_bytes$a_norm_bytes" in *[!0-9]*|'') no "(4) byte counts are not numeric ('$a_bytes' / '$a_norm_bytes')"; a_bytes=999999; a_norm_bytes=999999 ;; esac +[ "$a_norm_bytes" -lt 3000 ] && ok "(4) the floored bundle without e= and its clause is $a_norm_bytes B (< 3000; pre-fix was 5307 B on this fixture)" \ + || no "(4) the floored bundle without e= and its clause is $a_norm_bytes B, over the 3000 B ceiling" +[ "$a_bytes" -lt 3300 ] && ok "(4b) the whole floored bundle is $a_bytes B (< 3300 with e= and its legend clause)" \ + || no "(4b) the whole floored bundle is $a_bytes B, over the 3300 B ceiling" +if printf '%s' "$A_OUT" | grep -q ' e="'; then + [ "$a_norm_bytes" -lt "$a_bytes" ] \ + && ok "(4c) the normalisation in (4) removed e= bytes ($a_bytes -> $a_norm_bytes B), so the 3000 B twin is live" \ + || no "(4c) the answer carries e= but normalising it removed nothing — the (4) twin would be inert" +else + ok "(4c) this answer carries no e= (a binary without it): (4) measures the answer as it is" +fi # ═══════════════════════════════════════════════════════════════════════════ echo "=== (5) nothing matched ⇒ nothing claimed ===" diff --git a/test/resolverhonestycheck.sh b/test/resolverhonestycheck.sh index 8437be575..df223b31b 100755 --- a/test/resolverhonestycheck.sh +++ b/test/resolverhonestycheck.sh @@ -246,7 +246,8 @@ except Exception as e: for s in root.iter( "s" ): if s.get( "n" ) == sym: for c in s.findall( "c" ): - print( c.get( "n" ), c.get( "prov" ) or "-" ) + for _ in range( int( c.get( "x" ) or 1 ) ): # FE-B: a merged via="name" row x=N stands for N edges + print( c.get( "n" ), c.get( "prov" ) or "-" ) ' "$2" } # how many of SYM's edges carry prov="split" diff --git a/test/ripwirepubliccheck.sh b/test/ripwirepubliccheck.sh index 9b7747302..0289eb33d 100755 --- a/test/ripwirepubliccheck.sh +++ b/test/ripwirepubliccheck.sh @@ -1279,6 +1279,16 @@ fi # name), not an invocation. # docs/docs_commands_build.py:627,642 — generates prose ABOUT `git log` as an example of ambient # non-document output for docs/COMMANDS.md; it never invokes git. +# docs/captures/COMMANDS_showcase_2026-10-08.md:542,557 — PROSE inside the recorded `--recall` run over the scratch +# knowledge-base dir (the "dumped tool output" pattern quoting its own docs: "a `git log`, an API response dump...", +# "the `git log`, the `--help` text and the JSON access log are not in the population"). Nothing walks git history +# there. The scanner reads it as a shell recipe only because the PRECEDING recorded block ends inside a truncated +# "```bash" fence and arm 9's fence tracker does not know that the five-backtick capture fence that closes the block +# is not its closing fence (it sees a three-backtick line only), so the next block is read as recipe text. Which +# section the recall picks depends on the generated docs/COMMANDS.md, so this is exempted by the two lines' content +# hash (a re-captured line that changes at all drops back into the sweep); a regenerated capture under a new date +# re-keys these two entries. Added 2026-10-08 (lane/lean-answers-068 fix round 2); the tracker gap is a named +# deferral (harmless: it can only over-scan recorded output, never miss a recipe). ARM9_OK='bench/cppbench/run_cppbench.py:e5d3f485c59b0187 bench/cppbench/run_cppbench.py:7e89ac16d50cd5d2 bench/cppbench/run_cppbench.py:5d65fe73e9406e01 @@ -1289,7 +1299,9 @@ bench/roundc-h2h/derive_questions.py:3c59a07f84c2b48d bench/shotgun/cochange_history.py:eb47c55f978cf817 bench/substitution_report.py:c3043030dc3e5473 docs/docs_commands_build.py:fa972c42dc369a5c -docs/docs_commands_build.py:f10f0b8021b7b6c2' +docs/docs_commands_build.py:f10f0b8021b7b6c2 +docs/captures/COMMANDS_showcase_2026-10-08.md:118fbfbb8f6823fe +docs/captures/COMMANDS_showcase_2026-10-08.md:22be6025a57af773' python3 - "$TMP/tracked.z" "$ARM9_OK" > "$TMP/arm9" <<'PY' import hashlib, os, re, sys paths = [p.decode('utf-8', 'surrogateescape') diff --git a/test/routecheck.sh b/test/routecheck.sh index 9adfb8357..ba040aa3b 100755 --- a/test/routecheck.sh +++ b/test/routecheck.sh @@ -111,6 +111,19 @@ CONCEPT="how does resolution work" # ladder trimmed used to appear in neither section), and the clause defining the tail says so. Verified before # re-pinning: with every comment and est_tokens= normalized out, old and new documents are byte-identical — # this fixture's head covers every file, so its tail is unchanged and every ranking byte is unmoved. +# RE-PIN 2026-10-08 (train 26b: lean-answers merged onto for-spine-span): 4026 -> 4002 B, est_tokens="1533" -> "1524". +# The two lanes' changes compose: e= rows and clause plus the zero elision and its clause. Verified mechanically: +# undoing ONLY the zero elision (est_tokens=/at= masked) gives the train-side golden exactly. +# RE-PIN 2026-10-07 (lane for-spine-span-068, by design): 3776 -> 4026 B (+250 B), est_tokens="1433" -> "1533". CAUSE: --for +# rows with a known extent carry e="N" (the definition's last line) right after l= (3 rows here), and the full +# legend gains the clause defining e=. Verified before re-pinning (tmp re-pin script, base binary 0c115310 == old golden): +# with e= attributes, that clause and est_tokens normalized out, old and new documents are byte-identical — no +# ranking, body or route byte moved. +# RE-PIN 2026-10-07 (lane/lean-answers-068, --for zero elision): 3776 -> 3752 B, est_tokens="1433" -> "1424". ONE +# identified change: a lens row omits cx=/ccx=/in= when 0 and the full legend says ", each absent when 0;". Verified +# before re-pinning: the base binary (255dc199) reproduces the previous golden byte for byte, and putting the zeros back +# on every r= row, deleting that clause and masking est_tokens= gives the previous golden exactly — no ranking, body or +# route byte moved (route-neutrality, this golden's purpose, untouched). # L1 (2026-09-19): the CLI default legend is compact; golden_for.xml was recorded from the full default and the --query arm # reads the full legend's "routed:" comment, so those two runs ask for the full legend. "$BIN" routefix --no-cache --for="$CONCEPT" --no-route --legend=full >"$TMP/concept_noroute.xml" 2>/dev/null diff --git a/test/routefix/golden_for.xml b/test/routefix/golden_for.xml index 48407f2b1..ae45429ee 100644 --- a/test/routefix/golden_for.xml +++ b/test/routefix/golden_for.xml @@ -1,4 +1,4 @@ -A file whose prose describes how resolution works — a conceptual "how does resolution work" query should find this via subtoken+body matching ("resolution", "resolve"), not by any single whole-nameint lookupDefinition( int name )resolveSymbol — the resolution subsystem's entry point; the conceptual-query gate ("how does resolution work") should route to subtoken+body BM25 and can surface this by its "resolution" body vocabuint resolveSymbol( int id )how the resolver resolves a call: it resolves the callee name, then resolves overloads by arityvoid resolveCall()A file whose prose describes how resolution works — a conceptual "how does resolution work" query should find this via subtoken+body matching ("resolution", "resolve"), not by any single whole-nameint lookupDefinition( int name )resolveSymbol — the resolution subsystem's entry point; the conceptual-query gate ("how does resolution work") should route to subtoken+body BM25 and can surface this by its "resolution" body vocabuint resolveSymbol( int id )how the resolver resolves a call: it resolves the callee name, then resolves overloads by arityvoid resolveCall()/dev/null 2>&1 + got="$( rowget "$HD/routing.jsonl" 1 arm )" + [ "$got" = "$want" ] || DEFBAD="$DEFBAD [$sid -> $got, auto says $want]" +done +[ -z "$DEFBAD" ] && [ "$DEFCTL" -gt 0 ] && [ "$DEFTRT" -gt 0 ] \ + && ok "A7 arm default: no env and no meter.conf resolves to auto (both arms populated: $DEFCTL control / $DEFTRT treatment of 8)" \ + || no "A7 arm default: unconfigured router did not follow the auto split:$DEFBAD (control=$DEFCTL treatment=$DEFTRT)" +# An explicit `arm=treatment` in meter.conf is still honoured (the opt-out), for a session that auto +# would have put in control; and a meter.conf that sets other keys but no arm= takes the same default. +CTLSID="" +for sid in s1 s2 s3 s4 s5 s6 s7 s8; do + h="$( printf '%s' "$sid" | cksum | cut -d' ' -f1 )"; [ "$(( h % 100 ))" -lt 50 ] && { CTLSID="$sid"; break; } +done +H7A="$TMP/h7a"; mkdir -p "$H7A"; printf 'arm=treatment\n' >"$H7A/meter.conf" +route_run "$H7A" "$WITH_RIPWIRE" "$( promptjson "$CTLSID" "$REPO" "$RECPROMPT" )" >/dev/null 2>&1 +[ -n "$CTLSID" ] && [ "$( rowget "$H7A/routing.jsonl" 1 arm )" = "treatment" ] \ + && ok "A8 arm default: an explicit meter.conf arm=treatment is honoured over the auto default" \ + || no "A8 arm default: meter.conf arm=treatment gave arm=[$( rowget "$H7A/routing.jsonl" 1 arm )] for control-side session [$CTLSID]" +H7B="$TMP/h7b"; mkdir -p "$H7B"; printf 'sweep=0\n' >"$H7B/meter.conf" +route_run "$H7B" "$WITH_RIPWIRE" "$( promptjson "$CTLSID" "$REPO" "$RECPROMPT" )" >/dev/null 2>&1 +[ -n "$CTLSID" ] && [ "$( rowget "$H7B/routing.jsonl" 1 arm )" = "control" ] \ + && ok "A9 arm default: a meter.conf with no arm= line follows the auto default too" \ + || no "A9 arm default: arm-less meter.conf gave arm=[$( rowget "$H7B/routing.jsonl" 1 arm )] for control-side session [$CTLSID]" + +# meter.conf PARSING (issue #381 fix round 1; the same three arms run against resolve_arm in +# hooks/ripwire-claude-toolroute.sh (toolcallroutecheck) and meter_init in hooks/ripwire-nudge.sh +# (hookcheck)). Three bugs sat in the read loop of all three copies: +# - a FINAL LINE WITHOUT A NEWLINE is lost (`read` returns 1 on it), so `arm=control` there resolved to auto; +# - a CRLF value (`arm=control\r`) reads as `treatment` (the CR stays in the value); +# Each arm pairs a session on the side the BUGGY reading would NOT give: a control-side session for the +# forced treatment, a treatment-side session for the forced control, so none passes by coincidence. +TRTSID="" +for sid in s1 s2 s3 s4 s5 s6 s7 s8; do + h="$( printf '%s' "$sid" | cksum | cut -d' ' -f1 )"; [ "$(( h % 100 ))" -ge 50 ] && { TRTSID="$sid"; break; } +done +conf_arm() # conf_arm LABEL SESSION CONFBYTES(printf format) WANT +{ + _ch="$TMP/ca_$1"; mkdir -p "$_ch"; printf "$3" >"$_ch/meter.conf" + route_run "$_ch" "$WITH_RIPWIRE" "$( promptjson "$2" "$REPO" "$RECPROMPT" )" >/dev/null 2>&1 + _cg="$( rowget "$_ch/routing.jsonl" 1 arm )" + [ -n "$2" ] && [ "$_cg" = "$4" ] && ok "$1: meter.conf [$3] -> $4" \ + || no "$1: meter.conf [$3] gave arm=[$_cg], want $4 (session [$2])" +} +conf_arm A10 "$CTLSID" 'arm=treatment' treatment # no final newline: a lost line would leave auto = control +conf_arm A11 "$TRTSID" 'arm=control' control # no final newline: a lost line would leave auto = treatment +conf_arm A12 "$TRTSID" 'arm=control\r\n' control # CRLF: a CR left in the value reads as treatment +conf_arm A13 "$CTLSID" 'arm=auto\r\n' control # CRLF auto: the CR in the value read as treatment +conf_arm A14 "$TRTSID" 'sweep=0\r\narm=control\r\nenabled=1\r\n' control # CRLF file, arm= in the middle +conf_arm A15 "$TRTSID" 'sweep=0\narm=control' control # last line is the arm, no newline, other keys before it +# Near-misses the fix must NOT turn into a match: a different word, and a CR inside the value, still read as +# treatment (any unrecognized value fails toward treatment). +conf_arm A16 "$CTLSID" 'arm=controlx\n' treatment +conf_arm A17 "$CTLSID" 'arm=control\rx\n' treatment +conf_arm A18 "$CTLSID" 'arm=treatment\r\n' treatment # explicit treatment still honoured under CRLF + # ═══════════════════════════════════════════════════════════════════════════════════════════════════ # (O) ADOPTION-WITHIN-TWO — the loop the band is measured through, closed from the PreToolUse hook # ═══════════════════════════════════════════════════════════════════════════════════════════════════ @@ -609,6 +674,68 @@ printf '%s' "$ONOISE4" | grep -Fq -- '--expand' \ && ok "N6 notification: a real prompt merely MENTIONING the marker mid-sentence still routes normally" \ || no "N6 notification: the mid-sentence mention wrongly suppressed a real route: [$ONOISE4]" +# ── MCP channel events and sub-agent hand-backs are harness events too (issue #381, 2026-10-07) ───────── +# Claude Code delivers an MCP channel event (`…`) and a sub-agent hand-back +# (`…`) through this same UserPromptSubmit channel. Field data on +# one machine: roughly half of all injected recommendations were minted from ``; +# near-misses (``, ``, ``) and a mid-sentence mention still route. +CHROUT="Help me understand the implementation of alphaNode" +chan_skip() # chan_skip LABEL SESSION PROMPT +{ + _hd="$TMP/hc_$2"; mkdir -p "$_hd" + _co="$( route_run "$_hd" "$WITH_RIPWIRE" "$( promptjson "$2" "$NREPO" "$3" )" RIPWIRE_METER_ARM=treatment )"; _crc=$? + [ "$_crc" -eq 0 ] && [ -z "$_co" ] && [ "$( rowget "$_hd/routing.jsonl" 1 status )" = "skip-system" ] \ + && ok "$1: skipped with status=skip-system, nothing injected" \ + || no "$1: exit=$_crc out=[$_co] status=[$( rowget "$_hd/routing.jsonl" 1 status )] (expected empty + skip-system)" +} +chan_route() # chan_route LABEL SESSION PROMPT +{ + _hd="$TMP/hc_$2"; mkdir -p "$_hd" + _co="$( route_run "$_hd" "$WITH_RIPWIRE" "$( promptjson "$2" "$NREPO" "$3" )" RIPWIRE_METER_ARM=treatment )" + printf '%s' "$_co" | grep -Fq -- '--expand' && [ "$( rowget "$_hd/routing.jsonl" 1 status )" = "recommend" ] \ + && ok "$1: still routes normally" \ + || no "$1: wrongly suppressed or mis-logged: out=[$_co] status=[$( rowget "$_hd/routing.jsonl" 1 status )]" +} +chan_skip "N7 channel: a event skips" chan1 "$CHROUT" +chan_skip "N7b channel: leading whitespace/blank line before " chan2 "$( printf ' \n\t%s' "$CHROUT" )" +chan_skip "N7c channel: the bare opening tag skips" chan3 "$CHROUT" +chan_skip "N8 agent-message: an hand-back skips" agm1 "$CHROUT" +chan_skip "N8b agent-message: leading whitespace before " agm2 "$( printf '\n\n %s' "$CHROUT" )" +chan_skip "N8c agent-message: the bare opening tag skips" agm3 "$CHROUT" +chan_route "N9 channel near-miss: is not the wrapper" chn1 " $CHROUT" +chan_route "N9b channel near-miss: is not the wrapper" chn2 "$( printf '\n%s' "$CHROUT" )" +chan_route "N9c channel mention: mid-sentence still routes" chn3 "what does mean here? $CHROUT" +chan_route "N9d agent-message near-miss: is not the wrapper" chn4 " $CHROUT" +chan_route "N9e agent-message mention: mid-sentence still routes" chn5 "what does mean here? $CHROUT" +chan_route "N9f channel near-miss: a bare word 'channel' in prose routes" chn6 "channel the energy: $CHROUT" +# Leading VERTICAL TAB / FORM FEED: the hook's lead strip removes them, and src/taskroute.h's strip must remove +# the same bytes (it used to stop at space/tab/LF/CR, so `--help-task` routed a prompt the hook skipped). +chan_skip "N10 channel: a leading \\v before skips" chn7 "$( printf '\v%s' "$CHROUT" )" +chan_skip "N10b channel: a leading \\f before skips" chn8 "$( printf '\f %s' "$CHROUT" )" +chan_route "N10c channel: \\v then a near-miss still routes" chn9 "$( printf '\v %s' "$CHROUT" )" +# A Unicode space before the wrapper is NOT stripped (D-N1): the strip is the six ASCII bytes above, written out as +# `[!$' \t\n\v\f\r']`, because bash's `[:space:]` also matches U+00A0, U+2003, U+3000 … in a UTF-8 locale (macOS bash +# 3.2), and src/taskroute.h's strip stops at ASCII. So the hook skipped, under a UTF-8 locale only, a prompt --help-task +# routes. The arm runs the hook under a UTF-8 locale; without one installed it SKIPs by name. +UTF8LOC="" +for _l in en_US.UTF-8 C.UTF-8; do locale -a 2>/dev/null | grep -qix "$_l" && { UTF8LOC="$_l"; break; }; done +NBSP="$( printf '\302\240' )" +if [ -z "$UTF8LOC" ]; then + echo " SKIP N10d/N10e: no UTF-8 locale installed (locale -a lists neither en_US.UTF-8 nor C.UTF-8)" +else + for _case in "N10d:nbsp1:${NBSP}$CHROUT" "N10e:nbsp2:${NBSP} $CHROUT"; do + _lab="${_case%%:*}"; _r="${_case#*:}"; _ses="${_r%%:*}"; _pr="${_r#*:}" + _hd="$TMP/hc_$_ses"; mkdir -p "$_hd" + _co="$( route_run "$_hd" "$WITH_RIPWIRE" "$( promptjson "$_ses" "$NREPO" "$_pr" )" RIPWIRE_METER_ARM=treatment LC_ALL="$UTF8LOC" LANG="$UTF8LOC" )" + _ht="$( "$BIN" "$NREPO" --help-task="$_pr" 2>/dev/null | grep -c 'status="recommend"' )" + printf '%s' "$_co" | grep -Fq -- '--expand' && [ "$( rowget "$_hd/routing.jsonl" 1 status )" = "recommend" ] && [ "$_ht" -gt 0 ] \ + && ok "$_lab: a U+00A0 before the wrapper is not stripped under LC_ALL=$UTF8LOC: the hook routes, as --help-task does" \ + || no "$_lab: under LC_ALL=$UTF8LOC the hook and --help-task disagree: out=[$_co] status=[$( rowget "$_hd/routing.jsonl" 1 status )] help-task-recommend=[$_ht]" + done +fi + # ═══════════════════════════════════════════════════════════════════════════════════════════════════ # O9 — rw_is_ripwire_call: ONE block, three files, and the shapes an agent actually types (PR #215 item 6) # ═══════════════════════════════════════════════════════════════════════════════════════════════════ @@ -632,6 +759,49 @@ else no "O9 the three copies of rw_is_ripwire_call have DRIFTED — the hooks and the meter will disagree on the same command line" fi +# The harness-event list is a second mirrored block (issue #381): the Claude and Codex prompt hooks must +# agree on which wrappers are not user input, or one agent's router keeps minting recommendations from +# channel events while the other does not. +extract_hblock(){ awk '/^# ---- BEGIN MIRRORED BLOCK rw_is_harness_event/,/^# ---- END MIRRORED BLOCK rw_is_harness_event/' "$1"; } +HB_CLAUDE="$TMP/hb_claude.sh"; HB_CODEX="$TMP/hb_codex.sh" +extract_hblock "$ROOT/hooks/ripwire-claude-route.sh" > "$HB_CLAUDE" +extract_hblock "$ROOT/hooks/ripwire-codex-route.sh" > "$HB_CODEX" +if [ ! -s "$HB_CLAUDE" ]; then + no "O9b the rw_is_harness_event block is absent from hooks/ripwire-claude-route.sh (the diff below would prove nothing)" +elif diff -q "$HB_CLAUDE" "$HB_CODEX" >/dev/null; then + ok "O9b rw_is_harness_event is byte-identical in the Claude and Codex prompt hooks ($( wc -l < "$HB_CLAUDE" | tr -d ' ' ) lines)" +else + no "O9b the two copies of rw_is_harness_event have DRIFTED — Claude and Codex disagree on what a user prompt is" +fi + +# N3: the third copy of the list is src/taskroute.h's kHarnessEventTags, and nothing but behaviour used to tie it +# to the shell block (a tag ADDED to one copy only passed every behavioural arm). Compare them textually: +# each side is reduced to sorted `tag|whole` lines. Shell: a `''*` pattern is a whole tag, a `''*` +# PAIR is the attribute-carrying tag `$/ && ((substr(x, 1, length(x) - 1) " ") in seen)) { } + else print x "|1" + } }' | sort +} +cxx_tags(){ + awk '/kHarnessEventTags *\{/,/^\} \};/' "$1" | grep -o '{ *"[^"]*", *\(true\|false\) *}' \ + | sed -e 's/^{ *"//' -e 's/", *true *}$/|1/' -e 's/", *false *}$/|0/' | sort +} +CXX_TAGS="$( cxx_tags "$ROOT/src/taskroute.h" )" +SH_TAGS_CLAUDE="$( shell_tags "$HB_CLAUDE" )"; SH_TAGS_CODEX="$( shell_tags "$HB_CODEX" )" +if [ -z "$CXX_TAGS" ] || [ -z "$SH_TAGS_CLAUDE" ]; then + no "O9c could not extract the harness-event tag lists (C++ [$( printf '%s' "$CXX_TAGS" | wc -l | tr -d ' ' )] shell [$( printf '%s' "$SH_TAGS_CLAUDE" | wc -l | tr -d ' ' )]) — the textual comparison would prove nothing" +elif [ "$CXX_TAGS" = "$SH_TAGS_CLAUDE" ] && [ "$CXX_TAGS" = "$SH_TAGS_CODEX" ]; then + ok "O9c the shell harness-event tag list equals src/taskroute.h kHarnessEventTags ($( printf '%s\n' "$CXX_TAGS" | wc -l | tr -d ' ' ) tags: $( printf '%s' "$CXX_TAGS" | tr '\n' ' ' ))" +else + no "O9c the harness-event tag lists DISAGREE — C++: [$( printf '%s' "$CXX_TAGS" | tr '\n' ' ' )] claude: [$( printf '%s' "$SH_TAGS_CLAUDE" | tr '\n' ' ' )] codex: [$( printf '%s' "$SH_TAGS_CODEX" | tr '\n' ' ' )]" +fi + # The rule itself, sourced from the claude hook's copy so the arm tests what ships. cat "$B_CLAUDE" > "$TMP/rule.sh" printf 'if rw_is_ripwire_call "$1"; then echo 1; else echo 0; fi\n' >> "$TMP/rule.sh" diff --git a/test/routingreportcheck.sh b/test/routingreportcheck.sh index f857b4ab8..a5ebd8a98 100755 --- a/test/routingreportcheck.sh +++ b/test/routingreportcheck.sh @@ -322,6 +322,97 @@ echo "$OUT8T" | grep -q '=== router=toolcall ===' && ok "--router=toolcall: show echo "$OUT8T" | grep -q '=== router=prompt ===' && no "--router=toolcall: leaked the prompt section too" \ || ok "--router=toolcall: does not print the prompt section" +# ── C/D: per-arm counts, a loud NO CONTROL ARM, and the window-2 readout clock (issue #381) ────────── +# A treatment-only log is what an unconfigured install used to produce. The report must say so in so many +# words, however many treatment prompts there are, and must NOT say it when both arms have prompts. +DC="$TMP/dc"; mkdir -p "$DC" +python3 - "$DC" <<'PYEOF' +import json, sys +rows = [{"v": 2, "at": "2026-10-01T10:00:00Z", "agent": "claude", "event": "UserPromptSubmit", "status": "recommend", + "intent": "x", "recommended": "--for", "arm": "treatment", "session_hash": str(1000 + i), + "prompt_hash": str(2000 + i), "prompt_bytes": 10} for i in range(5)] +open(sys.argv[1] + "/routing.jsonl", "w").write("\n".join(json.dumps(r) for r in rows) + "\n") +open(sys.argv[1] + "/substitution.jsonl", "w").write("") +PYEOF +OUTC="$( run "$DC" )" +echo "$OUTC" | grep -q 'NO CONTROL ARM' && ok "C1: a treatment-only log prints NO CONTROL ARM" \ + || no "C1: treatment-only log did not say NO CONTROL ARM -- $OUTC" +echo "$OUTC" | grep -q 'arm counts: treatment=5 control=0' && ok "C2: per-arm counts are printed (treatment=5 control=0)" \ + || no "C2: per-arm count line missing or wrong -- $( echo "$OUTC" | grep 'arm counts' )" +echo "$OUTC" | grep -q 'NO TREATMENT ARM' && no "C3: a log WITH treatment prompts claimed NO TREATMENT ARM" \ + || ok "C3: no false NO TREATMENT ARM when treatment has prompts" +echo "$OUT1" | grep -q 'NO CONTROL ARM' && no "C4: a log with both arms populated still printed NO CONTROL ARM" \ + || ok "C4: both arms populated -> no NO CONTROL ARM line" +echo "$OUT1" | grep -q 'arm counts: treatment=43 control=40' && ok "C5: per-arm counts on the both-arms fixture (43 treatment incl. 3 edge-case prompts / 40 control)" \ + || no "C5: per-arm count line wrong on the 43/40 fixture -- $( echo "$OUT1" | grep 'arm counts' )" + +# The readout clock (owner decision 2026-10-07): the 2026-09-02 window had no control arm, so it is VOID and +# never gets a readout date; window 2 starts at the release that ships arm=auto, its start recorded as +# `**Window start:**` in the registration. Synthetic registrations pin each state; the last arms read the +# REAL docs/EVALS.md so the registration cannot lose its VOID/PENDING text without this gate noticing. +EV="$TMP/evals.md" +printf '## x\n\n### Claude Code prompt router — PRE-REGISTERED 2026-09-02 (test)\n\nbody\n\n**Window start:** 2026-10-20 (the release date)\n\n### Next section\n\n**Window start:** 2020-01-01\n' >"$EV" +ro(){ python3 "$SCRIPT" --routing "$DC/routing.jsonl" --meter "$DC/substitution.jsonl" --evals "$EV" --today "$1" 2>&1; } +# start 2026-10-20 -> readout 2026-11-17 (start + 28 days), extension 2026-12-01 (start + 42 days) +ro 2026-11-20 | grep -q 'window 2 readout date: 2026-11-17 -- PASSED 3 day' && ok "D1: window 2's readout date is start + 28 days, and a passed one is flagged PASSED" \ + || no "D1: passed window-2 date not flagged -- $( ro 2026-11-20 | grep -i readout )" +ro 2026-11-20 | grep -q 'window 2 extended readout date (one-time): 2026-12-01 -- in 11 day' && ok "D2: the extension is start + 42 days, not yet passed, printed without the flag" \ + || no "D2: extension date line wrong -- $( ro 2026-11-20 | grep -i extended )" +ro 2026-11-01 | grep -v 'window 1' | grep -q 'PASSED' && no "D3: nothing in window 2 has passed on 2026-11-01 but the report said PASSED" \ + || ok "D3: before both window-2 dates nothing is flagged PASSED" +ro 2026-11-17 | grep -q 'window 2 readout date: 2026-11-17 -- TODAY' && ok "D4: on the day itself the line says TODAY" \ + || no "D4: same-day line wrong -- $( ro 2026-11-17 | grep -i readout )" +ro 2026-12-05 | grep -q 'window 2 extended readout date (one-time): 2026-12-01 -- PASSED 4 day' && ok "D5: the extension flags PASSED too, once it passes" \ + || no "D5: passed extension not flagged -- $( ro 2026-12-05 | grep -i extended )" +ro 2026-11-20 | grep -q '2020-01-01\|2020-01-29' && no "D6: a window start from a LATER section leaked into the report" \ + || ok "D6: only the prompt-router section's window start is read" +printf '## nothing here\n' >"$EV" +ro 2026-10-07 | grep -q 'readout date: UNKNOWN' && ok "D7: a file without the registration prints UNKNOWN instead of staying silent" \ + || no "D7: missing registration not reported -- $( ro 2026-10-07 | grep -i readout )" +python3 "$SCRIPT" --routing "$DC/routing.jsonl" --meter "$DC/substitution.jsonl" --evals "$TMP/no-such-evals.md" --today 2026-10-07 2>&1 \ + | grep -q 'readout date: UNKNOWN.*not readable' && ok "D8: an unreadable registration file prints UNKNOWN" \ + || no "D8: unreadable registration file not reported" +# D9': the SHIPPED docs/EVALS.md. Window 1 is VOID, window 2 is PENDING (or carries a computed date once the +# release writes its start), and no line says PASSED, today or in 2027. RED on a head whose report flags the +# 2026-09-02 window's derived date 2026-09-30 as PASSED. +for TODAY in 2026-10-07 2027-01-01; do + OUTS="$( python3 "$SCRIPT" --routing "$DC/routing.jsonl" --meter "$DC/substitution.jsonl" --today "$TODAY" 2>&1 )" + echo "$OUTS" | grep -q 'window 1 (registered 2026-09-02): VOID -- no control arm (issue #381); not a readout' \ + && echo "$OUTS" | grep -Eq 'readout date: (PENDING -- window 2 starts at the release that ships arm=auto|20[0-9]{2}-[0-9]{2}-[0-9]{2} -- )' \ + && ok "D9'($TODAY): shipped docs/EVALS.md prints window 1 VOID and a window-2 date or PENDING" \ + || no "D9'($TODAY): shipped registration lines wrong -- $( echo "$OUTS" | grep -i 'window\|readout' )" +done +OUTS="$( python3 "$SCRIPT" --routing "$DC/routing.jsonl" --meter "$DC/substitution.jsonl" --today 2026-10-07 2>&1 )" +if grep -q '^\*\*Window start:\*\* PENDING' "$ROOT/docs/EVALS.md"; then + echo "$OUTS" | grep -q 'PASSED' && no "D9'a: a PENDING window start printed PASSED -- $( echo "$OUTS" | grep PASSED )" \ + || ok "D9'a: with the shipped window start PENDING, no line says PASSED (2026-10-07)" +fi +echo "$OUTS" | grep -q '2026-09-30' && no "D9'b: the void window's derived date 2026-09-30 is printed" \ + || ok "D9'b: the void window's derived date 2026-09-30 is not printed" +# Window 1 stays VOID even when window 2 has a start (the void line does not depend on the registration's dates). +printf '### Claude Code prompt router — PRE-REGISTERED 2026-09-02 (test)\n\n**Window start:** 2026-10-20\n' >"$EV" +ro 2027-06-01 | grep -q 'window 1 (registered 2026-09-02): VOID' && ! ro 2027-06-01 | grep 'window 1' | grep -q PASSED \ + && ok "D11: window 1 is VOID with a dated window 2, and its line never says PASSED" \ + || no "D11: window 1 line wrong with a dated window 2 -- $( ro 2027-06-01 | grep 'window 1' )" +ro 2027-06-01 | grep -q '2026-09-30' && no "D11b: 2026-09-30 printed with a dated window 2" || ok "D11b: 2026-09-30 is never printed" +# PENDING and a missing line both give the PENDING readout line; a start that is not a date gives UNKNOWN. +printf '### Claude Code prompt router — PRE-REGISTERED 2026-09-02 (test)\n\n**Window start:** PENDING (the release writes it)\n' >"$EV" +ro 2027-06-01 | grep -q 'prompt-router readout date: PENDING -- window 2 starts at the release that ships arm=auto (no Window start date in docs/EVALS.md yet)' \ + && ok "D12: a PENDING window start prints the PENDING readout line, whatever --today says" \ + || no "D12: PENDING start not reported -- $( ro 2027-06-01 | grep -i readout )" +printf '### Claude Code prompt router — PRE-REGISTERED 2026-09-02 (test)\n\nno start line here\n' >"$EV" +ro 2027-06-01 | grep -q 'readout date: PENDING' && ok "D13: a registration with no Window start line prints PENDING" \ + || no "D13: missing Window start line not PENDING -- $( ro 2027-06-01 | grep -i readout )" +printf '### Claude Code prompt router — PRE-REGISTERED 2026-09-02 (test)\n\n**Window start:** next Tuesday\n' >"$EV" +ro 2027-06-01 | grep -q 'readout date: UNKNOWN' && ! ro 2027-06-01 | grep -q 'PENDING --' && ok "D14: a Window start that is neither a date nor PENDING prints UNKNOWN" \ + || no "D14: a junk Window start was not UNKNOWN -- $( ro 2027-06-01 | grep -i readout )" +printf '### Claude Code prompt router — PRE-REGISTERED 2026-09-02 (test)\n\n**Window start:** 2026-02-30\n' >"$EV" +ro 2027-06-01 | grep -q 'readout date: UNKNOWN' && ok "D15: an impossible calendar date prints UNKNOWN" \ + || no "D15: 2026-02-30 not UNKNOWN -- $( ro 2027-06-01 | grep -i readout )" +python3 "$SCRIPT" --routing "$DC/routing.jsonl" --meter "$DC/substitution.jsonl" --today not-a-date >/dev/null 2>"$TMP/today.err"; RCT=$? +[ "$RCT" = 2 ] && grep -q -- '--today must be YYYY-MM-DD' "$TMP/today.err" && ok "D10: a malformed --today is a usage error (exit 2), not a silent default" \ + || no "D10: --today not-a-date gave rc=$RCT stderr=$( cat "$TMP/today.err" )" + echo "" if [ "$fail" = 0 ]; then echo "ALL PASS" diff --git a/test/rubyrecvnarrowcheck.sh b/test/rubyrecvnarrowcheck.sh index a18956d8c..421e6eba3 100755 --- a/test/rubyrecvnarrowcheck.sh +++ b/test/rubyrecvnarrowcheck.sh @@ -214,6 +214,22 @@ command -v xmllint >/dev/null 2>&1 && { if xmllint --noout "$MAP"; then ok "xmll SPLIT="$DIR/split"; sed 's/>\n"$SPLIT" rowOf(){ awk -v pat="$1" '$0 ~ pat{f=1;print;next} /^ row + its children edgesTo(){ echo "$1" | grep -c " rows of that name +# An ABSENCE arm on a --callers answer: read only off a run that succeeded and produced its root. A crash or a +# refusal leaves the capture empty, and a bare `grep -q ROW && no || ok` reads that as a PASS (checklist 14). +# callersLacks DIR SYMBOL ROW-PATTERN FAIL-MESSAGE PASS-MESSAGE +callersLacks(){ + local out + if ! out="$( "$BIN" "$1" --no-cache --callers="$2" 2>"$DIR/absent.err" )"; then + no "--callers=$2 exited non-zero, so its absence arm proves nothing: $( head -3 "$DIR/absent.err" )"; return + fi + case "$out" in + *' root, so its absence arm proves nothing"; return ;; + esac + if echo "$out" | grep -q "$3"; then no "$4"; else ok "$5"; fi +} +# how many edges of that name; FE-B: a merged via="name" row stands for N edges (serialize.h writeMapCalleeRows) +edgesTo(){ echo "$1" | grep -o "]*>" | awk '{ n = 1; if ( match( $0, / x="[0-9]+"/ ) ) n = substr( $0, RSTART + 4, RLENGTH - 5 ) + 0; s += n } END { print s + 0 }'; } echo "=== the fixture parsed the way this gate assumes ===" for want in 'n="add" sc="Calc"' 'n="add" sc="Tally"' 'n="run" sc="Engine"' 'n="run" sc="Motor"' \ @@ -237,7 +253,7 @@ CL="$( "$BIN" "$FIX" --no-cache --callers=Calc::add 2>/dev/null )" echo "$CL" | grep -q 'n="qualified_call"' && ok "--callers=Calc::add lists qualified_call" \ || no "--callers=Calc::add does not list qualified_call: $( echo "$CL" | grep -o ']*' )" CT="$( "$BIN" "$FIX" --no-cache --callers=Tally::add 2>/dev/null )" -echo "$CT" | grep -q 'n="qualified_call"' && no "--callers=Tally::add STILL lists qualified_call — the wrong half of the split survived" || ok "--callers=Tally::add does not list qualified_call" +callersLacks "$FIX" Tally::add 'n="qualified_call"' "--callers=Tally::add STILL lists qualified_call — the wrong half of the split survived" "--callers=Tally::add does not list qualified_call" echo "$CT" | grep -q 'count="0"' && ok "…and reports count=\"0\" (Tally::add has no caller in this tree)" \ || no "--callers=Tally::add did not report count=0: $( echo "$CT" | grep -o ']*' )" @@ -248,8 +264,7 @@ S="$( rowOf 'n="scoped_call" ' )" CE="$( "$BIN" "$FIX" --no-cache --callers=Engine::run 2>/dev/null )" echo "$CE" | grep -q 'n="scoped_call"' && ok "--callers=Engine::run lists scoped_call" \ || no "--callers=Engine::run does not list scoped_call" -CM="$( "$BIN" "$FIX" --no-cache --callers=Motor::run 2>/dev/null )" -echo "$CM" | grep -q 'n="scoped_call"' && no "--callers=Motor::run lists scoped_call — Outer::Engine reached Other::Motor" || ok "--callers=Motor::run does not list scoped_call" +callersLacks "$FIX" Motor::run 'n="scoped_call"' "--callers=Motor::run lists scoped_call — Outer::Engine reached Other::Motor" "--callers=Motor::run does not list scoped_call" A="$( rowOf 'n="absolute_call" ' )" [ "$( edgesTo "$A" ping )" -eq 1 ] && ok "::Top.ping → exactly one edge named ping (the absolute spelling has no scope: child)" \ @@ -257,8 +272,7 @@ A="$( rowOf 'n="absolute_call" ' )" CP="$( "$BIN" "$FIX" --no-cache --callers=Top::ping 2>/dev/null )" echo "$CP" | grep -q 'n="absolute_call"' && ok "--callers=Top::ping lists absolute_call" \ || no "--callers=Top::ping does not list absolute_call" -CR="$( "$BIN" "$FIX" --no-cache --callers=Radar::ping 2>/dev/null )" -echo "$CR" | grep -q 'n="absolute_call"' && no "--callers=Radar::ping lists absolute_call — ::Top reached Radar" || ok "--callers=Radar::ping does not list absolute_call" +callersLacks "$FIX" Radar::ping 'n="absolute_call"' "--callers=Radar::ping lists absolute_call — ::Top reached Radar" "--callers=Radar::ping does not list absolute_call" echo "=== a MODULE is a receiver too (Util.format, and the :: spelling of the same call) ===" M="$( rowOf 'n="module_call" ' )" @@ -363,8 +377,7 @@ MQ="$( "$BIN" "$MUT" --no-cache 2>/dev/null | sed 's/>\n/dev/null )" echo "$MCL" | grep -q 'n="qualified_call"' && ok "mutation: Tally.add → --callers=Tally::add now lists qualified_call" \ || no "mutation: the edge did not follow the receiver: $MQ" -MCC="$( "$BIN" "$MUT" --no-cache --callers=Calc::add 2>/dev/null )" -echo "$MCC" | grep -q 'n="qualified_call"' && no "mutation: --callers=Calc::add still lists qualified_call — the pin is not reading the receiver" || ok "mutation: Calc::add no longer lists it" +callersLacks "$MUT" Calc::add 'n="qualified_call"' "mutation: --callers=Calc::add still lists qualified_call — the pin is not reading the receiver" "mutation: Calc::add no longer lists it" echo [ "$fail" -eq 0 ] && echo "ALL PASS" || { echo "SOME CHECKS FAILED"; exit 1; } diff --git a/test/rubyscopecheck.sh b/test/rubyscopecheck.sh index cde09aa66..922af8f07 100755 --- a/test/rubyscopecheck.sh +++ b/test/rubyscopecheck.sh @@ -164,7 +164,9 @@ DMAP="$( "$BIN" "$DEL" --no-cache 2>/dev/null | sed 's/>\n]*edges=[0-9]*' | grep -o 'edges=[0-9]*' | cut -d= -f2 )" if [ "${DEDGES:-0}" -eq 2 ]; then ok "facade delegation keeps both real targets (edges=2)"; else no "facade delegation: expected edges=2, got ${DEDGES:-0} — the caller won its own locality tie-break?"; fi FAC="$( echo "$DMAP" | awk '/n="publish_event" sc="Facade"/{f=1;print;next} /^ stands for N callee rows (serialize.h writeMapCalleeRows) +FAC_ROWS="$( echo "$FAC" | grep -o ']*>' | awk '{ n = 1; if ( match( $0, / x="[0-9]+"/ ) ) n = substr( $0, RSTART + 4, RLENGTH - 5 ) + 0; s += n } END { print s + 0 }' )" +if [ "$FAC_ROWS" -eq 2 ]; then ok "Facade::publish_event → two publish_event callee rows (Fanout, Subscriber)"; else no "Facade::publish_event callee rows: $FAC_ROWS"; fi if echo "$FAC" | grep -q 'amb="1"'; then ok "…disclosed as an honest split (amb=\"1\"), not a locality guess"; else no "Facade::publish_event is not marked amb=\"1\": $( echo "$FAC" | head -1 )"; fi echo "$FAC" | grep -q 'lpin=' && no "Facade::publish_event carries lpin= — the tie-break picked ONE target from a two-way tie" || ok "…and no lpin= (a two-way tie is left a tie)" diff --git a/test/rubysettercheck.sh b/test/rubysettercheck.sh index 80a51f88e..3de567ff5 100755 --- a/test/rubysettercheck.sh +++ b/test/rubysettercheck.sh @@ -119,7 +119,9 @@ if grep -q 'n="count=" sc="W"' "$SPLIT"; then ok "def count=(v) → W::count="; if grep -q 'n="limit=" sc="W"' "$SPLIT"; then ok "def self.limit=(v) → W::limit= (singleton setter)"; else no "def self.limit=(v) not indexed"; fi if grep -q 'n="name=" sc="Other"' "$SPLIT"; then ok "Other#name= indexed separately"; else no "Other#name= not indexed"; fi if grep -q ' callee row also has `n="name" ` followed by a space, +# and it is a call, not a definition — the count this arm makes is of definitions named `name`. +if [ "$( grep -c '^]*n="name" ' "$SPLIT" )" -eq 1 ]; then ok "exactly one symbol named name (the getter)"; else no "expected one getter row, got $( grep -c '^]*n="name" ' "$SPLIT" )"; fi echo "=== a setter CALL edges to the setter, never to the getter ===" WR="$( rowOf 'n="writer" ' )" diff --git a/test/scipjoincheck.sh b/test/scipjoincheck.sh index 5001f9a65..c6431945a 100755 --- a/test/scipjoincheck.sh +++ b/test/scipjoincheck.sh @@ -19,7 +19,9 @@ # THE FIXTURE (test/scipjoinfix/: a.py, b.py, index.scip from make_index.py — line numbers there): # * local trap `local 0` def on a.py:2 (sum's line) + `local 0` ref on b.py:6 (inside K.run) # * parameter trap `Box#go().(model)` def on a.py:9 (go's own line); `model(1)` on a.py:10 resolves -# to that PARAMETER in SCIP, while ripwire's tier {Box.model, model} is S6-C-pinned +# to that PARAMETER in SCIP, while ripwire's tier {Box.model, model} was S6-C-pinned until FE-B +# (test/receiverevidencecheck.sh): a call THROUGH a parameter is name-only now, so the tier is +# kept whole — a disclosed split, both rows via="name" — and the join reads that split # * external `sum(vals)` on a.py:8 resolves to `builtins/sum().` in SCIP; ripwire -> a.py::sum # * control `helper()` on b.py:6 resolves to b.py::helper in both # @@ -43,9 +45,9 @@ echo "scipjoincheck: BIN=$BIN CORPUS=$CORPUS" # ── (A) the fixture reproduces the shapes: a locality pin and two unique resolutions ────────────── "$BIN" "$CORPUS" $EXC --no-cache --pin-census="$TMP/plain.tsv" >"$TMP/plain.xml" 2>"$TMP/plain.err" || no "(A) plain run failed" -awk -F'\t' '$1=="C" && $2=="locality" && $6 ~ /^a\.py::Box::go#/ && $7=="model" && $8 ~ /^a\.py::Box::model#/' "$TMP/plain.tsv" | grep -q . \ - && ok "(A) Box.go -> model is S6-C locality-pinned to Box.model (the shape under audit)" \ - || { no "(A) Box.go -> model is not a locality pin to Box.model"; grep '^C' "$TMP/plain.tsv" | sed 's/^/ /'; } +awk -F'\t' '$1=="C" && $2=="split" && $6 ~ /^a\.py::Box::go#/ && $7=="model" && $8 ~ /a\.py::Box::model#/ && $8 ~ /\|a\.py::model#/' "$TMP/plain.tsv" | grep -q . \ + && ok "(A) Box.go -> model is the whole name-only tier {Box.model, model} (the shape under audit; FE-B: never a pin through a parameter)" \ + || { no "(A) Box.go -> model is not the split over Box.model and model"; grep '^C' "$TMP/plain.tsv" | sed 's/^/ /'; } awk -F'\t' '$1=="C" && $2=="unique" && $6 ~ /^a\.py::Box::total#/ && $7=="sum"' "$TMP/plain.tsv" | grep -q . \ && ok "(A) Box.total -> sum resolves uniquely to a.py::sum" || no "(A) Box.total -> sum is not a unique resolution" awk -F'\t' '$1=="C" && $6 ~ /^b\.py::K::run#/ && $7=="helper"' "$TMP/plain.tsv" | grep -q . \ @@ -94,13 +96,14 @@ printf '%s' "$LINE" | grep -q '(1/1)' && ok "(F) stderr: SCIP matched 1/1 intern # ── (G) the harness reads both definitions ──────────────────────────────────────────────────────── python3 "$ROOT/bench/scip_pin_precision.py" --bin "$BIN" --repo "$CORPUS" --scip "$IDX" --exclude make_index.py \ --workdir "$TMP" --label join --json "$TMP/join.json" >"$TMP/harness.out" 2>&1 || no "(G) harness failed: $( tail -3 "$TMP/harness.out" )" -if python3 - "$TMP/join.json" <<'PY'; then ok "(G) harness: locality covered=1 precision=0.000 (full oracle), in-repo-only covered=0"; else no "(G) harness readout wrong: $( cat "$TMP/harness.out" | tail -12 )"; fi +if python3 - "$TMP/join.json" <<'PY'; then ok "(G) harness: the parameter site reads as split covered=1 precision=0.000 (full oracle), in-repo-only covered=0; locality covers none"; else no "(G) harness readout wrong: $( cat "$TMP/harness.out" | tail -12 )"; fi import json, sys j = json.load( open( sys.argv[ 1 ] ) ) -loc = j[ "rows" ][ "locality" ] -assert loc[ "covered" ] == 1 and loc[ "confirmed" ] == 0, loc -assert loc[ "covered_inrepo" ] == 0, loc -assert loc[ "sentinel_nondef" ] == 1 and loc[ "sentinel_external" ] == 0, loc +spl = j[ "rows" ][ "split" ] +assert spl[ "covered" ] == 1 and spl[ "confirmed" ] == 0, spl +assert spl[ "covered_inrepo" ] == 0, spl +assert spl[ "sentinel_nondef" ] == 1 and spl[ "sentinel_external" ] == 0, spl +assert j[ "rows" ].get( "locality", {} ).get( "covered", 0 ) == 0, j[ "rows" ].get( "locality" ) uni = j[ "rows" ][ "unique" ] assert uni[ "sentinel_external" ] == 1, uni PY diff --git a/test/selectorhonestycheck.sh b/test/selectorhonestycheck.sh index 03bb246de..7deb78dbb 100755 --- a/test/selectorhonestycheck.sh +++ b/test/selectorhonestycheck.sh @@ -91,7 +91,9 @@ g add -A; g commit -qm "doc quoting the entry point" g checkout -q master 2>/dev/null || g checkout -q main W="$TMP/w.xml" -"$BIN" "$R" --whereis=parseArgs --limit=200 >"$W" 2>/dev/null +# lean-answers lane: the §A7 arms read kind="ref" rows, which the DEFAULT listing counts rather than prints, so they read the +# whole list (--whereis-listing=all); the twin after them holds the same labels on the default listing. +"$BIN" "$R" --whereis=parseArgs --limit=200 --whereis-listing=all >"$W" 2>/dev/null tr '<' '\n' <"$W" | sed -n 's/^hit ref="\([^"]*\)".* p="\([^"]*\)" l="\([0-9]*\)" kind="\([a-z]*\)".*/\1 \4 \2:\3/p' >"$TMP/rows" head_rows(){ sed -n 's/^HEAD //p' "$TMP/rows"; } @@ -132,7 +134,19 @@ grep -q '^feat-doc def DOC\.md:' "$TMP/rows" \ || { no '§A7: whereis root still spells the scan denominator refs='; grep -o ']*>' "$W"; } # determinism + well-formedness of the changed verb -"$BIN" "$R" --whereis=parseArgs --limit=200 >"$TMP/w2.xml" 2>/dev/null +# TWIN on the default listing: the same def rows (HEAD's exactly the index def site, the branch doc's lexical def), no +# ref row printed, and the ref rows of the whole list — core.cpp:11 among them — counted exactly by . +WD="$TMP/wd.xml" +"$BIN" "$R" --whereis=parseArgs --limit=200 >"$WD" 2>/dev/null +tr '<' '\n' <"$WD" | sed -n 's/^hit ref="\([^"]*\)".* p="\([^"]*\)" l="\([0-9]*\)" kind="\([a-z]*\)".*/\1 \4 \2:\3/p' >"$TMP/rowsd" +NREFALL="$( grep -c ' ref ' "$TMP/rows" )" +{ [ "$( grep ' def ' "$TMP/rowsd" )" = "$( grep ' def ' "$TMP/rows" )" ] && ! grep -q ' ref ' "$TMP/rowsd" \ + && [ "$( sed -n 's/^HEAD //p' "$TMP/rowsd" | grep '^def ' )" = "def core.cpp:4" ] && grep -q '^feat-doc def DOC\.md:' "$TMP/rowsd" \ + && grep -q '^HEAD ref core\.cpp:11$' "$TMP/rows" && grep -q "]*>' "$WD"; } + +"$BIN" "$R" --whereis=parseArgs --limit=200 --whereis-listing=all >"$TMP/w2.xml" 2>/dev/null if cmp -s "$W" "$TMP/w2.xml"; then ok "GUARD §A7: whereis is byte-identical run-to-run"; else no "§A7: whereis is non-deterministic"; fi if command -v xmllint >/dev/null 2>&1; then if xmllint --noout "$W" 2>/dev/null; then ok "GUARD §A7: whereis XML well-formed"; else no "§A7: whereis XML malformed"; fi diff --git a/test/selfcheckcheck.sh b/test/selfcheckcheck.sh index 9f739827f..4167ee89e 100755 --- a/test/selfcheckcheck.sh +++ b/test/selfcheckcheck.sh @@ -147,9 +147,11 @@ ALLOW = { "std::string_view": "a non-owning view over storage the caller already holds", "rfind": "read-only search of a string the caller owns", "ends_with": "read-only suffix comparison of a string the caller owns (mcpCachePath's .cache postcondition)", + "starts_with": "read-only prefix comparison of a string the caller owns (serialize.h writeSigHeadWithEnd / spliceJsonEndLine: the row head opens with its l= field)", "std::all_of": "read-only walk of the given range (#150 keepStdQualifiedCandidates postcondition)", "isDefinitionNotDeclaration": "read-only span comparison (model.h); no state — the predicate std::all_of walks above", "isFunctionLikeKind": "read-only enum comparison (graph.h, a local lambda); no state — the F2 body-test scope guard", + "any": "read-only member predicate (pathgaps.h PathSearchGaps::any: calls > 0); no state — the gap-cone postcondition", } # (T) ASSUMED-THEN-TESTED allowlist: "site -> reason", one line each. A site lands here only when the equality # really is a true invariant (the re-test below is dead defensive code that should eventually be deleted, not diff --git a/test/shapingflagcheck.sh b/test/shapingflagcheck.sh index 2c42bb6fd..5ac0b39c8 100755 --- a/test/shapingflagcheck.sh +++ b/test/shapingflagcheck.sh @@ -416,7 +416,7 @@ fprobeFor() --format=) printf '%s' '--format=columnar' ;; --color-by=) printf '%s' '--color-by=lang' ;; --grep-scope=) printf '%s' '--grep-scope=file' ;; - --grep-in=) printf '%s' '--grep-in=any' ;; + --grep-in=) printf '%s' '--grep-in=any' ;; --whereis-listing=) printf '%s' '--whereis-listing=all' ;; --mcp-legend=) printf '%s' '--mcp-legend=inline' ;; # closed value sets: a bogus value refuses before any knob --legend=) printf '%s' '--legend=compact' ;; --slice-flow=) printf '%s' '--slice-flow=back' ;; --agent=) printf '%s' '--agent=codex' ;; diff --git a/test/showcase_capture.py b/test/showcase_capture.py index ee03fbab3..745f4c257 100644 --- a/test/showcase_capture.py +++ b/test/showcase_capture.py @@ -378,7 +378,7 @@ def add(section, cmd, what, **opts): add(S2, f"{BIN} . --around=rankGraphTeleport --around-depth=2", "The restoring knob: --around-depth=2 brings back the whole 2-hop neighbourhood (depth=\"2\" on the root) — pay for it only when the 1-hop view was not enough.") add(S2, f"{BIN} . --around=rankGraphTeleport --around-fanout=4", "The other knob: --around-fanout=4 keeps only the 4 strongest edges per node (default 32) — the same 1-hop depth, a quarter of the rows.") add(S2, f"{BIN} . --callers=rankGraphTeleport", "Who calls SYM (1-hop in-edges).") -add(S2, f"{BIN} . --callers=rankGraphTeleport --legend=compact", "The same rows under --legend=compact: the prose legend becomes one compact comment plus schema=\"ripwire.callers/v1\" on the root — every row byte and every completeness attribute (counts_floor=, graph_ambiguous=, next=) identical, ~3 KB of legend gone. Works on EVERY XML verb now, not four.") +add(S2, f"{BIN} . --callers=rankGraphTeleport --legend=full", "The same rows under --legend=full: the default is the compact dialect (one compact comment plus schema=\"ripwire.callers/v1\" on the root), and --legend=full restores the prose legend — every row byte and every completeness attribute (counts_floor=, graph_ambiguous=, next=) identical, ~3 KB more legend. Works on EVERY XML verb.") add(S2, f"{BIN} . --callers=DoesNotExist", "Unknown-symbol REFUSAL shape (exit 1) with a did-you-mean from real edit distance.") add(S2, f"{BIN} . --callees=rankGraphTeleport", "What SYM calls (1-hop out-edges).") add(S2, f"{BIN} . --uses=rankGraphTeleport", "The resolvable use-sites (call/read/write/import/extends) with file:line; count= is a floor.") @@ -488,7 +488,8 @@ def _strayScope( sub, kind ): add(S4, f"{BIN} . --stray-content={shlex.quote( _famB )} --plan", f"Select the genuinely-unmerged refs of {_strayScope( _famB, _famBKind )} and feed them to merge-scout for a landing order (a fully merged selection yields an empty landing set — still a measurement, disclosed on the root).", timeout=900) add(S4, f"{BIN} . --stray-content=zzzz-no-such-ref --plan", "A --plan filter that selects NO ref REFUSES (exit 1) naming the substring — before the wave-3 close this fell through to the '>512 refs match' sentence, and --abi under the same filter answered an empty measurement at exit 0.") add(S4, f"{BIN} . --stray-content={shlex.quote( _famB )} --abi", f"Cross-branch ABI-break gate over {_strayScope( _famB, _famBKind )}: struct byte-contract drift on each ref's AUTHORED paths — exit 2 when any drift row is found (the only kind that gates), 0 when the compared refs are clean, and exit 1 if the --stray-content filter matches no ref at all.", timeout=600) -add(S4, f"{BIN} . --whereis=rankGraphTeleport", "Which ref's tree defines or mentions SYM — HEAD first, then every local branch.", timeout=600) +add(S4, f"{BIN} . --whereis=rankGraphTeleport", "Which ref's tree defines or mentions SYM — HEAD first, then every local branch. The default lists the kind=\"def\" rows (here the first 60, most on branches, each carrying tip= and date=) and counts the references in : on this symbol HEAD's references would fill the 60-row cap before the branch definitions arrive, so the whole list (below) shows fewer definitions, and the default serves the page that shows more of them, whatever its bytes.", timeout=600) +add(S4, f"{BIN} . --whereis=rankGraphTeleport --whereis-listing=all", "The whole hit list asked for by name: under the same 60-row cap HEAD's references fill the list, so it shows far fewer definitions (here HEAD's one) than the default above (here 60). The default serves the all page only when it shows the same definitions as the defs page in strictly fewer bytes (a tie serves it too).", timeout=600) add(S4, f"{BIN} . --whereis=computeOnePairOverlap --with-history", "Same, plus a git-history row (never / removed-by-commit) for names no tree carries.", timeout=600) add(S4, f"{BIN} . --flags", "The dark-content dashboard: gates BUILT but OFF. CHANGED: no longer invents gates from comments/heredocs, so the count only reflects real ifndef/define, CMake option(), and getenv gates.") add(S4, f"{BIN} . --flags --flip=RIPWIRE_ASAN", "Blast radius of turning ONE gate on: live code, symbols, transitive reach, covering tests.") @@ -646,6 +647,7 @@ def chooseInWindow( lo=2, hi=14 ): add(S2B, f"{BIN} . --grep=DISCLOSE --handles", "h= on each editable enclosing-symbol row: a freshness-pinned identity an edit verb can target and must refuse on after any file change.") add(S2B, f"{BIN} . --grep=DISCLOSE --legend=compact", "The grep compact legend (ripwire.grep/v1).") add(S2B, f'{BIN} . --for="tree-sitter parse of a source file" --legend=compact', "The --for compact legend (ripwire.for/v1) — every data/completeness attribute kept.") +add(S2B, f'{BIN} . --for="tree-sitter parse of a source file" --sections=lego,compose', "Restore the / sections the --for bundle collapses to a counted stub by default (this is the stub's own next= spelling).") add(S2B, f'{BIN} . --for="tree-sitter parse of a source file" --auto-bodies', "Opt OUT of compact conceptual serving: restore the rank-first auto walk (bundle=\"auto\").") add(S2B, f'{BIN} . --for="quality delta acks ledger rubber stamp"', "Doc-mention surfacing (default ON): a markdown doc naming a top-resolved symbol in a backtick rides in below that symbol — the legend's [doc mentions: …] clause says it fired.") add(S2B, f'{BIN} . --for="quality delta acks ledger rubber stamp" --no-doc-mention', "The same task with doc-mention surfacing OFF — the contrast the flag exists for (no [doc mentions] clause, one fewer row).") @@ -677,6 +679,8 @@ def chooseInWindow( lo=2, hi=14 ): add(S4B, f"{BIN} . --legend-dict", "The session legend dictionary the MCP server serves as ripwire://legend-dict/full — one definition per line, headed by its dictv= version; no corpus needed. =roster lists the completeness attributes it defines.") add(S4B, f"{BIN} . --lint --lint-select=cache-", "Run ONLY one rule family; the root carries selected=\"K of N\" so a filtered zero is never confusable with an unfiltered one.") add(S4B, f"{BIN} . --lint --lint-ignore=naming-,cache-", "DROP two families, applied after selection; the raw select=/ignore= you passed rides on the root.") +add(S4B, f"{BIN} . --lint --lint-select=goto --lint-max-per-rule=4", "A rule that spends its own match budget (here 4, set BELOW the default 5000 so the floor shows on this repo): goto carries count_capped=\"1\", its count= is a FLOOR, and the root names the call that counts the rest — findings_next= re-runs only the floored rules under a 10x budget.") +add(S4B, f"{BIN} . --lint --lint-select=goto --lint-max-per-rule=40", "Pasting that findings_next=: the same rule under a 10x budget counts its true total — no count_capped=, no findings_next=.") add(S4B, f"{BIN} . --lint --lint-select=cach-", "An unresolvable PREFIX refuses (exit 1) with a did-you-mean from a real edit distance (one character off cache-).") add(S4B, f"{BIN} . --lint --lint-select=nosuchfamily", "A PREFIX with no near miss at all: the refusal points at --lint-catalog instead of guessing.") add(S4B, f"{BIN} . --lint --sarif", "The SAME findings as SARIF 2.1.0 (what github/codeql-action/upload-sarif consumes) — pure re-serialization, results count == the native run's.", @@ -704,14 +708,14 @@ def chooseInWindow( lo=2, hi=14 ): add(S8, f"{D} . --situ", "Situational report for a real diff: blast radius + tests + co-change + forgotten co-change partners.", cwd=DIRTY) add(S8, f"{D} . --test-gate", "The pre-PR gate with real obligations — exit 4 when tests-to-run or untested blast radius is non-empty.", cwd=DIRTY) add(S8, f"{D} . --quality-delta", "Every row carries p=\"file:line\", the gating rows are marked gating=\"1\" and now bar= (the threshold each numeric row is judged against), and the exit-2 refusal prints a naming line on stderr; a gating row carries a pasteable next=.", cwd=DIRTY) -add(S8, f"{D} . --quality-delta --legend=compact", "The same gating report under --legend=compact — same rows, same exit 2, schema=\"ripwire.quality-delta/v1\", ~4 KB of legend down to one comment: the shape an agent's edit loop should run.", cwd=DIRTY) +add(S8, f"{D} . --quality-delta --legend=full", "The same gating report under --legend=full — same rows, same exit 2; the default compact comment (schema=\"ripwire.quality-delta/v1\", the shape an agent's edit loop should run) becomes ~4 KB of prose legend.", cwd=DIRTY) add(S8, f"{D} . --quality-delta --json", "The same findings as JSON (one of the CI/scripting verbs --json supports) — same exit 2 as the XML form.", cwd=DIRTY) add(S8, f"{D} . --quality-delta --quality-ack --ack-only=zzznope", "NEW FLAG: --ack-only matching nothing REFUSES rather than falling back to acking everything.", cwd=DIRTY) add(S8, f"{D} . --quality-delta --quality-ack --ack-only=api-surface", "NEW FLAG: ack only the api-surface findings — a per-finding ratchet instead of a rubber stamp.", cwd=DIRTY) add(S8, f"{D} . --quality-delta", "Re-run after the partial ack: acked=3, the rest still gate (exit 2).", cwd=DIRTY) add(S8, f"{D} . --ack-only=gating", "--ack-only WITHOUT --quality-ack REFUSES loudly (exit 1, the pairing named) — it used to be silently ignored.", cwd=DIRTY) add(S8, f"{D} . --edit-check=nonNegativeFloatDescKey", "A real contract-change: was=1 now=2 params, with the call sites that are now provably incompatible, and a pasteable next= (--uses=SYM) on the root.", cwd=DIRTY) -add(S8, f"{D} . --edit-check=nonNegativeFloatDescKey --legend=compact", "The same verdict under --legend=compact: ~5.7 KB of legend becomes one comment, every row identical — the post-edit reflex at its cheapest.", cwd=DIRTY) +add(S8, f"{D} . --edit-check=nonNegativeFloatDescKey --legend=full", "The same verdict under --legend=full: the default's one compact comment (the post-edit reflex at its cheapest) becomes ~5.7 KB of prose legend, every row identical.", cwd=DIRTY) add(S8, f"{D} . --pr-context", "The review-evidence bundle with an actual changed file.", cwd=DIRTY) add(S8, f"{D} . --map-diff --top-k=5", "The map re-ranked with a teleport toward the changed file (changed=1 here, not 0).", cwd=DIRTY) add(S8, f"{D} . --clones", "The duplicated helper the sandbox edit introduced shows up as a clone group.", cwd=DIRTY) @@ -747,7 +751,11 @@ def chooseInWindow( lo=2, hi=14 ): add(S9, mcp(MCP_INIT, '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'), "initialize + tools/list: the manifest an agent host loads at session start — every verb's name, description and input schema.", post=mcp(MCP_INIT, '{"jsonrpc":"2.0","id":2,"method":"tools/list"}') + f" | tail -1 | python3 {manifest_py}", post_label="The manifest, summarised (name / description bytes / required args) — what the host pays in context every session:") +add(S9, mcp(MCP_INIT, '{"jsonrpc":"2.0","id":2,"method":"tools/list"}') + " --mcp-tools=core", "--mcp-tools=core: the server lists only the core profile's tools (explore, batch, from_trace, impact, uses, fetch_body, edit_check, quality_delta), so a host that loads every schema at session start pays only for those.", + post=mcp(MCP_INIT, '{"jsonrpc":"2.0","id":2,"method":"tools/list"}') + f" --mcp-tools=core | tail -1 | python3 {manifest_py}", + post_label="The core manifest, summarised (name / description bytes / required args):") add(S9, mcp(MCP_INIT, mcp_call("for", task="pagerank power iteration")), "MCP `for`: always bundle=sigs (never the CLI's compact route), the same ranked signatures as --for.") +add(S9, mcp(MCP_INIT, mcp_call("whereis", symbol="rankGraphTeleport"), mcp_call("whereis", symbol="computeOnePairOverlap").replace('"id":2', '"id":3', 1)) + " --mcp-legend=inline", "--mcp-legend=inline: every answer of the stdio session carries its own legend (the default, session, sends each definition once per session after the first answer).") add(S9, mcp(MCP_INIT, mcp_call("explore", task="add a new output format flag to the CLI", budget_tokens=2000)), "MCP `explore` = --pack-task under a token budget, one call.") add(S9, mcp(MCP_INIT, mcp_call("fetch_body", handle="rankGraphTeleport")), "MCP `fetch_body`: the lazy-body handle posture — bodies only after ranked retrieval, by bare name here.") add(S9, mcp(MCP_INIT, mcp_call("grep", pattern="DISCLOSE", limit=3)), "MCP `grep` with paging args.") @@ -947,7 +955,7 @@ def mustReplace( text, old, new, what ): doc.append("") doc.append("**How to read the blocks:** ripwire's real XML output is minified — often ONE long line. For scanability, long minified lines are displayed re-wrapped with a line break at every tag seam (`><`). Header COMMENT lines (the legends) always appear in full — they are exempt from the per-line cut; any OTHER display line over 300 bytes is cut with a `… [line truncated: N more bytes]` marker, which can hit a long root element or row. `--plan-lanes` emits JSON and is re-wrapped at object seams the same way. Long outputs are cut to their first ~30 display lines with a `… [N more display lines; full output is M bytes]` marker giving the true size. Exit codes are recorded when non-zero; wall time when >1s.") doc.append("") -doc.append(f"**Not run (and why):** `ripwire ` (network clone), `--listen` / `--mcp-token` / `--allow-remote-edits` (the HTTP-server posture; `--mcp` itself IS captured in its own section as a one-shot stdio JSON-RPC exchange, and `wrap claude` shows the wiring), `--arch --baseline[-update]` (state writer against the read-only repo — `--note-add` / `--quality-baseline` / `--quality-ack` / the three edit verbs / `--edit-plan` ARE shown, inside the throwaway sandbox clone; `--index-out` / `--pin-census` write to scratch), `--eval-mined` (needs a `minedpair.jsonl` artifact from `bench/mine_traces.py`; none present in the tree), `--refetch` (git-url only), `--force` (wrap-only modifier), `--scan-skills` bare form (would sweep `~/.claude/skills`; the explicit-DIR form is shown instead), `--help` ({help_line_count} lines — read it from the binary).") +doc.append(f"**Not run (and why):** `ripwire ` (network clone), `--listen` / `--mcp-token` / `--allow-remote-edits` (the HTTP-server posture; `--mcp` itself IS captured in its own section as a one-shot stdio JSON-RPC exchange, and `wrap claude` shows the wiring), `--arch --baseline[-update]` (state writer against the read-only repo — `--note-add` / `--quality-baseline` / `--quality-ack` / the three edit verbs / `--edit-plan` ARE shown, inside the throwaway sandbox clone; `--index-out` / `--pin-census` write to scratch), `--eval-mined` (needs a `minedpair.jsonl` artifact from `bench/mine_traces.py`; none present in the tree), `--refetch` (git-url only), `--lsp` (the editor transport — its one-shot dialogues are gated by `test/lspcheck.sh`, not by showcase blocks), `--max-memory` (the memory guard is silent on this repo by construction; its stops are gated by `test/memguardcheck.sh`), `--force` (wrap-only modifier), `--scan-skills` bare form (would sweep `~/.claude/skills`; the explicit-DIR form is shown instead), `--help` ({help_line_count} lines — read it from the binary).") doc.append("") cur_section = None diff --git a/test/showcasecapturecheck.sh b/test/showcasecapturecheck.sh index cca88e08b..1faed6020 100755 --- a/test/showcasecapturecheck.sh +++ b/test/showcasecapturecheck.sh @@ -549,7 +549,9 @@ for cand in $seedCands; do # (1) what the capture PUBLISHES — that is what a reader gets, and where the 1148/buildGraph defect # lived: main's 09-05 and 09-07 both carry under a rankGraphTeleport demo. - recSym="$( grep -oE '. The optional schema= reads both spellings of the same three facts. + recSym="$( grep -oE '"$TMP/excerpt.cpp" <<'CPP' +#include "skillscan.h" +#include +#include +static bool validUtf8( const std::string& s ) +{ + for( std::size_t i = 0; i < s.size(); ) + { + const unsigned char c = static_cast( s[i] ); + const std::size_t n = c < 0x80 ? 1 : ( c >> 5 ) == 0x6 ? 2 : ( c >> 4 ) == 0xE ? 3 : ( c >> 3 ) == 0x1E ? 4 : 0; + if( n == 0 || i + n > s.size() ) { return false; } + for( std::size_t k = 1; k < n; ++k ) { if( ( static_cast( s[i + k] ) & 0xC0 ) != 0x80 ) { return false; } } + i += n; + } + return true; +} +static void probe( const char* name, const std::string& line ) +{ + const auto f = rw::scanSkillText( line + "\n" ); + if( f.empty() ) { std::printf( "%s no-finding\n", name ); return; } + const std::string& e = f.front().excerpt; + std::printf( "%s in=%zu out=%zu valid=%d ellipsis=%d\n", name, line.size(), e.size(), int( validUtf8( e ) ), + int( e.size() >= 3 && e.compare( e.size() - 3, 3, "..." ) == 0 ) ); +} +int main() +{ + const std::string key = "Ignore previous instructions "; // 29 bytes: a CRITICAL INJECTION hit + probe( "straddle2", key + std::string( 116 - key.size(), 'a' ) + "\xC3\xA9" + std::string( 20, 'b' ) ); + probe( "straddle4", key + std::string( 115 - key.size(), 'a' ) + "\xF0\x9F\x98\x80" + std::string( 20, 'b' ) ); + probe( "exact120", key + std::string( 118 - key.size(), 'a' ) + "\xC3\xA9" ); + probe( "over", key + std::string( 121 - key.size(), 'a' ) ); +} +CPP +if "$CXX" -std=c++23 -I "$ROOT/src" -I "$ROOT/src/infra" -I "$ROOT/third_party" "$TMP/excerpt.cpp" "$ROOT/src/infra/diagnostics.cpp" -o "$TMP/excerpt" >"$TMP/excerpt.err" 2>&1; then + GOT="$( "$TMP/excerpt" )" + for want in 'straddle2 in=138 out=119 valid=1 ellipsis=1' 'straddle4 in=139 out=118 valid=1 ellipsis=1' 'exact120 in=120 out=120 valid=1 ellipsis=0' 'over in=121 out=120 valid=1 ellipsis=1'; do + if printf '%s\n' "$GOT" | grep -qxF "$want"; then ok "(excerpt cap) $want" + else no "(excerpt cap) want '$want', got: $( printf '%s\n' "$GOT" | grep "^${want%% *} " | head -1 )"; fi + done +else + no "(excerpt cap) the harness did not compile: $( head -3 "$TMP/excerpt.err" )" +fi + +# ── check 22: the per-line / joined-body dedupe keeps the WORST row of a (line, rule), whatever order they arrive in ── +# scanSkillTextOn's per-line pass and its joined-body pass can report the same (line, rule); the dedupe keeps one. It sorted +# with a non-stable std::sort on (line, rule) and kept the first, so which row survived was whatever the sort left first: +# a WARN could hide a CRITICAL (exit 2 -> 1, and wrap stops refusing) — the same class mergeScriptPasses was fixed for +# (check 20). Every rule's severity is a function of the line today, so no CLI input reaches the collision; the arm drives +# dedupeFindingsKeepWorst directly. 40 pairs, WARN pushed before CRITICAL in half of them and after in the other half, plus +# a lone row each side: past std::sort's insertion-sort cutoff, equal keys do not keep their arrival order. +cat >"$TMP/dedupe.cpp" <<'CPP' +#include "skillscan.h" +#include +int main() +{ + using rw::SkillFinding; using rw::SkillSeverity; + std::vector v; + for( int line = 1; line <= 40; ++line ) + { + const bool warnFirst = ( line % 2 ) != 0; + v.push_back( { warnFirst ? SkillSeverity::Warn : SkillSeverity::Critical, line, "INJECTION:disregard", warnFirst ? "w" : "c" } ); + v.push_back( { warnFirst ? SkillSeverity::Critical : SkillSeverity::Warn, line, "INJECTION:disregard", warnFirst ? "c" : "w" } ); + } + v.push_back( { SkillSeverity::Warn, 41, "SCOPE-CREEP:bash", "only" } ); + v.push_back( { SkillSeverity::Info, 3, "X:y", "other-rule" } ); + rw::dedupeFindingsKeepWorst( v ); + int rows = 0, nonCritical = 0, strayCritical = 0; + for( const SkillFinding& f : v ) + { + ++rows; + if( std::string_view( f.rule ) == "INJECTION:disregard" ) { nonCritical += f.sev != SkillSeverity::Critical; strayCritical += f.excerpt != "c"; } + } + std::printf( "rows=%d non_critical_kept=%d wrong_excerpt=%d\n", rows, nonCritical, strayCritical ); +} +CPP +if "$CXX" -std=c++23 -I "$ROOT/src" -I "$ROOT/src/infra" -I "$ROOT/third_party" "$TMP/dedupe.cpp" "$ROOT/src/infra/diagnostics.cpp" -o "$TMP/dedupe" >"$TMP/dedupe.err" 2>&1; then + GOT="$( "$TMP/dedupe" )" + if [ "$GOT" = "rows=42 non_critical_kept=0 wrong_excerpt=0" ]; then ok "(dedupe) 40 colliding (line, rule) pairs keep the CRITICAL row whichever came first (42 rows: 40 + the two lone ones)" + else no "(dedupe) want 'rows=42 non_critical_kept=0 wrong_excerpt=0', got: $GOT"; fi +else + no "(dedupe) the harness did not compile (no dedupeFindingsKeepWorst?): $( head -3 "$TMP/dedupe.err" )" +fi + # ── summary ─────────────────────────────────────────────────────────────────────────────────────── if [ "$fail" = "0" ]; then echo "ALL PASS" diff --git a/test/strkerncheck.sh b/test/strkerncheck.sh index 2628d90bd..e31b5360e 100755 --- a/test/strkerncheck.sh +++ b/test/strkerncheck.sh @@ -42,9 +42,10 @@ CXX="${CXX:-c++}" # ask THIS front end how it spells C++23 (see scripts/cxxstd.sh — AppleClang 15 rejects -std=c++23) . "$ROOT/scripts/cxxstd.sh" +. "$ROOT/scripts/asanprobe.sh" CXXSTD="$( ripwire_cxx_std_flag "$CXX" )" SRC="$ROOT/test/verify_strkern.cpp" -WORK="$( mktemp -d )"; trap 'rm -rf "$WORK"' EXIT +WORK="$( mktemp -d )"; trap 'gate_bounded_reap; rm -rf "$WORK"' EXIT; gate_bounded_arm ARCH="$( uname -m )" fail=0 @@ -57,6 +58,17 @@ MIN_ASSERTIONS=19 echo "strkerncheck: CXX=$CXX arch=$ARCH target=ripwire_test_strkern" +# ── 0: can this toolchain start an ASan program at all? ─────────────────────────────────────────────────── +# The CMake target below is built with RIPWIRE_ASAN=ON, whose configure step refuses (and says why) on a macOS whose +# sanitizer runtime deadlocks before main, and every arm after it reads that target's output. A named, expected missing +# premise (checklist 14/17), so the gate SKIPs here; nothing it asserts changes. Probe-only on purpose: the CMake leg +# takes its compiler from CC/CXX/LDFLAGS (CONTRIBUTING.md, Sanitizer build), so a caller who exports Homebrew llvm@22 +# there gets the real run. +if ! ripwire_asan_probe "$CXX" "$WORK"; then + echo "strkerncheck: SKIP — $RIPWIRE_ASAN_PROBE_WHY. Export CC/CXX/LDFLAGS for Homebrew llvm@22 or update the Command Line Tools/Xcode (CONTRIBUTING.md, Sanitizer build)." + exit 0 +fi + # ── 1: the CMake target, under the complete G1 sanitizer stack ──────────────────────────────────────── # FETCHCONTENT_FULLY_DISCONNECTED=ON because every dependency is vendored: a gate must not reach the # network, and if one ever tries, this is where it fails loudly instead of hanging. diff --git a/test/taskroutecheck.sh b/test/taskroutecheck.sh index 053142007..197f49e31 100755 --- a/test/taskroutecheck.sh +++ b/test/taskroutecheck.sh @@ -236,6 +236,28 @@ case "$SYS3" in *'status="abstain"'*) ok "leading whitespace before the marker s SYS4="$( route 'what does mean in the hook? also, help me understand the implementation of targetSymbol' )" case "$SYS4" in *'status="recommend"'*'intent="understand-symbol"'*'--expand='*'targetSymbol'*) ok "a real prompt merely mentioning the marker mid-sentence still routes normally";; *) no "mid-sentence mention wrongly suppressed a real route: $SYS4";; esac +# MCP channel events (``) and sub-agent hand-backs (``) arrive +# on the same channel (issue #381). The prefix is the opening tag plus a space or `>`; every positive +# below ends with a sentence that routes by itself (SYS4's), so only the guard can make it abstain. +SYSTASK='help me understand the implementation of targetSymbol' +for sysp in "$SYSTASK" "$SYSTASK" \ + "$( printf '\n \t%s' "$SYSTASK" )" \ + "$SYSTASK" "$SYSTASK" \ + "$( printf ' \n%s' "$SYSTASK" )" \ + "$( printf '\v%s' "$SYSTASK" )" \ + "$( printf ' \f\v%s' "$SYSTASK" )" \ + "$( printf '\f%s' "$SYSTASK" )"; do + SYSC="$( route "$sysp" )" + case "$SYSC" in *'status="abstain"'*'resolved_symbols="0"'*) ok "harness wrapper abstains: $( printf '%s' "$sysp" | tr '\n\t\v\f' ' ' | cut -c1-40 )";; *) no "harness wrapper routed: $sysp => $SYSC";; esac +done +# Near-misses: a different tag, or a mention that is not the leading bytes, is a user prompt and routes. +for nearp in " $SYSTASK" " $SYSTASK" " $SYSTASK" \ + "what does mean? $SYSTASK" "what does mean? $SYSTASK" \ + "channel the energy: $SYSTASK" "$( printf '\v %s' "$SYSTASK" )"; do + SYSN="$( route "$nearp" )" + case "$SYSN" in *'status="recommend"'*'--expand='*'targetSymbol'*) ok "near-miss still routes: $( printf '%s' "$nearp" | tr '\n\t\v\f' ' ' | cut -c1-40 )";; *) no "near-miss wrongly suppressed: $nearp => $SYSN";; esac +done + # ── the weak tier may not confirm itself, and may not read a config key as code (2026-09-10) ─────────── # Two independent defects, two independent arms each; all four recommend-side arms are RED against a # pre-change binary (each recommended understand-symbol with an --expand). diff --git a/test/tokenbudgetcheck.sh b/test/tokenbudgetcheck.sh index 371552d43..ef91a893c 100755 --- a/test/tokenbudgetcheck.sh +++ b/test/tokenbudgetcheck.sh @@ -361,7 +361,25 @@ else # L1 (2026-09-19): the o200k/cl100k counts were pinned (bench/tokenaudit/pin.py, needs tiktoken) on the FULL-legend # documents, the default when they were taken; the CLI default is compact now, so the pinned argv asks for the # document the counts describe. Re-pinning the default posture needs tiktoken — recorded in the L1 lane report. - EST_OUT="$( cd "$EST_TMP" && "$BIN" f $rest --legend=full 2>/dev/null )" + # A LEADING NAME=VALUE token is an environment assignment (pin.py's ENV_TOKEN), not argv: for-named-noe, the + # e=-free twin of for-named, sets RIPWIRE_FOR_ENDLINES=never. Every pin runs with that switch otherwise UNSET, so + # an ambient value cannot move a pin. ${a[@]+...} keeps an empty array legal under set -u on bash 3.2. + est_env=(); est_args=() + for est_tok in $rest; do + if [ "${#est_args[@]}" -eq 0 ] && printf '%s' "$est_tok" | grep -Eq '^[A-Z][A-Z0-9_]*='; then est_env+=( "$est_tok" ); else est_args+=( "$est_tok" ); fi + done + EST_OUT="$( cd "$EST_TMP" && env -u RIPWIRE_FOR_ENDLINES ${est_env[@]+"${est_env[@]}"} "$BIN" f ${est_args[@]+"${est_args[@]}"} --legend=full 2>/dev/null )" + # #18d TWIN PREMISE: for-named prices the e= rows the lane added and for-named-noe the e=-free answer it twins; a + # twin whose env token was dropped would silently re-measure for-named, so each must show its own side. + case "$label" in + for-named) + if printf '%s' "$EST_OUT" | grep -Eq ' rows (premise)" + elif printf '%s' "$EST_OUT" | grep -Eq ' e="[0-9]+"'; then no "#18d for-named-noe: the twin carries e= — RIPWIRE_FOR_ENDLINES=never did not reach the binary" + else ok "#18d for-named-noe: the twin is the e=-free answer (RIPWIRE_FOR_ENDLINES=never)"; fi ;; + esac EST_GOT="$( printf '%s' "$EST_OUT" | grep -oE 'est_tokens="[0-9]+"' | head -1 | grep -oE '[0-9]+' )" if [ -z "$EST_GOT" ]; then no "#18 $label: no est_tokens in the output — the pin says this verb prices itself" @@ -393,9 +411,10 @@ else fi fi - # #18c MUTATION CONTROL. Eight pins are committed; a loop that measured fewer (a manifest truncated by - # a merge, a verb that stopped printing est_tokens) asserted less than the PASS lines above suggest. - if [ "$est_pins" -ge 8 ]; then + # #18c MUTATION CONTROL. Nine pins are committed (eight verbs + for-named's e=-free twin); a loop that measured + # fewer (a manifest truncated by a merge, a verb that stopped printing est_tokens) asserted less than the PASS + # lines above suggest. + if [ "$est_pins" -ge 9 ]; then ok "#18c $est_pins pins measured against the tokenizer manifest" else no "#18c only $est_pins pin(s) measured — the band arms above asserted almost nothing" diff --git a/test/toolcallroutecheck.sh b/test/toolcallroutecheck.sh index 721180572..0be4b0c80 100755 --- a/test/toolcallroutecheck.sh +++ b/test/toolcallroutecheck.sh @@ -35,7 +35,10 @@ ln -sf "$BIN" "$BINDIR/ripwire" export PATH="$BINDIR:$PATH" export RIPWIRE_HOME="$TMP/home"; mkdir -p "$RIPWIRE_HOME" export TMPDIR="$TMP/tmp"; mkdir -p "$TMPDIR" -unset RIPWIRE_METER_ARM RIPWIRE_METER RIPWIRE_ROUTE_METER +unset RIPWIRE_METER RIPWIRE_ROUTE_METER +# The unconfigured default arm is `auto` (issue #381), so the corpus pass below pins `treatment` to score the +# classifier on every row; the default itself is asserted separately in the "default arm" section. +export RIPWIRE_METER_ARM=treatment LOG="$RIPWIRE_HOME/routing.jsonl" run_hook() @@ -146,12 +149,75 @@ tail -n1 "$LOG" | jq -e '.status == "abstain" and .reason == "cap"' >/dev/null 2 : >"$LOG" export RIPWIRE_METER_ARM=control COUT="$( run_hook Bash "$( jq -cn '{command:"grep -rn controlPattern src/"}' )" "control-session-1" )" -unset RIPWIRE_METER_ARM +export RIPWIRE_METER_ARM=treatment if [ -z "$COUT" ]; then ok "control arm: injects nothing"; else no "control arm: injected anyway -- $COUT"; fi tail -n1 "$LOG" | jq -e '.status == "recommend" and .arm == "control" and .recommended == "--grep"' >/dev/null 2>&1 \ && ok "control arm: still logs the recommend decision (status/arm/recommended all correct)" \ || no "control arm: logged row wrong -- $( tail -n1 "$LOG" )" +# ═══════════════════════════════════════════════════════════════════════════════════════════════════ +# Default arm (issue #381): no RIPWIRE_METER_ARM and no meter.conf resolves to `auto`, the meter's own +# session-hash split. It used to be `treatment` for every session, so the registered treatment-minus- +# control difference could never be computed. RED on a hook whose default is treatment. An explicit +# `arm=treatment` in meter.conf is still honoured. +# ═══════════════════════════════════════════════════════════════════════════════════════════════════ +DEFBAD=""; DEFCTL=0; DEFTRT=0; DEFCTLSID="" +for sid in d1 d2 d3 d4 d5 d6 d7 d8; do + h="$( printf '%s' "$sid" | cksum | cut -d' ' -f1 )" + if [ "$(( h % 100 ))" -lt 50 ]; then want=control; DEFCTL=$(( DEFCTL + 1 )); [ -n "$DEFCTLSID" ] || DEFCTLSID="$sid"; else want=treatment; DEFTRT=$(( DEFTRT + 1 )); fi + : >"$LOG" + ( unset RIPWIRE_METER_ARM; run_hook Bash "$( jq -cn --arg c "grep -rn defaultArm$sid src/" '{command:$c}' )" "$sid" >/dev/null ) + got="$( tail -n1 "$LOG" | jq -r '.arm // ""' 2>/dev/null )" + [ "$got" = "$want" ] || DEFBAD="$DEFBAD [$sid -> $got, auto says $want]" +done +[ -z "$DEFBAD" ] && [ "$DEFCTL" -gt 0 ] && [ "$DEFTRT" -gt 0 ] \ + && ok "default arm: unconfigured toolroute follows the auto split (both arms populated: $DEFCTL control / $DEFTRT treatment of 8)" \ + || no "default arm: unconfigured toolroute did not follow the auto split:$DEFBAD (control=$DEFCTL treatment=$DEFTRT)" +: >"$LOG"; printf 'arm=treatment\n' >"$RIPWIRE_HOME/meter.conf" +( unset RIPWIRE_METER_ARM; run_hook Bash "$( jq -cn '{command:"grep -rn confTreat src/"}' )" "$DEFCTLSID" >/dev/null ) +[ -n "$DEFCTLSID" ] && [ "$( tail -n1 "$LOG" | jq -r '.arm // ""' 2>/dev/null )" = "treatment" ] \ + && ok "default arm: an explicit meter.conf arm=treatment is honoured over the auto default" \ + || no "default arm: meter.conf arm=treatment gave $( tail -n1 "$LOG" | cut -c1-160 ) for control-side session [$DEFCTLSID]" +rm -f "$RIPWIRE_HOME/meter.conf" + +# meter.conf PARSING (issue #381 fix round 1; same arms as A10-A18 in routehookcheck, for this copy of +# resolve_arm): a final line without a newline is not lost, a CRLF value is not read as treatment, and a +# near-miss value still reads as treatment. Each session sits on the side the buggy reading would not give. +DEFTRTSID="" +for sid in d1 d2 d3 d4 d5 d6 d7 d8; do + h="$( printf '%s' "$sid" | cksum | cut -d' ' -f1 )"; [ "$(( h % 100 ))" -ge 50 ] && { DEFTRTSID="$sid"; break; } +done +conf_arm() # conf_arm LABEL SESSION CONFBYTES(printf format) WANT +{ + : >"$LOG"; printf "$3" >"$RIPWIRE_HOME/meter.conf" + ( unset RIPWIRE_METER_ARM; run_hook Bash "$( jq -cn --arg c "grep -rn confArm$1 src/" '{command:$c}' )" "$2" >/dev/null ) + got="$( tail -n1 "$LOG" | jq -r '.arm // ""' 2>/dev/null )" + rm -f "$RIPWIRE_HOME/meter.conf" + [ -n "$2" ] && [ "$got" = "$4" ] && ok "$1: toolroute meter.conf [$3] -> $4" \ + || no "$1: toolroute meter.conf [$3] gave arm=[$got], want $4 (session [$2])" +} +conf_arm T10 "$DEFCTLSID" 'arm=treatment' treatment +conf_arm T11 "$DEFTRTSID" 'arm=control' control +conf_arm T12 "$DEFTRTSID" 'arm=control\r\n' control +conf_arm T13 "$DEFCTLSID" 'arm=auto\r\n' control +conf_arm T14 "$DEFTRTSID" 'sweep=0\r\narm=control\r\nenabled=1\r\n' control +conf_arm T15 "$DEFTRTSID" 'sweep=0\narm=control' control +conf_arm T16 "$DEFCTLSID" 'arm=controlx\n' treatment +conf_arm T17 "$DEFCTLSID" 'arm=control\rx\n' treatment +conf_arm T18 "$DEFCTLSID" 'arm=treatment\r\n' treatment + +# ═══════════════════════════════════════════════════════════════════════════════════════════════════ +# This hook's notification guard matches markers INSIDE a tool input; it deliberately does NOT carry the +# prompt hooks' `` is ordinary RSS/XML text that a grep pattern +# legitimately names. A grep for it must still route. +# ═══════════════════════════════════════════════════════════════════════════════════════════════════ +: >"$LOG" +CHOUT="$( run_hook Grep "$( jq -cn '{pattern:"",path:"src"}' )" "chan-tool-1" )" +tail -n1 "$LOG" | jq -e '.status == "recommend"' >/dev/null 2>&1 && [ -n "$CHOUT" ] \ + && ok "a Grep whose pattern names still routes (the guard is for prompt wrappers, not tool inputs)" \ + || no "a Grep for was suppressed: out=[$CHOUT] row=$( tail -n1 "$LOG" | cut -c1-160 )" + # ═══════════════════════════════════════════════════════════════════════════════════════════════════ # Malformed / degenerate input: exit 0, empty (or valid-JSON) stdout, never a crash. # ═══════════════════════════════════════════════════════════════════════════════════════════════════ diff --git a/test/truncvocabcheck.sh b/test/truncvocabcheck.sh index ed9bfba24..503aaf4c8 100755 --- a/test/truncvocabcheck.sh +++ b/test/truncvocabcheck.sh @@ -318,7 +318,9 @@ run "$TINY" --for="add one to a number" > "$TMP/tiny_for.xml" grep -q '' "$TMP/tiny_for.xml" \ && ok "(C) --for untrimmed: bare , no marker (rule 5: absent = untrimmed)" \ || no "(C) --for on a two-symbol corpus is not the bare untrimmed " -grep -qE '' "$TMP/sweep_forbudget.xml" \ +# knob-honesty-068: a trimmed tag also carries its continuation, next= (the call that serves it uncut) — the pair is +# still asserted immediately after the tag name; only the closing `>` admits the trailing attributes the tag may carry. +grep -qE '' "$TMP/sweep_forbudget.xml" \ && ok "(C) --for trimmed by --token-budget: (rule 5, amended: the pair rides on a byte trim too)" \ || no "(C) --for --token-budget=800 did not mark its trimmed payload : $( grep -oE ']*>' "$TMP/sweep_forbudget.xml" | head -1 )" diff --git a/test/xmlwellformed.sh b/test/xmlwellformed.sh index 80549e5a9..ff77af983 100755 --- a/test/xmlwellformed.sh +++ b/test/xmlwellformed.sh @@ -14,6 +14,8 @@ # --arch --baseline arch layering with baseline mode # --around=SYM ego-graph pack, walked over a SYMBOL SAMPLE (see §B4b below) # --edit-check=SYM contract-vs-HEAD bundle (CA4 §B15: this gate did not exercise it at all) +# --impact=SYM --legend=full with a via="name" row: the present-only FE-B clause inside the legend comment +# (CLI, columnar and the MCP twin — CodeRabbit 5468003465) # + the additive-flag shapes (--for --detail/--with-graph, --pack-task, --lego, --exemplar, --query # --adaptive, --expand+--outline+--pack-signatures together, --max-tokens): a verb's WIDEST form is # where a second top-level element appears, and a bare invocation cannot see it (trap #7). @@ -334,6 +336,34 @@ case "$MCPTXT" in esac fi # end of the python3-dependent #60 section +# ── FE-B via="name" under --legend=full: --impact's present-only clause (CodeRabbit 5468003465) ────────── +# viaNameLegend( present, impact = true ) is spliced INSIDE the --impact legend comment, on the CLI and on MCP, and +# rides only when a shown row is via="name" and the posture is full — the compact default never carries it, which +# is why no arm above could see "An --impact row" put a double hyphen inside a comment. Premise first: this root's +# answer really carries a via="name" row AND the clause, so the well-formedness arms below are not vacuous. +RE="$ROOT/test/receiverevidencefix/js" +IMPF="$( "$BIN" "$RE" --no-cache --impact=lib/reply.js:send --legend=full 2>/dev/null )" +if printf '%s' "$IMPF" | grep -q ' via="name"' && printf '%s' "$IMPF" | grep -q 'impact row reached only through such an edge'; then + ok "FE-B premise: --impact=lib/reply.js:send --legend=full carries a via=\"name\" row and the impact clause" +else + no "FE-B premise: no via=\"name\" row or no impact clause in --impact=lib/reply.js:send --legend=full — the arms below prove nothing" +fi +check_xml "FE-B --impact with a via=\"name\" row, --legend=full" "$RE" --impact=lib/reply.js:send --legend=full +check_xml "FE-B --impact with a via=\"name\" row, --legend=full --format=columnar" "$RE" --impact=lib/reply.js:send --legend=full --format=columnar +if ! command -v python3 >/dev/null 2>&1; then + printf ' SKIP FE-B MCP impact twin — python3 is not on PATH (prerequisite, not a failure)\n' +else + MIMP="$( mcp_text impact "{\"path\":\"$RE\",\"symbol\":\"lib/reply.js:send\",\"legend\":\"full\"}" )" + case "$MIMP" in + __ERROR__*|"") no "FE-B MCP impact did not answer: ${MIMP:-empty}" ;; + *'impact row reached only through such an edge'*) + printf '%s' "$MIMP" | xmllint --noout - 2>/dev/null \ + && ok "FE-B MCP impact (legend=full, a via=\"name\" row) is well-formed" \ + || { no "FE-B MCP impact (legend=full, a via=\"name\" row) rejected by xmllint"; printf '%s' "$MIMP" | xmllint --noout - 2>&1 | head -3; } ;; + *) no "FE-B MCP impact premise: legend=full answer carries no impact clause — the arm proves nothing" ;; + esac +fi + if [ "$fail" -eq 0 ]; then echo "ALL PASS" exit 0

@@ -2962,7 +2978,7 @@ $ ./build/ripwire . --quality-delta --quality-ack --ack-only=zzznope **Answers:** (with --quality-ack) ack only SOME findings — those whose KIND, canonical id or FACET matches (with --quality-ack) ack only SOME findings — those whose KIND, canonical id, or FACET contains one of these; -the pseudo-token 'gating' selects exactly what would exit 2. Bare --quality-ack accepts the WHOLE report, so accepting one deliberate change silently accepts the rest — how a ratchet turns into a rubber stamp. Prefer the facet: --ack-only=contract-change acks the deliberate arity changes WITHOUT the never-gating api-surface new-symbol rows. Matching nothing refuses (exit 1) rather than falling back to acking everything. Whatever you leave unacked stays visible. +the pseudo-token 'gating' selects exactly what would exit 2. Bare --quality-ack accepts the WHOLE report, so accepting one deliberate change silently accepts the rest — how a ratchet turns into a rubber stamp. Prefer the facet: --ack-only=contract-change acks the deliberate arity changes WITHOUT the never-gating api-surface new-symbol rows; a defect-shape facet (format-arity, utf8-cut, dedup-first, vacuous-assert) selects that shape alone. Matching nothing refuses (exit 1) rather than falling back to acking everything. Whatever you leave unacked stays visible. **Try it** @@ -2977,7 +2993,7 @@ $ ./build/ripwire . --ack-only=gating **Caveats (stated by the binary):** -- Prefer the facet: --ack-only=contract-change acks the deliberate arity changes WITHOUT the never-gating api-surface new-symbol rows. +- Prefer the facet: --ack-only=contract-change acks the deliberate arity changes WITHOUT the never-gating api-surface new-symbol rows; - Matching nothing refuses (exit 1) rather than falling back to acking everything. ### `--scope=GLOB[,GLOB...]` @@ -2992,8 +3008,8 @@ _OWNERSHIP partition for a shared tree: every regression here lives in src/infra ``` $ ./build/ripwire . --quality-delta --scope=src/graph.h - -, acked. value-ref-excluded=N: internal functions kept off because a table, field or argument in another function or at file scope holds them (matched by name, not a proven call); a floor. at=: commit+dirty+shallow. stale=N: ack ledger rows whose target no longer applies (sa rows); never gating. preexisting-worse=N: regressions on symbols that existed at baseline; they gate when major. new-symbol=N: findings on NEW code; new-symbol rows never gate, except defect-shape format-arity. register-macro-excluded=N: symbols kept out of dead-code as self-registering test/bench macros; a floor. api-new-surface=N: new PUBLIC symbols; a count, never gates, not in regressions=. renames=/rename_window_commits=: git rename pairs read over that many commits, to re-file baseline and acks. acked_by_rename=/acked_by_content=: acked= suppressions matched via git renames / an equal body hash. renames_window_truncated=1: history is deeper than the rename window, older renames unread. r surface=: the api-surface tier, new-symbol or contract-change. acked=N: findings suppressed by the ack ledger, listed as sa rows; never gating. r sev=minor: a small numeric delta, counted in minor=, never gating (absent: major). r origin=new-symbol: a finding on NEW code (absent: preexisting-worse). sa key=/why=: the stale ack's ledger hash / target-gone (names nothing now) or finding-gone (no longer fires). r members=/tokens=: a duplication row's clone group (member ids) / their shared normalized-token count. --> + @@ -3022,15 +3038,15 @@ _Fast per-symbol post-edit contract check vs git HEAD (unchanged on a clean tree ``` $ ./build/ripwire . --edit-check=rankGraphTeleport - - + + - - - - - - + + + + + + ``` @@ -3077,11 +3093,12 @@ $ ./build/ripwire . --insert-before-symbol=nonNegativeFloatDescKey --edit-payloa {"n":"benchAdaptive","p":"bench/bench_radix_ab.cpp:157","l":[162]}, {"n":"radixSortNonNegativeFloatsDesc","p":"src/infra/sortutil.h:105","l":[114]}, {"n":"radixSortByScoreDescId","p":"src/infra/sortutil.h:120","l":[180]}], -"graph_ambiguous":7928,"graph_unresolved":4925,"graph_unindexed":219,"counts_floor":true},"tests_to_run":[{"p":"test/verify_radix.cpp","hops":1,"run":"bash test/greptiercheck.sh"}, +"graph_ambiguous":12495,"graph_unresolved":12552,"graph_unindexed":255,"counts_floor":true},"tests_to_run":[{"p":"test/verify_radix.cpp","hops":1,"run":"bash test/greptiercheck.sh"}, {"p":"test/adaptivecutshapefix/adaptive_cut_shape_test.cpp","hops":2,"run":"bash test/adaptivecutshapecheck.sh"}, {"p":"test/includeprecise_unit.cpp","hops":2,"run":"bash test/includeprecisecheck.sh"}, -{"p":"test/verify_csr.cpp","hops":2,"run":"bash test/a9disclosurecheck.sh"}], -"order":"evidence","partners":0,"tests":4,"script_gates_unmodelled":675,"graph_ambiguous":7928,"graph_unresolved":4925,"graph_unindexed":219,"counts_floor":true,"next":"--uses=src/infra/sortutil.h:nonNegativeFloatDescKey"} +{"p":"test/verify_csr.cpp","hops":2,"run":"bash test/a9disclosurecheck.sh"}, +{"p":"test/rustimport_unit.cpp","hops":4,"run":"bash test/rustimportprecisecheck.sh"}], +"order":"evidence","partners":0,"run_first":1,"tests":5,"script_gates_unmodelled":743,"graph_ambiguous":12495,"graph_unresolved":12552,"graph_unindexed":255,"counts_floor":true,"next":"--uses=src/infra/sortutil.h:nonNegativeFloatDescKey"} ``` ### `--insert-after-symbol=TARGET` @@ -3097,11 +3114,12 @@ _Insert immediately AFTER one uniquely-resolved definition; replaced_bytes=0 bec ``` $ ./build/ripwire . --insert-after-symbol=lessByScoreDescId --edit-payload=/aux/payload_note.h {"applied":"insert_after_symbol","symbol":"lessByScoreDescId","file":"src/infra/sortutil.h","span":{"start":2375,"end":2463},"lines":{"start":56,"end":58},"replaced_bytes":0,"old_file_bytes":10824,"new_file_bytes":10912,"file_eol":"lf","eol_normalized":false,"trailing_newline_folded":true,"separator … [line truncated: 652 more bytes on this line] -"graph_ambiguous":7928,"graph_unresolved":4925,"graph_unindexed":219,"counts_floor":true},"tests_to_run":[{"p":"test/verify_radix.cpp","hops":1,"run":"bash test/greptiercheck.sh"}, +"graph_ambiguous":12495,"graph_unresolved":12552,"graph_unindexed":255,"counts_floor":true},"tests_to_run":[{"p":"test/verify_radix.cpp","hops":1,"run":"bash test/greptiercheck.sh"}, {"p":"test/adaptivecutshapefix/adaptive_cut_shape_test.cpp","hops":2,"run":"bash test/adaptivecutshapecheck.sh"}, {"p":"test/includeprecise_unit.cpp","hops":2,"run":"bash test/includeprecisecheck.sh"}, -{"p":"test/verify_csr.cpp","hops":2,"run":"bash test/a9disclosurecheck.sh"}], -"order":"evidence","partners":0,"tests":4,"script_gates_unmodelled":675,"graph_ambiguous":7928,"graph_unresolved":4925,"graph_unindexed":219,"counts_floor":true,"next":"bash test/greptiercheck.sh"} +{"p":"test/verify_csr.cpp","hops":2,"run":"bash test/a9disclosurecheck.sh"}, +{"p":"test/rustimport_unit.cpp","hops":4,"run":"bash test/rustimportprecisecheck.sh"}], +"order":"evidence","partners":0,"run_first":1,"tests":5,"script_gates_unmodelled":743,"graph_ambiguous":12495,"graph_unresolved":12552,"graph_unindexed":255,"counts_floor":true,"next":"bash test/greptiercheck.sh"} ``` **Caveats (stated by the binary):** @@ -3147,11 +3165,12 @@ $ ./build/ripwire . --insert-before-symbol=nonNegativeFloatDescKey --edit-payloa {"n":"benchAdaptive","p":"bench/bench_radix_ab.cpp:157","l":[162]}, {"n":"radixSortNonNegativeFloatsDesc","p":"src/infra/sortutil.h:105","l":[114]}, {"n":"radixSortByScoreDescId","p":"src/infra/sortutil.h:120","l":[180]}], -"graph_ambiguous":7928,"graph_unresolved":4925,"graph_unindexed":219,"counts_floor":true},"tests_to_run":[{"p":"test/verify_radix.cpp","hops":1,"run":"bash test/greptiercheck.sh"}, +"graph_ambiguous":12495,"graph_unresolved":12552,"graph_unindexed":255,"counts_floor":true},"tests_to_run":[{"p":"test/verify_radix.cpp","hops":1,"run":"bash test/greptiercheck.sh"}, {"p":"test/adaptivecutshapefix/adaptive_cut_shape_test.cpp","hops":2,"run":"bash test/adaptivecutshapecheck.sh"}, {"p":"test/includeprecise_unit.cpp","hops":2,"run":"bash test/includeprecisecheck.sh"}, -{"p":"test/verify_csr.cpp","hops":2,"run":"bash test/a9disclosurecheck.sh"}], -"order":"evidence","partners":0,"tests":4,"script_gates_unmodelled":675,"graph_ambiguous":7928,"graph_unresolved":4925,"graph_unindexed":219,"counts_floor":true,"next":"--uses=src/infra/sortutil.h:nonNegativeFloatDescKey"} +{"p":"test/verify_csr.cpp","hops":2,"run":"bash test/a9disclosurecheck.sh"}, +{"p":"test/rustimport_unit.cpp","hops":4,"run":"bash test/rustimportprecisecheck.sh"}], +"order":"evidence","partners":0,"run_first":1,"tests":5,"script_gates_unmodelled":743,"graph_ambiguous":12495,"graph_unresolved":12552,"graph_unindexed":255,"counts_floor":true,"next":"--uses=src/infra/sortutil.h:nonNegativeFloatDescKey"} ``` **Shaped by:** `--insert-after-symbol` @@ -3210,11 +3229,11 @@ _The same plan committed: per-file locks, re-verify-before-write, atomic rename, ``` $ ./build/ripwire . --edit-plan=/aux/edit_plan.json --apply -{"schema":"ripwire.edit-plan/v1","mode":"apply","edits":1,"files":1,"callers_union":4,"graph_ambiguous":7928,"graph_unresolved":4925,"graph_unindexed":219,"counts_floor":true,"applied":1,"atomic_files":1,"atomic_scope":"per-file","rollback_on_write_error":true,"recheck_before_each_write":true,"multi … [line truncated: 536 more bytes on this line] +{"schema":"ripwire.edit-plan/v1","mode":"apply","edits":1,"files":1,"callers_union":4,"graph_ambiguous":12495,"graph_unresolved":12552,"graph_unindexed":255,"counts_floor":true,"applied":1,"atomic_files":1,"atomic_scope":"per-file","rollback_on_write_error":true,"recheck_before_each_write":true,"mul … [line truncated: 529 more bytes on this line] {"n":"benchAdaptive","p":"bench/bench_radix_ab.cpp:157","l":[162]}, {"n":"radixSortNonNegativeFloatsDesc","p":"src/infra/sortutil.h:107","l":[116]}, {"n":"radixSortByScoreDescId","p":"src/infra/sortutil.h:122","l":[182]}], -"graph_ambiguous":7928,"graph_unresolved":4925,"graph_unindexed":219,"counts_floor":true}}]} +"graph_ambiguous":12495,"graph_unresolved":12552,"graph_unindexed":255,"counts_floor":true}}]} ``` **Shaped by:** `--edit-check` @@ -3228,7 +3247,7 @@ $ ./build/ripwire . --edit-plan=/aux/edit_plan.json --apply **Answers:** "can I delete this?" — one call joining callers, blast radius, tests and history "can I delete this?" — ONE call composing signals the tool already computes for one already-resolved SYM: 1-hop callers=, the transitive --impact blast radius (impact_reaches=), every --uses read/write/import/call/extends site (uses=), how much of the blast radius the tested= lens covers (tested_self=/radius_tested=/radius_untested=), and --dead-code's own zero-caller/internal-linkage shape at defs=1 (dead_code_candidate=). -ambiguous_callers= names callers whose own calls include an ambiguously-resolved one (g.ambOut) — a caveat, not a count of proven-wrong edges. FACTS only: risk= names what was found — none-found (zero callers AND zero uses), untested-radius (a radius exists and none of it is test-covered), or uses-exist (a radius exists and some of it is tested) — never a go/no-go verdict. +ambiguous_callers= names callers whose own calls include an ambiguously-resolved one (g.ambOut) — a caveat, not a count of proven-wrong edges. FACTS only: risk= names what was found — none-found (zero callers AND zero uses), untested-radius (a radius exists and none of it is test-covered), or uses-exist (a radius exists and some of it is tested) — never a go/no-go verdict; unmodelled when nothing was found for a kind used by reading or naming it (a variable, class, struct) whose uses those counts cannot see. callers_floor=/uses_floor= mark a count the index holds evidence of a miss for, and next= is the call that lists the rest. A caller row's sites_l= is its call-site LINES (p= stays the line where the caller is defined). **Try it** @@ -3244,7 +3263,8 @@ $ ./build/ripwire . --safe-delete=DoesNotExist **Caveats (stated by the binary):** - ambiguous_callers= names callers whose own calls include an ambiguously-resolved one (g.ambOut) — a caveat, not a count of proven-wrong edges. -- FACTS only: risk= names what was found — none-found (zero callers AND zero uses), untested-radius (a radius exists and none of it is test-covered), or uses-exist (a radius exists and some of it is tested) — never a go/no-go verdict. +- FACTS only: risk= names what was found — none-found (zero callers AND zero uses), untested-radius (a radius exists and none of it is test-covered), or uses-exist (a radius exists and some of it is tested) — never a go/no-go verdict; +- unmodelled when nothing was found for a kind used by reading or naming it (a variable, class, struct) whose uses those counts cannot see. ### `--slice=SYM[:VAR]` @@ -3258,20 +3278,20 @@ _Bare --slice=SYM: the INVENTORY of sliceable locals (), so a calle ``` $ ./build/ripwire . --slice=rankGraphTeleport - - - - - - - - - - - - - - + + + + + + + + + + + + + + ... [3 more line(s); run it to see the whole thing] ``` @@ -3295,21 +3315,21 @@ _Forward flow: which statements the seed's value reaches, at the default depth b ``` $ ./build/ripwire . --slice=rankGraphTeleport:teleport --slice-flow=fwd - - - - + + + + + + teleport( pw.begin(), pw.end() );]]> - + - + - - -... [23 more line(s); run it to see the whole thing] +... [22 more line(s); run it to see the whole thing] ``` **Shaped by:** `--slice-depth` @@ -3350,11 +3370,10 @@ $ ./build/ripwire . --slice-depth=3 _Hold a LOCATION, not a name: the enclosing-definition chain at FILE:LINE (a compiler error, a diff hunk, a stack frame), outermost -> innermost._ ``` -$ ./build/ripwire . --at=src/graph.h:7397 - - - - +$ ./build/ripwire . --at=src/graph.h:7926 + + + ``` @@ -3377,8 +3396,8 @@ _No-LLM review-evidence bundle for the working-tree diff (clean tree = empty)._ ``` $ ./build/ripwire . --pr-context - - + + ``` @@ -3403,12 +3422,12 @@ _Pairwise cross-arm conflict sites + suggested landing order (any committish sha ``` $ ./build/ripwire . --merge-scout=HEAD~2,HEAD~1 - - - + + + - + @@ -3478,13 +3497,22 @@ Per local ref (SUBSTR filters ref names): the lines its own divergent work AUTHO **Try it** -_Which refs of the one branch `main` (no ref family left to select on this checkout) still hold divergent authored work vs HEAD, with verdicts._ +_Which refs of the `lane/` ref family still hold divergent authored work vs HEAD, with verdicts._ ``` -$ ./build/ripwire . --stray-content=main - - - +$ ./build/ripwire . --stray-content=lane/ + + + + + + + + + + + +... [20 more line(s); run it to see the whole thing] ``` **Shaped by:** `--plan`, `--abi`, `--whereis`, `--limit`, `--eval-stray` @@ -3503,13 +3531,25 @@ $ ./build/ripwire . --stray-content=main **Try it** -_Select the genuinely-unmerged refs of the one branch `main` (no ref family left to select on this checkout) and feed them to merge-scout for a landing order (a fully merged selection yields an empty landing set — still a measurement, disclosed on the root)._ +_Select the genuinely-unmerged refs of the `lane/` ref family and feed them to merge-scout for a landing order (a fully merged selection yields an empty landing set — still a measurement, disclosed on the root)._ ``` -$ ./build/ripwire . --stray-content=main --plan - - - +$ ./build/ripwire . --stray-content=lane/ --plan + + + + + + + + + + + + + + +... [17 more line(s); run it to see the whole thing] ``` **Shaped by:** `--stray-content`, `--legend`, `--json` @@ -3527,13 +3567,25 @@ SCOPE is what each ref AUTHORED — the paths `git diff base..tip` reports again **Try it** -_Cross-branch ABI-break gate over the one branch `main` (no ref family left to select on this checkout): struct byte-contract drift on each ref's AUTHORED paths — exit 2 when any drift row is found (the only kind that gates), 0 when the compared refs are clean, and exit 1 if the --stray-content filter matches no ref at all._ +_Cross-branch ABI-break gate over the `lane/` ref family: struct byte-contract drift on each ref's AUTHORED paths — exit 2 when any drift row is found (the only kind that gates), 0 when the compared refs are clean, and exit 1 if the --stray-content filter matches no ref at all._ ``` -$ ./build/ripwire . --stray-content=main --abi - - - +$ ./build/ripwire . --stray-content=lane/ --abi + + + + + + + + + + + + + +... [2 more line(s); run it to see the whole thing] ``` **Caveats (stated by the binary):** @@ -3546,25 +3598,25 @@ $ ./build/ripwire . --stray-content=main --abi **Answers:** find which branch's tree defines or mentions SYM, HEAD first which REF's tree defines or mentions SYM — HEAD first, then every local branch, with on-head="0" naming the case the verb exists for: content that lives only on a branch. -Each distinct blob is read ONCE (content-addressed), so N branches cost ~one tree. kind="def" on a HEAD row is the PARSED index's answer (head_labels="index"); on a REF row it is a LEXICAL heuristic — ref blobs are raw text, never ingested, so a doc quoting a signature still reads as a definition. head_labels="lexical" ⇒ HEAD fell back to that heuristic too (no indexed def of the name, or a working tree that drifted from HEAD). refs_scanned= is the SCAN denominator (refs read besides HEAD), not a matched count. Read-only; single-root only. A checkout that differs from HEAD is read from disk: each changed path's rows say ref="worktree" and replace HEAD's, at= gains +dirty, and worktree= says whether every one was read. LIMITS: a TREE scan finds only what some ref STILL carries, so hits="0" alone cannot tell a name this repo never had from one it deleted, and content dropped by every tree is invisible. Add --with-history: a row then says v="never" or v="removed" with the commit, date and file that removed it. Remote-tracking refs are excluded (they mirror local ones); refs are capped, narrow with --stray-content=SUBSTR. +Each distinct blob is read ONCE (content-addressed), so N branches cost ~one tree. kind="def" on a HEAD row is the PARSED index's answer (head_labels="index"); on a REF row it is a LEXICAL heuristic — ref blobs are raw text, never ingested, so a doc quoting a signature still reads as a definition. head_labels="lexical" ⇒ HEAD fell back to that heuristic too (no indexed def of the name, or a working tree that drifted from HEAD). refs_scanned= is the SCAN denominator (refs read besides HEAD), not a matched count. Read-only; single-root only. A checkout that differs from HEAD is read from disk: each changed path's rows say ref="worktree" and replace HEAD's, at= gains +dirty, and worktree= says whether every one was read. LIMITS: a TREE scan finds only what some ref STILL carries, so hits="0" alone cannot tell a name this repo never had from one it deleted, and content dropped by every tree is invisible. Add --with-history: a row then says v="never" or v="removed" with the commit, date and file that removed it. Remote-tracking refs are excluded (they mirror local ones); refs are capped, narrow with --stray-content=SUBSTR. LISTING: by default only the kind="def" rows are listed and the kind="ref" rows counted in one element, when that page lists MORE definitions than the --whereis-listing=all page under the row cap, or the same ones in strictly fewer bytes; otherwise every hit is listed. **Try it** -_Which ref's tree defines or mentions SYM — HEAD first, then every local branch._ +_Which ref's tree defines or mentions SYM — HEAD first, then every local branch. The default lists the kind="def" rows (here the first 60, most on branches, each carrying tip= and date=) and counts the references in : on this symbol HEAD's references would fill the 60-row cap before the branch definitions arrive, so the whole list (below) shows fewer definitions, and the default serves the page that shows more of them, whatever its bytes._ ``` $ ./build/ripwire . --whereis=rankGraphTeleport - - - - - - + + + + + + ... [24 more line(s); run it to see the whole thing] ``` -**Shaped by:** `--with-history`, `--limit` +**Shaped by:** `--whereis-listing`, `--with-history`, `--limit` **Caveats (stated by the binary):** @@ -3572,6 +3624,36 @@ $ ./build/ripwire . --whereis=rankGraphTeleport - head_labels="lexical" ⇒ HEAD fell back to that heuristic too (no indexed def of the name, or a working tree that drifted from HEAD). - refs_scanned= is the SCAN denominator (refs read besides HEAD), not a matched count. +### `--whereis-listing=WHICH` + +**Answers:** with --whereis: which rows to list, defs, refs or all (default: more defs, else shorter). + +The default lists defs when that page lists MORE definitions than all under the same row cap (references can fill a capped all page before the branch definitions arrive), whatever its bytes; when both list the same definitions, defs only when strictly shorter in bytes, else all (a tie lists all). defs lists every kind="def" row and COUNTS the kind="ref" rows in one element whose next= lists exactly them (refs). kind="def" is the parser's label: a definition it does not model (define_method, setattr, a name bound by assignment) is a counted ref. With no ref row, or no def row (the mentions are then the answer), every hit is listed and the root carries no listing=. all lists every row, the whole hit list. shown=/capped= and --limit/--offset window the LISTED rows; hits= counts every row. Refused without --whereis, and on an unknown value. + +**Try it** + +_The whole hit list asked for by name: under the same 60-row cap HEAD's references fill the list, so it shows far fewer definitions (here HEAD's one) than the default above (here 60). The default serves the all page only when it shows the same definitions as the defs page in strictly fewer bytes (a tie serves it too)._ + +``` +$ ./build/ripwire . --whereis=rankGraphTeleport --whereis-listing=all + + + + + + + - + - + - + @@ -3666,9 +3748,9 @@ _The cache-locality lens over every aggregate: fields READ TOGETHER but declared ``` $ ./build/ripwire . --field-affinity - - + + @@ -3679,7 +3761,7 @@ $ ./build/ripwire . --field-affinity - + ... [17 more line(s); run it to see the whole thing] ``` @@ -3701,18 +3783,18 @@ _Which of this repo's doc claims are now false. CHANGED: row attribute at= renam ``` $ ./build/ripwire . --doc-drift - - - - - - - - - - - - + + + + + + + + + + + + ... [19 more line(s); run it to see the whole thing] ``` @@ -3736,18 +3818,18 @@ _Which of this repo's doc claims are now false. CHANGED: row attribute at= renam ``` $ ./build/ripwire . --doc-drift - - - - - - - - - - - - + + + + + + + + + + + + ... [19 more line(s); run it to see the whole thing] ``` @@ -3770,19 +3852,19 @@ _Same report, with git history splitting stale mentions into deleted-by-commit v ``` $ ./build/ripwire . --doc-drift --with-history - - - - - - - - - - - - - + + + + + + + + + + + + + ... [18 more line(s); run it to see the whole thing] ``` @@ -3806,8 +3888,8 @@ _The house PLAN/DESIGN format's STRUCTURE check — never semantics; exit 2 when ``` $ ./build/ripwire . --plan-lint=test/planlintfix/wave.md - - + + @@ -3842,10 +3924,10 @@ $ ./build/ripwire . --from-trace=- AddressSanitizer:DEADLYSIGNAL ================================================================= ==41337==ERROR: AddressSanitizer: SEGV on unknown address 0x000000000018 (pc 0x000102f4a1c8 bp 0x00016d2f1a40 sp 0x00016d2f19e0 T0) - #0 0x102f4a1c8 in rw::rankGraphTeleport(Graph const&, std::vector const&, float) src/graph.h:3949 - #1 0x102f3e884 in rw::rankGraph(Graph const&, float) src/graph.h:3447 - #2 0x102e11f30 in runDefaultMap(MainDispatch const&) src/main.cpp:1441 - #3 0x102e01a44 in main src/main.cpp:3475 + #0 0x102f4a1c8 in rw::rankGraphTeleport(Graph const&, std::vector const&, float) src/graph.h:7926 + #1 0x102f3e884 in rw::rankGraph(Graph const&, float) src/graph.h:7967 + #2 0x102e11f30 in runDefaultMap(MainDispatch const&) src/main.cpp:1634 + #3 0x102e01a44 in main src/main.cpp:3999 #4 0x1a2b3c0dc in start+0x9dc (dyld:arm64e+0x60dc) ==41337==ABORTING ``` @@ -3870,9 +3952,9 @@ _A command that exits 0: a minimal success record (exit, measured duration, disc ``` $ ./build/ripwire . --run-trace="true" - - - + + + ``` @@ -3931,12 +4013,13 @@ _List all field notes (write-side memory) — the committed .ripwire_notes at th ``` $ ./build/ripwire . --notes - - + + + - @@ -3944,8 +4027,7 @@ $ ./build/ripwire . --notes gate scripts' claim (~line 1305) is NOT enforced — the derived-vs-stated sibling loop here covers docs/EVALS.md only. It drifted 407→451 unnoticed (fixed 2026-08-23). To close: grep both files ('file:line:' parsing) in the gateCountClaims arm.]]> - -... [1 more line(s); run it to see the whole thing] +... [2 more line(s); run it to see the whole thing] ``` **Shaped by:** `--no-redact` @@ -3962,19 +4044,21 @@ _ONE budget-shared bundle: ranking + top bodies + caller sigs + notes + tests_to ``` $ ./build/ripwire . --pack-task="add a new output format flag to the CLI" - - - + + - + Print the authoritative CLI usage and flag catalog to the caller-provided output streaminline void printUsage( std::FILE* out ) noexcept - -ONE recipe path for every agent that can shell out, and the point is that the RECOMMENDATION does not vary by agent — only the MCP alternative does. Before this there were four near-identical CLI-fiinline void wrapEmitCliFirst( const AgentTarget& row, const std::string& token, c … [line truncated: 104 more bytes on this line] - -knows; these are all OUTSIDE that table. DERIVED, NOT ENUMERATED — the argument htmlPreemptedBy makes in full above, which applies here verbatim: firstFlagOutside() walks the rows parseArgs itself minline constexpr std::string_view kInRideAlong[] = - -F7: a hostile/garbled frame line number (e.g. a fuzzed or truncated trace) can exceed UINT32_MAX; unchecked `v*10+d` wraps mod 2^32 (4294967297 -> 1), which then confidently maps to a REAL line in theinline std::uint32_t toUint( std::string_view s, bool& overflowed ) noexcept -... [19 more line(s); run it to see the whole thing] + +claude's `mcp add … -- ripwire --mcp`: the flag goes on the end of that command line, ahead of its newlineinline std::string wrapCliAddPost( const AgentTarget& row, std::string_view toolsArg ) +... [17 more line(s); run it to see the whole thing] ``` **Shaped by:** `--top-k`, `--token-budget`, `--for`, `--test-gate`, `--expand`, `--compress`, `--no-redact`, `--partition` @@ -3997,21 +4081,19 @@ _Fan-out form: one shared core + 3 per-agent slices carved along call-graph comm ``` $ ./build/ripwire . --pack-task="add a new output format flag to the CLI" --partition=3 - - - + wraps a ; bundle role=core|partition i= symbols= modules= bytes= tokens=: one agent's ctx, symbols= ids assigned, bytes= its size; tokens= = est_tokens= = bytes/2.36 (flat, the densest rate; the inner ctx est_tokens= is language-weighted). Inner ctx task= root= budget_tokens= dropped_positive=: the task, p= base, the slice's token target, ranked candidates its budget cut. of_top=: rows ranked (far) / bodies (callers); s rel=caller|callee shared=: 1-hop edge, bodies reached (absent at 1). window: shown= total= capped= (capped=1 cut). run_unknown=1: no runner derivable (a guess would be worse). est_tokens=: price as emitted (an upper bound under compact). : that callee matched by name alone (receiver unproven); it does NOT mean the edge is false. : rank N in this ranking, rows in r= order. : cyclomatic/cognitive complexity. : N callers in the index (absent: not measured). route=: the ranker: name-exact(X) = the task names symbol X (anchors: its evidence), subtoken+body = conceptual BM25 (:broad = 1-2 plain words, plain rg may also win; :declined(...) = a name hit refused as a common name). sc=: enclosing scope; the full id is p::sc::n (p= of the row or its ) and selectors take it. next=: the one pasteable follow-up. pure=1: const/constexpr signature (Swift: non-mutating) and no transitive side effect found; a hint. partitions=/requested=: partitions carved / asked for. modules=/split=: call-graph groups found / cuts forced to split one. core_symbols=/surface=: ids in the shared core / core plus the assignable remainder. budget_per_agent_tokens=: one agent's budget, core_budget_tokens= plus partition_budget_tokens=. total_bytes=N: bytes of all bundles together. overlap_mean=/overlap_max=: pairwise Jaccard over the ids partitions name, before trimming. shared_symbols=/union_symbols=: ids two or more partitions name / ids any partition names. core_overlap=: share of the core surface a partition reaches anyway. --> + + + - + Print the authoritative CLI usage and flag catalog to the caller-provided output streaminline void printUsage( std::FILE* out ) noexcept - -ONE recipe path for every agent that can shell out, and the point is that the RECOMMENDATION doe…inline void wrapEmitCliFirst( const AgentTarget& row, const std::string& token, const std::string_view executablePath, const std::vector<std::string>& verbLines… - -knows; these are all OUTSIDE that table. DERIVED, NOT ENUMERATED — the argument htmlPreemptedB…inline constexpr std::string_view kInRideAlong[] = - -... [17 more line(s); run it to see the whole thing] + +claude's `mcp add … -- ripwire --mcp`: the flag goes on the end of that command line, ahead of…inline std::string wrapCliAddPost( const AgentTarget& row, std::string_view toolsArg ) + +ONE recipe path for every agent that can shell out, and the point is that the RECOMMENDATION doe…inline void wrapEmitCliFirst( const AgentTarget& row, const std::string& token, const std::string_view executablePath, const WrapListing& listing ) noexcept +... [19 more line(s); run it to see the whole thing] ``` **Caveats (stated by the binary):** @@ -4032,19 +4114,17 @@ _Task lens + a compact Mermaid flowchart of the top anchors' 1-hop edges._ ``` $ ./build/ripwire . --for="pagerank power iteration" --with-graph - - - - - + + + + + +NodeId order. Renumbering an otherwise-identical graph (the exact "same probe file, different so…inline constexpr float kScoreTieAbsEps = 1e-6f + Render one form of the disclosure. Empty string whenever there is nothing to say — no power it…inline std::string renderDisclosure( const RankDisclosure& d, DiscloseAs as ) - -What a ranked document discloses about the power iteration that ordered it. `isPageRank == false…struct RankDisclosure - -What a rank call hands back: the vector, and the power iteration's own account of itself. Struct…struct RankedGraph - -L10 (2026-09-04): the old wording claimed "the same corpus ranked by pagerank orders differently…inline constexpr const char* kChurnRankLegend = "<!-- rank_by=churn: k= is PageRank re-run with the teleport BIASED by git CHANGE-FREQUENCY over window= " "(a c… -... [19 more line(s); run it to see the whole thing] + +The PageRank power iteration itself — the numeric kernel every ranked document's order comes f…PageRankRun pageRankDouble( const sparseCsr<float>& inEdges, std::span<const double> weightedOutDegree, std::span<const double> teleport, std::span<double> r … [line truncated: 10 more bytes on this line] +... [21 more line(s); run it to see the whole thing] ``` **Shaped by:** `--partition` @@ -4080,7 +4160,7 @@ _One-turn sweep: 4 newline-delimited verb:arg sub-queries answered in ONE dedupe $ ./build/ripwire . --batch=/aux/batch2.txt for:incremental cache invalidation callers:rankGraphTeleport -grep:DEGRADED_PATH_ALERT +grep:DISCLOSE lego:Vehicle ``` @@ -4107,16 +4187,17 @@ _Environment self-check: binary staleness, grammars, cache dir, git, tracked-bin ``` $ ./build/ripwire . --doctor - - - - - - - - + + + + + + + ``` @@ -4140,20 +4221,20 @@ _--doctor plus a LIVE integration inspection for one agent: PATH binary, install ``` $ ./build/ripwire . --doctor --agent=claude - - - - - - - - + + + + + + + - - +... [2 more line(s); run it to see the whole thing] ``` **Caveats (stated by the binary):** @@ -4172,11 +4253,11 @@ _WHY a file is not in the index (oversize / excluded / unsupported-ext / gitigno ``` $ ./build/ripwire . --skipped - - + + - @@ -4213,7 +4294,9 @@ _Scan a single skill file for injection/exfiltration patterns before installing. ``` $ ./build/ripwire --scan-skill=skills/ripwire-orient/SKILL.md - + + + ``` **Shaped by:** `--scan-skills` @@ -4230,7 +4313,9 @@ _Scan a whole skills directory (exit 2 = CRITICAL, 1 = WARN). Explicit-DIR form ``` $ ./build/ripwire --scan-skills=skills - + + + ``` **Caveats (stated by the binary):** @@ -4258,21 +4343,21 @@ _Rank by git change-frequency prior instead of PageRank._ ``` $ ./build/ripwire . --rank-by=churn --top-k=5 - - - - - - - - - - - + + + + + + + + + - + -... [16 more line(s); run it to see the whole thing] + + +... [9 more line(s); run it to see the whole thing] ``` **Shaped by:** `--since`, `--in` @@ -4293,22 +4378,22 @@ a trailing slash is ignored; absolute paths and '..' are refused). The global block stays byte-identical, a second page follows it — n=/of= are its counts (of= IS the total, so the paging half carries no total=), capped="1" has_more="1" next_offset= offset= limit= page it, and next= replays THIS run's own corpus flags (--since/--exclude) so the page it names is a page of the same answer. The symbol map collapses to a disclosed stub — the map was not asked for and was not ranked at all, which is why the header carries no pr_iters=. merge_bombs_skipped= stays on the global block: it counts the window's skipped commits, not the directory's._ ``` -$ ./build/ripwire . --rank-by=churn-decay --since=HEAD~7 --exclude=test --exclude=docs --exclude=skills --in=src --limit=3 - - - - - - - - - - +$ ./build/ripwire . --rank-by=churn-decay --since=HEAD~4 --exclude=test --exclude=docs --exclude=skills --in=src --limit=3 + + + + + + + + + + + + - - - -... [16 more line(s); run it to see the whole thing] + +... [11 more line(s); run it to see the whole thing] ``` **Shaped by:** `--external-surface`, `--doctor`, `--legend`, `--max-memory` @@ -4361,16 +4446,16 @@ $ ./build/ripwire . --callers=rankGraphTeleport --format=bogus **Answers:** legend posture for every XML verb — compact is the default; -full restores the prose output legend posture for EVERY XML verb. The DEFAULT is compact: the legend is a FIXED ~3 KB of prose per call in its full form, so its share is a function of ANSWER SIZE, not of the verb: at least 40% of a small --callers/--uses/--impact/--affected answer (and more on --callees and --edit-check), a little of a large --for bundle — and the callers who pay it are agents, scripts and harnesses making repeated calls. READING ONE MAP AS A HUMAN, or need a definition's reasoning (a term you do not recognise, a floor or cap explained)? pass --legend=full: it restores the full prose legend, byte-identical to the default of 0.6.1 and earlier. compact keeps every row byte and every data/completeness attribute (counts_floor= capped= shown= total= has_more= next_offset= est_tokens= at= root= graph_ambiguous= …), adds a versioned schema id on the root (schema="ripwire./v1") and replaces the explanatory prose with ONE legend defining exactly the attributes the answer carries — the meanings live here and in the full legend. DATA comments stay (the map header, pack-task's body-omitted rows, +more). Per call this drops 2.8-5.8 KB on the navigation verbs (--edit-check's legend 7.4 KB -> 0.9 KB). --for compacts too (ripwire.for/v1 header); under --token-budget it never costs a row --legend=full would keep. The MCP twin is the argument legend, compact by default there as well, legend:"full" restores the prose. Runs with nothing to compact ignore the default; an ASKED --legend=compact refuses there, naming the verb: prose/markdown/JSON answers (--situ --recall --report --mermaid --html --plan-lanes --sarif --eval* --json), where --legend=full is a no-op, and the writers and servers (edit verbs, --note-add, --quality-baseline/--quality-ack, --index-out, --export, the server transports), which refuse either posture. ref is the MCP server's SESSION posture, not a CLI one: once a session reads the resource ripwire://legend-dict, answers list rows first, carry each definition once per session and end with . A CLI run has no session to hold a definition, so --legend=ref refuses; --legend-dict prints them all. +full restores the prose output legend posture for EVERY XML verb. The DEFAULT is compact: the legend is a FIXED ~3 KB of prose per call in its full form, so its share is a function of ANSWER SIZE, not of the verb: at least 40% of a small --callers/--uses/--impact/--affected answer (and more on --callees and --edit-check), a little of a large --for bundle — and the callers who pay it are agents, scripts and harnesses making repeated calls. READING ONE MAP AS A HUMAN, or need a definition's reasoning (a term you do not recognise, a floor or cap explained)? pass --legend=full: it restores the full prose legend, byte-identical to the default of 0.6.1 and earlier. compact keeps every row byte and every data/completeness attribute (counts_floor= capped= shown= total= has_more= next_offset= est_tokens= at= root= graph_ambiguous= …), adds a versioned schema id on the root (schema="ripwire./v1") and replaces the explanatory prose with ONE legend defining exactly the attributes the answer carries — the meanings live here and in the full legend. DATA comments stay (the map header, pack-task's body-omitted rows, +more). Per call this drops 2.8-5.8 KB on the navigation verbs (--edit-check's legend 7.4 KB -> 0.9 KB). --for compacts too (ripwire.for/v1 header); under --token-budget it never costs a row --legend=full would keep, unless its own header is the larger one and the row pays for to fit. The MCP twin is the argument legend, compact by default there as well, legend:"full" restores the prose. Runs with nothing to compact ignore the default; an ASKED --legend=compact refuses there, naming the verb: prose/markdown/JSON answers (--situ --recall --report --mermaid --html --plan-lanes --sarif --eval* --json), where --legend=full is a no-op, and the writers and servers (edit verbs, --note-add, --quality-baseline/--quality-ack, --index-out, --export, the server transports), which refuse either posture. ref is the MCP server's SESSION posture, not a CLI one: once a session reads the resource ripwire://legend-dict, answers list rows first, carry each definition once per session and end with . A CLI run has no session to hold a definition, so --legend=ref refuses; --legend-dict prints them all. **Try it** -_The same gating report under --legend=compact — same rows, same exit 2, schema="ripwire.quality-delta/v1", ~4 KB of legend down to one comment: the shape an agent's edit loop should run._ +_The same gating report under --legend=full — same rows, same exit 2; the default compact comment (schema="ripwire.quality-delta/v1", the shape an agent's edit loop should run) becomes ~4 KB of prose legend._ ``` -$ ./build/ripwire . --quality-delta --legend=compact - - + @@ -4383,7 +4468,7 @@ $ ./build/ripwire . --quality-delta --legend=compact **Caveats (stated by the binary):** - READING ONE MAP AS A HUMAN, or need a definition's reasoning (a term you do not recognise, a floor or cap explained)? pass --legend=full: it restores the full prose legend, byte-identical to the default of 0.6.1 and earlier. -- under --token-budget it never costs a row --legend=full would keep. +- under --token-budget it never costs a row --legend=full would keep, unless its own header is the larger one and the row pays for to fit. - ref is the MCP server's SESSION posture, not a CLI one: once a session reads the resource ripwire://legend-dict, answers list rows first, carry each definition once per session and end with . ### `--legend-dict[=roster]` @@ -4398,7 +4483,7 @@ _The session legend dictionary the MCP server serves as ripwire://legend-dict/fu ``` $ ./build/ripwire . --legend-dict -ripwire legend dictionary ripwire.dict/v1 dictv=04d7833c60d2d5bd entries=784 +ripwire legend dictionary ripwire.dict/v1 dictv=593769ab691623d8 entries=806 : the answer's rows come first; its root keeps only task= changed= from= to=, and this LAST child carries every other root attribute unchanged (schema= included); legend="ref": a definition is sent once per session (this dictionary's core, or the first answer that ne … [line truncated: 83 more bytes on this line] schema=ripwire.KEY/v1: the line ripwire.KEY/v1 below reads the answer's rows window: shown= total= capped= has_more= next_offset= offset= limit= page a list (capped=1 cut; next_offset= pastes as offset=) @@ -4449,20 +4534,20 @@ _The family join: per function, which of four orthogonal evidence families fire, ``` $ ./build/ripwire . --ensemble --limit=8 - - - - + + - + - - + + + + ... [17 more line(s); run it to see the whole thing] ``` @@ -4484,21 +4569,21 @@ _Drop matching paths (repeatable) before ranking._ ``` $ ./build/ripwire . --exclude=present --exclude=bench --top-k=5 - - - - - - - - - + + + + - + - + + + + -... [10 more line(s); run it to see the whole thing] + + +... [11 more line(s); run it to see the whole thing] ``` **Shaped by:** `--skipped`, `--in`, `--index-out` @@ -4515,20 +4600,20 @@ _Full map re-ranked with teleport toward git-changed files — clean tree, so ch ``` $ ./build/ripwire . --map-diff --top-k=5 - - - - - - - - - - + + + + - + + + + + + - + + ... [11 more line(s); run it to see the whole thing] ``` @@ -4549,21 +4634,20 @@ _Explicit incremental cache at a path OUTSIDE the repo (first call writes it)._ ``` $ ./build/ripwire . --cache=/aux/warm2.ripwirecache --top-k=3 - - - - - - - - - + + + + - + - + + + + -... [2 more line(s); run it to see the whole thing] + + ``` **Shaped by:** `--doctor`, `--index-out` @@ -4599,21 +4683,20 @@ _Force a cold parse (bypass the warm TMPDIR cache) — shows the cold-vs-warm co ``` $ ./build/ripwire . --no-cache --top-k=3 - - - - - - - - - + + + + - + - + + + + -... [2 more line(s); run it to see the whole thing] + + ``` **Shaped by:** `--doctor` @@ -4630,21 +4713,20 @@ _Crawl paths the repo's own .gitignore covers (default honours it and discloses ``` $ ./build/ripwire . --no-ignore --top-k=3 - - - - - - - - - + + + + - + - + + + + -... [2 more line(s); run it to see the whole thing] + + ``` **Caveats (stated by the binary):** @@ -4664,21 +4746,21 @@ _Skip files above a size bound before parsing (note the corpus shrink in the hea ``` $ ./build/ripwire . --max-file-size=8K --top-k=3 - - - - - - - - - - - + + + + + + + + + + -... [8 more line(s); run it to see the whole thing] + +... [3 more line(s); run it to see the whole thing] ``` **Shaped by:** `--skipped` @@ -4744,21 +4826,20 @@ _Eval-only: a per-call-site census of WHICH mechanism resolved each call, and th ``` $ ./build/ripwire . --pin-census=/aux/pin_census.tsv --top-k=3 - - - - - - - - - + + + + - + - + + + + -... [2 more line(s); run it to see the whole thing] + + ``` ### `--mcp` @@ -4773,11 +4854,11 @@ _initialize + tools/list: the manifest an agent host loads at session start — ``` $ ./build/ripwire '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize"}' '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' | ./build/ripwire --mcp -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-11-25","serverInfo":{"name":"ripwire","version":"1.0"},"capabilities":{"tools":{}},"instructions":"Map before reading files. Start a new task with explore; use from_trace for an error; use impact plus uses before changing a symbol; run edit_c … [line truncated: 368 more bytes on this line] +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-11-25","serverInfo":{"name":"ripwire","version":"1.0"},"capabilities":{"tools":{},"resources":{}},"instructions":"Map before reading files. Start a new task with explore; use from_trace for an error; use impact plus uses before changing a sym … [line truncated: 738 more bytes on this line] {"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"analyze","description":"Architecture map for a directory: signatures and the call graph for the top symbols. Use when landing cold in a repo or subdir, before reading files; for a task-scoped inventory use 'for', for one symbol's neighborhood find_ … [line truncated: 742 more bytes on this line] -{"name":"find_symbol","description":"A symbol's 1-hop neighborhood: the symbol (with a fetch_body handle) plus direct callers (calledBy) and callees (calls). Full transitive reach: 'impact'. Read/write/import sites, not just calls: 'uses'. JSON {symbol, calledBy, calls, defs, count, hop_tested, hop_ … [line truncated: 1111 more bytes on this line] -{"name":"find_referencing_symbols","description":"Direct (1-hop) callers of a symbol, each with a fetch_body handle. For the full transitive blast radius use 'impact', for read/write/import sites 'uses'. JSON {symbol, calledBy, defs, count, hop_tested, hop_untested, declined_calls, counts_floor}; ca … [line truncated: 874 more bytes on this line] -... [28 more line(s); run it to see the whole thing] +{"name":"rank_by","description":"The SAME architecture map 'analyze' serves, ranked by a different signal instead of plain PageRank. rank_by = pagerank (default, omit it — the CLI's own unbiased --rank-by=pagerank; 'analyze' can rank differently on a tree with uncommitted changes, where it biases … [line truncated: 1077 more bytes on this line] +{"name":"find_symbol","description":"A symbol's 1-hop neighborhood: the symbol (with a fetch_body handle) plus direct callers (calledBy) and callees (calls). Full transitive reach: 'impact'. Read/write/import sites, not just calls: 'uses'. JSON {symbol, calledBy, calls, defs, count, hop_tested, hop_ … [line truncated: 1181 more bytes on this line] +... [30 more line(s); run it to see the whole thing] ``` **Shaped by:** `--no-stable`, `--no-redact`, `--agent`, `--mcp-tools`, `--lsp`, `--listen` @@ -4787,12 +4868,44 @@ $ ./build/ripwire '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize"}' '{"jso - A root that is $HOME itself (a git repository or not), a filesystem or drive root, or a system tree (/usr, /etc, /System, %WINDIR% ...) is refused with "no project root: