diff --git a/.ripwire_quality_acks b/.ripwire_quality_acks index c0f8aa7ca..490c96ea5 100644 --- a/.ripwire_quality_acks +++ b/.ripwire_quality_acks @@ -95,6 +95,7 @@ ack api-surface 7ed8ad2c213537a4 7 R-R root-relative emission lane: threading th ack api-surface 7f2c3eefdf6e512e 3 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 api-surface 7f5e07a10dd97563 4 cid=1cc3fb32d2789e8a P2-2 regex hoist: passesPredicates takes the per-predicate compiled-regex table as one explicit parameter, every caller updated in the same commit (lane F; --lint/--match byte-identical) ack api-surface 802e513731103806 2 cid=55e7936512b0563d A3: TestRunnerIndex takes the run's crawl root so run= is spelled relative to root= (one absolute root per document, test/rootrelemitcheck.sh ARM 9). The parameter is the fix; every caller passes the root it already holds. +ack api-surface 80a2f3a7c92fbed4 5 cid=77016cc820932a87 param-receiver lane (2026-09-16, test/narrowcheck.sh arms 7-18): Narrower's constructor takes the one new input the fix needs — ScopedRecvDecls, the lexical declaration table Rule 2 and CHA-lite now read a parameter-declared receiver type from (4 -> 5 parameters); the table and the bindings it indexes are one struct precisely so this is ONE argument, not two. Single construction site (buildGraph), updated in the same commit. Every other quality-delta row this lane produced was fixed in code rather than acked: the ctorTypeOf|nodeTextOf and emitFnBind|pushTypedBind clones, the 6-parameter constructor, the 30-complexity builder, and bindsVisitNode's added ternaries. ack api-surface 80b08c75913ae76c 8 cid=2b1373e91d63396e by=src/* lane/n6-d, the registered offset-table retry of docs/EVALS.md 'The auto-cache key ignores --exclude' (bands (6)-(8)). All seven gating rows are this lane's own footprint on the two cache seams; the three rows that were REAL are FIXED rather than acked (below). (1) api-surface contract-change loadCache 4->5 and runParsePool 7->8. loadCache's old fourth parameter was 'long long& blobWriteNsOut'; it is replaced by the crawled-file list plus a CacheLoadStats out-struct, because the whole point of v15 is that a load deserialises ONLY the records for the files THIS crawl asked for, and a load that is not told the crawl cannot do that. runParsePool takes that same struct through so the RIPWIRE_CACHE_STATS line can report cached_records=/blob_entries= — the two numbers that make band (2) an executable fact instead of a wall-clock claim (test/cacheoffsetcheck.sh check (e)). Both are internal to ingest.cpp's single TU, one call site each, updated in the same commit; no consumer outside the TU ever saw either signature. (2) five short-horizon-churn churn=self rows on kCacheVersion, kIngestCacheVersionMirror, loadCache, saveCache and runParsePool: the footprint of editing exactly the symbols a format bump must edit, in a window that also holds the gate commit. Not thrash — a version constant and its gated mirror must move together in one commit by construction (qextractionkeycheck). WHAT WAS FIXED INSTEAD OF ACKED, because it was real: saveCache's complexity 94->125 and verbosity 285->408 are gone (zero regression) after the seven per-file fact-grouping loops moved to buildCacheFileIndexes, the path/order prologue to buildCachePathKeys, and the plan/carry/trailer work to buildCacheWritePlan/appendCarryRecord/finishCacheBlob; and the duplication row against ingest_sidecap.h TreeGuard::operator= is gone because ReadFd dropped its move-assignment for an openOnce() that fills an empty guard, the only mutation the type needs. Verification at this head: test/cacheoffsetcheck.sh ALL PASS (written RED first at 8411f7e), the whole cache family green, ASan+UBSan+LSan clean on cold store, warm load, subset load and carry-over save on both the fixture and this repo, three-run byte determinism, warm==--no-cache, xmllint clean. ack api-surface 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 | 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 api-surface 84b6bfc164c989e8 2 cid=87c39ba5f968fb34 M21(a) sa sym=/p=: staleAcksXml takes the caller's XML escaper as a template parameter (+1 param) because sym= carries a canonical id — corpus text — and quality.h sits BELOW serialize.h in the include order. testmap.h's runHint uses the same seam for the same reason; the alternative was including serialize.h from quality.h, which inverts the order. @@ -131,6 +132,7 @@ ack api-surface c22ce1db6b79b087 4 cid=3b28778477fcc103 M13 paging/budget parity ack api-surface c30037c3e4f345a9 3 fnbody-require lane: deliberate additive API widening (optional out-params, default nullptr, every existing caller unaffected) to disclose the lazy require/import distinction on --impact's importer tier; residual complexity/duplication is the twin-dialect emitter shape and the dual-mode importersOfFiles scan this feature requires, already extracted where a helper genuinely reduced it (scanImporterEdges, recordLazyPair); short-horizon-churn is this same commit's own edit history ack api-surface c4d50b7393d16274 6 cid=f8caa0a79ac1b73e 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 api-surface c8c7bb0104aa16b8 4 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) +ack api-surface c923e661c197b265 5 cid=dfc12ed8a1345875 param-receiver lane (2026-09-16, test/narrowcheck.sh arms 7-18): Narrower's constructor takes the one new input the fix needs — ScopedRecvDecls, the lexical declaration table Rule 2 and CHA-lite now read a parameter-declared receiver type from (4 -> 5 parameters); the table and the bindings it indexes are one struct precisely so this is ONE argument, not two. Single construction site (buildGraph), updated in the same commit. Every other quality-delta row this lane produced was fixed in code rather than acked: the ctorTypeOf|nodeTextOf and emitFnBind|pushTypedBind clones, the 6-parameter constructor, the 30-complexity builder, and bindsVisitNode's added ternaries. ack api-surface c9ff98d0736199ea 2 finding #7 (2026-08-15 harvest): gitOnlyOmissionNote must know WHICH git-only cause it is (no .git at all vs .git-with-no-HEAD) to stop asserting the false 'not a git repository' claim on an empty repo — the new isGitDir bool is the smallest signature that carries that fact ack api-surface ca97a4b6bf07887b 26 cid=239d91ee15dbd72e 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: 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 api-surface cb0f0b806aa1e4e8 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. diff --git a/CHANGELOG.md b/CHANGELOG.md index e342e942a..ac9aa70bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,62 @@ macOS arm64 on a later release) each went red against a mutant installer that re the arch or the OS alone. `test/portablebuildcheck.sh` #2h, which held the leg to its verified runner, Xcode and deployment target, retires with it. +### Fixed — a member call through a typed parameter was pinned to the caller's own class + +`int Decoy::plainCaller( Target& other ) { return other.pick( 1 ); }` answered `--callees=plainCaller` with one edge +to `Decoy::pick` — precise, no `amb=`, nothing disclosed, and wrong. Rule 2 narrowed a receiver only through a typed +LOCAL; a parameter's written type had been captured since the member-variable round but was read only by the field +use-site index, so the call fell through to the name ladder, whose locality tie-break hands a same-file tie to the +caller's own class. The same call through `Target other;` resolved correctly. + +Rule 2, and CHA-lite with it, now reads the written type of a parameter, a lambda parameter, a typed range-for +variable and a reference local LEXICALLY: the innermost declaration of the name whose scope covers the call site +decides, and only a written, unqualified type narrows. Both limits were measured before they were chosen. Folding +these types into Rule 2's flat per-function table minted three precise wrong edges on the gate fixture — a range-for +variable's type reaching a later `auto` loop of the same name, a same-named field read after the loop, and a +parameter hidden by an untyped loop variable. And a written type is recorded as its final segment against class +names that carry no namespace, so `const std::map& ref; ref.lower_bound( q )` narrowed to an unrelated in-repo +`map`: three such edges on a private C++/ObjC++ corpus of 129,759 call sites, which refusing qualified types removes +at the cost of 11 correct narrows through namespace- or class-qualified in-repo types (those sites keep their previous +answer). An include-visibility guard was measured first and rejected: path-precise includes miss include-root +spellings such as `"LinearMath/btVector3.h"`, and it refused about 150 correct narrows on that corpus to stop the +same three. The qualified text rides the declaration's record, so **kParserVer moves 96 → 97** and a warm cache is +reparsed once. + +Measured with `--pin-census --no-cache`, the `main` binary at `f8e6087c` against this change, on that corpus: 587 call +sites change target — 373 splits narrow (300 to a Rule-2 pin or the type's own overload set, 73 through the CHA cone), 147 +calls the ladder had declined gain an edge (`bound=` 80,432 → 80,583, `declined=` 17,552 → 17,401), 66 pins or splits +that did not contain the parameter's type move to it (40 of them `unique` pins to the one same-file method of the +wrong class), and one edge is lost — a friend function ripwire scopes inside its class, which the parameter's type +then names as the caller itself. 956 more sites keep their target and are now decided by Rule 2. Every category was +sampled and read against the source. On this repository's `src/`, 53 splits become one Rule-2 pin and nothing else +moves target. Wall time is unchanged within noise (three cold runs each on the same corpus, 1.66–2.51 s both). + +`test/narrowcheck.sh` arms 7-18 are the gate: nine rows red on `main`, arms 12-14 red on the flat-table fold, arm 17 +red on the lexical lookup without the qualifier guard, arm 15 asserting through the census that the site is decided +by Rule 2 rather than the locality tie-break. Five gates' controls were built on "a parameter has no binding" and now +use an untyped `auto` receiver — `narrowcheck`, `chacheck`, `chaconecheck`, `localitycheck` (whose call no longer +reached the tie-break it exists to test) and `resolverhonestycheck` F9 (whose `check_signal` row had gone vacuous on a +single edge). `fieldnarrowcheck`'s ambiguity gauge moves 7 → 6 because `shadowParam( Decoy& m_x )` now resolves to the +parameter's type, and its arm (s1) now also asserts that the shadowed field's `Pool::acquire` is not linked. Still +open, and unchanged by this entry: an untyped receiver (`auto x = make(); x.m()`) still reaches the locality +tie-break, and a typed LOCAL still reads the flat table, qualified-type collision included. + +Two floors this change does NOT remove, stated because the first one moves edges the wrong way. +**An abstract parameter type narrows onto its namesakes.** Rule 2 resolves `m` against definitions only, so a parameter +typed as an interface whose methods are pure-virtual declarations cannot narrow to it — and when unrelated classes +share the interface's final name segment and define `m`, the narrow lands on them instead. On rocksdb at +`0e2801ac3`, `--pin-census --no-cache` with the `main` binary at `f8e6087c` against this change: 79 call sites +(88 census rows) through an `Iterator*` parameter, such as `AssertItersEqual( Iterator* iter1, Iterator* iter2 )` in +`utilities/write_batch_with_index/write_batch_with_index_test.cc`, now split five ways over the nested `Iterator` classes in +`memtable/` (`skiplist.h`, `inlineskiplist.h`, `skiplistrep.cc`, `vectorrep.cc`, `hash_skiplist_rep.cc`), and none of +the five is right. Before this change 25 of them were a unique pin to a plausible override (`BlobCountingIterator::key`), +26 were a different split over overrides, and 28 had no edge. Every one is disclosed (`amb=`, `prov="split"`), but +each is a wrong answer rather than a missing one, and 28 are new edges. It is the same final-segment collision typed +locals already have on `main`; this change extends it to parameters. **A call in a constructor's member-initializer +list is not narrowed:** `Decoy( Target& t ) : v( t.pick( 3 ) )` sits outside the parameter's scope span (the body), +so it keeps `main`'s answer — on a same-named `Decoy::pick`, the locality tie-break's wrong pin. + ## [0.6.1] — 2026-09-14 **A header selector answers only with the definitions it can tie to that header, every number a compact answer prints diff --git a/docs/EVALS.md b/docs/EVALS.md index 9dc1d27ef..149d3198b 100644 --- a/docs/EVALS.md +++ b/docs/EVALS.md @@ -3459,6 +3459,9 @@ Python and TypeScript all refuse. One limit the fixture DISCOVERED and now pins: (`void f( DCfg cfg ){ cfg.opts.enable(); }`) cannot narrow, because the binding capture records a parameter's name but not its type, and the parameter still shadows a same-named field — so the honest split is the only sound answer. That is the same answer Rule 2 already gives a depth-1 parameter receiver. +*(Superseded 2026-09-16 for depth 1: Rule 2 now reads a parameter's UNQUALIFIED written type lexically — the +declaration in scope at the call site decides — so a depth-1 parameter receiver narrows; test/narrowcheck.sh +arms 7-18. The depth-2 parameter base above is unchanged.)* **The recon report's `composeEdges` hoist is unnecessary — re-derived, and the design changed by it.** `graph.h::buildFieldNarrowTables` (`graph.h:648`) already runs at `graph.h:919`, ahead of the resolve diff --git a/src/graph.h b/src/graph.h index 15fe7cb92..8227d02a8 100644 --- a/src/graph.h +++ b/src/graph.h @@ -1956,11 +1956,11 @@ inline Graph buildGraph( const IngestResult& ing, const ScipOverlay* scip = null } const bool ffiActive = !pybindAlias.empty() || !externCAlias.empty(); - // P2-D one-hop type narrowing: reuses the canonical scope::name map above (no new pass). Rule 1 pins a - // `this->m()` / `self.m()` call to the caller's enclosing class; Rule 2 pins an `x.m()` named-receiver call - // to the variable's type; Rule 3 pins a call to the ONE file the caller includes that defines it — all - // BEFORE the bare-name spray below. See resolve.h. - const Narrower narrower( canonByName, varType, fileIncludes, symFileId ); + // P2-D one-hop type narrowing: reuses the canonical scope::name map above (no new pass). Rule 1 pins a `this->m()` / `self.m()` call to the + // caller's enclosing class; Rule 2 pins an `x.m()` named-receiver call to the variable's type (a parameter's through resolve.h's lexical table); + // Rule 3 pins a call to the ONE file the caller includes that defines it — all BEFORE the bare-name spray below. See resolve.h. + const ScopedRecvDecls scopedRecvDecls = buildScopedRecvDecls( ing ); + const Narrower narrower( canonByName, varType, scopedRecvDecls, fileIncludes, symFileId ); const ElixirResolver elixirResolver( ing ); // ONE apply step for every receiver rule (1 / 2 / 2c / 2b): keep the rule's definition ids that are // language-compatible with the call and inside the same root, and say whether anything survived. The diff --git a/src/ingest_binds.h b/src/ingest_binds.h index 40aaf099c..ce6eb5e1a 100644 --- a/src/ingest_binds.h +++ b/src/ingest_binds.h @@ -230,7 +230,8 @@ inline std::string_view declaratorVarName( TSNode decl, std::string_view src ) // answer plus one shape it refuses on purpose: a reference_declarator holds its inner declarator as an UNNAMED // child (`Counter& c` — the `&` is the only anonymous sibling), so the `declarator` field probe is null there. // Unwrapped HERE, for the ParamType record alone — widening declaratorVarName itself would mint Rule-2 Type -// records for `Foo& x = …` locals too and move call edges outside this round's gate. +// records for `Foo& x = …` locals too, which Rule 2's flat per-function table would leak past their scope +// (ParamType records reach Rule 2 only through the lexical lookup, resolve.h buildScopedRecvDecls). inline std::string_view paramDeclaratorVarName( TSNode decl, std::string_view src ) { if( !ts_node_is_null( decl ) && kindIs( ts_node_type( decl ), "reference_declarator" ) @@ -245,11 +246,11 @@ inline std::string_view paramDeclaratorVarName( TSNode decl, std::string_view sr // (new_expression). Final segment of the callee/constructor identifier. "" if the value isn't a // plain constructor call (so `auto x = makeFoo()` infers nothing here unless `makeFoo` names a class — // and the class-name filter in buildGraph is what makes that safe). -inline std::string ctorTypeOf( TSNode value, std::string_view src ) +inline TSNode ctorNameNode( TSNode value ) { if( ts_node_is_null( value ) ) { - return {}; + return TSNode{}; } const char* vt = ts_node_type( value ); TSNode idn {}; @@ -263,15 +264,19 @@ inline std::string ctorTypeOf( TSNode value, std::string_view src ) } if( ts_node_is_null( idn ) ) { - return {}; + return TSNode{}; } const char* it = ts_node_type( idn ); if( !kindIs( it, "identifier" ) && !kindIs( it, "type_identifier" ) && !kindIs( it, "qualified_identifier" ) && !kindIs( it, "scoped_identifier" ) ) { - return {}; + return TSNode{}; } - const std::uint32_t a = ts_node_start_byte( idn ), b = ts_node_end_byte( idn ); - return ( a <= b && b <= src.size() ) ? finalSegment( src.substr( a, b - a ) ) : std::string{}; + return idn; +} + +inline std::string ctorTypeOf( TSNode value, std::string_view src ) +{ + return finalSegment( nodeTextOf( ctorNameNode( value ), src ) ); // a null node reads "", and "" splits to "" } // the written type name of a `type:`-field type node (`type_identifier`, or a qualified/scoped one). "" for @@ -292,6 +297,47 @@ inline std::string writtenTypeOf( TSNode typeNode, std::string_view src ) return {}; // auto / template / decltype — type not directly written → try the initializer } +// Rule 2's qualifier guard (2026-09-16, test/narrowcheck.sh arm 17): the text of a type or constructor NAME node +// when it is QUALIFIED — it carries `::` past a leading global `::` (`std::map`, `ext::Widget`, +// `Outer::Inner`) — else "". writtenTypeOf/ctorTypeOf keep the final segment alone, and Rule 2 matches that +// segment against class names that carry no namespace, so `const std::map& ref` read as `map` and narrowed to +// an unrelated in-repo `map` (measured on a private C++ corpus). The whole text rides the declaration's Type/ParamType +// record in RawBind::importedName; Rule 2's lexical lookup refuses to narrow on it. +inline std::string qualifiedNameText( TSNode nameNode, std::string_view src ) +{ + std::string_view text = nodeTextOf( nameNode, src ); + while( !text.empty() && ( text.front() == ' ' || text.front() == ':' ) ) + { + text.remove_prefix( 1 ); // a leading `::` names the global namespace — not a qualifier + } + return ( text.find( "::" ) != std::string_view::npos ) ? std::string( text ) : std::string{}; +} + +// one declaration's recorded type: the name Rule 2 matches (the final segment) and, when the type was written +// QUALIFIED, its whole text — carried together so no emitter can record one without the other. +struct DeclType +{ + std::string name; + std::string qualified; +}; + +// a type or constructor NAME node's DeclType +inline DeclType declTypeOfName( TSNode typeNode, std::string_view src ) +{ + return DeclType{ writtenTypeOf( typeNode, src ), qualifiedNameText( typeNode, src ) }; +} + +// a declarator's recorded type: the declaration's WRITTEN type when it has one, else the type of the constructor its +// initializer calls (`auto x = Foo()`, `auto x = ns::Foo()`) +inline DeclType declaredTypeOf( const DeclType& written, TSNode value, std::string_view src ) +{ + if( !written.name.empty() ) + { + return written; + } + return DeclType{ ctorTypeOf( value, src ), qualifiedNameText( ctorNameNode( value ), src ) }; +} + // ── L3 fn-pointer/callback binding capture helpers ─────────────────────────────────────────────────── // the bound-function TARGET of an initializer/assignment RHS value node, for a var→FUNCTION binding: @@ -738,26 +784,35 @@ struct BindSite // the ONE bind-record emitter. A nameless declarator records nothing. kind=VarDecl is the r9 shadow- // evidence record: typeName stays EMPTY on it (shadow evidence, not narrowing fuel — nothing downstream // ever reads a type off it), so the empty-typeName refusal applies to every OTHER kind, where it is -// load-bearing for Rule 2 (an undecidable type must degrade to §2a, not mint a half-record). -inline void pushRawBind( std::uint32_t fileId, Lang lang, std::string_view var, std::string typeName, - BindSite site, LocalBindKind kind, std::vector& binds ) +// load-bearing for Rule 2 (an undecidable type must degrade to §2a, not mint a half-record). The DeclType's +// qualified text rides importedName (see qualifiedNameText) — non-empty only on a declaration's Type/ParamType record. +inline void pushTypedBind( std::uint32_t fileId, Lang lang, std::string_view var, DeclType type, BindSite site, LocalBindKind kind, + std::vector& binds ) { - if( var.empty() || ( typeName.empty() && kind != LocalBindKind::VarDecl ) ) + if( var.empty() || ( type.name.empty() && kind != LocalBindKind::VarDecl ) ) { return; } RawBind b; - b.fileId = fileId; - b.startByte = site.startByte; - b.lang = lang; - b.kind = kind; - b.spanStart = site.spanStart; - b.spanEnd = site.spanEnd; + b.fileId = fileId; + b.startByte = site.startByte; + b.lang = lang; + b.kind = kind; + b.spanStart = site.spanStart; + b.spanEnd = site.spanEnd; b.var.assign( var ); - b.typeName = std::move( typeName ); + b.typeName = std::move( type.name ); + b.importedName = std::move( type.qualified ); binds.push_back( std::move( b ) ); } +// the same record with no qualified text — every emitter but a declaration's Type/ParamType +inline void pushRawBind( std::uint32_t fileId, Lang lang, std::string_view var, std::string typeName, + BindSite site, LocalBindKind kind, std::vector& binds ) +{ + pushTypedBind( fileId, lang, var, DeclType{ std::move( typeName ), std::string{} }, site, kind, binds ); +} + // emit a Rule-2 binding from one declared variable: prefer the WRITTEN type; else infer from a // constructor-style initializer (`auto x = Foo()`). Records nothing when neither is decidable. inline void emitBind( std::uint32_t fileId, Lang lang, std::string_view var, std::string typeName, @@ -892,21 +947,21 @@ inline void emitShadowVarDecls( std::uint32_t fileId, Lang lang, TSNode decl, st // one DECLARATOR → both records: the Rule-2 var→type binding and the r9 VarDecl shadow record(s). The two // name reads stay separate on purpose — declaratorVarName descends into a function declarator (harmless // for narrowing), emitShadowVarDecls refuses it (load-bearing for suppression). -inline void emitDeclBinds( std::uint32_t fileId, Lang lang, TSNode declNode, std::string_view src, std::string type, +inline void emitDeclBinds( std::uint32_t fileId, Lang lang, TSNode declNode, std::string_view src, DeclType type, BindSite site, std::vector& binds ) { const std::string_view var = declaratorVarName( declNode, src ); - if( var.empty() && !type.empty() ) + if( var.empty() && !type.name.empty() ) { // member-variable round (card A3): a REFERENCE local (`const Symbol& s = ing.symbols[ i ];`) is the one - // typed declaration Rule 2 refuses (declaratorVarName cannot see through the unnamed reference child). - // Recorded as a ParamType fact — the field use-site index's own kind — so `s.name` resolves there while - // Rule 2's call narrowing (kind == Type) stays byte-identical. - pushRawBind( fileId, lang, paramDeclaratorVarName( declNode, src ), std::move( type ), BindSite{ site.startByte, 0u, 0u }, LocalBindKind::ParamType, binds ); + // typed declaration Rule 2's flat table refuses (declaratorVarName cannot see through the unnamed reference + // child). Recorded as a ParamType fact, so `s.name` resolves in the field use-site index and `s.m()` narrows + // through Rule 2's LEXICAL lookup (resolve.h buildScopedRecvDecls) — only where this declaration is in scope. + pushTypedBind( fileId, lang, paramDeclaratorVarName( declNode, src ), std::move( type ), BindSite{ site.startByte, 0u, 0u }, LocalBindKind::ParamType, binds ); } else { - emitBind( fileId, lang, var, std::move( type ), site.startByte, binds ); + pushTypedBind( fileId, lang, var, std::move( type ), BindSite{ site.startByte, 0u, 0u }, LocalBindKind::Type, binds ); } emitShadowVarDecls( fileId, lang, declNode, src, site, binds ); } @@ -930,10 +985,10 @@ inline void emitShadowParamDecls( TSNode params, std::uint32_t fileId, Lang lang const TSNode declarator = fieldChild( p, NodeField::Declarator ); emitShadowVarDecls( fileId, lang, declarator, src, bodySite, binds ); // member-variable round (card A3): the parameter's WRITTEN type as a ParamType record (`Counter& c` → - // c:Counter), read by the field use-site index alone — see LocalBindKind::ParamType. `auto`, templated + // c:Counter), read by the field use-site index and Rule 2's lexical lookup — see LocalBindKind::ParamType. `auto`, templated // and decltype types write nothing (writtenTypeOf's own refusal), and pushRawBind drops the record. - pushRawBind( fileId, lang, paramDeclaratorVarName( declarator, src ), writtenTypeOf( fieldChild( p, NodeField::Type ), src ), - BindSite{ ts_node_start_byte( p ), 0u, 0u }, LocalBindKind::ParamType, binds ); + pushTypedBind( fileId, lang, paramDeclaratorVarName( declarator, src ), declTypeOfName( fieldChild( p, NodeField::Type ), src ), + BindSite{ ts_node_start_byte( p ), 0u, 0u }, LocalBindKind::ParamType, binds ); } } @@ -1096,10 +1151,11 @@ inline void captureShadowScopeDecls( TSNode n, const char* t, std::uint32_t file const TSNode loopDeclarator = fieldChild( n, NodeField::Declarator ); emitShadowVarDecls( fileId, lang, loopDeclarator, src, loopSite, binds ); // member-variable round (card A3): the loop variable's WRITTEN type (`for( const Symbol& s : v )` → - // s:Symbol) as a ParamType record for the field use-site index — the single most common typed - // receiver shape in this repo's own source (`s.name`), and `auto` writes nothing, as for parameters. - pushRawBind( fileId, lang, paramDeclaratorVarName( loopDeclarator, src ), writtenTypeOf( fieldChild( n, NodeField::Type ), src ), - BindSite{ ts_node_start_byte( n ), 0u, 0u }, LocalBindKind::ParamType, binds ); + // s:Symbol) as a ParamType record for the field use-site index and Rule 2's lexical lookup — the single + // most common typed receiver shape in this repo's own source (`s.name`), and `auto` writes nothing, as for + // parameters. + pushTypedBind( fileId, lang, paramDeclaratorVarName( loopDeclarator, src ), declTypeOfName( fieldChild( n, NodeField::Type ), src ), + BindSite{ ts_node_start_byte( n ), 0u, 0u }, LocalBindKind::ParamType, binds ); return; } if( isLambda ) @@ -1355,8 +1411,7 @@ void bindsVisitNode( BindCtx& cx, TSNode n, const char* t ) // C++/ObjC: `Foo x;` · `Foo* x;` · `Foo x = Foo();` · `auto x = Foo();` if( ( lang == Lang::Cpp || lang == Lang::ObjC ) && kindIs( t, "declaration" ) ) { - const TSNode typeNode = fieldChild( n, NodeField::Type ); - std::string written = writtenTypeOf( typeNode, src ); + const DeclType written = declTypeOfName( fieldChild( n, NodeField::Type ), src ); // A5 fix round: the declared names shadow within their enclosing block (or, for a control-statement // header declaration, that whole statement) — one parent walk per declaration node, shared by every // declarator child below; each declarator then contributes its own declaration POINT as the span's @@ -1382,13 +1437,12 @@ void bindsVisitNode( BindCtx& cx, TSNode n, const char* t ) if( kindIs( ct, "init_declarator" ) ) { const TSNode declarator = fieldChild( c, NodeField::Declarator ); - std::string type = written.empty() ? ctorTypeOf( fieldChild( c, NodeField::Value ), src ) : written; - emitDeclBinds( fileId, lang, declarator, src, std::move( type ), + emitDeclBinds( fileId, lang, declarator, src, declaredTypeOf( written, fieldChild( c, NodeField::Value ), src ), BindSite{ ts_node_start_byte( n ), shadowSpanStart( scope, declarator ), scope.end }, binds ); } else // plain declarator (identifier / pointer_declarator / reference_declarator), no initializer { - emitDeclBinds( fileId, lang, c, src, std::string( written ), + emitDeclBinds( fileId, lang, c, src, written, BindSite{ ts_node_start_byte( n ), shadowSpanStart( scope, c ), scope.end }, binds ); } return true; diff --git a/src/ingest_cache.h b/src/ingest_cache.h index 11cbfd1b7..a6e58dfbd 100644 --- a/src/ingest_cache.h +++ b/src/ingest_cache.h @@ -231,7 +231,17 @@ constexpr std::uint32_t kCacheVersion = 22; // 22: RawDef gains `inter // (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 = 96; // bump on any grammar/.scm/extraction change +constexpr std::uint32_t kParserVer = 97; // bump on any grammar/.scm/extraction change + // 97 = 2026-09-16 (parameter receivers, test/narrowcheck.sh arm + // 17): a declaration's Type/ParamType RawBind records its + // written type WHOLE in importedName when the type is + // QUALIFIED (`std::map`). Record FORMAT unchanged (the + // field was already serialised, empty on these kinds), but a + // 96 blob holds "" there and would let Rule 2's lexical lookup + // narrow a qualified parameter type to an unrelated same-named + // in-repo class on a warm run: content change, bump required. + // (Binding::startByte, same lane, is re-derived from the cached + // RawBind::startByte and needed none.) // 96 = 2026-09-13 (internal linkage, test/decltodefcheck.sh arm // B2): every C/C++ def carries a new syntactic // `internalLinkage` bit — inside an anonymous namespace at any diff --git a/src/ingest_model.h b/src/ingest_model.h index b6ec18e03..7381aadc0 100644 --- a/src/ingest_model.h +++ b/src/ingest_model.h @@ -763,6 +763,7 @@ inline void emitBindings( IngestResult& result, std::vector& rawBinds, Binding& b = result.bindings[ outBindIndex++ ]; b.fileId = rb.fileId; b.kind = rb.kind; + b.startByte = rb.startByte; // the declaration a VarDecl and its typed record share (Rule 2 lexical lookup) b.spanStart = rb.spanStart; // shadow fix round: the declaring block's span rides through b.spanEnd = rb.spanEnd; b.var = std::move( rb.var ); diff --git a/src/model.h b/src/model.h index 34254037d..ec00d4693 100644 --- a/src/model.h +++ b/src/model.h @@ -625,10 +625,15 @@ enum class LocalBindKind : std::uint8_t // (kind != Type) and the L3 fn tables skip it (typeName empty). APPENDED so no persisted // kind value renumbers (RawBind rides kind through the cache as a u8). ParamType, // member-variable round (card A3): a C++/ObjC function DEFINITION parameter's WRITTEN type - // (`void f( Counter& c )` → c:Counter), so `c.count` resolves to Counter.count in the field - // use-site index (graph.h collectFieldUseSites). Consumed THERE ONLY, deliberately: Rule 2's - // call narrowing (kind == Type) does not read it, so no call edge changes; the L3 fn tables - // skip it by kind; shadow suppression already holds the parameter's VarDecl record. + // (`void f( Counter& c )` → c:Counter) — also a lambda parameter's, a typed range-for + // variable's and a reference local's — so `c.count` resolves to Counter.count in the field + // use-site index (graph.h collectFieldUseSites). Rule 2's call narrowing reads it too, but + // LEXICALLY (resolve.h buildScopedRecvDecls, 2026-09-16): every one of these shapes is scoped + // narrower than the whole function or can be redeclared inside it, so the flat per-function + // varType table would leak the type to other declarations of the name. The L3 fn tables skip + // it by kind; shadow suppression already holds the declaration's VarDecl record. importedName + // holds the written type WHOLE when it is qualified (`std::map`), else "" — the same on a + // declaration's Type record — so the lexical lookup can refuse a name that is only a final segment. // APPENDED for the same cache reason as VarDecl. Import, // Phase 5 (docs/EVALS.md "Phase 5", kParserVer 77): a FILE-SCOPE import binding — `var` is the // name the import binds in the module namespace, `typeName` the module target as written @@ -660,6 +665,11 @@ struct Binding NodeId fromSymbol = kNoNode; // enclosing function/method (the binding's scope); kNoNode if file-scope std::uint32_t fileId = 0; LocalBindKind kind = LocalBindKind::Type; + std::uint32_t startByte = 0; // the record's own position (RawBind::startByte). ONE declaration's + // VarDecl and its typed record (Type or ParamType) carry the SAME + // value — that shared byte is how Rule 2's lexical receiver lookup + // (buildScopedRecvDecls) knows a scope and a written type belong to + // one declaration. Rides the padding after `kind`: no size change. std::uint32_t spanStart = 0; // VarDecl: the byte span the name shadows within — a block std::uint32_t spanEnd = 0; // declaration runs from its DECLARATION POINT (end of the complete // declarator, [basic.scope.pdecl]) to the block's end; a whole-scope @@ -669,12 +679,15 @@ struct Binding // {0,0} on a scope-less shadow capture (contains nothing). std::string var; // the declared variable identifier (`x`) std::string importedName; // JsImport: the requested export name; never a global-name fallback. + // Type/ParamType: the written type WHOLE when it is qualified, else "". // JsExport: the LOCAL name the exported spelling binds (empty when // the two are identical). Elixir: see LocalBindKind's field contracts. std::string typeName; // kind==Type: the written type's final segment (`Foo`), resolved to a // class in buildGraph. kind==FnDecl/FnAssign: the bound FUNCTION // name as written minus `&` (`alpha`, `ns::alpha`), or a sentinel. }; +static_assert( sizeof( Binding ) == 6 * sizeof( std::uint32_t ) + 3 * sizeof( std::string ), + "Binding's scalars are five u32 and a u8 kind in 24 bytes — startByte rides the padding after `kind`" ); // R5 cross-language FFI binding alias. A language-binding DECLARATION found in a C/C++ file (or a // ctypes-handle assignment in a Python file) that makes a C/C++ definition reachable under a DIFFERENT diff --git a/src/quality.h b/src/quality.h index 1dd363395..bc583f0d7 100644 --- a/src/quality.h +++ b/src/quality.h @@ -1972,7 +1972,10 @@ inline std::string cacheRootKeyHex( const std::string& root ) // FOLLOW-UP for whoever owns ingest.{h,cpp}: promote the two constants into ingest.h and turn the gate into a // `static_assert` — this lane's file boundary forbade editing those files. constexpr std::uint32_t kIngestCacheVersionMirror = 22; // MUST equal ingest.cpp's kCacheVersion (gated) -constexpr std::uint32_t kIngestParserVerMirror = 96; // MUST equal ingest.cpp's kParserVer (gated) +constexpr std::uint32_t kIngestParserVerMirror = 97; // MUST equal ingest.cpp's kParserVer (gated) + // 97 = 2026-09-16 (parameter receivers): a declaration's qualified + // written type rides its Type/ParamType RawBind (importedName). + // See ingest_cache.h's kParserVer note. // 95 = 2026-09-12 (Elixir module/name/arity resolution, PR #81): // RE-BUMPED from the branch's 87 over #139's 93 and #172's 94. // See ingest_cache.h's kParserVer note. diff --git a/src/resolve.h b/src/resolve.h index 29cbcb3d9..3aba92877 100644 --- a/src/resolve.h +++ b/src/resolve.h @@ -50,13 +50,16 @@ #include "smallvec.h" #include "infra/sortutil.h" // radixSortIdsAscending — the id-set sort buildGraph/2b below runs F times #include "infra/profileScope.h" // PROFILE_SCOPE self-profiling — gated by PROFILE_ENABLED (off unless -DRIPWIRE_PROFILE=ON) +#include "infra/Diagnostics.h" // VERIFY — buildScopedRecvDecls' index-range precondition #include #include #include #include // fopen/fread — workspace-only config-file evidence (go.mod / tsconfig.json), §3.2 +#include #include #include +#include #include namespace rw @@ -2055,6 +2058,145 @@ inline std::size_t sharedLocality( std::string_view a, std::string_view b ) noex return cut; } +// P2-D Rule 2, PARAMETER receivers (2026-09-16, test/narrowcheck.sh arms 7-18): one DECLARATION of a receiver +// name inside one definition — the scope its VarDecl record covers and the written type its Type/ParamType +// record carries, joined on the record position the two share (Binding::startByte). +// buildScopedRecvDecls (below) builds the table; Narrower::recvVarTypeName reads it. +struct ScopedRecvDecl +{ + std::uint32_t declByte; // Binding::startByte of the declaration's records + std::uint32_t spanStart; // the VarDecl span: where the name denotes THIS declaration + std::uint32_t spanEnd; + std::uint32_t typeBinding; // index into ing.bindings of the typed record, or one of the two sentinels below +}; +static_assert( std::is_trivially_copyable_v && sizeof( ScopedRecvDecl ) == 16, "four u32 — an rw::svector element" ); +inline constexpr std::uint32_t kRecvDeclUntyped = 0xFFFFFFFFu; // no typed record at this declaration (`auto`, a capture) +inline constexpr std::uint32_t kRecvDeclConflicted = 0xFFFFFFFEu; // two typed records disagree — never narrows + +// the lexical table and the bindings its typeBinding indices point into, held together so they cannot be paired wrong +struct ScopedRecvDecls +{ + // "#" → that name's declarations in the definition, in declaration-byte order. Holds ONLY names + // with at least one ParamType record; every other name keeps the flat varType table, byte-identical. + HashMap> byName; + const std::vector* bindings = nullptr; +}; + +// ── P2-D Rule 2 PARAMETER receivers: building the lexical declaration table (2026-09-16, test/narrowcheck.sh arms 7-18) ── +// A ParamType record — a definition or lambda parameter, a typed range-for variable, a reference local — was read +// by the field use-site index alone, so `int Decoy::plainCaller( Target& other ) { return other.pick( 1 ); }` fell +// through Rule 2 and the S6-C locality tie-break handed the site to Decoy::pick: one precise wrong edge, no amb=. +// It cannot simply join the flat per-definition varType table: every one of those shapes is scoped narrower than +// the definition or can be hidden by a nested redeclaration, and the naive fold was MEASURED to mint three precise +// wrong edges on the gate fixture (arms 12-14: a range-for variable's type reaching a later `auto` loop of the same +// name, a same-named field read after the loop, and a parameter hidden by an untyped loop variable). So for every +// name with a ParamType record, this lists ALL its declarations in the definition — each VarDecl with its scope +// span — and attaches each Type/ParamType record to the declaration whose VarDecl shares its record position; +// Narrower::recvVarTypeName asks which one is innermost at the call site, and narrows on its type only when the type +// was written UNQUALIFIED — a written type is its final segment alone, and a parameter's is often a library container +// (`const std::map&`) whose name an unrelated in-repo class shares (measured: three such precise wrong edges on a +// private C++ corpus, arm 17; the qualified text rides Binding::importedName). A typed record with no VarDecl at its +// position (a shape the shadow capture refuses) types nothing: a lost narrow, never a wrong one. Names with no +// ParamType record are absent and keep the flat varType answer, byte-identically. Deterministic: ing.bindings is +// totally ordered, lists are appended in that order, and nothing iterates the map into output. +inline void attachRecvDeclType( ScopedRecvDecl& decl, std::uint32_t bindIndex, const std::vector& bindings ) noexcept +{ + if( decl.typeBinding == kRecvDeclUntyped ) + { + decl.typeBinding = bindIndex; + } + else if( decl.typeBinding != kRecvDeclConflicted && bindings[ decl.typeBinding ].typeName != bindings[ bindIndex ].typeName ) + { + decl.typeBinding = kRecvDeclConflicted; // one declaration, two written types — trust neither + } +} + +// a binding record the lexical table can key: attributed to a definition, naming a variable +inline bool isScopedBindRecord( const Binding& b ) noexcept +{ + return b.fromSymbol != kNoNode && !b.var.empty(); +} + +// the names the table covers: every "#" with a ParamType record +inline void addParamTypedNames( const IngestResult& ing, ScopedRecvDecls& table, std::string& key ) +{ + const auto isParamType = []( const Binding& b ) noexcept { return b.kind == LocalBindKind::ParamType && isScopedBindRecord( b ); }; + table.byName.reserve( std::size_t( std::ranges::count_if( ing.bindings, isParamType ) ) ); + for( const Binding& b : ing.bindings ) + { + if( isParamType( b ) ) + { + buildShadowKey( key, b.fromSymbol, b.var ); + table.byName.try_emplace( key ); + } + } +} + +// every declaration of those names: the VarDecl records, in (file, byte) order — an exact repeat of the previous one +// (the same declaration captured twice) is dropped, or it would tie with itself and refuse the site +inline void addRecvDeclScopes( const IngestResult& ing, ScopedRecvDecls& table, std::string& key ) +{ + for( const Binding& b : ing.bindings ) + { + if( b.kind != LocalBindKind::VarDecl || !isScopedBindRecord( b ) ) + { + continue; + } + buildShadowKey( key, b.fromSymbol, b.var ); + const auto it = table.byName.find( key ); + if( it == table.byName.end() ) + { + continue; + } + const ScopedRecvDecl decl{ b.startByte, b.spanStart, b.spanEnd, kRecvDeclUntyped }; + const bool repeat = !it->second.empty() && it->second.back().declByte == decl.declByte && it->second.back().spanStart == decl.spanStart + && it->second.back().spanEnd == decl.spanEnd; + if( !repeat ) + { + it->second.push_back( decl ); + } + } +} + +// each written type onto the declaration that shares its record position +inline void attachRecvDeclTypes( const IngestResult& ing, ScopedRecvDecls& table, std::string& key ) +{ + for( std::uint32_t bindIndex = 0; bindIndex < std::uint32_t( ing.bindings.size() ); ++bindIndex ) + { + const Binding& b = ing.bindings[ bindIndex ]; + if( ( b.kind != LocalBindKind::Type && b.kind != LocalBindKind::ParamType ) || !isScopedBindRecord( b ) || b.typeName.empty() ) + { + continue; + } + buildShadowKey( key, b.fromSymbol, b.var ); + const auto it = table.byName.find( key ); + if( it == table.byName.end() ) + { + continue; + } + for( ScopedRecvDecl& decl : it->second ) + { + if( decl.declByte == b.startByte ) { attachRecvDeclType( decl, bindIndex, ing.bindings ); } + } + } +} + +inline ScopedRecvDecls buildScopedRecvDecls( const IngestResult& ing ) +{ + PROFILE_SCOPE_DESCRIBE( "buildGraph/2j: Rule-2 lexical receiver declarations" ); + VERIFY( ing.bindings.size() < kRecvDeclConflicted ); // typeBinding indices stay clear of the two sentinels + ScopedRecvDecls table; + table.bindings = &ing.bindings; + std::string key; + addParamTypedNames( ing, table, key ); + if( !table.byName.empty() ) + { + addRecvDeclScopes( ing, table, key ); + attachRecvDeclTypes( ing, table, key ); + } + return table; +} + // One-hop receiver narrowing over the canonical scope::name → definition-ids map (built once by buildGraph). // Holds only const references to maps buildGraph owns — no state, no allocation, no copy of the symbol table. struct Narrower @@ -2065,6 +2207,9 @@ struct Narrower // one scope) — looked up but never narrowed. buildGraph builds it from IngestResult::bindings. Empty when // there are no bindings, so Rule 2 simply never fires (degrades to the unchanged ladder). const HashMap& varType; + // Rule 2's LEXICAL table for names with a ParamType declaration (see ScopedRecvDecl above). A name found here is + // answered here ONLY — varType is not consulted. + const ScopedRecvDecls& scopedDecls; // P2-D Rule 3 include table: caller fileId → the sorted, deduped set of fileIds it #includes / imports // (resolved file→file by basename, exactly like graph.h::resolveIncludeAdj; the caller's own file is NEVER // in its own set). buildGraph builds it once from IngestResult::includes. Empty when the repo has no @@ -2086,9 +2231,10 @@ struct Narrower explicit Narrower( const HashMap>& canon, const HashMap& vt, + const ScopedRecvDecls& scoped, const std::vector>& incl, const std::vector& symFile ) noexcept - : canonByName( canon ), varType( vt ), fileIncludes( incl ), symFileId( symFile ) {} + : canonByName( canon ), varType( vt ), scopedDecls( scoped ), fileIncludes( incl ), symFileId( symFile ) {} // append base-10 `n` to `dst` without an intermediate std::to_string allocation (matches to_string bytes). static void appendUint( std::string& dst, std::uint32_t n ) @@ -2154,7 +2300,8 @@ struct Narrower // // Airtight "no wrong narrow" (the contract): it narrows ONLY when ALL hold — (1) the call has a named // receiver variable; (2) that var has EXACTLY ONE type binding in this scope (an ambiguous var is tombstoned - // by buildGraph → empty type → no narrow); (3) the bound type defines `m` (canonByName, DEFS only). So the + // by buildGraph → empty type → no narrow) — or, for a name with a PARAMETER-typed declaration, the declaration + // in scope AT THE SITE has a written type (recvVarTypeName); (3) the bound type defines `m` (canonByName, DEFS only). So the // returned ids are always real `Foo::m` definitions the bare ladder could also reach — Rule 2 just picks the // type-correct one earlier. Any uncertainty (no binding, conflicting bindings, type has no such method) → // honest ambiguity via §2a, never a guess. Deterministic: canonByName insertion order = symbol-id order. @@ -2173,22 +2320,17 @@ struct Narrower return nullptr; // file-scope call: no per-def binding scope } - // the var's resolved type in THIS scope. Empty value = tombstone (ambiguous var) → no narrow. Key built - // in the reused buffer (identical bytes to `std::to_string( r.fromSymbol ) + "#" + r.recvVar`). - keyBind.clear(); - appendUint( keyBind, r.fromSymbol ); - keyBind.push_back( '#' ); - keyBind.append( r.recvVar ); - const auto vit = varType.find( keyBind ); - if( vit == varType.end() || vit->second.empty() ) + // the var's type at THIS site. Empty = unbound, tombstoned, or an untyped declaration in scope → no narrow. + const std::string_view boundType = recvVarTypeName( r ); + if( boundType.empty() ) { return nullptr; } // resolve `m` against the bound type's own methods (defs only). Miss ⇒ degrade to §2a. Reused buffer, - // identical bytes to `vit->second + "::" + r.calleeName`. + // identical bytes to `boundType + "::" + r.calleeName`. keyScope.clear(); - keyScope.append( vit->second ).append( "::" ).append( r.calleeName ); + keyScope.append( boundType ).append( "::" ).append( r.calleeName ); const auto it = canonByName.find( keyScope ); if( it == canonByName.end() || it->second.size() == 0 ) { @@ -2510,18 +2652,66 @@ struct Narrower } if( r.recv == RecvKind::NamedVar && !r.recvVar.empty() && r.fromSymbol != kNoNode ) { - keyBind.clear(); - appendUint( keyBind, r.fromSymbol ); - keyBind.push_back( '#' ); - keyBind.append( r.recvVar ); - const auto vit = varType.find( keyBind ); - if( vit == varType.end() || vit->second.empty() ) + return recvVarTypeName( r ); // unbound, tombstoned or untyped-in-scope → "" → no CHA-lite + } + return {}; + } + + // Rule 2's receiver-VARIABLE type at one call site — the ONE lookup Rule 2 and CHA-lite share, so they can never + // disagree about a receiver. A name with a ParamType declaration in this definition is answered LEXICALLY: the + // innermost declaration whose scope covers the site decides, and only its own written type counts — a + // parameter shadowed by an `auto` loop variable, a range-for variable read after its loop, and two declarations + // with one scope all answer "" — and so does a QUALIFIED written type (`const std::map&`): the type name is + // its final segment and class names carry no namespace, so `map` cannot be told apart from an unrelated in-repo + // `map` (ingest_binds.h qualifiedNameText). Every other name reads the flat varType table exactly as before + // ("" = tombstone). + // KNOWN FLOOR, the span model's own: a range-for variable's span is the whole loop statement, so a same-named + // outer variable used inside the loop's own range expression reads as the loop variable. + std::string_view recvVarTypeName( const Reference& r ) const + { + // key built in the reused buffer (identical bytes to `std::to_string( r.fromSymbol ) + "#" + r.recvVar`) + keyBind.clear(); + appendUint( keyBind, r.fromSymbol ); + keyBind.push_back( '#' ); + keyBind.append( r.recvVar ); + if( const auto sit = scopedDecls.byName.find( keyBind ); sit != scopedDecls.byName.end() ) + { + const ScopedRecvDecl* const innermost = innermostCoveringDecl( sit->second, r.startByte ); + if( innermost == nullptr || innermost->typeBinding >= kRecvDeclConflicted ) { - return {}; // unbound or tombstoned (ambiguous) → no type + return {}; // no declaration in scope (a field or global of the name), a tie, or untyped/conflicted } - return std::string_view( vit->second ); + const Binding& declared = ( *scopedDecls.bindings )[ innermost->typeBinding ]; + return declared.importedName.empty() ? std::string_view( declared.typeName ) : std::string_view{}; // qualified → no narrow } - return {}; + const auto vit = varType.find( keyBind ); + return ( vit == varType.end() ) ? std::string_view{} : std::string_view( vit->second ); + } + + // the innermost declaration whose span covers `siteByte`, or nullptr when none does or two declarations share + // the innermost span (one scope cannot declare a name twice, so a tie is evidence we mis-read — refuse). Spans + // inside one definition nest, so the innermost covering span is the one that starts LAST. + static const ScopedRecvDecl* innermostCoveringDecl( std::span decls, std::uint32_t siteByte ) noexcept + { + const ScopedRecvDecl* best = nullptr; + bool tied = false; + for( const ScopedRecvDecl& d : decls ) + { + if( siteByte < d.spanStart || siteByte >= d.spanEnd ) + { + continue; + } + if( best == nullptr || d.spanStart > best->spanStart || ( d.spanStart == best->spanStart && d.spanEnd < best->spanEnd ) ) + { + best = &d; + tied = false; + } + else if( d.spanStart == best->spanStart && d.spanEnd == best->spanEnd ) + { + tied = true; + } + } + return tied ? nullptr : best; } // Rule 3 — import/include-based FILE narrow. Given the bare-name candidate defs `cands` for a call inside diff --git a/test/chacheck.sh b/test/chacheck.sh index d04d1e828..f8dcb4df2 100755 --- a/test/chacheck.sh +++ b/test/chacheck.sh @@ -7,8 +7,8 @@ # The chafix corpus: # cha.cpp — Animal (bodied speak), Dog+Cat implementors (neither overrides speak), Robot (UNRELATED, # its own speak). g() calls `Dog d; d.speak()` → CHA-lite narrows to the Dog cone {Dog,Animal}, -# dropping Robot::speak → resolves to Animal::speak ALONE. h() calls the same through a -# PARAMETER (no var→type binding) → receiver type unknown → CHA can't fire → stays ambiguous. +# dropping Robot::speak → resolves to Animal::speak ALONE. h() calls the same through an +# untyped `auto` local (no var→type binding) → receiver type unknown → CHA can't fire → stays ambiguous. # arity.cpp — emit(int) / emit(int,int,int) / emit(const char*,...). caller() does emit(1,2,3): B2.2 drops # the arity-1 overload (fixed arity 1 != 3), keeps the arity-3 overload, and KEEPS the variadic # overload (variadic is never a fixed arity → never provably wrong). @@ -53,13 +53,13 @@ else no "CHA-lite positive: g() targets = [$(echo $gT)] (want ONLY line $ANIMAL_LINE=Animal; NOT $ROBOT_LINE=Robot)" fi -# ── 2) CHA control: h() (receiver is a parameter → unknown type) stays AMBIGUOUS — BOTH speak defs survive. ── +# ── 2) CHA control: h() (receiver is an untyped local → unknown type) stays AMBIGUOUS — BOTH speak defs survive. ── hT="$( targets h )" hN="$( printf '%s\n' "$hT" | grep -c . )" if [ "$hN" = "2" ] && printf '%s\n' "$hT" | grep -qx "$ANIMAL_LINE" && printf '%s\n' "$hT" | grep -qx "$ROBOT_LINE"; then ok "CHA-lite control: h() stays AMBIGUOUS (both Animal::speak + Robot::speak — unknown receiver keeps current behavior)" else - no "CHA-lite control: h() targets = [$(echo $hT)] (want BOTH $ANIMAL_LINE + $ROBOT_LINE — CHA must NOT fire on a param receiver)" + no "CHA-lite control: h() targets = [$(echo $hT)] (want BOTH $ANIMAL_LINE + $ROBOT_LINE — CHA must NOT fire on an untyped receiver)" fi # ── 3) amb honesty: g is resolved (no amb marker), h stays flagged (amb=). ── diff --git a/test/chaconecheck.sh b/test/chaconecheck.sh index 5269f2ae5..cc226f41c 100755 --- a/test/chaconecheck.sh +++ b/test/chaconecheck.sh @@ -85,11 +85,12 @@ if [ "$( count g4 )" = 3 ] && printf '%s\n' "$T" | grep -qx "$ANIMAL_LINE" && pr ok "g4 (Lamp): cone {Lamp} keeps nothing → DEGRADE, all three targets kept, amb= honest" else no "g4 (Lamp): expected the untouched 3-way split — got count=$( count g4 ) lines={$( printf '%s' "$T" | tr '\n' ' ')}"; fi -# ── 4) control: a parameter receiver has no var→type binding, so no cone can fire ─────────────────────── +# ── 4) control: an untyped `auto` receiver has no var→type binding, so no cone can fire. (A `Hound&` PARAMETER +# was this control until 2026-09-16; Rule 2 reads a parameter's written type now — narrowcheck arms 7-18.) ── T="$( targets g5 )" if [ "$( count g5 )" = 3 ] && hasamb g5; then - ok "g5 (Hound& parameter): receiver type unknown → 3-way split kept, amb= honest (control)" -else no "g5 (Hound& parameter): control should stay ambiguous — got count=$( count g5 )"; fi + ok "g5 (untyped auto receiver): receiver type unknown → 3-way split kept, amb= honest (control)" +else no "g5 (untyped auto receiver): control should stay ambiguous — got count=$( count g5 )"; fi # ── 6) a hit AFTER the memo grew: g6 fills Droid's cone, then g7 asks for Hound again ────────────────────── T="$( targets g6 )" diff --git a/test/chaconefix/b.cpp b/test/chaconefix/b.cpp index 382af7ea8..537d275f9 100644 --- a/test/chaconefix/b.cpp +++ b/test/chaconefix/b.cpp @@ -5,6 +5,6 @@ void g2() { Hound d; d.vocalize(); } // memo hit: byte-identical to g1's answer (Creature::vocalize only) void g3() { Lynx c; c.vocalize(); } // a DIFFERENT cone {Lynx, Creature} keyed on the same callee `vocalize` void g4() { Lamp l; l.vocalize(); } // cone {Lamp} keeps nothing → DEGRADE: tier untouched, stays ambiguous -void g5( Hound& p ) { p.vocalize(); } // control: parameter receiver → no var→type binding → cone cannot fire +Hound* pack[ 2 ]; void g5( int slot ) { auto p = pack[ slot ]; p->vocalize(); } // control: untyped `auto` receiver → no var→type binding → cone cannot fire void g6() { Droid d; d.vocalize(); } // a THIRD cone {Droid, Machine}: the memo grows AFTER Hound's entry exists void g7() { Hound h; h.vocalize(); } // Hound AGAIN, after the memo grew: a hit must be Hound's cone, not the newest diff --git a/test/chafix/cha.cpp b/test/chafix/cha.cpp index bd267f860..6ca64bd5d 100644 --- a/test/chafix/cha.cpp +++ b/test/chafix/cha.cpp @@ -44,11 +44,16 @@ void g() d.speak(); // CHA-lite: cone(Dog) = {Dog, Animal} → Animal::speak ONLY (Robot excluded) } -// NEGATIVE control: the SAME call shape, but the receiver is a function PARAMETER — no local var→type -// binding is captured, so the receiver's static type is UNKNOWN to the narrower → CHA-lite cannot fire → -// the call stays HONESTLY AMBIGUOUS (edges to BOTH Animal::speak and Robot::speak). This contrast is the -// proof that the positive case is a REAL hierarchy narrow, not a vacuously-unambiguous fixture. -void h( Dog* p ) +// NEGATIVE control: the SAME call shape, but the receiver is a local the capture cannot type (`auto` from a +// subscript) — no var→type binding, so the receiver's static type is UNKNOWN to the narrower → CHA-lite cannot +// fire → the call stays HONESTLY AMBIGUOUS (edges to BOTH Animal::speak and Robot::speak). This contrast is the +// proof that the positive case is a REAL hierarchy narrow, not a vacuously-unambiguous fixture. (Until +// 2026-09-16 the receiver was a `Dog* p` PARAMETER; a parameter's written type is read by Rule 2 and CHA-lite +// now — test/narrowcheck.sh arms 7-18 — so it no longer demonstrates an unknown type.) +Dog* kennel[ 2 ]; + +void h( int slot ) { + auto p = kennel[ slot ]; p->speak(); // unknown receiver type → Animal::speak + Robot::speak both survive (amb) } diff --git a/test/fieldnarrowcheck.sh b/test/fieldnarrowcheck.sh index 05e575449..4a4af37e5 100755 --- a/test/fieldnarrowcheck.sh +++ b/test/fieldnarrowcheck.sh @@ -175,6 +175,11 @@ SHP="$( callees shadowParam )" printf '%s\n' "$SHP" | grep -q 'a.cpp:2"' \ && ok "(s1) shadowParam( Decoy& m_x ) keeps its Decoy::acquire edge — the param shadows field m_x" \ || no "(s1) shadowParam lost Decoy::acquire — the field type was wrongly narrowed over the shadowing param" +# since 2026-09-16 Rule 2 reads the parameter's written type (narrowcheck arms 7-18), so the parameter's Decoy is +# the WHOLE answer — main's binary still linked the field's Pool::acquire here as half of a split +printf '%s\n' "$SHP" | grep -q 'a.cpp:1"' \ + && no "(s1) shadowParam linked to Pool::acquire — the FIELD type beat the shadowing Decoy& parameter" \ + || ok "(s1) shadowParam field type Pool NOT linked (the typed parameter shadows the field)" SHL="$( callees shadowLocal )" printf '%s\n' "$SHL" | grep -q 'a.cpp:2"' \ && ok "(s2) shadowLocal's local Decoy m_y still wins (Rule 2 narrow preserved)" \ @@ -193,13 +198,14 @@ TS="$( callees to_go )" && 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" -# ── (h) the header gauge agrees with the arms above: exactly the 7 honest splits remain ambiguous -# (expl, unk, freeuse, multi, shadowParam, po_go, to_go — run/ptr/inh_go narrowed, shadowLocal was Rule 2). +# ── (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 +# 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=7" ] \ - && ok "(h) header gauge ambiguous=7 — only the honest splits remain" \ - || no "(h) header gauge is '$AMB', expected ambiguous=7 (3 field-typed calls narrowed, 7 honest splits kept)" +[ "$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)" # ── (n) same-NAMED class collision (FIX2): conflicting same-named fields tombstone — NEITHER Dup::go narrows ── MAP2="$( "$BIN" "$FIX2" --no-cache 2>/dev/null | tr '>' '\n' )" @@ -222,7 +228,7 @@ GO2="$( "$BIN" "$FIX2" --callees=go --no-cache 2>/dev/null | grep -o '"$LIT/src/literals.ts" <<'EOF' diff --git a/test/localityfix/loc.cpp b/test/localityfix/loc.cpp index a1acbbd0b..930a8c689 100644 --- a/test/localityfix/loc.cpp +++ b/test/localityfix/loc.cpp @@ -9,8 +9,8 @@ // THE FIX: `sharedLocality` compares on WHOLE `/`- and `::`-delimited SEGMENTS. A partial overlap inside a // segment (`Xenon` vs `Xtra`) counts as ZERO locality. So both `Xtra` and `Bravo` share only the file PATH with // the caller — they TIE — no candidate is strictly more local, and the call stays HONESTLY AMBIGUOUS (count=2, -// ambiguous=1) instead of a false-confident wrong pick. The receiver is a PARAMETER (`Bravo* b`), NOT a local: -// P2-D Rule 2 narrows a *local* `Bravo b` to its type (before the tie-break), but a param has no binding to use. +// ambiguous=1) instead of a false-confident wrong pick. The receiver is an UNTYPED `auto` local: P2-D Rule 2 +// narrows a typed local or PARAMETER to its type before the tie-break, so only an untyped receiver reaches it. // // Out-of-line method defs (the realistic C++ layout) give each `go` its enclosing scope, so the canonical ids // `…::Xtra::go` / `…::Bravo::go` exist and the tie-break has scopes to (correctly NOT) discriminate on. @@ -28,13 +28,17 @@ struct Bravo int Xtra::go() { return 1; } int Bravo::go() { return 2; } +Bravo* roster[ 2 ]; + struct Xenon { - void call( Bravo* b ); + void call( int slot ); }; -void Xenon::call( Bravo* b ) +void Xenon::call( int slot ) { - // b is a PARAMETER → no var→type binding → Rule 2 cannot fire → this call reaches the locality tie-break. + // b is an `auto` local with a subscript initializer → no var→type binding → Rule 2 cannot fire → this call + // reaches the locality tie-break. (Until 2026-09-16 b was a `Bravo* b` PARAMETER; Rule 2 reads that now.) + auto b = roster[ slot ]; b->go(); // FIXED: stays AMBIGUOUS (Xtra/Bravo tie on path-only locality) — never a confident Xtra::go pick } diff --git a/test/narrowcheck.sh b/test/narrowcheck.sh index 0650ef0d4..9ef3034c5 100755 --- a/test/narrowcheck.sh +++ b/test/narrowcheck.sh @@ -8,9 +8,26 @@ # DEFINES the called method (canonByName hit) — otherwise it degrades to §2a unchanged. # # The fixture pairs each narrowed caller with a NEGATIVE CONTROL of the SAME call shape whose receiver -# is a function PARAMETER (`Foo* p; p->run()`) — no local var→type binding, so Rule 2 can't fire and the -# call stays HONESTLY AMBIGUOUS. The narrowed-vs-control contrast is the proof the narrow is REAL (a -# binding-driven resolution, not a vacuously-unambiguous fixture). +# is a local the capture cannot type (`auto p = pool[ slot ]; p->run()`) — no var→type binding, so Rule 2 +# can't fire and the call stays HONESTLY AMBIGUOUS. The narrowed-vs-control contrast is the proof the narrow +# is REAL (a binding-driven resolution, not a vacuously-unambiguous fixture). The control used to be a +# function PARAMETER; arms 7-18 are why it is not any more. +# +# Arms 7-16 — PARAMETER receivers (2026-09-16). A parameter's written type (LocalBindKind::ParamType) was +# captured but read only by the field use-site index, so `int Decoy::plainCaller( Target& other ) { return +# other.pick( 1 ); }` fell through Rule 2 to the S6-C locality tie-break, which hands the tie to the CALLER'S +# OWN class: one precise edge to Decoy::pick, no amb=, nothing disclosed. Rule 2 now reads ParamType records +# LEXICALLY — the innermost declaration of the name whose scope covers the call site decides, and only a +# declaration with a written type narrows. The fixture is GENERATED (line numbers are load-bearing: p= tells +# Target's methods, line 1, from Other's, line 2, and Decoy's, lines 5-6). Two kinds of arm: +# * RED on the unfixed binary: the parameter/range-for/lambda receivers that must now pin to Target. +# * RED on a NAIVE fix that folds ParamType into Rule 2's flat per-function table (observed, see the +# commit): a range-for variable's type leaking to a later `auto` loop of the same name (12), to a +# same-named FIELD read outside the loop (13), and an untyped nested redeclaration of a parameter (14); +# plus (10), where the flat table's tombstone would throw away two precise answers. +# * Arms 17-18 — a QUALIFIED written type (`ext::map&`) never narrows, because the recorded type is +# its final segment and class names carry no namespace (kParserVer 97 records the qualified text); (17) is +# RED on the lexical lookup without that guard, (18) is its unqualified control. # # Usage: # RIPWIRE_BIN=build/ripwire bash test/narrowcheck.sh @@ -35,17 +52,17 @@ echo "narrowcheck: BIN=$BIN CORPUS=test/narrowfix" "$BIN" "$FIX" --no-cache >"$TMP/map" 2>/dev/null -# ── 1) headline: exactly ONE ambiguous call remains — the parameter control. The two local-var callers +# ── 1) headline: exactly ONE ambiguous call remains — the untyped control. The two local-var callers # (cpp g, py g) narrowed away their ambiguity entirely. ───────────────────────────────────────── amb="$( grep -o 'ambiguous=[0-9]*' "$TMP/map" | head -1 | grep -o '[0-9]*' )" -[ "$amb" = "1" ] && ok "exactly one ambiguous call remains (ambiguous=1 — only the param control)" \ - || no "ambiguous=$amb (expected 1: the two local-var calls should narrow, the param stays split)" +[ "$amb" = "1" ] && ok "exactly one ambiguous call remains (ambiguous=1 — only the untyped control)" \ + || no "ambiguous=$amb (expected 1: the two local-var calls should narrow, the untyped control stays split)" -# ── 2) the parameter control `h` (Foo* p; p->run()) MUST stay ambiguous — no var→type binding for a param, +# ── 2) the untyped control `h` (auto p = pool[ slot ]; p->run()) MUST stay ambiguous — no var→type binding, # so Rule 2 cannot fire and the call honestly splits to BOTH run defs. ────────────────────────────── grep -q 'n="h" amb="1"' "$TMP/map" \ - && ok "param control h() stays AMBIGUOUS (amb=1 — proves the narrow needs a real binding)" \ - || { no "param control h() is not amb=1 (the negative control failed — narrow may be vacuous)"; grep -o 'n="h"[^>]*' "$TMP/map" | head; } + && ok "untyped control h() stays AMBIGUOUS (amb=1 — proves the narrow needs a real binding)" \ + || { no "untyped control h() is not amb=1 (the negative control failed — narrow may be vacuous)"; grep -o 'n="h"[^>]*' "$TMP/map" | head; } # ── 3) the local-var callers `g` (cpp `Foo x`/`auto y=Bar()`, py `x=Foo()`) MUST be narrowed → NO amb= marker. # (Same call shape as the control; the ONLY difference is the local binding ⇒ this is the real-narrow proof.) @@ -78,5 +95,151 @@ diff -q "$TMP/warm" "$TMP/cold" >/dev/null \ && ok "cache-transparent (bindings round-trip: warm == cold)" \ || { no "binding cache changes output (warm != cold)"; diff "$TMP/cold" "$TMP/warm" | head -6; } +# ── Arms 7-16: PARAMETER receivers (see the header). LINE NUMBERS ARE ASSERTED BELOW — edit with care. +# Target::pick/peek c.cpp:1 Other::pick/peek c.cpp:2 Decoy::pick c.cpp:5, Decoy::peek c.cpp:6 +PFIX="$TMP/paramfix" +mkdir -p "$PFIX" +cat >"$PFIX/c.cpp" <<'EOF' +struct Target { int pick( int n ) { return n; } int peek( int n ) { return n; } }; +struct Other { int pick( int n ) { return n; } int peek( int n ) { return n; } }; +struct Decoy +{ + int pick( int n ) { return n; } + int peek( int n ) { return n; } + int plainCaller( Target& other ) { return other.pick( 1 ); } + int ptrCaller( Target* other ) { return other->pick( 1 ); } + int localCaller() { Target other; return other.pick( 1 ); } + int nestedTyped( Target& other, Other* os[] ) { int n = 0; for( const Other* other : os ) { n += other->peek( 1 ); } return n + other.pick( 2 ); } + int lambdaCaller() { auto f = []( Target& t ) { return t.pick( 1 ); }; Target held; return f( held ); } +}; +struct Box +{ + Other* item; + Target ts[ 2 ]; + Other* os[ 2 ]; + int loopLeak() { int n = 0; for( const Target& t : ts ) { n += t.pick( 1 ); } for( auto t : os ) { n += t->peek( 2 ); } return n; } + int fieldLeak() { int n = 0; for( const Target& item : ts ) { n += item.pick( 1 ); } return n + item->peek( 2 ); } + int untypedShadow( Target& other ) { int n = 0; for( auto other : os ) { n += other->peek( 1 ); } return n + other.pick( 2 ); } +}; +EOF + +# one caller's callee rows as sorted `name@line` words, restricted to one method name. The probe must RUN: +# a missing element (unknown flag, refused selector, crash) prints a marker no assertion accepts. +rowsOf(){ + local out + out="$( "$BIN" "$PFIX" "--callees=$1" --no-cache 2>/dev/null )" + printf '%s' "$out" | grep -q "]*of=\"$1\" defs=\"1\"" || { printf 'NO-CALLEES-ANSWER'; return; } + printf '%s' "$out" | grep -o ']*>' | sed -n 's/.* n="\([^"]*\)".* p="c\.cpp:\([0-9]*\)".*/\1@\2/p' \ + | grep "^$2@" | sort -u | tr '\n' ' ' | sed 's/ $//' +} +expectRows(){ # arm label, caller, method, the exact expected row set + local got + got="$( rowsOf "$2" "$3" )" + if [ "$got" = "$4" ]; then + ok "$1 $2(): $3 -> [$got]" + else + no "$1 $2(): $3 -> [$got], want [$4]" + fi +} +expectIncludes(){ # arm label, caller, method, a row the honest split must keep + local got + got="$( rowsOf "$2" "$3" )" + case " $got " in + *" $4 "*) ok "$1 $2(): $3 keeps $4 in its split -> [$got]" ;; + *) no "$1 $2(): $3 -> [$got] has no $4 — a declaration's type leaked past its scope" ;; + esac +} + +# presence guard: every probed caller and every candidate def is indexed, or the arms below prove nothing +PMAP="$( "$BIN" "$PFIX" --no-cache 2>/dev/null | tr '>' '\n' )" +pmiss=0 +for want in 'n="pick" sc="Target"' 'n="peek" sc="Target"' 'n="pick" sc="Other"' 'n="peek" sc="Other"' 'n="pick" sc="Decoy"' 'n="peek" sc="Decoy"' \ + 'n="plainCaller" sc="Decoy"' 'n="ptrCaller" sc="Decoy"' 'n="localCaller" sc="Decoy"' 'n="nestedTyped" sc="Decoy"' \ + 'n="lambdaCaller" sc="Decoy"' 'n="loopLeak" sc="Box"' 'n="fieldLeak" sc="Box"' 'n="untypedShadow" sc="Box"'; do + printf '%s\n' "$PMAP" | grep -qF "$want" || { no "presence guard: paramfix symbol $want not indexed"; pmiss=1; } +done +[ "$pmiss" = 0 ] && ok "presence: all paramfix symbols indexed" + +# ── 7) THE DEFECT: a reference parameter's method call pins to the parameter's type — not the enclosing class's +# same-named method (Decoy::pick, line 5), which the locality tie-break used to hand it. ──────────────── +expectRows "(7)" plainCaller pick "pick@1" +# ── 8) a POINTER parameter, `other->pick( 1 )` — same fact, other declarator shape. ──────────────────────────── +expectRows "(8)" ptrCaller pick "pick@1" +# ── 9) control: the typed LOCAL of the same name already narrowed through Rule 2's Type record. ────────────── +expectRows "(9)" localCaller pick "pick@1" +# ── 10) a typed range-for variable shadows the parameter INSIDE the loop only: peek goes to Other, the pick after +# the loop to the parameter's Target. A flat per-function table would tombstone both (split). ────────── +expectRows "(10)" nestedTyped peek "peek@2" +expectRows "(10)" nestedTyped pick "pick@1" +# ── 11) a LAMBDA parameter types the call inside the lambda body. ──────────────────────────────────────────── +expectRows "(11)" lambdaCaller pick "pick@1" +# ── 12) scope leak, sibling loop: `for( const Target& t : ts )` narrows its own pick, and must NOT type the +# later `for( auto t : os )` — that t is untyped, so its peek stays the honest split (Other::peek in it). +expectRows "(12)" loopLeak pick "pick@1" +expectIncludes "(12)" loopLeak peek "peek@2" +# ── 13) scope leak, field: `item->peek( 2 )` after the loop names the FIELD `Other* item`, not the loop variable. +expectRows "(13)" fieldLeak pick "pick@1" +expectIncludes "(13)" fieldLeak peek "peek@2" +# ── 14) an UNTYPED nested redeclaration (`for( auto other : os )`) hides the Target parameter inside the loop: +# no narrow there; after the loop the parameter is back in scope and narrows. ─────────────────────────── +expectIncludes "(14)" untypedShadow peek "peek@2" +expectRows "(14)" untypedShadow pick "pick@1" + +# ── 15) the mechanism, not just the answer: the census names Rule 2 (receiver-rule) for plainCaller's site, where +# the unfixed binary names the locality tie-break. ───────────────────────────────────────────────────────── +"$BIN" "$PFIX" --no-cache --pin-census="$TMP/census.tsv" >/dev/null 2>&1 +mech="$( awk -F '\t' '$1 == "C" && $6 ~ /::Decoy::plainCaller#/ && $7 == "pick" { print $2 }' "$TMP/census.tsv" 2>/dev/null | sort -u | tr '\n' ' ' | sed 's/ $//' )" +[ "$mech" = "receiver-rule" ] \ + && ok "(15) plainCaller's pick site is decided by receiver-rule (Rule 2), not the locality tie-break" \ + || no "(15) plainCaller's pick site mech=[${mech:-NO-CENSUS-ROW}], want [receiver-rule]" + +# ── 16) determinism + cache transparency on the parameter fixture: the declaration byte Rule 2 now matches on is +# re-derived from the cached record, so warm must equal cold. ───────────────────────────────────────────── +"$BIN" "$PFIX" --callees=nestedTyped --no-cache >"$TMP/p1" 2>/dev/null +"$BIN" "$PFIX" --callees=nestedTyped --no-cache >"$TMP/p2" 2>/dev/null +rm -f "$TMP/pc" +"$BIN" "$PFIX" --cache="$TMP/pc" >/dev/null 2>&1 +"$BIN" "$PFIX" --callees=nestedTyped --cache="$TMP/pc" >"$TMP/pwarm" 2>/dev/null +if [ -s "$TMP/p1" ] && cmp -s "$TMP/p1" "$TMP/p2" && cmp -s "$TMP/p1" "$TMP/pwarm"; then + ok "(16) paramfix --callees=nestedTyped byte-identical: cold, cold again, and warm" +else + no "(16) paramfix --callees=nestedTyped differs across runs or warm vs cold"; diff "$TMP/p1" "$TMP/pwarm" | head -6 +fi + +# ── Arms 17-18: a written parameter type is only its FINAL segment (`ext::map&` records `map`), and class +# names carry no namespace, so a QUALIFIED parameter type cannot be told apart from an unrelated same-named in-repo +# class — measured on a private C++ corpus as precise wrong edges from `ankerl::unordered_dense::map<…>& t; t.find()` +# and `const std::map& ref; ref.lower_bound()` to an in-repo `map`. Rule 2 does not narrow on a qualified +# written type (the qualified text rides the record, kParserVer 97). An include-visibility guard was measured first +# and rejected: path-precise includes miss include-root spellings (`"LinearMath/btVector3.h"`), so it refused ~150 +# correct narrows on that corpus to stop these two. Candidates live in two directories apart from the caller, so a +# refused narrow declines (no edge) instead of landing on a same-file or same-directory guess. +VFIX="$TMP/visfix" +mkdir -p "$VFIX/lib" "$VFIX/lib2" "$VFIX/app" +printf 'struct map { int find( int k ) { return k; } };\n' >"$VFIX/lib/map.h" +printf 'struct dict { int find( int k ) { return k; } };\n' >"$VFIX/lib2/dict.h" +printf 'int lookupHidden( ext::map& table ) { return table.find( 1 ); }\n' >"$VFIX/app/hidden.cpp" +printf '#include "../lib/map.h"\nint lookupSeen( map& table ) { return table.find( 1 ); }\n' >"$VFIX/app/seen.cpp" +visRows(){ # the find@ rows one caller's callees answer; NO-CALLEES-ANSWER when the probe did not run + local out + out="$( "$BIN" "$VFIX" "--callees=$1" --no-cache 2>/dev/null )" + printf '%s' "$out" | grep -q "]*of=\"$1\" defs=\"1\"" || { printf 'NO-CALLEES-ANSWER'; return; } + printf '%s' "$out" | grep -o ']*>' | sed -n 's/.* n="find".* p="\([^"]*\)".*/find@\1/p' | sort -u | tr '\n' ' ' | sed 's/ $//' +} +# ── 17) `ext::map&` is qualified: no narrow to the unrelated in-repo lib/map.h `map`. ────────────────── +got="$( visRows lookupHidden )" +case "$got" in + NO-CALLEES-ANSWER) no "(17) lookupHidden(): --callees did not answer" ;; + "find@lib/map.h:1") no "(17) lookupHidden(): ext::map& narrowed to the unrelated in-repo lib/map.h map::find" ;; + *) ok "(17) lookupHidden(): a qualified written type is not a narrow -> [${got:-no edge}]" ;; +esac +# ── 18) control — the same call shape with an UNQUALIFIED `map&` narrows to lib/map.h exactly. ────────────────── +got="$( visRows lookupSeen )" +if [ "$got" = "find@lib/map.h:1" ]; then + ok "(18) lookupSeen(): the unqualified map& narrows -> [$got]" +else + no "(18) lookupSeen(): -> [$got], want [find@lib/map.h:1]" +fi + [ "$fail" = 0 ] && echo "ALL PASS" || echo "FAILURES ABOVE" exit $fail diff --git a/test/narrowfix/control/param.cpp b/test/narrowfix/control/param.cpp deleted file mode 100644 index 58b3feb6e..000000000 --- a/test/narrowfix/control/param.cpp +++ /dev/null @@ -1,35 +0,0 @@ -// narrowfix/control/param.cpp — the NEGATIVE control for P2-D Rule 2 receiver-variable narrowing. -// -// Same two-classes-share-a-method setup as cpp/recv.cpp, but the receiver is a function PARAMETER -// (`Foo* p`), which has NO captured local var→type binding. So Rule 2 CANNOT fire: `p->run()` falls -// through to the §2a ladder and stays HONESTLY AMBIGUOUS (an edge to BOTH Foo::run and Bar::run, -// `ambiguous=1`). This is what makes the narrowcheck gate meaningful — it proves the cpp/recv.cpp -// `ambiguous=0` is a REAL narrow on a real binding, not a vacuously-unambiguous fixture: remove the -// binding (use a parameter) and the very same call shape goes ambiguous again. - -struct Foo -{ - void run(); - int value = 0; -}; - -struct Bar -{ - void run(); - int value = 0; -}; - -void Foo::run() -{ - value = 1; -} - -void Bar::run() -{ - value = 2; -} - -void h( Foo* p ) -{ - p->run(); // p is a PARAMETER (no var→type binding) → Rule 2 cannot fire → stays AMBIGUOUS (§2a) -} diff --git a/test/narrowfix/control/untyped.cpp b/test/narrowfix/control/untyped.cpp new file mode 100644 index 000000000..b4d7305ab --- /dev/null +++ b/test/narrowfix/control/untyped.cpp @@ -0,0 +1,43 @@ +// narrowfix/control/untyped.cpp — the NEGATIVE control for P2-D Rule 2 receiver-variable narrowing. +// +// Same two-classes-share-a-method setup as cpp/recv.cpp, but the receiver is a local whose type the capture +// cannot read: `auto p = pool[ slot ];` writes no type and its initializer is a subscript, not a constructor +// call, so no var→type binding exists for `p`. Rule 2 CANNOT fire: `p->run()` falls through to the §2a +// ladder and stays HONESTLY AMBIGUOUS (an edge to BOTH Foo::run and Bar::run, `ambiguous=1`). This is what +// makes the narrowcheck gate meaningful — it proves the cpp/recv.cpp `ambiguous=0` is a REAL narrow on a +// real binding, not a vacuously-unambiguous fixture: remove the binding and the very same call shape goes +// ambiguous again. +// +// This control used to be a function PARAMETER (`void h( Foo* p )`). A parameter's written type is a real +// binding now (Rule 2 reads it lexically, see narrowcheck arms 7-18), so a parameter no longer demonstrates +// the absence of one. + +struct Foo +{ + void run(); + int value = 0; +}; + +struct Bar +{ + void run(); + int value = 0; +}; + +void Foo::run() +{ + value = 1; +} + +void Bar::run() +{ + value = 2; +} + +Foo* pool[ 2 ]; + +void h( int slot ) +{ + auto p = pool[ slot ]; + p->run(); // p has NO var→type binding (subscript initializer, `auto`) → Rule 2 cannot fire → AMBIGUOUS (§2a) +} diff --git a/test/qschemetrip.hash b/test/qschemetrip.hash index e4552f0ff..9dc0b023f 100644 --- a/test/qschemetrip.hash +++ b/test/qschemetrip.hash @@ -1 +1 @@ -24fd4dd9e2dce231e703b7c5f1867d5046aa87f8ad0130188a244cca1d788864 +db0e8cce58c3d6451293425ad58a50318f447ca1b501a253638f352ca30beb56 diff --git a/test/resolverhonestycheck.sh b/test/resolverhonestycheck.sh index e21c79d44..8437be575 100755 --- a/test/resolverhonestycheck.sh +++ b/test/resolverhonestycheck.sh @@ -113,11 +113,13 @@ printf 'int foo8(double);\n' > "$F/f8/b.cpp" printf 'int bar8() { return foo8(1); }\n' > "$F/f8/caller.cpp" # F9 [B] PURE-VIRTUAL multi-candidate (realistic decl-only): two abstract area() decls in two ifaces, -# caller calls s->area() → 2 edges → MUST carry amb=1. +# caller calls s->area() → 2 edges → MUST carry amb=1. The receiver is an UNTYPED `auto` local: it was a +# `Shape* s` PARAMETER until 2026-09-16, when Rule 2 began reading parameter types (narrowcheck arms 7-18) and +# resolved it to Shape::area alone — which left check_signal's F9 row passing on ONE edge, vacuously. mkdir -p "$F/f9" printf 'struct Shape { virtual int area() const = 0; };\n' > "$F/f9/s.h" printf 'struct Region { virtual int area() const = 0; };\n' > "$F/f9/r.h" -printf 'int compute9(Shape* s) { return s->area(); }\n' > "$F/f9/u.cpp" +printf 'extern Shape* shapes9[ 2 ];\nint compute9(int i) { auto s = shapes9[ i ]; return s->area(); }\n' > "$F/f9/u.cpp" # ═══════════════════════════════════════════════════════════════════════════════════════════════════ # INVARIANT 1 — SOUNDNESS: every resolved edge points at a def whose NAME matches the call and whose