diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ec71177c..15236c815 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,50 @@ not published here — see `docs/EVALS.md` for the instruments behind the headli ## [Unreleased] +### Fixed — a C++ member call with explicit template arguments is a call (parser version 99) + +`r.get( 1 )`, `p->get( 1 )` and `x.template get()` minted no reference at all. Their callee parses as a +`template_method` under the member access — inside a `dependent_name` when the `template` keyword is spelled — and no +C++ reference pattern bound either shape, so the call was dropped at extraction, before `ambiguous=`/`unresolved=` +could count it: a four-line repro answered `--callers=get` count="0" beside count="1" for `r.plain( 1 )`, +`--callers`/`--uses`/`--impact`/`--safe-delete` under-counted every such call, and `--quality-delta` could report a +method reached only this way as `kind="dead-code"`. A new `queries/cpp/tags.scm` pattern binds both shapes, and the +receiver reader now steps over the wrapper — without that step `other.pick()` reads as a bare call and the +enclosing-class rule binds it to the caller's own same-named method. The qualified dependent spelling had the same +defect family in another place: `X::template make()` was extracted under the NAME `template make`, which +resolves to nothing, and `X::template Rebind::f()` keyed its qualifier as `template Rebind`, so a same-named +definition in another scope split the call. The keyword is now stepped over in both halves. Free `f( x )` and +qualified `ns::f( x )` (the `std::get<0>( t )` shape) were already bound and are unchanged. + +Measured with the pre-fix (`f8e6087c`) and fixed (`1171f775`) binaries, `--no-cache`, map header plus +`ripwire_probe`'s reference total. On this repository's `src/` (169 files) the change is small: references 168,457 → +168,463 and edges 18,308 → 18,309 — the six `r.pod()` reads in `src/gitoracle.h`'s cache loader, so `--uses=pod` +goes 1 → 7. On a template-heavy tree it is not small. `clang/include` plus `clang/lib` from llvm-project `4d5358b1d` +(2,515 files) gains 10,175 references (1,855,925 → 1,866,100), 3,907 edges (409,860 → 413,767, +0.95%) and 972 +`ambiguous=` (81,601 → 82,573), with `unresolved=` unchanged at 3,893; `--callers=getAs` goes 48 → 492 and +`--callers=hasAttr` 45 → 377. On `clang/lib/AST` (155 files) the reference delta, 2,027, equals the `--match` hit count +of the new call shape exactly (`hits_capped="0"`), so extraction moved only the intended class. + +The new sites resolve exactly as a plain member call to the same name does, so a name like `getAs` or `hasAttr` gains +its real callers and also that resolver's wrong ones. On the same clang tree `FD->hasAttr()` in +`ASTContext::getDeclAlign` binds `Type::hasAttr(attr::Kind)` at `lib/AST/Type.cpp:2026`, not `Decl::hasAttr()`, +with no `amb=` on the row. The plain spelling does the same on main: in a reduced five-file repro, `FD->plainAttr()` +binds `Type::plainAttr(int)` rather than `Decl::plainAttr()`. That is a resolver defect this change widens the reach +of, not one it introduces, and it is not fixed here. + +`test/cppqualcheck.sh` §12 adds a corpus, `test/cppqualtmplfix/`, with one literal per spelling plus receiver, arity +and qualifier decoys: 19 of its checks fail on the pre-fix binary, and a mutation build that reverts each of the three +mechanisms (the receiver step, the keyword skip, `callArity`'s hop bound) turns that mechanism's own arms red. The +spellings still not bound are pinned at zero behind a check that each is still written in the fixture: `r.f<0>( x )` +without `template` (tree-sitter reads it as two comparisons, a read of the member), a base-qualified member +`r.Base::f()` / `p->Base::f()` (the plain `r.Base::f()` is absent too, so the gap is not template-shaped), and +`r.operator()()`. `test/callformcheck.sh` row 11, `b.template memberTmpl()`, was pinned as documented-absent +at literal 0 and now pins 1. + +`kParserVer` 96 → 99 with `quality.h`'s `kIngestParserVerMirror` in the same commit — 97 and 98 are held by open +pull requests, per the next-free rule in `src/ingest_cache.h`; `kCacheVersion` stays 22, and `test/qschemetrip.hash` +is re-pinned with a RE-PIN LOG line. + ## [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/queries/cpp/tags.scm b/queries/cpp/tags.scm index 31f8ecea6..b9e99bc00 100644 --- a/queries/cpp/tags.scm +++ b/queries/cpp/tags.scm @@ -355,6 +355,28 @@ function: (template_function (identifier) @name)) @reference.call +; MEMBER CALLS WITH EXPLICIT TEMPLATE ARGUMENTS: `r.f( x )` / `p->f( x )` parse as +; call_expression function: (field_expression field: (template_method name: (field_identifier) arguments: …)), +; and the disambiguated `x.template f()` / `this->template f()` wrap that template_method in a +; dependent_name. The member pattern above binds `field: (field_identifier)` and the pattern just above binds +; a template_function, so neither shape minted a reference (measured 2026-09-16: `--callers=get` count="0" +; for `r.get( 1 )` beside count="1" for `r.plain( 1 )`, and --quality-delta read a method reached only +; this way as dead code). test/cppqualcheck.sh §12. +; +; @name is the field_identifier, as in the plain member pattern, so the NAME needs no text surgery. What the +; wrapper does change is every parent walk that starts at @name: receiverOf (ingest_binds.h) climbs through +; template_method/dependent_name to the field_expression — otherwise `other.f()` reads as a BARE call and +; the enclosing-class rule pins it to the caller's own same-named method — and callArity's bounded parent +; walk still reaches the call node (3 hops, 4 behind `template`). +; +; One alternation, one pattern: a node matches exactly one branch, so each call is minted once. +(call_expression + function: (field_expression + field: [ + (template_method name: (field_identifier) @name) + (dependent_name (template_method name: (field_identifier) @name)) + ])) @reference.call + ; MACRO-DEFINED TEST BODIES (LB-E, r10 gitnexus harvest 2026-08-20): `TEST_CASE( "title" ) { … }` — ; doctest/Catch2's block-forming test macros — cannot be expanded by tree-sitter, so the source parses ; as TWO SIBLING nodes: an (expression_statement (call_expression …) (MISSING ";")) and a bare diff --git a/src/ingest_binds.h b/src/ingest_binds.h index 40aaf099c..5fcff6cd6 100644 --- a/src/ingest_binds.h +++ b/src/ingest_binds.h @@ -139,6 +139,29 @@ inline RecvShape classifyReceiver( TSNode node, Lang lang, std::string_view src, return {}; // parenthesized / subscripted / call receiver → not one-hop } +// The node a call's @name hangs under once the C++ template-argument wrappers are stepped over. For +// `x.m()` that is the name's own parent. `x.m()` puts ONE wrapper between them — field_expression +// field: (template_method name: m arguments: …) — and the disambiguated `x.template m()` a second — +// field: (dependent_name (template_method …)) — so the climb steps over exactly those two kinds, in that +// order, and a name under any other parent keeps the parent it had. Both kinds are tree-sitter-cpp's (the +// CUDA grammar is generated from it); no other grammar names either, so the climb is inert everywhere else. +// A template_method that is not a member callee (a qualified definition's declarator) climbs to a parent +// that is no member access, which every caller already reads as "not a member call". +inline TSNode calleeAccessParent( TSNode nameNode ) noexcept +{ + TSNode parent = ts_node_parent( nameNode ); + if( ts_node_is_null( parent ) || !kindIs( ts_node_type( parent ), "template_method" ) ) + { + return parent; + } + parent = ts_node_parent( parent ); + if( !ts_node_is_null( parent ) && kindIs( ts_node_type( parent ), "dependent_name" ) ) + { + parent = ts_node_parent( parent ); + } + return parent; +} + // P2-D RECEIVER capture: classify the call-site receiver of `recv.method()` / `recv->method()` so // resolve.h can narrow before the ambiguous §2a name spray. `nameNode` is the @name capture (the called // identifier). When it is the `.field`/`.attribute` of a member-access node, inspect that node's @@ -161,9 +184,14 @@ inline RecvShape classifyReceiver( TSNode node, Lang lang, std::string_view src, // bound: the depth-3 call now takes the honest ladder, never Rule 1's enclosing-class pin. // Pure-syntactic, deterministic, allocation-light: at most two short identifier copies, and none at all // for the None/ThisObj shapes that dominate. +// +// The parent it inspects is calleeAccessParent's, not the raw parent: a C++ member call with explicit +// template arguments puts a template_method (and, behind `template`, a dependent_name) between the name and +// its field_expression. Read through the raw parent, `other.f()` classified None — a BARE call — and the +// enclosing-class rule pinned it to the caller's own same-named method (test/cppqualcheck.sh §12 (d)). inline RecvShape receiverOf( TSNode nameNode, Lang lang, std::string_view src ) { - const TSNode parent = ts_node_parent( nameNode ); + const TSNode parent = calleeAccessParent( nameNode ); if( ts_node_is_null( parent ) ) { return {}; diff --git a/src/ingest_cache.h b/src/ingest_cache.h index 11cbfd1b7..82c17fa33 100644 --- a/src/ingest_cache.h +++ b/src/ingest_cache.h @@ -231,7 +231,18 @@ 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 = 99; // bump on any grammar/.scm/extraction change + // 99 = 2026-09-16 (member template calls, test/cppqualcheck.sh + // §12): a C++ member call with explicit template arguments + // (`r.f()`, `p->f()`, `x.template f()`) mints a + // call reference with its receiver, where it minted none; + // a `template` disambiguator no longer leaks into a qualified + // call's name (`template f`) or qualifier (`template Rebind`). + // The extracted SET and names change; no record changes shape + // (kCacheVersion stays 22). Takes 99, not 97: open PRs #235 + // (97, 98) and #233 (98) hold those numbers in flight — the + // 78/80/88/91 next-free rule. quality.h's + // kIngestParserVerMirror bumped in the SAME commit. // 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_names.h b/src/ingest_names.h index eba9d507f..43285f75f 100644 --- a/src/ingest_names.h +++ b/src/ingest_names.h @@ -25,10 +25,53 @@ namespace // scope component ("A", or "B" from `A::B::b`). enclosingScopeOf walks ancestors to the nearest // class/struct/namespace and returns its name (for in-class method DEFS). Both "" when absent → caller // falls back to bare-name resolution (so non-C++ langs and unqualified calls are unaffected). +// +// The C++ `template` DISAMBIGUATOR is a KEYWORD, never part of a name. `X::template make()` and +// `X::template Rebind::f()` spell it in front of a dependent template name, and tree-sitter-cpp keeps it +// inside the dependent_name node whose TEXT the reference path reads. Returns `text` past one leading +// `template` token and the whitespace/comments after it, or `text` unchanged when it does not start with +// that token — the keyword must be followed by a separator, so `templateFn` stays a plain identifier. +// Measured before this existed (test/cppqualcheck.sh §12 (c)/(d)): `X::template tqDepQualTmpl()` minted +// a reference NAMED `template tqDepQualTmpl`, which resolves to nothing, and +// `X::template TqRebind::tqScopedFn()` keyed its qualifier as `template TqRebind`, so the canonical tier +// missed and the call split over a same-named decoy in another scope. +inline std::string_view skipTemplateDisambiguator( std::string_view text ) noexcept +{ + constexpr std::string_view kKeyword = "template"; + if( !text.starts_with( kKeyword ) ) + { + return text; + } + std::string_view rest = text.substr( kKeyword.size() ); + while( !rest.empty() ) + { + if( std::isspace( static_cast( rest.front() ) ) ) + { + rest.remove_prefix( 1 ); + } + else if( rest.starts_with( "/*" ) ) + { + const std::size_t close = rest.find( "*/", 2 ); + rest.remove_prefix( close == std::string_view::npos ? rest.size() : close + 2 ); + } + else if( rest.starts_with( "//" ) ) + { + const std::size_t eol = rest.find( '\n' ); + rest.remove_prefix( eol == std::string_view::npos ? rest.size() : eol + 1 ); + } + else + { + break; + } + } + const bool separated = rest.size() < text.size() - kKeyword.size(); + return separated && !rest.empty() ? rest : text; +} + inline std::string immediateScope( std::string_view full ) { const std::size_t cc = full.rfind( "::" ); - return std::string( cc == std::string_view::npos ? full : full.substr( cc + 2 ) ); + return std::string( skipTemplateDisambiguator( cc == std::string_view::npos ? full : full.substr( cc + 2 ) ) ); } // ── H4 qualified-call re-split helpers ─────────────────────────────────────────────────────────────────── @@ -163,6 +206,52 @@ inline std::size_t lastTopLevelScopeSep( std::string_view text ) noexcept } return std::string_view::npos; } + +// H4 RE-SPLIT of a C++ call reference's (name, qualifier), moved out of captureTagsFacts (whose complexity is +// measured). The widened qualified-call pattern binds the INNER node, so a 3+-segment call's captured text +// still carries scope (`inner::targetFn`). Recover the pair the canonical tier keys on — name = the final +// segment, qualifier = the IMMEDIATE scope — from the text itself. This must run AFTER the reference's +// finalSegment() name and qualifierOf() qualifier are set, and overwrites both when it splits: finalSegment +// truncates at the first '<', which would name `numeric_limits::max` as `numeric_limits` and mint +// an edge to the wrong symbol. Inert for every 2-segment call (`rw::midFn` binds a bare identifier — no +// top-level `::` in the text) and for `ns::tmplFn()` (whose captured text is just `tmplFn`), so +// those keep their qualifierOf() result untouched. +// +// An OPERATOR tail is recognised first: its `<`/`>` are part of the NAME, so handing it to the angle-depth +// scan binds the wrong scope for the whole `>` family. See operatorNameStart. When the operator spelling +// starts at index 0 the capture IS the bare operator name, its parent is the qualified_identifier, and +// qualifierOf() already put the immediate scope in r.qualifier — nothing to re-split. +// +// The `template` disambiguator (see skipTemplateDisambiguator) is stepped over on the NAME half here — at +// 2 segments too, where the capture is the dependent_name itself (`template make`) and nothing splits — +// and on the QUALIFIER half inside immediateScope. +inline void cppResplitRefName( RawRef& r, std::string_view nameTxt ) +{ + const std::size_t opStart = operatorNameStart( nameTxt ); + const bool opScoped = opStart != std::string_view::npos && opStart >= 2 && nameTxt[ opStart - 1 ] == ':' && nameTxt[ opStart - 2 ] == ':'; + if( opScoped ) + { + r.name = finalSegment( nameTxt.substr( opStart ) ); // `operator>` verbatim + r.qualifier = immediateScope( namesplit::stripTemplateArgs( nameTxt.substr( 0, opStart - 2 ) ) ); + return; + } + if( opStart != std::string_view::npos ) + { + return; + } + const std::size_t sep = lastTopLevelScopeSep( nameTxt ); + const bool hasSep = sep != std::string_view::npos; + const std::string_view tail = hasSep ? nameTxt.substr( sep + 2 ) : nameTxt; + const std::string_view bare = skipTemplateDisambiguator( tail ); + if( hasSep || bare.size() != tail.size() ) // neither a split nor a keyword: finalSegment( nameTxt ) already named it + { + r.name = finalSegment( bare ); + } + if( hasSep ) + { + r.qualifier = immediateScope( namesplit::stripTemplateArgs( nameTxt.substr( 0, sep ) ) ); + } +} // True when a qualified_identifier's `::` separator is a MISSING node — a zero-width token tree-sitter // INSERTED during error recovery, not one that is written in the source. Recovery reaches for this shape // whenever two identifiers sit adjacent where the grammar expected one, so ` name(...)` after diff --git a/src/ingest_sidecap.h b/src/ingest_sidecap.h index 696ea7b33..7ff932d9b 100644 --- a/src/ingest_sidecap.h +++ b/src/ingest_sidecap.h @@ -2072,46 +2072,13 @@ void captureTagsFacts( TSQueryCursor* cursor, const LangEntry& le, std::uint32_t if( le.lang == Lang::Cpp ) { r.qualifier = qualifierOf( nameNode, src ); // `A::b()` → "A" (E#4 canonical resolve) + cppResplitRefName( r, nameTxt ); // H4 RE-SPLIT at 3+ segments, operator tails, `template` disambiguator } else if( le.lang == Lang::Rust ) { r.qualifier = rustQualifierOf( nameNode, src ); // H4: `Widget::new()` → "Widget" } - // H4 RE-SPLIT: the widened qualified-call pattern binds the INNER node, so a 3+-segment call's - // captured text still carries scope (`inner::targetFn`). Recover the pair the canonical tier - // keys on — name = the final segment, qualifier = the IMMEDIATE scope — from the text itself. - // This must run INSTEAD OF the finalSegment() above (it overwrites both fields): finalSegment - // truncates at the first '<', which would name `numeric_limits::max` as - // `numeric_limits` and mint an edge to the wrong symbol. Inert for every 2-segment call - // (`rw::midFn` binds a bare identifier — no top-level `::` in the text) and for - // `ns::tmplFn()` (whose `::` sits inside no group but whose captured text is just - // `tmplFn`), so those keep their qualifierOf() result untouched. - if( le.lang == Lang::Cpp ) - { - // An OPERATOR tail is recognised first: its `<`/`>` are part of the NAME, so handing it to - // the angle-depth scan below binds the wrong scope for the whole `>` family. See - // operatorNameStart. When the operator spelling starts at index 0 the capture IS the bare - // operator name, its parent is the qualified_identifier, and qualifierOf() already put the - // immediate scope in r.qualifier — nothing to re-split. - const std::size_t opStart = operatorNameStart( nameTxt ); - const bool opScoped = opStart != std::string_view::npos && opStart >= 2 - && nameTxt[ opStart - 1 ] == ':' && nameTxt[ opStart - 2 ] == ':'; - if( opScoped ) - { - r.name = finalSegment( nameTxt.substr( opStart ) ); // `operator>` verbatim - r.qualifier = immediateScope( namesplit::stripTemplateArgs( nameTxt.substr( 0, opStart - 2 ) ) ); - } - else if( opStart == std::string_view::npos ) - { - if( const std::size_t sep = lastTopLevelScopeSep( nameTxt ); sep != std::string_view::npos ) - { - r.name = finalSegment( nameTxt.substr( sep + 2 ) ); - r.qualifier = immediateScope( namesplit::stripTemplateArgs( nameTxt.substr( 0, sep ) ) ); - } - } - } - if( !isImportRef && le.lang != Lang::Elixir ) // an import site has no receiver and no argument list — { // the defaults (RecvKind::None, argCountKnown=false) are the truth RecvShape rs = receiverOf( nameNode, le.lang, src ); // P2-D: `this`/`self`/`x`/`base.field` shape diff --git a/src/quality.h b/src/quality.h index 1dd363395..1f77ca29f 100644 --- a/src/quality.h +++ b/src/quality.h @@ -1972,7 +1972,12 @@ 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 = 99; // MUST equal ingest.cpp's kParserVer (gated) + // 99 = 2026-09-16 (member template calls, test/cppqualcheck.sh + // §12): `r.f()` / `x.template f()` mint call references; + // the `template` disambiguator leaves names and qualifiers. + // See ingest_cache.h's kParserVer note. + // 96 = 2026-09-13 (internal linkage): see ingest_cache.h's 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/test/callformcheck.sh b/test/callformcheck.sh index 97a898843..a4732eba9 100755 --- a/test/callformcheck.sh +++ b/test/callformcheck.sh @@ -13,9 +13,10 @@ # cannot be expressed inside a single-directory fixture. # # THE DOCUMENTED-ABSENT ROWS ARE THE POINT. Roughly a quarter of the arms below assert that a -# spelling produces NOTHING: C++ casts, most-vexing-parse declarations, member-templates and -# destructor spellings; Go's explicit generic instantiation; Java's method references and both -# generic `new` forms; Ruby's bare paren-less call; Swift's explicit specialization; computed +# spelling produces NOTHING: C++ casts, most-vexing-parse declarations and destructor spellings (the +# member-template row left this list 2026-09-16, when the spelling started to extract); Go's explicit +# generic instantiation; Java's method references and both generic `new` forms; Ruby's bare paren-less +# call; Swift's explicit specialization; computed # `new a.b[c]()` in TS and JS. Those are honest rejects — several of them unfixable by any query — # and an arm that fences them goes RED if a naive widening lands. A matrix that only recorded the # successes would be a celebration, not a gate. @@ -218,12 +219,15 @@ calleeRow cpp callerOperator 'operator>' "14. …and it is operator> itself" # tier misses and the call resolves by the bare-name ladder — precise here because aliasFn is unique, # and exactly as precise as a bare call elsewhere. That residual is why this arm exists. uses cpp aliasFn 1 "15. NOT-CHECKED->MEASURED: namespace-alias call qa::aliasFn() is CAPTURED (resolves bare, not canonically)" -# 11. NOT-CHECKED in the survey — the member-template spelling. MEASURED: DROPPED, entirely. -# `b.template memberTmpl()` yields no reference of any name; the `template` disambiguator makes -# the callee child a shape none of the three C++ reference patterns bind. +# 11. NOT-CHECKED in the survey — the member-template spelling. MEASURED: DROPPED, entirely, until +# 2026-09-16, when this row was a documented-absent literal 0 (plus a probeBlind): the callee of +# `b.template memberTmpl()` parses as field_expression field: (dependent_name (template_method …)), and +# of `r.f()` as field: (template_method …) — shapes no C++ reference pattern bound. The member-template +# pattern binds both now. test/cppqualcheck.sh §12 owns the spelling matrix and the receiver/arity/qualifier +# decoys; this row only pins that the matrix's own spelling extracts and resolves. fixtureHasLit cpp/main.cpp 'b.template memberTmpl()' "11. the member-template spelling is still WRITTEN" -uses cpp memberTmpl 0 "11. NOT-CHECKED->MEASURED: obj.template f() is ABSENT (literal 0)" -probeBlind cpp callerTemplates memberTmpl "11. …and it is absent at EXTRACTION, not lost in resolution" +uses cpp memberTmpl 1 "11. obj.template f() resolves (was ABSENT, literal 0, before the member-template pattern)" +probeSees cpp callerTemplates memberTmpl "11. …and it is extracted under its own name, not merely resolved" # 16. cast keywords. tree-sitter-cpp parses every cast as call_expression function: template_function # — the same node shape as spelling 9 — so the exclusion lives at capture time in ingest.cpp. Asserted # pre-resolution: a cast ref resolves to nothing and would vanish while still inflating every count. diff --git a/test/cppqualcheck.sh b/test/cppqualcheck.sh index 65ee55fed..8ca26250a 100755 --- a/test/cppqualcheck.sh +++ b/test/cppqualcheck.sh @@ -13,10 +13,15 @@ # definition written with two or more qualifier segments was dropped by the mirror-image defect in the # definition pattern, just as silently. Read its own section header for that round's evidence. # -# THREE CORPORA. test/cppqualfix/ proves each CALL SPELLING extracts and resolves; every name in it has +# §12 (added 2026-09-16) carries the MEMBER half of the explicit-template-argument drop: `r.f( x )`, +# `p->f( x )` and `x.template f()` minted no reference, and the `template` disambiguator corrupted the +# name or qualifier of the qualified form. Read its own section header for that round's evidence. +# +# FOUR CORPORA. test/cppqualfix/ proves each CALL SPELLING extracts and resolves; every name in it has # exactly one definition, so those arms cannot prove the re-split chose the RIGHT def. test/cppqualdecoyfix/ # (§8) gives each name a same-final-name DECOY in another scope, so a wrong qualifier binds provably wrong. # test/cppqualdeffix/ (§11) is the definition-side corpus, with its own same-final-name decoy. +# test/cppqualtmplfix/ (§12) is the member-template corpus, with receiver, arity and qualifier decoys. # # EVERY expected count below is a LITERAL, read by hand off the fixtures (plan §7 trap 1: a gate that derives # its expected number the way the code does cannot catch the derivation). Each fixture's header comment maps @@ -520,15 +525,165 @@ printf '%s' "$DEFMAP" | grep -qE 'files=1 symbols=30 edges=7 shown=30 est_tokens && ok "def §11 header: symbols=30 edges=7 ambiguous=0 unresolved=0 (was symbols=24 edges=1)" \ || no "def §11 header wrong: $( printf '%s' "$DEFMAP" | grep -oE 'files=1 [^-]*' | head -1 )" +# ── §12 MEMBER calls with explicit template arguments, and the `template` disambiguator ───────────────── +# §1 proved `freeTmpl()` and `a::scopedTmpl()` extract. The MEMBER spelling of the same call — +# `r.get( 1 )`, `p->get( 1 )`, `x.template get()` — never did: its callee parses as +# `field_expression field: (template_method …)`, or `field: (dependent_name (template_method …))` behind the +# disambiguator, and no C++ reference pattern bound either. Measured 2026-09-16 on main 3bf884e2: a +# four-line repro answered `--callers=get` count="0" beside `--callers=plain` count="1", and in the wild +# --quality-delta reported a method reached ONLY this way as kind="dead-code". The QUALIFIED dependent form +# did extract, but wrong: `X::template f()` minted a reference NAMED `template f`, and +# `X::template Rebind::g()` keyed its qualifier as `template Rebind`, which no definition carries. +# +# FOURTH CORPUS: test/cppqualtmplfix/ — its own directory for the reason §11 gives (§3's header literal). +# Every literal below is read by hand off that fixture; its header maps each spelling to its line. +# +# RED-FIRST (recorded 2026-09-16, plain builds, two reference binaries, each pinned to what it proves): +# vs the pre-fix binary (main f8e6087c): 19 of this section's 24 new checks FAIL — all but the four +# controls (a) and the determinism arm. Header `edges=6 ambiguous=1` (now 19 / 0); every member-template +# --uses/--callers reads 0; --callees=tqCallRebind is 2 rows (:132 and the decoy :133); --callers=pod +# names only Reader::str. +# vs a MUTATION build of the fix (the same tree with three one-line reversions; 8 checks FAIL): +# receiverOf reading the raw parent -> tqDecoyCaller binds the decoy :110; +# skipTemplateDisambiguator inert -> --uses=tqDepQualTmpl/tqDeepDepTmpl/tqCommentDepTmpl read 0, the probe +# name arm fails, and tqCallRebind splits onto :133; +# callArity's hop bound 4 -> 3 -> tqDepArityCaller splits over :122 and :123; +# and the header, which all three move (edges=18 ambiguous=2). +# All 90 checks PASS against the fixed binary. (g)'s six documented-absent checks, added later from the +# review of #243, pass on the pre-fix binary and the fix alike: they fence a future widening. +TMPLFIX="$ROOT/test/cppqualtmplfix" +[ -d "$TMPLFIX" ] || { echo "no test/cppqualtmplfix dir — the member-template fixture is missing"; exit 2; } +TMPLMAP="$( run "$TMPLFIX" --no-cache )" + +tmplexpect(){ # $1 verb $2 sym $3 want $4 prose + local got; got="$( cnt "$( run "$TMPLFIX" "--$1=$2" --no-cache )" )" + [ "${got:-REFUSED}" = "$3" ] && ok "tmpl §12 --$1=$2 count=$3 — $4" \ + || no "tmpl §12 --$1=$2 expected count=$3, got '${got:-REFUSED}' — $4" +} +# --callees= must be exactly ONE row, at member.cpp: — the def the call must bind. +tmplbinds(){ # $1 caller $2 line $3 prose + local out; out="$( run "$TMPLFIX" --callees="$1" --no-cache )" + { [ "$( cnt "$out" )" = 1 ] && printf '%s' "$out" | grep -qE "]*p=\"member\.cpp:$2\""; } \ + && ok "tmpl §12 precision: --callees=$1 is ONE row, member.cpp:$2 — $3" \ + || no "tmpl §12 precision: --callees=$1 expected one row at member.cpp:$2 — $3. Got count='$( cnt "$out" )' rows: $( printf '%s' "$out" | grep -oE ']*>' | tr '\n' ' ' )" +} + +# (a) POSITIVE CONTROLS first (L-6 vacuity guard): spellings the pre-round patterns already bound. A 0 here +# means the corpus or the run is broken, and every arm below would be vacuous. +tmplexpect uses tqFreeTmpl 1 "6. CONTROL: free f( x )" +tmplexpect uses tqQualTmpl 1 "7. CONTROL: ns::f( x )" +tmplexpect uses tqIndexTmpl 1 "8. CONTROL: ns::f<0>( x ), the std::get<0>( t ) shape" +tmplexpect uses tqStaticTmpl 1 "9. CONTROL: ns::Holder::f( x )" + +# (b) one literal per MEMBER spelling. `--uses` counts SITES, so a site double-minted (a call row plus a +# read row for the same field_identifier) would read one high rather than pass. +tmplexpect uses tqDotTmpl 2 "1. r.f( x ) AND 13. o.reader.f( x ) — both call rows, no read row" +tmplexpect uses tqArrowTmpl 1 "2. p->f( x )" +tmplexpect uses tqDepDotTmpl 1 "3. x.template f()" +tmplexpect uses tqDepArrowTmpl 1 "4. p->template f()" +tmplexpect uses tqThisTmpl 1 "5. this->template f()" +tmplexpect uses tqNestedArgTmpl 1 "12. r.f>>( x ) — arguments closing on >>" +tmplexpect callers tqDotTmpl 2 "the defect's own verb: both member-template callers are named" +tmplexpect callers tqArrowTmpl 1 "p->f( x ) names its caller" + +# (c) the QUALIFIED dependent form extracted under the wrong NAME. Resolution alone cannot prove the name +# is clean — a `template tqDepQualTmpl` reference resolves to nothing and vanishes from every graph +# verb — so the name is read PRE-resolution from the probe's raw reference list, after a presence guard. +tmplexpect uses tqDepQualTmpl 1 "10. X::template f() resolves (was named \`template tqDepQualTmpl\`)" +tmplexpect uses tqDeepDepTmpl 1 "11. X::Y::template f() resolves through the re-split" +tmplexpect uses tqCommentDepTmpl 1 "15. X::template /* c */ f() — a comment between the keyword and the name" +if [ -x "$PROBE" ]; then + TMPLREFS="$( perl -e 'alarm 30; exec @ARGV' "$PROBE" "$TMPLFIX" 2>/dev/null | grep -A1 ' tqCallDependentQualified ' | grep 'calls:' )" + if [ -z "$TMPLREFS" ]; then + no "tmpl §12 probe: tqCallDependentQualified has no reference list — the name arm would be vacuous" + else + { printf '%s' "$TMPLREFS" | grep -qw 'tqDepQualTmpl' && printf '%s' "$TMPLREFS" | grep -qw 'tqDeepDepTmpl' \ + && printf '%s' "$TMPLREFS" | grep -qw 'tqCommentDepTmpl' \ + && ! printf '%s' "$TMPLREFS" | grep -qw 'template' && ! printf '%s' "$TMPLREFS" | grep -q '/\*'; } \ + && ok "tmpl §12 probe: the dependent qualified calls extract as tqDepQualTmpl/tqDeepDepTmpl/tqCommentDepTmpl — no keyword, no comment" \ + || no "tmpl §12 probe: a \`template\` disambiguator leaked into a reference NAME: $TMPLREFS" + fi +else + no "ripwire_probe missing at $PROBE — the pre-resolution name arm cannot run, and a gate that cannot run must say so" +fi + +# (d) PRECISION: the right definition, not merely a definition. Each caller has a same-final-name decoy. +# RECEIVER: read as a BARE call, `other.tqPick( 1 )` inside TqDecoy would be pinned to TqDecoy's own +# tqPick (:110) by the enclosing-class rule; the receiver's type pins TqTarget's (:105). The receiver is a +# LOCAL on purpose: parameter-type narrowing is a separate resolver rule (measured on main f8e6087c there was +# none, and a plain `other.pick( 1 )` on a `TqTarget&` parameter bound the enclosing class; #248 changes +# that), so a parameter receiver would measure that rule instead of the template_method climb. TqTarget has +# ONE tqPick for the same kind of reason: measured on main f8e6087c, type narrowing had no arity prune, so a +# plain `other.pick( 1 )` split over a same-class two-parameter overload as well. +tmplbinds tqDecoyCaller 105 "the receiver's type, never the enclosing class's same-named :110" +tmplbinds tqSelfCaller 110 "this->template binds the enclosing class" +# ARITY: the call-site argument count survives the wrapper — 3 hops from the name to the call through +# template_method, 4 behind `template` (callArity's whole bound). Unknown arity splits over :122 and :123. +tmplbinds tqArityCaller 123 "a.f( 3, 4 ) binds the two-parameter overload only" +tmplbinds tqDepArityCaller 123 "a.template f( 5, 6 ) binds the two-parameter overload only, at the hop bound" +# QUALIFIER: `X::template TqRebind::tqScopedFn` keys as TqRebind::tqScopedFn (:132); keyed as +# `template TqRebind` the canonical tier missed and the call split over :132 and the decoy :133. +tmplbinds tqCallRebind 132 "the scope behind a template disambiguator keys canonically, no split onto :133" + +# (e) the fixture header, hand-read: 19 distinct caller->callee pairs (1 viaThis + 2 tqCallMember + +# 2 tqCallDependent + 4 tqCallControls + 3 tqCallDependentQualified + 2 tqCallShapes + 1 each for +# tqDecoyCaller, tqSelfCaller, tqArityCaller, tqDepArityCaller, tqCallRebind), none ambiguous, none +# unresolved; 54 symbols (43, plus §12e's 3 structs, 4 member functions and 4 callers — which add no edge). +printf '%s' "$TMPLMAP" | grep -qE 'files=1 symbols=54 edges=19 shown=54 est_tokens=[0-9]+ ambiguous=0 unresolved=0' \ + && ok "tmpl §12 header: symbols=54 edges=19 ambiguous=0 unresolved=0 (was edges=6 ambiguous=1)" \ + || no "tmpl §12 header wrong: $( printf '%s' "$TMPLMAP" | grep -oE 'files=1 symbols=[0-9]+ edges=[0-9]+ shown=[0-9]+ est_tokens=[0-9]+ ambiguous=[0-9]+ unresolved=[0-9]+' | head -1 )" + +# (f) REPO ROOT: src/gitoracle.h's loader reads every field through `r.pod()`, so its only caller-edge to +# the reader was the one spelled bare inside Reader::str. Presence guard first: the arm proves nothing +# once the loader stops spelling the member form. +if grep -q 'r\.pod<' src/gitoracle.h; then + POD="$( run . --callers=pod --no-cache )" + printf '%s' "$POD" | grep -qE ']*n="loadOracleCache"' \ + && ok "repo: --callers=pod names loadOracleCache (was only Reader::str — the r.pod() sites minted nothing)" \ + || no "repo: --callers=pod does not name loadOracleCache. Rows: $( printf '%s' "$POD" | grep -oE ']*>' | tr '\n' ' ' )" +else + no "repo: src/gitoracle.h no longer spells r.pod<…>() — re-point this arm at a live member-template call site" +fi + +# (g) DOCUMENTED-ABSENT (review F4 on #243): the spellings this round does NOT bind, each a literal 0 behind a +# SOURCE presence guard — an absence arm is satisfied as well by deleting the spelling as by the tool +# behaving (callformcheck.sh's V5 MED-3). Measured 0 on the pre-fix binary and on the fix alike, so these +# are fences for a future widening, not evidence of this round: one that starts binding a spelling here, +# correctly or not, has to move a pin deliberately. +tmplabsent(){ # $1 the spelling as written in member.cpp $2 its caller $3 prose + if ! grep -qF -- "$1" "$TMPLFIX/member.cpp"; then + no "tmpl §12 absent: \`$1\` is no longer written in member.cpp — the zero for --callees=$2 would be vacuous" + return + fi + local got; got="$( cnt "$( run "$TMPLFIX" --callees="$2" --no-cache )" )" + [ "${got:-REFUSED}" = 0 ] && ok "tmpl §12 absent: --callees=$2 count=0 — $3" \ + || no "tmpl §12 absent: --callees=$2 expected count=0, got '${got:-REFUSED}' — $3. A widening landed: move this pin on purpose" +} +tmplabsent 'r.tqLiteralArgTmpl<0>( x )' tqCallLiteralArg "r.f<0>( x ) without \`template\` parses as two comparisons" +tmplabsent 'r.TqAbsentBase::tqBaseQualTmpl( 1 )' tqCallBaseQual "r.Base::f() (the qualified-field family)" +tmplabsent 'p->TqAbsentBase::tqBaseQualTmpl( 2 )' tqCallBaseQual "p->Base::f() (the qualified-field family)" +tmplabsent 'r.TqAbsentBase::tqBasePlain( 1 )' tqCallBasePlain "the plain twin r.Base::f() — absent on main too, so the gap is not template-shaped" +tmplabsent 'r.operator()( 1 )' tqCallOperatorTmpl "r.operator()()" +# …and the comparison parse is a READ of the member, exactly one row, never a call row. +LITUSES="$( run "$TMPLFIX" --uses=tqLiteralArgTmpl --no-cache )" +{ [ "$( cnt "$LITUSES" )" = 1 ] && printf '%s' "$LITUSES" | grep -qE ']*>' | grep -q 'role="call"'; } \ + && ok "tmpl §12 absent: --uses=tqLiteralArgTmpl is ONE role=\"read\" row at member.cpp:158, no call row" \ + || no "tmpl §12 absent: --uses=tqLiteralArgTmpl expected one read row at member.cpp:158 and no call row. Rows: $( printf '%s' "$LITUSES" | grep -oE ']*>' | tr '\n' ' ' )" + # ── §10 determinism + well-formed XML ─────────────────────────────────────────────────────────────────── [ "$( run "$FIX" --no-cache )" = "$( run "$FIX" --no-cache )" ] \ && ok "determinism: the fixture map is byte-identical run-to-run" \ || no "determinism: the fixture map is not byte-identical" +[ "$TMPLMAP" = "$( run "$TMPLFIX" --no-cache )" ] \ + && ok "determinism: the §12 member-template fixture map is byte-identical run-to-run" \ + || no "determinism: the §12 member-template fixture map is not byte-identical" # V4 MED-1: a missing xmllint is a FAILURE, not a silent skip — G4 is a hard guardrail and a PASS # count that silently shrinks hides the hole (same law floormarkcheck/rustqualcheck already follow). if command -v xmllint >/dev/null 2>&1; then printf '%s' "$MAP" | xmllint --noout - 2>/dev/null && printf '%s' "$CALLEES" | xmllint --noout - 2>/dev/null \ - && ok "xml well-formed (fixture map + --callees)" || no "xml malformed" + && printf '%s' "$TMPLMAP" | xmllint --noout - 2>/dev/null \ + && ok "xml well-formed (fixture map + --callees + the §12 member-template map)" || no "xml malformed" else no "xmllint not on PATH — the G4 arm cannot run; install libxml2/xmllint (a gate that cannot run must say so)" fi diff --git a/test/cppqualtmplfix/member.cpp b/test/cppqualtmplfix/member.cpp new file mode 100644 index 000000000..8e4c2c7d1 --- /dev/null +++ b/test/cppqualtmplfix/member.cpp @@ -0,0 +1,166 @@ +// C++ TEMPLATE-ARGUMENT CALL fixture — read by test/cppqualcheck.sh §12. +// +// A MEMBER call whose callee carries explicit template arguments — `r.f( x )`, `p->f( x )`, +// `x.template f()` — parses as +// call_expression function: (field_expression field: (template_method name: (field_identifier))) +// or, with the `template` disambiguator, `field: (dependent_name (template_method …))`. No C++ reference +// pattern matched either shape, so every such call minted NO reference at all: --callers/--uses/--impact +// read zero and --quality-delta called a method reached only this way dead-code. The QUALIFIED dependent +// form (`X::template f()`) did extract, but under the NAME `template f`, and a scope spelled +// `X::template Rebind::g()` keyed its qualifier as `template Rebind` — a name no definition carries. +// +// §12a: EVERY callee has a UNIQUE name, so `--uses=` is the number of call sites written below for +// that name, read off this file by hand — never derived by running the query the extractor runs (plan §7 +// trap 1). Spellings 6-9 are CONTROLS the pre-round patterns already bound. +// §12b: the names are SHARED on purpose — each decoy is a same-final-name definition the call must NOT bind. §12e: see the end. + +namespace tq +{ + +template T tqQualTmpl( int n ) { return T( n ); } + +template int tqIndexTmpl( int n ) { return n + I; } + +enum class TqKind : unsigned char { A, B }; + +struct TqHolder +{ + template static T tqStaticTmpl( int n ) { return T( n ); } +}; + +} // namespace tq + +template T tqFreeTmpl( int n ) { return T( n ); } + +template struct TqPair { A first; B second; }; + +struct TqReader +{ + template E tqDotTmpl( int n ) { return E( n ); } + template E tqArrowTmpl( int n ) { return E( n ); } + template E tqDepDotTmpl( int n ) { return E( n ); } + template E tqDepArrowTmpl( int n ) { return E( n ); } + template int tqNestedArgTmpl( int n ) { return n; } +}; + +struct TqOuter +{ + TqReader reader; +}; + +template struct TqCrtp +{ + template E tqThisTmpl( int n ) { return E( n ); } + int viaThis() { return this->template tqThisTmpl( 5 ); } // 5. this->template f() +}; + +struct TqMaker +{ + template static T tqDepQualTmpl( int n ) { return T( n ); } + template static T tqCommentDepTmpl( int n ) { return T( n ); } + struct Inner + { + template static T tqDeepDepTmpl( int n ) { return T( n ); } + }; +}; + +// ── §12a the spellings ───────────────────────────────────────────────────────────────────────────── +int tqCallMember( TqReader& r, TqReader* p ) +{ + int a = int( r.tqDotTmpl( 1 ) ); // 1. member dot — the defect's repro shape + a += p->tqArrowTmpl( 2 ); // 2. member arrow + return a; +} + +template int tqCallDependent( X& x, X* px ) +{ + return x.template tqDepDotTmpl( 3 ) // 3. x.template f() + + px->template tqDepArrowTmpl( 4 ); // 4. p->template f() +} + +int tqCallControls() +{ + return tqFreeTmpl( 6 ) // 6. CONTROL: free f( x ) + + tq::tqQualTmpl( 7 ) // 7. CONTROL: ns::f( x ) + + tq::tqIndexTmpl<0>( 8 ) // 8. CONTROL: std::get<0>( t )-style non-type argument + + tq::TqHolder::tqStaticTmpl( 9 ); // 9. CONTROL: 3-segment static member template +} + +template int tqCallDependentQualified() +{ + return X::template tqDepQualTmpl( 10 ) // 10. X::template f() — was named `template tqDepQualTmpl` + + X::Inner::template tqDeepDepTmpl( 11 ) // 11. X::Y::template f() — same, through the re-split + + X::template /* disambiguated */ tqCommentDepTmpl( 15 ); // 15. a comment between the keyword and the name +} + +int tqCallShapes( TqReader& r, TqOuter& o ) +{ + return r.tqNestedArgTmpl>>( 12 ) // 12. nested template arguments closing on `>>` + + o.reader.tqDotTmpl( 13 ); // 13. member template through a chained receiver +} + +// ── §12b precision: the RIGHT definition, not merely a definition ────────────────────────────────── +struct TqTarget +{ + template E tqPick( int n ) { return E( n ); } +}; + +struct TqDecoy +{ + template E tqPick( int n ) { return E( n ); } // RECEIVER decoy: same name, same arity + + // `other` is a TqTarget LOCAL, so this binds TqTarget::tqPick; read as a BARE call the enclosing-class + // rule pins TqDecoy::tqPick. A local, so the arm measures the climb and not parameter-type narrowing. + int tqDecoyCaller() { TqTarget other; return other.tqPick( 1 ); } + + // `this->template`: the enclosing class is the receiver, so this binds TqDecoy::tqPick. + int tqSelfCaller() { return this->template tqPick( 2 ); } +}; + +struct TqArity +{ + template E tqArityPick( int n ) { return E( n ); } + template E tqArityPick( int n, int m ) { return E( n + m ); } // ARITY decoy: two parameters +}; + +// Two arguments bind the two-parameter overload only — through template_method (3 hops from the name to the +// call) and behind `template` (4 hops, callArity's whole bound). Free functions, so no enclosing class narrows +// first and the arity prune is what decides. +int tqArityCaller( TqArity& a ) { return a.tqArityPick( 3, 4 ); } +int tqDepArityCaller( TqArity& a ) { return a.template tqArityPick( 5, 6 ); } + +template struct TqRebind { static int tqScopedFn( int n ) { return n; } }; +struct TqOtherScope { static int tqScopedFn( int n ) { return n; } }; // QUALIFIER decoy + +// `X::template TqRebind::tqScopedFn` keys on the immediate scope `TqRebind`, so it binds that +// definition; keyed as `template TqRebind` the canonical tier misses and both definitions tie. +template int tqCallRebind() { return X::template TqRebind::tqScopedFn( 14 ); } + +// ── §12e DOCUMENTED-ABSENT: spellings this round does NOT bind, pinned at zero ───────────────────────── +// Each is a literal 0 in the gate, behind a check that the spelling is still written here, so a later +// widening that starts binding one of them (correctly or not) has to move a pin instead of slipping in. +struct TqAbsent +{ + template int tqLiteralArgTmpl( int n ) { return n + N; } + template int operator()( int n ) { return n; } +}; + +struct TqAbsentBase +{ + template int tqBaseQualTmpl( int n ) { return n; } + int tqBasePlain( int n ) { return n; } +}; + +struct TqAbsentDerived : TqAbsentBase {}; + +// A literal non-type argument without `template`: tree-sitter reads `r.tqLiteralArgTmpl < 0 > ( x )` as two +// comparisons, so the site is a READ of the member, never a call. (`x.template f<0>()` is bound — spelling 3.) +int tqCallLiteralArg( TqAbsent& r, int x ) { return r.tqLiteralArgTmpl<0>( x ); } + +// A base-qualified member name, dot and arrow. The plain twin below is absent on main too: this is the +// qualified-field family, not a template gap. +int tqCallBaseQual( TqAbsentDerived& r, TqAbsentDerived* p ) { return r.TqAbsentBase::tqBaseQualTmpl( 1 ) + p->TqAbsentBase::tqBaseQualTmpl( 2 ); } +int tqCallBasePlain( TqAbsentDerived& r ) { return r.TqAbsentBase::tqBasePlain( 1 ); } + +// An operator name with explicit template arguments. +int tqCallOperatorTmpl( TqAbsent& r ) { return r.operator()( 1 ); } diff --git a/test/qschemetrip.hash b/test/qschemetrip.hash index e4552f0ff..02b83629b 100644 --- a/test/qschemetrip.hash +++ b/test/qschemetrip.hash @@ -1 +1 @@ -24fd4dd9e2dce231e703b7c5f1867d5046aa87f8ad0130188a244cca1d788864 +01ea4c637367f3395847af7e5ac6c1b8b673216c5b0ca6fa6ebb8871a3d16ea4 diff --git a/test/qschemetripcheck.sh b/test/qschemetripcheck.sh index 276dd0ab8..fb7beeb77 100755 --- a/test/qschemetripcheck.sh +++ b/test/qschemetripcheck.sh @@ -34,6 +34,12 @@ SRC="$ROOT/src/quality.h" ING="$ROOT/src/ingest_cache.h" # extraction-identity constants moved here (2026-08-29 ingest.cpp section split); the hashed CONCAT label keeps its historical spelling so the pin holds PIN="$ROOT/test/qschemetrip.hash" # RE-PIN LOG (the pin is a bare hash, so its justification has to live here). +# 2026-09-16, MEMBER TEMPLATE CALLS (lane/template-call-edges; test/cppqualcheck.sh §12): RE-PIN ONLY, kQSnapCacheScheme STAYS 12. +# kParserVer 96 -> 99 and kIngestParserVerMirror -> 99; kCacheVersion STAYS 22. A C++ member call with explicit +# template arguments (`r.f()`, `p->f()`, `x.template f()`) now mints a call reference, and the `template` +# disambiguator no longer leaks into a qualified call's name or qualifier, so the extracted SET and names change and +# only the parser version can reject a v96 blob; no record changes shape. 99, not 97: open PRs #235 (97, 98) and +# #233 (98) hold those numbers in flight. No manifest function changed. # 2026-09-16, PYTHON DISPATCH (#228): inherited self/cls calls make overrides possible live targets. # kQSnapCacheScheme moves 11 -> 12; pythonDispatchedMethodIds joins the semantic manifest. # Both snapshot and delta consult the same set; extraction identity is unchanged.