From 3d2d642bd060630c7c0251efe824212daf4facb2 Mon Sep 17 00:00:00 2001 From: joyful-ii-V-I Date: Wed, 16 Sep 2026 13:39:46 -0400 Subject: [PATCH 1/5] fix(resolve): a member call through a typed parameter was pinned to the caller's own class MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `int Decoy::plainCaller( Target& other ) { return other.pick( 1 ); }` answered --callees=plainCaller with ONE edge to Decoy::pick: precise, no amb=, wrong. Rule 2 narrowed a receiver only through a typed local (LocalBindKind::Type); a parameter's written type (ParamType) was captured but read only by collectFieldUseSites, so the call fell through to the name ladder and the S6-C locality tie-break gave the caller's own class the scope credit (pin census: mech=locality, 2 -> 1). The fix: Rule 2, and CHA-lite through the same lookup (Narrower::recvVarTypeName), reads ParamType records LEXICALLY. graph.h buildScopedRecvDecls lists, for every name with a ParamType record, 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 (Binding::startByte, new, in the padding after `kind`: sizeof unchanged, pinned). The innermost declaration covering the call site decides; an untyped one, a tie, or no covering declaration answers nothing. Names with no ParamType record keep the flat varType table byte-identically. Why not the obvious fold into varType: measured first. It fixed arms 7/8/11/15 and minted three NEW 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, a parameter hidden by an untyped loop variable), and arm 10's tombstone kept the locality pin. Qualifier guard: a written type is its final segment and class names 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 129,759-call-site C++/ObjC++ corpus. A declaration's qualified written type now rides its Type/ParamType RawBind (importedName, ingest_binds.h qualifiedNameText) and the lexical lookup refuses it: removes all three, forgoes 11 correct narrows through qualified in-repo types (those keep main's answer). kParserVer 96 -> 97 (+ quality.h mirror): the record format is unchanged, its content is not, and a warm 96 blob would narrow a qualified type. An include-visibility guard was measured first and rejected: path-precise includes miss include-root spellings and it refused ~150 correct narrows there. Measured, --pin-census --no-cache, main f8e6087c binary vs this change: private corpus: 587 of 129,759 sites change target — 373 splits narrow (300 Rule 2, 73 CHA cone), 147 declined calls gain an edge (bound 80,432 -> 80,583), 66 pins/splits move to the parameter's type, 1 edge lost (a friend function scoped inside its class self-loops); 956 more keep their target, now decided by Rule 2. Every category sampled and read against the source. Wall time within noise (3 cold runs each, 1.66-2.51 s). this repo src/: 53 splits -> one Rule-2 pin; nothing else moves target. Gate: test/narrowcheck.sh arms 7-18 (generated fixture). RED on main: 10 rows (7, 8, 10 x2, 11, 12, 13, 14, 15). RED on the flat-table fold: 10 x2, 12, 13, 14. RED on the lexical lookup without the qualifier guard: 17. GREEN on this commit: 23 PASS, and under ASan. Controls that encoded "a parameter has no binding" moved to an untyped `auto` receiver: narrowcheck (control/param.cpp -> control/untyped.cpp), chacheck, chaconecheck g5, localitycheck (its call no longer reached the tie-break it tests), resolverhonestycheck F9 (check_signal had gone vacuous on one edge). fieldnarrowcheck (h) 7 -> 6: shadowParam( Decoy& m_x ) now resolves to the parameter's type; (s1) also asserts Pool::acquire is not linked (red on main). Out of scope, stated: an untyped receiver still reaches the locality tie-break; typed locals keep the flat table and its qualified-type collision. The reported arity split on a typed local (pick(int) + pick(int,int) for a 1-arg call) is B2.2's documented argCount > params rule and reproduces identically on the bare-name ladder. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 41 +++++++ docs/EVALS.md | 3 + src/graph.h | 96 +++++++++++++++- src/ingest_binds.h | 93 ++++++++++++---- src/ingest_cache.h | 12 +- src/ingest_model.h | 1 + src/model.h | 21 +++- src/quality.h | 5 +- src/resolve.h | 113 +++++++++++++++---- test/chacheck.sh | 8 +- test/chaconecheck.sh | 7 +- test/chaconefix/b.cpp | 2 +- test/chafix/cha.cpp | 15 ++- test/fieldnarrowcheck.sh | 18 ++- test/localityfix/loc.cpp | 14 ++- test/narrowcheck.sh | 173 +++++++++++++++++++++++++++-- test/narrowfix/control/param.cpp | 35 ------ test/narrowfix/control/untyped.cpp | 43 +++++++ test/resolverhonestycheck.sh | 6 +- 19 files changed, 585 insertions(+), 121 deletions(-) delete mode 100644 test/narrowfix/control/param.cpp create mode 100644 test/narrowfix/control/untyped.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ec71177c..c349489a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,47 @@ not published here — see `docs/EVALS.md` for the instruments behind the headli ## [Unreleased] +### 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: ten 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. + ## [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..c0c4703a6 100644 --- a/src/graph.h +++ b/src/graph.h @@ -966,6 +966,98 @@ inline void keepOwnJvmLanguageCandidates( const IngestResult& ing, const Referen // (from,to) out-edge(s) are stamped prov="scip". Name-based call-sites elsewhere are untouched. Passing // nullptr (the default) yields byte-identical output to the pre-overlay build. Deterministic: the overlay // is sorted, so candidate order and thus edge order are unchanged. + +// ── P2-D Rule 2 PARAMETER receivers: 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 + } +} + +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 + const auto isScopedRecord = []( const Binding& b ) noexcept { return b.fromSymbol != kNoNode && !b.var.empty(); }; + ScopedRecvDecls table; + table.reserve( std::size_t( std::ranges::count_if( ing.bindings, [ & ]( const Binding& b ) { return b.kind == LocalBindKind::ParamType && isScopedRecord( b ); } ) ) ); + std::string key; + for( const Binding& b : ing.bindings ) + { + if( b.kind == LocalBindKind::ParamType && isScopedRecord( b ) ) + { + buildShadowKey( key, b.fromSymbol, b.var ); + table.try_emplace( key ); + } + } + if( table.empty() ) + { + return table; + } + + // 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 + for( const Binding& b : ing.bindings ) + { + if( b.kind != LocalBindKind::VarDecl || !isScopedRecord( b ) ) + { + continue; + } + buildShadowKey( key, b.fromSymbol, b.var ); + const auto it = table.find( key ); + if( it == table.end() ) + { + continue; + } + const ScopedRecvDecl decl{ b.startByte, b.spanStart, b.spanEnd, kRecvDeclUntyped }; + if( it->second.empty() || it->second.back().declByte != decl.declByte || it->second.back().spanStart != decl.spanStart || it->second.back().spanEnd != decl.spanEnd ) + { + it->second.push_back( decl ); + } + } + + // each written type onto the declaration that shares its record position + 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 ) || !isScopedRecord( b ) || b.typeName.empty() ) + { + continue; + } + buildShadowKey( key, b.fromSymbol, b.var ); + if( const auto it = table.find( key ); it != table.end() ) + { + for( ScopedRecvDecl& decl : it->second ) + { + if( decl.declByte == b.startByte ) { attachRecvDeclType( decl, bindIndex, ing.bindings ); } + } + } + } + return table; +} + // ── L3 fn-pointer/callback binding tables (var→FUNCTION, Rule 2's exact discipline). Two scopes: // varFn "#var" → bound function name — LOCAL bindings (decls AND assignments inside one // function). First binding wins; a DIFFERENT later target tombstones (value ""), so a var @@ -1960,7 +2052,9 @@ inline Graph buildGraph( const IngestResult& ing, const ScipOverlay* scip = null // `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 ); + // Rule 2's lexical table for names with a ParamType declaration — built by buildScopedRecvDecls above. + const ScopedRecvDecls scopedRecvDecls = buildScopedRecvDecls( ing ); + const Narrower narrower( canonByName, varType, scopedRecvDecls, ing.bindings, 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..4fdc0c1e0 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, graph.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,10 +264,20 @@ 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 TSNode{}; + } + return idn; +} + +inline std::string ctorTypeOf( TSNode value, std::string_view src ) +{ + const TSNode idn = ctorNameNode( value ); + if( ts_node_is_null( idn ) ) { return {}; } @@ -292,6 +303,31 @@ 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 ) +{ + if( ts_node_is_null( nameNode ) ) + { + return {}; + } + const std::uint32_t a = ts_node_start_byte( nameNode ), b = ts_node_end_byte( nameNode ); + if( a > b || b > src.size() ) + { + return {}; + } + std::string_view text = src.substr( a, b - a ); + 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{}; +} + // ── L3 fn-pointer/callback binding capture helpers ─────────────────────────────────────────────────── // the bound-function TARGET of an initializer/assignment RHS value node, for a var→FUNCTION binding: @@ -740,7 +776,7 @@ struct BindSite // 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 ) + BindSite site, LocalBindKind kind, std::vector& binds, std::string qualifiedType = {} ) { if( var.empty() || ( typeName.empty() && kind != LocalBindKind::VarDecl ) ) { @@ -755,15 +791,19 @@ inline void pushRawBind( std::uint32_t fileId, Lang lang, std::string_view var, b.spanEnd = site.spanEnd; b.var.assign( var ); b.typeName = std::move( typeName ); + if( kind == LocalBindKind::Type || kind == LocalBindKind::ParamType ) + { + b.importedName = std::move( qualifiedType ); // the written type WHOLE when qualified (qualifiedNameText), else "" + } binds.push_back( std::move( b ) ); } // 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, - std::uint32_t startByte, std::vector& binds ) + std::uint32_t startByte, std::vector& binds, std::string qualifiedType = {} ) { - pushRawBind( fileId, lang, var, std::move( typeName ), BindSite{ startByte, 0u, 0u }, LocalBindKind::Type, binds ); + pushRawBind( fileId, lang, var, std::move( typeName ), BindSite{ startByte, 0u, 0u }, LocalBindKind::Type, binds, std::move( qualifiedType ) ); } // the scope a `declaration` node's names shadow within: the byte span, plus whether that span came from a @@ -893,20 +933,21 @@ inline void emitShadowVarDecls( std::uint32_t fileId, Lang lang, TSNode decl, st // 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, - BindSite site, std::vector& binds ) + std::string qualifiedType, BindSite site, std::vector& binds ) { const std::string_view var = declaratorVarName( declNode, src ); if( var.empty() && !type.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 (graph.h buildScopedRecvDecls) — only where this declaration is in scope. + pushRawBind( fileId, lang, paramDeclaratorVarName( declNode, src ), std::move( type ), BindSite{ site.startByte, 0u, 0u }, LocalBindKind::ParamType, binds, + std::move( qualifiedType ) ); } else { - emitBind( fileId, lang, var, std::move( type ), site.startByte, binds ); + emitBind( fileId, lang, var, std::move( type ), site.startByte, binds, std::move( qualifiedType ) ); } emitShadowVarDecls( fileId, lang, declNode, src, site, binds ); } @@ -930,10 +971,11 @@ 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 ); + const TSNode paramType = fieldChild( p, NodeField::Type ); + pushRawBind( fileId, lang, paramDeclaratorVarName( declarator, src ), writtenTypeOf( paramType, src ), + BindSite{ ts_node_start_byte( p ), 0u, 0u }, LocalBindKind::ParamType, binds, qualifiedNameText( paramType, src ) ); } } @@ -1096,10 +1138,12 @@ 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. + const TSNode loopType = fieldChild( n, NodeField::Type ); + pushRawBind( fileId, lang, paramDeclaratorVarName( loopDeclarator, src ), writtenTypeOf( loopType, src ), + BindSite{ ts_node_start_byte( n ), 0u, 0u }, LocalBindKind::ParamType, binds, qualifiedNameText( loopType, src ) ); return; } if( isLambda ) @@ -1357,6 +1401,7 @@ void bindsVisitNode( BindCtx& cx, TSNode n, const char* t ) { const TSNode typeNode = fieldChild( n, NodeField::Type ); std::string written = writtenTypeOf( typeNode, src ); + const std::string writtenQualified = written.empty() ? std::string{} : qualifiedNameText( typeNode, 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 +1427,15 @@ 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 ), + const TSNode value = fieldChild( c, NodeField::Value ); + std::string type = written.empty() ? ctorTypeOf( value, src ) : written; + std::string qualified = written.empty() ? qualifiedNameText( ctorNameNode( value ), src ) : writtenQualified; + emitDeclBinds( fileId, lang, declarator, src, std::move( type ), std::move( qualified ), 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, std::string( written ), std::string( writtenQualified ), 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..a5c50ac0d 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 (graph.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..2532ba2a6 100644 --- a/src/resolve.h +++ b/src/resolve.h @@ -55,8 +55,10 @@ #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 +2057,25 @@ 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). graph.h +// buildScopedRecvDecls 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 + +// "#" → 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. +using ScopedRecvDecls = HashMap>; + // 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 +2086,10 @@ 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), and the bindings + // its typeBinding indices point into. A name found here is answered here ONLY — varType is not consulted. + const ScopedRecvDecls& scopedDecls; + const std::vector& bindings; // 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 +2111,11 @@ struct Narrower explicit Narrower( const HashMap>& canon, const HashMap& vt, + const ScopedRecvDecls& scoped, + const std::vector& binds, const std::vector>& incl, const std::vector& symFile ) noexcept - : canonByName( canon ), varType( vt ), fileIncludes( incl ), symFileId( symFile ) {} + : canonByName( canon ), varType( vt ), scopedDecls( scoped ), bindings( binds ), 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 +2181,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 +2201,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 +2533,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.find( keyBind ); sit != scopedDecls.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 = 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..5af3d95c5 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,143 @@ 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" )" + [ "$got" = "$4" ] && ok "$1 $2(): $3 -> [$got]" || no "$1 $2(): $3 -> [$got], want [$4]" +} +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 )" +[ "$got" = "find@lib/map.h:1" ] && ok "(18) lookupSeen(): the included class narrows -> [$got]" || no "(18) lookupSeen(): -> [$got], want [find@lib/map.h:1]" + [ "$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/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 From 1508bd102add3f7296c3ebed74d19413b1573ffd Mon Sep 17 00:00:00 2001 From: joyful-ii-V-I Date: Wed, 16 Sep 2026 13:51:18 -0400 Subject: [PATCH 2/5] refactor(resolve): the lexical receiver table grew a clone, a six-argument constructor and a 30-complexity builder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit quality-delta over the merge-base range reported gating=3 on this lane's own rows: ctorTypeOf's byte-bounds tail cloned nodeTextOf (duplication + new-clone-of-reused-helper), and Narrower's constructor went 4 -> 6 parameters against a bar of 5. Non-gating but real: the new buildScopedRecvDecls at cognitive complexity 30, and bindsVisitNode 88 -> 94 with three new parameters across pushRawBind/emitBind/emitDeclBinds. - ctorTypeOf and qualifiedNameText read through nodeTextOf. - ScopedRecvDecls is one struct holding the table and the bindings its indices point into, so the Narrower takes one argument for both (5 parameters). - buildScopedRecvDecls is three single-loop steps: addParamTypedNames, addRecvDeclScopes, attachRecvDeclTypes. - DeclType { name, qualified } travels as one argument. pushTypedBind is now the ONE record body and pushRawBind a one-line wrapper with no qualified text, so pushRawBind and emitBind keep their signatures, and declaredTypeOf takes the declaration branch's two ternaries out of bindsVisitNode. (A first cut stamped importedName after the push, emitFnBind's shape — and quality-delta reported it as a 70-token clone of emitFnBind.) Zero behaviour change, measured: --pin-census and the default map on the private corpus are byte-identical (cmp) to the previous commit's binary, as is the census over src/; narrowcheck, chacheck, fieldnarrowcheck, resolverhonestycheck, fieldusescheck, shadowcheck and cacheidentitycheck ALL PASS. Co-Authored-By: Claude Opus 5 --- src/graph.h | 78 +++++++++++++++++++----------- src/ingest_binds.h | 115 ++++++++++++++++++++++++--------------------- src/resolve.h | 23 +++++---- 3 files changed, 125 insertions(+), 91 deletions(-) diff --git a/src/graph.h b/src/graph.h index c0c4703a6..5417ccd03 100644 --- a/src/graph.h +++ b/src/graph.h @@ -996,65 +996,89 @@ inline void attachRecvDeclType( ScopedRecvDecl& decl, std::uint32_t bindIndex, c } } -inline ScopedRecvDecls buildScopedRecvDecls( const IngestResult& ing ) +// a binding record the lexical table can key: attributed to a definition, naming a variable +inline bool isScopedBindRecord( const Binding& b ) noexcept { - PROFILE_SCOPE_DESCRIBE( "buildGraph/2j: Rule-2 lexical receiver declarations" ); - VERIFY( ing.bindings.size() < kRecvDeclConflicted ); // typeBinding indices stay clear of the two sentinels - const auto isScopedRecord = []( const Binding& b ) noexcept { return b.fromSymbol != kNoNode && !b.var.empty(); }; - ScopedRecvDecls table; - table.reserve( std::size_t( std::ranges::count_if( ing.bindings, [ & ]( const Binding& b ) { return b.kind == LocalBindKind::ParamType && isScopedRecord( b ); } ) ) ); - std::string key; + 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( b.kind == LocalBindKind::ParamType && isScopedRecord( b ) ) + if( isParamType( b ) ) { buildShadowKey( key, b.fromSymbol, b.var ); - table.try_emplace( key ); + table.byName.try_emplace( key ); } } - if( table.empty() ) - { - return table; - } +} - // 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 +// 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 || !isScopedRecord( b ) ) + if( b.kind != LocalBindKind::VarDecl || !isScopedBindRecord( b ) ) { continue; } buildShadowKey( key, b.fromSymbol, b.var ); - const auto it = table.find( key ); - if( it == table.end() ) + const auto it = table.byName.find( key ); + if( it == table.byName.end() ) { continue; } const ScopedRecvDecl decl{ b.startByte, b.spanStart, b.spanEnd, kRecvDeclUntyped }; - if( it->second.empty() || it->second.back().declByte != decl.declByte || it->second.back().spanStart != decl.spanStart || it->second.back().spanEnd != decl.spanEnd ) + 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 +// 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 ) || !isScopedRecord( b ) || b.typeName.empty() ) + if( ( b.kind != LocalBindKind::Type && b.kind != LocalBindKind::ParamType ) || !isScopedBindRecord( b ) || b.typeName.empty() ) { continue; } buildShadowKey( key, b.fromSymbol, b.var ); - if( const auto it = table.find( key ); it != table.end() ) + const auto it = table.byName.find( key ); + if( it == table.byName.end() ) { - for( ScopedRecvDecl& decl : it->second ) - { - if( decl.declByte == b.startByte ) { attachRecvDeclType( decl, bindIndex, ing.bindings ); } - } + 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; } @@ -2054,7 +2078,7 @@ inline Graph buildGraph( const IngestResult& ing, const ScipOverlay* scip = null // BEFORE the bare-name spray below. See resolve.h. // Rule 2's lexical table for names with a ParamType declaration — built by buildScopedRecvDecls above. const ScopedRecvDecls scopedRecvDecls = buildScopedRecvDecls( ing ); - const Narrower narrower( canonByName, varType, scopedRecvDecls, ing.bindings, fileIncludes, symFileId ); + 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 4fdc0c1e0..a3dfcdc8d 100644 --- a/src/ingest_binds.h +++ b/src/ingest_binds.h @@ -276,13 +276,7 @@ inline TSNode ctorNameNode( TSNode value ) inline std::string ctorTypeOf( TSNode value, std::string_view src ) { - const TSNode idn = ctorNameNode( value ); - if( ts_node_is_null( idn ) ) - { - return {}; - } - 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 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 @@ -311,16 +305,7 @@ inline std::string writtenTypeOf( TSNode typeNode, std::string_view src ) // record in RawBind::importedName; Rule 2's lexical lookup refuses to narrow on it. inline std::string qualifiedNameText( TSNode nameNode, std::string_view src ) { - if( ts_node_is_null( nameNode ) ) - { - return {}; - } - const std::uint32_t a = ts_node_start_byte( nameNode ), b = ts_node_end_byte( nameNode ); - if( a > b || b > src.size() ) - { - return {}; - } - std::string_view text = src.substr( a, b - a ); + 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 @@ -328,6 +313,31 @@ inline std::string qualifiedNameText( TSNode nameNode, std::string_view src ) 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: @@ -774,36 +784,41 @@ 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, std::string qualifiedType = {} ) +// 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 ); - if( kind == LocalBindKind::Type || kind == LocalBindKind::ParamType ) - { - b.importedName = std::move( qualifiedType ); // the written type WHOLE when qualified (qualifiedNameText), else "" - } + 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, - std::uint32_t startByte, std::vector& binds, std::string qualifiedType = {} ) + std::uint32_t startByte, std::vector& binds ) { - pushRawBind( fileId, lang, var, std::move( typeName ), BindSite{ startByte, 0u, 0u }, LocalBindKind::Type, binds, std::move( qualifiedType ) ); + pushRawBind( fileId, lang, var, std::move( typeName ), BindSite{ startByte, 0u, 0u }, LocalBindKind::Type, binds ); } // the scope a `declaration` node's names shadow within: the byte span, plus whether that span came from a @@ -932,22 +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, - std::string qualifiedType, BindSite site, std::vector& binds ) +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'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 (graph.h buildScopedRecvDecls) — only where this declaration is in scope. - pushRawBind( fileId, lang, paramDeclaratorVarName( declNode, src ), std::move( type ), BindSite{ site.startByte, 0u, 0u }, LocalBindKind::ParamType, binds, - std::move( qualifiedType ) ); + 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, std::move( qualifiedType ) ); + pushTypedBind( fileId, lang, var, std::move( type ), BindSite{ site.startByte, 0u, 0u }, LocalBindKind::Type, binds ); } emitShadowVarDecls( fileId, lang, declNode, src, site, binds ); } @@ -973,9 +987,8 @@ inline void emitShadowParamDecls( TSNode params, std::uint32_t fileId, Lang lang // 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 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. - const TSNode paramType = fieldChild( p, NodeField::Type ); - pushRawBind( fileId, lang, paramDeclaratorVarName( declarator, src ), writtenTypeOf( paramType, src ), - BindSite{ ts_node_start_byte( p ), 0u, 0u }, LocalBindKind::ParamType, binds, qualifiedNameText( paramType, src ) ); + pushTypedBind( fileId, lang, paramDeclaratorVarName( declarator, src ), declTypeOfName( fieldChild( p, NodeField::Type ), src ), + BindSite{ ts_node_start_byte( p ), 0u, 0u }, LocalBindKind::ParamType, binds ); } } @@ -1141,9 +1154,8 @@ inline void captureShadowScopeDecls( TSNode n, const char* t, std::uint32_t file // 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. - const TSNode loopType = fieldChild( n, NodeField::Type ); - pushRawBind( fileId, lang, paramDeclaratorVarName( loopDeclarator, src ), writtenTypeOf( loopType, src ), - BindSite{ ts_node_start_byte( n ), 0u, 0u }, LocalBindKind::ParamType, binds, qualifiedNameText( loopType, src ) ); + 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 ) @@ -1399,9 +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 std::string writtenQualified = written.empty() ? std::string{} : qualifiedNameText( 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 @@ -1427,15 +1437,12 @@ void bindsVisitNode( BindCtx& cx, TSNode n, const char* t ) if( kindIs( ct, "init_declarator" ) ) { const TSNode declarator = fieldChild( c, NodeField::Declarator ); - const TSNode value = fieldChild( c, NodeField::Value ); - std::string type = written.empty() ? ctorTypeOf( value, src ) : written; - std::string qualified = written.empty() ? qualifiedNameText( ctorNameNode( value ), src ) : writtenQualified; - emitDeclBinds( fileId, lang, declarator, src, std::move( type ), std::move( qualified ), + 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 ), std::string( writtenQualified ), + emitDeclBinds( fileId, lang, c, src, written, BindSite{ ts_node_start_byte( n ), shadowSpanStart( scope, c ), scope.end }, binds ); } return true; diff --git a/src/resolve.h b/src/resolve.h index 2532ba2a6..d435b2023 100644 --- a/src/resolve.h +++ b/src/resolve.h @@ -2072,9 +2072,14 @@ static_assert( std::is_trivially_copyable_v && sizeof( ScopedRec 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 -// "#" → 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. -using ScopedRecvDecls = HashMap>; +// 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; +}; // 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. @@ -2086,10 +2091,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), and the bindings - // its typeBinding indices point into. A name found here is answered here ONLY — varType is not consulted. + // 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; - const std::vector& bindings; // 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 @@ -2112,10 +2116,9 @@ struct Narrower explicit Narrower( const HashMap>& canon, const HashMap& vt, const ScopedRecvDecls& scoped, - const std::vector& binds, const std::vector>& incl, const std::vector& symFile ) noexcept - : canonByName( canon ), varType( vt ), scopedDecls( scoped ), bindings( binds ), 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 ) @@ -2555,14 +2558,14 @@ struct Narrower appendUint( keyBind, r.fromSymbol ); keyBind.push_back( '#' ); keyBind.append( r.recvVar ); - if( const auto sit = scopedDecls.find( keyBind ); sit != scopedDecls.end() ) + 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 {}; // no declaration in scope (a field or global of the name), a tie, or untyped/conflicted } - const Binding& declared = bindings[ innermost->typeBinding ]; + const Binding& declared = ( *scopedDecls.bindings )[ innermost->typeBinding ]; return declared.importedName.empty() ? std::string_view( declared.typeName ) : std::string_view{}; // qualified → no narrow } const auto vit = varType.find( keyBind ); From 17a9f5f29718eb969ce4830a2320f64595558d3b Mon Sep 17 00:00:00 2001 From: joyful-ii-V-I Date: Wed, 16 Sep 2026 13:56:39 -0400 Subject: [PATCH 3/5] chore(quality): ack the Narrower constructor's one new input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit quality-delta --quality-delta=$(git merge-base origin/main HEAD)..HEAD reported gating=2 after the refactor: api-surface contract-change on Narrower::Narrower and its one call site in buildGraph (4 -> 5 parameters). That is the fix itself — the Narrower has to be handed the lexical declaration table — so it is acked through the binary (--quality-ack --ack-only=api-surface), which wrote exactly two +ack rows, one per symbol, and nothing else. Co-Authored-By: Claude Opus 5 --- .ripwire_quality_acks | 2 ++ 1 file changed, 2 insertions(+) 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. From eaadb9d08dc7523466de925a5013ba02e7ce382c Mon Sep 17 00:00:00 2001 From: joyful-ii-V-I Date: Wed, 16 Sep 2026 14:29:12 -0400 Subject: [PATCH 4/5] =?UTF-8?q?fix(lane):=20the=20full=20suite's=20three?= =?UTF-8?q?=20reds=20=E2=80=94=20a=20line-shifted=20published=20seed,=20a?= =?UTF-8?q?=20moved=20parser-version=20pin,=20two=20one-line=20verdicts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full suite at 17a9f5f2: gates=632 pass=627 skip=2 fail=3, all three this lane's own. showcasecapturecheck (H): the 0.6.1 release capture publishes --at=src/graph.h:3406 as the FIRST body line of rankGraphTeleport, so it has zero slack — the ~100 lines this lane added to graph.h above it made 3406 resolve to buildGraph. The capture is the release PR's to regenerate (a lane recording it publishes local branch names), and the code had a better home anyway: the lexical table builder (buildScopedRecvDecls and its three steps) moves from graph.h to resolve.h, beside the ScopedRecvDecls type and Narrower::recvVarTypeName that reads it. graph.h's diff is now five lines in, five out, at the Narrower construction; rankGraphTeleport is back at 3404 on this tree. qschemetripcheck: its manifest hashes ingest_cache.h's kParserVer declaration, which moved 96 -> 97 with the quality.h mirror in the same diff (qextractionkeycheck green), so this is the gate's own re-pin case: UPDATE_GOLDEN=1, and the new pin equals the current= hash the suite printed. gateexitcheck (G2): narrowcheck's expectRows helper and arm 18 reported through a one-line 'A && ok || no'; both are if/else now. No behaviour change: --pin-census and the default map over the private corpus are byte-identical (cmp) to the fix commit's binary. qschemetripcheck, gateexitcheck, showcasecapturecheck, narrowcheck, chacheck, chaconecheck, localitycheck, fieldnarrowcheck, resolverhonestycheck, fieldusescheck, shadowcheck, fnptrcheck, cacheidentitycheck, qextractionkeycheck, manifestcheck ALL PASS. Co-Authored-By: Claude Opus 5 --- src/graph.h | 124 +----------------------------------------- src/ingest_binds.h | 4 +- src/model.h | 2 +- src/resolve.h | 120 +++++++++++++++++++++++++++++++++++++++- test/narrowcheck.sh | 12 +++- test/qschemetrip.hash | 2 +- 6 files changed, 135 insertions(+), 129 deletions(-) diff --git a/src/graph.h b/src/graph.h index 5417ccd03..8227d02a8 100644 --- a/src/graph.h +++ b/src/graph.h @@ -966,122 +966,6 @@ inline void keepOwnJvmLanguageCandidates( const IngestResult& ing, const Referen // (from,to) out-edge(s) are stamped prov="scip". Name-based call-sites elsewhere are untouched. Passing // nullptr (the default) yields byte-identical output to the pre-overlay build. Deterministic: the overlay // is sorted, so candidate order and thus edge order are unchanged. - -// ── P2-D Rule 2 PARAMETER receivers: 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; -} - // ── L3 fn-pointer/callback binding tables (var→FUNCTION, Rule 2's exact discipline). Two scopes: // varFn "#var" → bound function name — LOCAL bindings (decls AND assignments inside one // function). First binding wins; a DIFFERENT later target tombstones (value ""), so a var @@ -2072,11 +1956,9 @@ 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. - // Rule 2's lexical table for names with a ParamType declaration — built by buildScopedRecvDecls above. + // 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 ); diff --git a/src/ingest_binds.h b/src/ingest_binds.h index a3dfcdc8d..ce6eb5e1a 100644 --- a/src/ingest_binds.h +++ b/src/ingest_binds.h @@ -231,7 +231,7 @@ inline std::string_view declaratorVarName( TSNode decl, std::string_view src ) // 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, which Rule 2's flat per-function table would leak past their scope -// (ParamType records reach Rule 2 only through the lexical lookup, graph.h buildScopedRecvDecls). +// (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" ) @@ -956,7 +956,7 @@ inline void emitDeclBinds( std::uint32_t fileId, Lang lang, TSNode declNode, std // member-variable round (card A3): a REFERENCE local (`const Symbol& s = ing.symbols[ i ];`) is the one // 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 (graph.h buildScopedRecvDecls) — only where this declaration is in scope. + // 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 diff --git a/src/model.h b/src/model.h index a5c50ac0d..ec00d4693 100644 --- a/src/model.h +++ b/src/model.h @@ -628,7 +628,7 @@ enum class LocalBindKind : std::uint8_t // (`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 (graph.h buildScopedRecvDecls, 2026-09-16): every one of these shapes is scoped + // 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 diff --git a/src/resolve.h b/src/resolve.h index d435b2023..3aba92877 100644 --- a/src/resolve.h +++ b/src/resolve.h @@ -50,6 +50,7 @@ #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 @@ -2059,8 +2060,8 @@ inline std::size_t sharedLocality( std::string_view a, std::string_view b ) noex // 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). graph.h -// buildScopedRecvDecls builds the table; Narrower::recvVarTypeName reads it. +// 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 @@ -2081,6 +2082,121 @@ struct ScopedRecvDecls 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 diff --git a/test/narrowcheck.sh b/test/narrowcheck.sh index 5af3d95c5..9ef3034c5 100755 --- a/test/narrowcheck.sh +++ b/test/narrowcheck.sh @@ -135,7 +135,11 @@ rowsOf(){ expectRows(){ # arm label, caller, method, the exact expected row set local got got="$( rowsOf "$2" "$3" )" - [ "$got" = "$4" ] && ok "$1 $2(): $3 -> [$got]" || no "$1 $2(): $3 -> [$got], want [$4]" + 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 @@ -231,7 +235,11 @@ case "$got" in esac # ── 18) control — the same call shape with an UNQUALIFIED `map&` narrows to lib/map.h exactly. ────────────────── got="$( visRows lookupSeen )" -[ "$got" = "find@lib/map.h:1" ] && ok "(18) lookupSeen(): the included class narrows -> [$got]" || no "(18) lookupSeen(): -> [$got], want [find@lib/map.h:1]" +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/qschemetrip.hash b/test/qschemetrip.hash index e4552f0ff..9dc0b023f 100644 --- a/test/qschemetrip.hash +++ b/test/qschemetrip.hash @@ -1 +1 @@ -24fd4dd9e2dce231e703b7c5f1867d5046aa87f8ad0130188a244cca1d788864 +db0e8cce58c3d6451293425ad58a50318f447ca1b501a253638f352ca30beb56 From 186fcb8473b89e8bc4a8b751885a6b9f6f8118e0 Mon Sep 17 00:00:00 2001 From: joyful-ii-V-I Date: Wed, 16 Sep 2026 20:46:47 -0400 Subject: [PATCH 5/5] docs(changelog): nine rows were red on main, not ten, and the abstract-parameter floor was unstated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of #248 (CI coordinator, 2026-09-16) found two text defects in this lane's entry; both re-measured here rather than copied. Count: test/narrowcheck.sh at this branch against the main binary (f8e6087c) exits rc=1 with NINE FAIL rows — (7) (8) (10)x2 (11) (12) (13) (14) (15). The fix commit's own list named nine; 'ten' was a miscount. Floor: a parameter typed as an interface whose methods are pure-virtual declarations cannot narrow to it (Rule 2 resolves against definitions only), and the narrow lands on unrelated classes that share the final name segment. rocksdb @ 0e2801ac3, --pin-census --no-cache, main f8e6087c vs eaadb9d0: 79 sites (88 rows) through an Iterator* parameter now split five ways over the nested memtable/ Iterator classes, none right — 25 were a unique override pin, 26 a split over overrides, 28 had no edge. Receivers read at the source are all 'Iterator* iter' parameters. (The review's 32 counts unique->disjoint-split only, main 31e788ce against the #254 tip.) Also stated: a member-initializer call through a typed parameter is outside the body span and keeps main's answer — measured on this binary as main's own wrong locality pin. CHANGELOG-reading gates (anchorbodycheck, deckcheck, docdemotecheck, prcontextcheck, recallevalcheck, ripwirepubliccheck, rubysettercheck, traceminecheck) ALL PASS. No code, pin, kParserVer or qschemetrip.hash change; main is deliberately not merged (integration train 2). Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 30aae140f..ac9aa70bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,7 +70,7 @@ then names as the caller itself. 956 more sites keep their target and are now de 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: ten rows red on `main`, arms 12-14 red on the flat-table fold, arm 17 +`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 @@ -80,6 +80,21 @@ parameter's type, and its arm (s1) now also asserts that the shadowed field's `P 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