Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<K>( 1 )`, `p->get<K>( 1 )` and `x.template get<K>()` 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<int>()` 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<int>()` was extracted under the NAME `template make`, which
resolves to nothing, and `X::template Rebind<int>::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<T>( x )` and
qualified `ns::f<T>( 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<T>()` 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<PackedAttr>()` in
`ASTContext::getDeclAlign` binds `Type::hasAttr(attr::Kind)` at `lib/AST/Type.cpp:2026`, not `Decl::hasAttr<T>()`,
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<T>()` / `p->Base::f<T>()` (the plain `r.Base::f()` is absent too, so the gap is not template-shaped), and
`r.operator()<T>()`. `test/callformcheck.sh` row 11, `b.template memberTmpl<int>()`, 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
Expand Down
22 changes: 22 additions & 0 deletions queries/cpp/tags.scm
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,28 @@
function: (template_function
(identifier) @name)) @reference.call

; MEMBER CALLS WITH EXPLICIT TEMPLATE ARGUMENTS: `r.f<T>( x )` / `p->f<T>( x )` parse as
; call_expression function: (field_expression field: (template_method name: (field_identifier) arguments: …)),
; and the disambiguated `x.template f<T>()` / `this->template f<T>()` 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<K>( 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<T>()` 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
Expand Down
30 changes: 29 additions & 1 deletion src/ingest_binds.h
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>()` puts ONE wrapper between them — field_expression
// field: (template_method name: m arguments: …) — and the disambiguated `x.template m<T>()` 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
Expand All @@ -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<T>()` 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 {};
Expand Down
13 changes: 12 additions & 1 deletion src/ingest_cache.h
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>()`, `p->f<T>()`, `x.template f<T>()`) 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
Expand Down
91 changes: 90 additions & 1 deletion src/ingest_names.h
Original file line number Diff line number Diff line change
Expand Up @@ -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<int>()` and
// `X::template Rebind<int>::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<int>()` minted
// a reference NAMED `template tqDepQualTmpl`, which resolves to nothing, and
// `X::template TqRebind<int>::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<unsigned char>( 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 ───────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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<std::size_t>::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<int>()` (whose captured text is just `tmplFn<int>`), 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<int>`) 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 `<ReturnType> name(...)` after
Expand Down
Loading