diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 608d7305..3789a23b 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -785,7 +785,7 @@ $ ./build/ripwire . --callees=rankGraphTeleport **Answers:** show every place SYM is used, not just called — reads, writes, imports, extends the statically resolvable use-sites of SYM (role=call|macro|read|write|import|extends|type, file:line); -external="1" if SYM has no in-corpus def. file:name narrows defs= AND the role="call" sites (kept only where the call RESOLVES to a chosen def — --callers' own narrowing); read/write/import/extends carry no resolution and stay name-matched. narrowed_roles=/defs_of_name=/call_sites_of_name= (file: qualifier only) disclose what narrowed and the un-narrowed totals; a file: qualifier naming a file with no such def REFUSES, like --callers/--impact Owner.field (also Owner::field, or the id=) — a MEMBER VARIABLE's own use-sites, RESOLVED per site: this->f/self.f/bare f inside the owner pin; v.f pins through v's recorded type, else every owner is a candidate and the row carries amb=K (never a silent pin); write = assignment/compound/++ (address-of and by-reference passing are NOT claimed). A bare field name shared by several owners REFUSES with the Owner.field spellings; C/C++/Python fields only, others refuse +external="1" if SYM has no in-corpus def. file:name or a "::" spelling narrows defs= AND the role="call" sites (kept only where the call RESOLVES to a chosen def — --callers' own narrowing); read/write/import/extends carry no resolution and stay name-matched. narrowed_roles=/defs_of_name=/call_sites_of_name= (qualifier only) disclose what narrowed and the un-narrowed totals; a file: qualifier naming a file with no such def REFUSES, like --callers/--impact Owner.field (also Owner::field, or the id=) — a MEMBER VARIABLE's own use-sites, RESOLVED per site: this->f/self.f/bare f inside the owner pin; v.f pins through v's recorded type, else every owner is a candidate and the row carries amb=K (never a silent pin); write = assignment/compound/++ (address-of and by-reference passing are NOT claimed). A bare field name shared by several owners REFUSES with the Owner.field spellings; C/C++/Python fields only, others refuse **Try it** @@ -793,7 +793,7 @@ _The resolvable use-sites (call/read/write/import/extends) with file:line; count ``` $ ./build/ripwire . --uses=rankGraphTeleport - + diff --git a/docs/captures/COMMANDS_showcase_2026-09-13.md b/docs/captures/COMMANDS_showcase_2026-09-13.md index f9fee964..3545882a 100644 --- a/docs/captures/COMMANDS_showcase_2026-09-13.md +++ b/docs/captures/COMMANDS_showcase_2026-09-13.md @@ -854,7 +854,7 @@ ripwire: --callers symbol not found: DoesNotExist *The resolvable use-sites (call/read/write/import/extends) with file:line; count= is a floor.* ````` - + diff --git a/src/cli.h b/src/cli.h index 6cc86354..74218184 100644 --- a/src/cli.h +++ b/src/cli.h @@ -1056,9 +1056,9 @@ inline constexpr char kHelpHead[] = " --callees=SYM what SYM calls (1-hop out-edges). file:name disambiguates like --callers\n" " --uses=SYM show every place SYM is used, not just called — reads, writes, imports, extends\n" " the statically resolvable use-sites of SYM (role=call|macro|read|write|import|extends|type, file:line); external=\"1\" if SYM has no in-corpus def.\n" - " file:name narrows defs= AND the role=\"call\" sites (kept only where the call RESOLVES to a chosen def —\n" + " file:name or a \"::\" spelling narrows defs= AND the role=\"call\" sites (kept only where the call RESOLVES to a chosen def —\n" " --callers' own narrowing); read/write/import/extends carry no resolution and stay name-matched.\n" - " narrowed_roles=/defs_of_name=/call_sites_of_name= (file: qualifier only) disclose what narrowed and\n" + " narrowed_roles=/defs_of_name=/call_sites_of_name= (qualifier only) disclose what narrowed and\n" " the un-narrowed totals; a file: qualifier naming a file with no such def REFUSES, like --callers/--impact\n" " Owner.field (also Owner::field, or the id=) — a MEMBER VARIABLE's own use-sites, RESOLVED per site: this->f/self.f/bare f\n" " inside the owner pin; v.f pins through v's recorded type, else every owner is a candidate and the row carries amb=K\n" diff --git a/src/mcpverbs.h b/src/mcpverbs.h index 073c3a87..e154cdea 100644 --- a/src/mcpverbs.h +++ b/src/mcpverbs.h @@ -2488,6 +2488,20 @@ inline std::string usesSelectorRefusal( const IngestResult& ing, const std::stri const std::size_t lastColon = symbol.rfind( ':' ); if( lastColon != std::string::npos && lastColon + 1 < symbol.size() ) { + // Issue #164, option (b): a RESOLVING "::" spelling (canonical id or Scope::name) is the one + // qualified shape the CLI answers and this verb cannot narrow — its scan is name-wide with no + // narrowing machinery, so serving it is the silent count="0" the CLI just fixed. Refuse with the + // retry instead, the way a file:name spelling already refuses below. A non-resolving "::" spelling + // falls through to the shared refusal (byte-identical); a member spelling keeps its member answer + // (resolveFieldSelector is consulted first, mirroring the CLI's memberUsesArm precedence). + if( symbol.find( "::" ) != std::string::npos && !resolveAllByName( ing, symbol ).empty() + && resolveFieldSelector( ing, symbol ).empty() ) + { + const std::string bareName = symbol.substr( symbol.rfind( ':' ) + 1 ); + return "qualified '::' selectors are CLI-only on this verb — pass the bare name '" + bareName + + "' (the union across its defs), or use the CLI form `ripwire --uses=" + symbol + + "` for the narrowed answer"; + } return qualifiedSelectorRefusal( ing, symbol, "--uses=" ); // "" when the qualified spelling resolves } @@ -2624,11 +2638,11 @@ inline std::string usesText( const std::string& root, const std::string& symbol, // §H4 §3.4 item 2: the opener is the SHARED one (src/graphlegend.h) — this copy and the CLI's were the // same false "every use-site of SYM" promise emitted twice, and a fix applied to one of two echo sites // is the §B4 failure family. The BODY deliberately stays surface-specific: the CLI legend documents the - // file:name selector attributes, which this verb has no selector for and does not emit. + // qualified-selector attributes, which this verb has no selector for and does not emit. rw::emitTo( mem, "{}" "Reference-name-based (same heuristic level as call edges) — verify in source if a name is overloaded. " "external=\"1\" means SYM has no definition in the indexed tree under ANY spelling (stdlib/third-party); " - "a qualified file:name spelling whose bare name IS defined refuses instead (the CLI uses verb narrows it). " + "qualified file:name and \"::\" spellings whose bare name IS defined refuse instead (the CLI uses verb narrows them). " "{}{}-->{}", kUsesLegendOpen, capLegendClause( computePageDisclosure( upageRows, sites.size(), upw.end, page.limit, page.offset, usDiscloseCap ).active ), diff --git a/src/verbs_navigate.h b/src/verbs_navigate.h index 34ef0518..e5a66f3d 100644 --- a/src/verbs_navigate.h +++ b/src/verbs_navigate.h @@ -334,15 +334,43 @@ std::optional runGraphQuery( const MainDispatch& d ) } // §P10.2: --uses' selector-parsing seam, factored out so the file:name fix adds a new small symbol -// instead of growing the already-hot runUses. fileQualified excludes a canonical id ("::") — that was -// never a use-site match key and stays byte-identical. siteMatchName filters sites (name-only, can't -// split per-def); suggestName is the NAME half for did-you-mean (--expand/--outline's Lane H rule), so a +// instead of growing the already-hot runUses. fileQualified excludes a canonical id ("::") — that spelling +// takes scopeNarrowed instead (issue #164): the resolved defs share one name, and the call role narrows to +// it through usesChosenCallers exactly as a file:name selector does. siteMatchName filters sites (name-only, +// can't split per-def); suggestName is the NAME half for did-you-mean (--expand/--outline's Lane H rule), so a // "file:" prefix never again poisons the suggester (the constant "srcmut_sigchange" bug). defsOfName is the -// un-narrowed def count for the disclosure attribute — meaningful only when fileQualified. -struct UsesSelector { bool fileQualified; std::string_view siteMatchName; std::string_view suggestName; std::size_t defsOfName; std::vector elixirDefs; }; -inline UsesSelector resolveUsesSelector( const rw::IngestResult& ing, std::string_view sym, std::size_t defsCount ) +// un-narrowed def count for the disclosure attribute — meaningful only when the answer narrowed. +struct UsesSelector { bool fileQualified; bool scopeNarrowed; std::string_view siteMatchName; std::string_view suggestName; std::size_t defsOfName; std::vector elixirDefs; }; + +// Issue #164: the site-match name for a "::" spelling, read off the RESOLVED defs, never off the spelling. +// Every "::" tier keys on the trailing name (the canonical tier tail-matches path::scope::name, the scope +// tier matches s.name plus a scope suffix), so a non-empty resolution shares ONE name — but only the defs +// prove it (an Elixir arity suffixes the name: f/1 and f/2 are different site-match keys). Mixed names keep +// today's whole-spelling key, which matches nothing, rather than a strip-and-match the precision controls +// forbid. Bounds-checked: a def id past the symbol table cannot name anything. +inline bool commonDefsName( const rw::IngestResult& ing, std::span defs, std::string_view& nameOut ) { - UsesSelector u; + if( defs.empty() ) + { + return false; + } + if( defs[ 0 ] >= ing.symbols.size() ) + { + return false; + } + nameOut = ing.symbols[ defs[ 0 ] ].name; + for( std::size_t defIndex = 1; defIndex < defs.size(); ++defIndex ) + { + if( defs[ defIndex ] >= ing.symbols.size() || ing.symbols[ defs[ defIndex ] ].name != nameOut ) + { + return false; + } + } + return true; +} +inline UsesSelector resolveUsesSelector( const rw::IngestResult& ing, std::string_view sym, std::span defs ) +{ + UsesSelector u{}; // both narrowing flags start false; every arm below sets what it means u.elixirDefs = rw::resolveAllByNameQualified( ing, sym ); std::erase_if( u.elixirDefs, [ & ]( rw::NodeId node ) { return ing.symbols[ node ].lang != rw::Lang::Elixir; } ); if( !sym.empty() && sym.front() == '@' ) @@ -375,12 +403,21 @@ inline UsesSelector resolveUsesSelector( const rw::IngestResult& ing, std::strin { rw::splitQualifiedSpec( sym, file, u.siteMatchName ); } + else if( sym.find( "::" ) != std::string_view::npos && commonDefsName( ing, defs, u.siteMatchName ) ) + { + // A "::" spelling whose defs share one name narrows exactly like a file:name selector: the site + // scan matches that name, and the call role narrows to the defs through usesChosenCallers. Set + // only on a non-empty, single-named resolution, so a wrong scope (Nope::ctwin) keeps the + // whole-spelling key and the generic refusal's bytes. A member spelling backed by no symbol + // (empty defs) never sets the flag, so memberUsesArm still serves it upstream exactly as today. + u.scopeNarrowed = true; + } else { u.siteMatchName = sym; } rw::splitQualifiedSpec( sym, file, u.suggestName ); - u.defsOfName = u.fileQualified ? rw::resolveAllByName( ing, u.siteMatchName ).size() : defsCount; + u.defsOfName = ( u.fileQualified || u.scopeNarrowed ) ? rw::resolveAllByName( ing, u.siteMatchName ).size() : defs.size(); return u; } @@ -457,10 +494,11 @@ collectUseSites( const rw::IngestResult& ing, const UsesSelector& sel, std::span { ++callSitesOfName; } - // the file: qualifier's call-role narrowing. A file-scope call site (fromSymbol==kNoNode) carries no - // resolved edge to test, so it cannot be SHOWN to reach the chosen def and is dropped with the rest — - // call_sites_of_name= keeps the size of what was dropped visible. - if( sel.fileQualified && r.role == RefRole::Call && ( r.fromSymbol >= isChosenCaller.size() || !isChosenCaller[r.fromSymbol] ) ) + // the file: qualifier's call-role narrowing — and, since issue #164, a "::" spelling's too + // (scopeNarrowed, the same file:name semantics off a scope-qualified selector). A file-scope call site + // (fromSymbol==kNoNode) carries no resolved edge to test, so it cannot be SHOWN to reach the chosen + // def and is dropped with the rest — call_sites_of_name= keeps the size of what was dropped visible. + if( ( sel.fileQualified || sel.scopeNarrowed ) && r.role == RefRole::Call && ( r.fromSymbol >= isChosenCaller.size() || !isChosenCaller[r.fromSymbol] ) ) { continue; } @@ -525,8 +563,8 @@ std::optional runUses( const MainDispatch& d ) // ROLE (call/read/write/import/extends) and p="file:line", plus the enclosing symbol. Reference-name-based // (same heuristic level as the call edges), so a BARE name shared by several symbols reports the union of // all their use-sites. external="1" when SYM has NO in-corpus definition at all. §P10.2/§A6b: SYM also - // accepts "file:name" (resolveUsesSelector) — that narrows defs= AND the call-role sites (usesChosenCallers); - // the other roles stay name-matched, and defs_of_name=/call_sites_of_name= disclose both gaps. + // accepts "file:name" and "::" spellings (resolveUsesSelector) — both narrow defs= AND the call-role sites + // (usesChosenCallers); the other roles stay name-matched, and defs_of_name=/call_sites_of_name= disclose both gaps. // Deterministic: use-sites sorted by (file path, line, role, enclosing-id); every value XML-escaped. if( !cfg.usesSym.empty() ) { @@ -538,7 +576,7 @@ std::optional runUses( const MainDispatch& d ) // below drops every site that resolves to them, which reached the reader as a bare count="0". std::size_t usUnprovenDefs = 0; const std::vector defs = resolveAllByNameQualified( ing, sym, &usUnprovenDefs ); - const UsesSelector sel = resolveUsesSelector( ing, sym, defs.size() ); + const UsesSelector sel = resolveUsesSelector( ing, sym, defs ); // member-variable round (card A3): ONE resolved field takes the per-site path (fielduses.h — the renderer // the MCP twin returns); a bare field name declared by several owners refuses with the Owner.field @@ -549,7 +587,7 @@ std::optional runUses( const MainDispatch& d ) } // §A6b(iii): external="1" is the claim "this name has NO definition in the indexed tree" — it may only - // be made when that is what was measured. With a file: qualifier defs= is a NARROWED count, so the + // be made when that is what was measured. With a qualifier defs= is a NARROWED count, so the // un-narrowed defs_of_name= is the one that can license the claim; pre-fix a non-defining qualifier // printed external="1" beside defs_of_name="3", which says the opposite in the same element. const bool external = defs.empty() && sel.defsOfName == 0; @@ -557,7 +595,7 @@ std::optional runUses( const MainDispatch& d ) // §A6b(i): the call sites that resolve to the CHOSEN defs (empty ⇒ nothing narrows, every role stays // name-matched, and the un-qualified output is byte-identical). - const std::vector isChosenCaller = sel.fileQualified ? usesChosenCallers( ing, g, defs ) : std::vector{}; + const std::vector isChosenCaller = ( sel.fileQualified || sel.scopeNarrowed ) ? usesChosenCallers( ing, g, defs ) : std::vector{}; // the sorted use-sites, plus the un-narrowed call-role total the disclosure reports. const auto [ sites, callSitesOfName ] = collectUseSites( ing, sel, isChosenCaller, @@ -585,7 +623,7 @@ std::optional runUses( const MainDispatch& d ) // §A6b: the qualifier disclosure, built once for both emitters. defs_of_name= is the un-narrowed DEF // count; narrowed_roles="call" names which roles the qualifier actually narrowed and call_sites_of_name= // is that role's un-narrowed total, so "how much did the qualifier drop" is arithmetic, not a guess. - const std::string selectorAttrs = sel.fileQualified + const std::string selectorAttrs = ( sel.fileQualified || sel.scopeNarrowed ) ? " defs_of_name=\"" + std::to_string( sel.defsOfName ) + "\" narrowed_roles=\"call\" call_sites_of_name=\"" + std::to_string( callSitesOfName ) + "\"" : std::string{}; @@ -605,10 +643,10 @@ std::optional runUses( const MainDispatch& d ) "Reference-name-based (same heuristic level as call edges) — verify in source if a name is overloaded. " "external=\"1\" ⇒ SYM has no definition in the indexed tree under ANY spelling (stdlib/third-party) — " "never merely none in the file you qualified with (that spelling refuses instead). " - "A \"file:name\" SYM narrows defs= AND the role=\"call\" sites, which are kept only where the call RESOLVES to a " + "A \"file:name\" or \"::\" SYM narrows defs= AND the role=\"call\" sites, which are kept only where the call RESOLVES to a " "chosen def (the callers verb's own narrowing, read the other way, so the two agree); read/write/import/extends carry no " "resolution and stay name-matched across every def sharing the name. narrowed_roles= names what narrowed, and " - "defs_of_name=/call_sites_of_name= (file: qualifier only) are the un-narrowed totals. " + "defs_of_name=/call_sites_of_name= (qualifier only) are the un-narrowed totals. " "{}{}{}-->{}{}", rw::kUsesLegendOpen, rw::unprovenDefsVerbLegend( rw::UnprovenDefsVerb::Uses, usUnprovenDefs > 0 ).c_str(), // H1: exactly when the root carries unproven_defs= rw::capLegendClause( rw::computePageDisclosure( pageRows, sites.size(), upw.end, @@ -671,8 +709,7 @@ std::optional runUses( const MainDispatch& d ) // Composes four signals this tool already computes elsewhere, over ONE already-resolved selector, instead // of a new analysis: the transitive blast radius (rw::transitiveCallers, --impact's own walk), every // read/write/import/call/extends use-site (resolveUsesSelector/collectUseSites, --uses' own machinery, -// called verbatim — this verb never takes the file: qualifier --uses does, so the selector is always -// name-wide), whether an indexed test transitively reaches the symbol and how much of its blast radius +// called verbatim — a qualified selector narrows here exactly as on --uses), whether an indexed test transitively reaches the symbol and how much of its blast radius // does too (the same forward test-seed BFS computeQMetrics's tested= column runs, re-derived locally here // rather than reached through MainDispatch's testedPtr — that pointer is null unless --metrics/--for/ // --exemplar is ALSO given, and a single-symbol BFS has nothing to amortize against that gate), and @@ -812,11 +849,11 @@ std::optional runSafeDelete( const MainDispatch& d ) // transitive blast radius: the --impact walk, verbatim. const std::vector reach = rw::transitiveCallers( g, defs ); - // every use-site: the --uses walk, verbatim. Always a bare-name selector (fileQualified is only ever - // set by resolveUsesSelector when SYM itself carries a file: prefix, which still works here — --uses' - // own selector grammar, unchanged). - const UsesSelector sel = resolveUsesSelector( ing, cfg.safeDeleteSym, defs.size() ); - const std::vector isChosenCaller = sel.fileQualified ? usesChosenCallers( ing, g, defs ) : std::vector{}; + // every use-site: the --uses walk, verbatim. A "file:name" or "::" selector narrows here exactly as on + // --uses (fileQualified/scopeNarrowed out of resolveUsesSelector) — --safe-delete takes --uses' own + // selector grammar, unchanged. + const UsesSelector sel = resolveUsesSelector( ing, cfg.safeDeleteSym, defs ); + const std::vector isChosenCaller = ( sel.fileQualified || sel.scopeNarrowed ) ? usesChosenCallers( ing, g, defs ) : std::vector{}; const auto sitesPair = collectUseSites( ing, sel, isChosenCaller, sdSingleRoot ? std::string_view( cfg.roots[0] ) : std::string_view{} ); // .second (the un-narrowed @@ -1509,8 +1546,8 @@ std::optional runVerify( const MainDispatch& d ) { const std::string_view sym = claim.arg1; const std::vector defs = resolveAllByNameQualified( ing, sym, &vfUnprovenDefs ); // H1: the residue --uses discloses - const UsesSelector sel = resolveUsesSelector( ing, sym, defs.size() ); - const std::vector isChosenCaller = sel.fileQualified ? usesChosenCallers( ing, g, defs ) : std::vector{}; + const UsesSelector sel = resolveUsesSelector( ing, sym, defs ); + const std::vector isChosenCaller = ( sel.fileQualified || sel.scopeNarrowed ) ? usesChosenCallers( ing, g, defs ) : std::vector{}; const auto [ sites, callSitesOfName ] = collectUseSites( ing, sel, isChosenCaller, verSingleRoot ? std::string_view( cfg.roots[0] ) : std::string_view{} ); (void) callSitesOfName; diff --git a/test/printf_parity.manifest b/test/printf_parity.manifest index 0d151973..5f868028 100644 --- a/test/printf_parity.manifest +++ b/test/printf_parity.manifest @@ -13,7 +13,7 @@ help 0 933da452b605799455b582aec8d73c5497dcc35eba574d01bc430a9a2ab94ab0 e3b0c442 expand 0 f06c70abc1d5c8a941bd340adc08173232e83790ce024a14d20e1bcba5d2808d e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 callees 0 69470d30c1abfb98cebbabf5d62f502822cd8a0cb44dee8de42b2ba3a6349ec5 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 around 0 1f3efff7030b4a0a13e44736476bb920c3ae911559dd940b728e8ef5d985dc82 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 -uses 0 e8eac2b7d6798ad1166bb3f6d767436ca4689c0b26f8555e147380ea8e0615a5 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 +uses 0 3ed4a98eb747bfdbcfa8605514a0ddc1d5181195152531273de8afd26f25b2a0 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 path 0 7058d89f7bab7aabe0a5cbf921959bc8a2c346fe65888b776996f879f04a58a2 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 connect 0 31c7e3a689a6dcddf5eae17283740823005c3efdfdf3945be5770b2660a152b7 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 grep 0 b171aa1e5c28b47827f9148c2a5fc6948fb22938d3da21e6551b12432f492472 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 @@ -38,5 +38,5 @@ safe_delete 0 b06980d52e4991e57563059d8be986bb4614779e20ccfd3790aac1ee076d2512 e verify_layer 1 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 5288c345d7d6e335f88b9c1daa8935db22e1dcf89c0c8bc1f6140d4cb5af0b48 graph_query 1 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 bfa4319feb9dee09cfbd3991cf6fbd752297e99d14de9e75768820d2a9c8832f callers_limit 0 ab9dee52240f70055fa4d82d6b928ef52f4f5781c6a80ee39edb49805b892719 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 -help_all 0 eac1e5908ba075975a09c4104bd40fbca8af60d744660f04b9b3eb7463b8bbb1 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 -help_one 0 d958f81abe53aa21051deaf47dded37bf80d707a049148a6356c96e331a28da1 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 +help_all 0 23a83bad1ef6e54ad8edf143121dba3ca533d72c389e9cb5298d2956320c20f1 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 +help_one 0 938ff4f8802a8f5d9cb10f9a1f3804ac755be44c8ab00e24e45bfe8255224a21 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 diff --git a/test/usesselectorcheck.sh b/test/usesselectorcheck.sh index 6ba2cb38..887639cf 100755 --- a/test/usesselectorcheck.sh +++ b/test/usesselectorcheck.sh @@ -21,11 +21,11 @@ # test/usesselectorcheck.sh # uses build/ripwire on the repo root # RIPWIRE_BIN=build_base/ripwire test/usesselectorcheck.sh # must FAIL — the pre-fix binary (RED proof) # -# (f), added 2026-09-11, is a KNOWN GAP section for the help-wanted prompt prompts/help-wanted/uses-qualified-selector.md: -# a "::" selector (the canonical id path::scope::name, or Scope::name) resolves defs= and then answers count="0", -# because the site scan matches the WHOLE spelling against reference names, which are always bare. Its KNOWN GAP arms, -# and arm (d)'s canonical-id arm, pin TODAY's wrong answer and PASS; flipping them is that prompt's acceptance test. -# Its premise and control arms pass before AND after the fix. It runs on two fixtures other gates own and pin: +# (f), added 2026-09-11, was the KNOWN GAP section for the help-wanted prompt prompts/help-wanted/uses-qualified-selector.md +# (issue #164): a "::" selector (the canonical id path::scope::name, or Scope::name) resolves defs= and then answers count="0", +# because the site scan matches the WHOLE spelling against reference names, which are always bare. Its arms, and arm (d)'s +# canonical-id arm, pinned that wrong answer and PASSED; issue #164's fix flipped each to its FIXED line, which is that +# prompt's acceptance test. It runs on two fixtures other gates own and pin: # test/declinefix (C++, Python — declinecheck.sh) and test/rustqualfix (Rust — rustqualcheck.sh). set -u @@ -42,6 +42,25 @@ echo "usesselectorcheck: BIN=$BIN ROOT=$ROOT" uses_elem(){ "$BIN" "$ROOT" --uses="$1" --no-cache 2>/dev/null | grep -o ']*>'; } attr(){ printf '%s' "$1" | grep -o "$2=\"[0-9]*\"" | grep -o '[0-9]*'; } +# the role="call" sites (p=file:line) a uses answer lists that NO caller row of a callers answer holds — +# test/declinecheck.sh's call_sites helper (line 107), copied verbatim: the enclosing symbol is (file, in_id +# leaf), the caller rows are (p, n leaf). Empty output means the two verbs agree. +# Defined up here (not with the other helpers below) because arm (d) calls it at line ~170. +call_sites(){ python3 - "$@" <<'PY' +import sys, xml.etree.ElementTree as ET +leaf = lambda s: s.rsplit( "::", 1 )[ -1 ] +where = lambda p, n: ( p.rsplit( ":", 1 )[ 0 ], leaf( n ) ) +try: + uses = ET.parse( sys.argv[ 1 ] ).getroot() + rows = ET.parse( sys.argv[ 2 ] ).getroot().iter( "s" ) if len( sys.argv ) > 2 else [] +except ( ET.ParseError, OSError ): + sys.exit( 1 ) +bound = { where( s.get( "p", "" ), s.get( "n", "" ) ) for s in rows } +for u in uses.iter( "u" ): + if u.get( "role" ) == "call" and where( u.get( "p", "" ), u.get( "in_id", "" ) ) not in bound: + print( u.get( "p" ) ) +PY +} # ── (a) --uses=src/graph.h:buildGraph must resolve (not refuse), narrow defs= AND the call-role sites ─── # @@ -142,16 +161,36 @@ BARE_B="$( uses_elem 'buildGraph' )" if [ "$BARE_A" = "$BARE_B" ]; then ok "bare-name form is stable/reproducible: $BARE_A"; else no "bare-name form not reproducible"; fi CANON_A="$( uses_elem "$CANON_ID" )" if [ -n "$CANON_A" ]; then ok "canonical-id form still resolves: $CANON_A"; else no "canonical-id form stopped resolving: $CANON_ID"; fi -# KNOWN GAP (help wanted: prompts/help-wanted/uses-qualified-selector.md). This arm used to call the zero below -# "documented, unchanged behaviour". It is the gap section (f) pins on fixtures: the canonical id resolves defs="1", -# then the site scan compares the WHOLE spelling with reference names, which are always bare, so count="0" while -# --callers on the same id counts hundreds of callers on this repo. -# FIXED: 1 <= count= <= the bare --uses=empty count — the narrowing arm (b) already asserts for src/notes.h:empty. +# FIXED (issue #164; was the KNOWN GAP for prompts/help-wanted/uses-qualified-selector.md). This arm used to call +# the zero below "documented, unchanged behaviour": the canonical id resolved defs="1", then the site scan compared +# the WHOLE spelling with reference names, which are always bare, so count="0" while --callers on the same id counts +# hundreds of callers on this repo. +# FIXED (issue #164): 1 <= count= <= the bare --uses=empty count — the narrowing arm (b) already asserts for src/notes.h:empty. if [ -n "$CANON_A" ]; then - if [ "$( attr "$CANON_A" count )" = "0" ]; then - ok "KNOWN GAP (help wanted: prompts/help-wanted/uses-qualified-selector.md): the canonical id $CANON_ID answers count=\"0\" — flipping this is the acceptance test" + CC="$( attr "$CANON_A" count )"; CBE="$( attr "$E_BARE" count )" + if [ -n "$CC" ] && [ -n "$CBE" ] && [ "$CC" -ge 1 ] && [ "$CC" -le "$CBE" ] \ + && printf '%s' "$CANON_A" | grep -q 'narrowed_roles="call"' && [ -n "$( attr "$CANON_A" call_sites_of_name )" ]; then + ok "(d) FIXED: the canonical id $CANON_ID answers count=\"$CC\" (bare --uses=empty: $CBE) with the narrowing disclosure" + else + no "(d) FIXED (issue #164): the canonical id $CANON_ID should answer 1 <= count <= ${CBE:-?} with narrowed_roles=: ${CANON_A:-no root}" + fi + # FIXED, same definition same rows: the canonical id against the file:name spelling arm (b) proves. + QR="$( "$BIN" "$ROOT" --uses="$CANON_ID" --no-cache 2>/dev/null | grep -o ']*>' | sort )" + FR="$( "$BIN" "$ROOT" --uses='src/notes.h:empty' --no-cache 2>/dev/null | grep -o ']*>' | sort )" + if [ -n "$QR" ] && [ "$QR" = "$FR" ]; then + ok "(d) FIXED: the canonical id and src/notes.h:empty list identical rows" + else + no "(d) FIXED (issue #164): canonical-id rows and file:name rows for one definition differ" + fi + # FIXED, the two verbs agree: every role="call" row sits inside a caller the --callers answer lists + # (the relation test/declinecheck.sh's call_sites helper reads). + "$BIN" "$ROOT" --uses="$CANON_ID" --no-cache >"$TMP/d_uses.xml" 2>/dev/null + "$BIN" "$ROOT" --callers="$CANON_ID" --no-cache >"$TMP/d_callers.xml" 2>/dev/null + DU="$( call_sites "$TMP/d_uses.xml" "$TMP/d_callers.xml" )" + if [ -z "$DU" ]; then + ok "(d) FIXED: every role=\"call\" row of the canonical-id answer sits inside a --callers-listed caller" else - no "KNOWN GAP MOVED (prompts/help-wanted/uses-qualified-selector.md): the canonical id now answers count=$( attr "$CANON_A" count ) — if the fix landed, rewrite this arm to its FIXED line" + no "(d) FIXED (issue #164): call rows with no --callers edge: $( printf '%s' "$DU" | tr '\n' ' ' )" fi fi @@ -167,19 +206,20 @@ if command -v xmllint >/dev/null 2>&1; then && ok "--uses=file:name xml well-formed" || no "--uses=file:name xml malformed" fi -# ── (f) KNOWN GAP (help wanted: prompts/help-wanted/uses-qualified-selector.md) — a "::" selector's use-sites ──────── -# THE GAP. Every SYM-taking verb resolves a "::" spelling through graph.h resolveAllByNameQualified: the canonical-id +# ── (f) FIXED (issue #164; was the KNOWN GAP for prompts/help-wanted/uses-qualified-selector.md) — a "::" selector's use-sites ─ +# THE GAP (pre-fix). Every SYM-taking verb resolves a "::" spelling through graph.h resolveAllByNameQualified: the canonical-id # tier (path::scope::name, the id= the map prints), then the scope tier (Scope::name, the sym= --edit-check prints). # --callers/--callees/--impact/--expand read the call graph by NodeId after that, so the spelling no longer matters. -# --uses does not: verbs_navigate.h resolveUsesSelector sets fileQualified only for a ':' with no "::" and otherwise -# keeps the WHOLE spelling as siteMatchName, and collectUseSites compares that with r.calleeName, which is always a bare -# name. defs= resolves, count="0" follows, and nothing — no refusal, no call_sites_of_name= — says the zero is a -# spelling artifact rather than "no use exists". --safe-delete's uses= and --verify's uses()/unused() ride the same -# scan; the MCP uses twin (mcpverbs.h usesText) carries its own copy of the whole-spelling match. +# --uses did not: verbs_navigate.h resolveUsesSelector set fileQualified only for a ':' with no "::" and otherwise +# kept the WHOLE spelling as siteMatchName, and collectUseSites compared that with r.calleeName, which is always a bare +# name. defs= resolved, count="0" followed, and nothing — no refusal, no call_sites_of_name= — said the zero was a +# spelling artifact rather than "no use exists". --safe-delete's uses= and --verify's uses()/unused() rode the same +# scan; the MCP uses twin (mcpverbs.h usesText) carried its own copy of the whole-spelling match. # -# KNOWN GAP arms pin TODAY's wrong answer and PASS now; each one's FIXED comment says what it asserts once the gap is -# closed. A FAIL on a KNOWN GAP arm means the gap moved: rewrite the arm to its FIXED line, never delete it. Premise -# and control arms are not gaps — they pass before AND after the fix, and a fix that turns one red is wrong. +# The gap arms below were KNOWN GAP arms pinning the wrong answer; each one's FIXED comment said what it asserts +# once the gap is closed, and a FAIL on one meant the gap moved. (2026-09-14: issue #164's fix flipped every gap +# arm below to its FIXED line. The premises, controls, precision and +# negative arms are byte-identical to the pre-fix file — a change that turns one red is wrong.) # # Every site below is a literal read off the fixture source, never derived the way the code derives it: # test/declinefix cpp/pair/{one,two}.cpp share ctwin in one directory (called once, cpp/pair/user.cpp:3); @@ -189,12 +229,12 @@ fi # test/rustqualfix util::tool (src/lib.rs:79) and Widget::new (src/lib.rs:77, src/gadget/mod.rs:47), each call # isolated in one function; Vec::::new() (src/lib.rs:91) binds nothing; Gadget::spin is never called UQ_PROMPT="prompts/help-wanted/uses-qualified-selector.md" -UQ_GAP="KNOWN GAP (help wanted: $UQ_PROMPT)" fx(){ local d="$ROOT/test/$1"; shift; ( cd "$d" && "$BIN" . --no-cache "$@" 2>/dev/null ]*)?>" | head -1; } val_of(){ printf '%s' "$1" | grep -oE " $2=\"[^\"]*\"" | head -1 | sed -E 's/^[^"]*"([^"]*)"$/\1/'; } lists_site(){ printf '%s' "$1" | grep -qF "/dev/null 2>&1 || no "(f) python3 is required by the MCP arms below — they would read nothing" # fixture | "::" selector | bare name | the call site a FIXED --uses lists | --callers= count (premise) | shape @@ -204,7 +244,8 @@ command -v python3 >/dev/null 2>&1 || no "(f) python3 is required by the MCP arm # inside a caller that ALSO calls Widget::new. That is the file:name rule's own granularity, not this gap. while IFS='|' read -r fix sel bare site ncall shape; do [ -z "$fix" ] && continue - CR="$( tag_of "$( fx "$fix" --callers="$sel" )" callers )" + fx "$fix" --callers="$sel" >"$TMP/callers.xml" 2>/dev/null + CR="$( tag_of "$( cat "$TMP/callers.xml" )" callers )" [ "$( val_of "$CR" count )" = "$ncall" ] \ && ok "(f) premise, $shape: --callers=$sel resolves and counts $ncall caller(s)" \ || no "(f) premise, $shape: --callers=$sel should count $ncall — the fixture moved, so the arm below proves nothing: ${CR:-no root}" @@ -212,12 +253,27 @@ while IFS='|' read -r fix sel bare site ncall shape; do lists_site "$BARE" "$site" \ && ok "(f) control, $shape: the bare --uses=$bare lists $site" \ || no "(f) control, $shape: the bare --uses=$bare no longer lists $site: $( tag_of "$BARE" uses )" + BARE_COUNT="$( val_of "$( tag_of "$BARE" uses )" count )" OUT="$( fx "$fix" --uses="$sel" )"; RC=$? + printf '%s' "$OUT" >"$TMP/uses.xml" U="$( tag_of "$OUT" uses )" - if [ "$RC" = 0 ] && [ -n "$U" ] && [ "$( val_of "$U" count )" = 0 ] && ! printf '%s' "$OUT" | grep -qE ' root}" + fi + # FIXED, the two verbs agree: every role="call" row sits inside a caller the --callers answer lists. + UNACC="$( call_sites "$TMP/uses.xml" "$TMP/callers.xml" )" + if [ -z "$UNACC" ]; then + ok "(f) FIXED, $shape: every role=\"call\" row sits inside a --callers-listed caller" else - no "(f) KNOWN GAP MOVED ($UQ_PROMPT), $shape: --uses=$sel is no longer the silent zero (rc=$RC ${U:-no root}) — if the fix landed, rewrite this arm to its FIXED line" + no "(f) FIXED ($UQ_PROMPT), $shape: call rows with no --callers edge: $( printf '%s' "$UNACC" | tr '\n' ' ' )" fi done <<'EOF' declinefix|cpp/pair/one.cpp::One::ctwin|ctwin|cpp/pair/user.cpp:3|1|C++ canonical id @@ -231,7 +287,7 @@ EOF # the pointer --callers hands the reader. On a bound call next= is --uses on the SAME selector (the next-uses-bare-name # prompt changes next= only on answers with declined_calls, and this ctwin call is bound), so run verbatim it lands on -# the silent zero. FIXED: that pointer, run verbatim, lists cpp/pair/user.cpp:3. +# the fixed answer. FIXED: that pointer, run verbatim, lists cpp/pair/user.cpp:3 at count="1" with the disclosure. CR="$( tag_of "$( fx declinefix --callers=cpp/pair/one.cpp::One::ctwin )" callers )" NEXT="$( val_of "$CR" next )" [ "$NEXT" = "--uses=cpp/pair/one.cpp::One::ctwin" ] && [ "$( val_of "$CR" count )" = 1 ] \ @@ -240,26 +296,35 @@ NEXT="$( val_of "$CR" next )" : >"$TMP/next.xml" case "$NEXT" in --uses=*) fx declinefix "$NEXT" >"$TMP/next.xml" ;; esac NOUT="$( cat "$TMP/next.xml" )" -if [ -n "$NOUT" ] && [ "$( val_of "$( tag_of "$NOUT" uses )" count )" = 0 ] && ! lists_site "$NOUT" cpp/pair/user.cpp:3; then - ok "(f) $UQ_GAP: the callers answer's own next=, run verbatim, lands on count=\"0\" — flipping this is the acceptance test" +NU="$( tag_of "$NOUT" uses )" +if lists_site "$NOUT" cpp/pair/user.cpp:3 && [ "$( val_of "$NU" count )" = 1 ] && [ -n "$( val_of "$NU" call_sites_of_name )" ]; then + ok "(f) FIXED: the callers answer's own next=, run verbatim, lists cpp/pair/user.cpp:3 with the narrowing disclosure" else - no "(f) KNOWN GAP MOVED ($UQ_PROMPT): next=\"${NEXT:-absent}\" run verbatim is no longer the silent zero — if the fix landed, rewrite this arm: it lists cpp/pair/user.cpp:3" + no "(f) FIXED ($UQ_PROMPT): next=\"${NEXT:-absent}\" run verbatim should list cpp/pair/user.cpp:3 at count=\"1\": ${NU:-no root}" fi # control: the file:name spelling of the SAME definition already lands — the contrast that makes the "::" zero a bug OUT="$( fx declinefix --uses=cpp/pair/one.cpp:ctwin )" lists_site "$OUT" cpp/pair/user.cpp:3 && [ "$( val_of "$( tag_of "$OUT" uses )" call_sites_of_name )" = 1 ] \ && ok "(f) control: --uses=cpp/pair/one.cpp:ctwin (file:name, the same definition) lists cpp/pair/user.cpp:3 with call_sites_of_name=\"1\"" \ || no "(f) control: --uses=cpp/pair/one.cpp:ctwin stopped listing cpp/pair/user.cpp:3: $( tag_of "$OUT" uses )" +# FIXED, same definition same rows: the canonical id against the file:name spelling above. +QROWS="$( printf '%s' "$( fx declinefix --uses=cpp/pair/one.cpp::One::ctwin )" | grep -o ']*>' | sort )" +FROWS="$( printf '%s' "$OUT" | grep -o ']*>' | sort )" +if [ -n "$QROWS" ] && [ "$QROWS" = "$FROWS" ]; then + ok "(f) FIXED: the canonical id and the file:name spelling of one definition list identical rows" +else + no "(f) FIXED ($UQ_PROMPT): canonical-id rows and file:name rows for one definition differ" +fi # --safe-delete's uses= rides the same scan. FIXED: --safe-delete=Solo::conly reads uses="1", the bare spelling's number. SDB="$( tag_of "$( fx declinefix --safe-delete=conly )" safe-delete )" SDQ="$( tag_of "$( fx declinefix --safe-delete=Solo::conly )" safe-delete )" [ "$( val_of "$SDB" uses )" = 1 ] && ok "(f) control: --safe-delete=conly reads uses=\"1\"" \ || no "(f) control: --safe-delete=conly should read uses=\"1\": ${SDB:-no root}" -if [ "$( val_of "$SDQ" uses )" = 0 ] && [ "$( val_of "$SDQ" callers )" = 1 ]; then - ok "(f) $UQ_GAP: --safe-delete=Solo::conly reads uses=\"0\" beside callers=\"1\" — flipping this is the acceptance test" +if [ "$( val_of "$SDQ" uses )" = 1 ] && [ "$( val_of "$SDQ" callers )" = 1 ]; then + ok "(f) FIXED: --safe-delete=Solo::conly reads uses=\"1\" beside callers=\"1\"" else - no "(f) KNOWN GAP MOVED ($UQ_PROMPT): --safe-delete=Solo::conly is no longer uses=\"0\" callers=\"1\" (${SDQ:-no root}) — rewrite this arm to uses=\"1\"" + no "(f) FIXED ($UQ_PROMPT): --safe-delete=Solo::conly should read uses=\"1\" callers=\"1\": ${SDQ:-no root}" fi # --verify's uses()/unused() claims read the same scan. FIXED: uses(Solo::conly) is verdict="confirmed" count="1". @@ -267,15 +332,15 @@ VB="$( tag_of "$( fx declinefix --verify='uses(conly)' )" verify )" VQ="$( tag_of "$( fx declinefix --verify='uses(Solo::conly)' )" verify )" [ "$( val_of "$VB" verdict )" = confirmed ] && ok "(f) control: --verify=\"uses(conly)\" is confirmed" \ || no "(f) control: --verify=\"uses(conly)\" should be confirmed: ${VB:-no root}" -if [ "$( val_of "$VQ" verdict )" = not-established ] && [ "$( val_of "$VQ" count )" = 0 ]; then - ok "(f) $UQ_GAP: --verify=\"uses(Solo::conly)\" is not-established on count=\"0\" — flipping this is the acceptance test" +if [ "$( val_of "$VQ" verdict )" = confirmed ] && [ "$( val_of "$VQ" count )" = 1 ]; then + ok "(f) FIXED: --verify=\"uses(Solo::conly)\" is confirmed on count=\"1\"" else - no "(f) KNOWN GAP MOVED ($UQ_PROMPT): --verify=\"uses(Solo::conly)\" moved (${VQ:-no root}) — rewrite this arm to verdict=\"confirmed\" count=\"1\"" + no "(f) FIXED ($UQ_PROMPT): --verify=\"uses(Solo::conly)\" should be confirmed count=\"1\": ${VQ:-no root}" fi -# the MCP uses twin keeps its OWN copy of the whole-spelling match (mcpverbs.h usesText) — JSON-RPC over stdio, as a -# client speaks it. FIXED: rows listing cpp/pair/user.cpp:3, OR a refusal that names the retry (the plan picks one) — -# never a silent count="0". +# the MCP uses twin kept its OWN copy of the whole-spelling match (mcpverbs.h usesText) — JSON-RPC over stdio, as a +# client speaks it. FIXED (issue #164, the plan's option b): a resolving "::" spelling refuses as CLI-only, naming +# the bare-name retry and the CLI form — never a silent count="0". mcp_uses(){ printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize"}' \ "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"uses\",\"arguments\":{\"path\":\"$ROOT/test/declinefix\",\"symbol\":\"$1\"}}}" \ @@ -289,10 +354,11 @@ MB="$( mcp_uses ctwin )" lists_site "$MB" cpp/pair/user.cpp:3 && ok "(f) control: MCP uses symbol=\"ctwin\" lists cpp/pair/user.cpp:3" \ || no "(f) control: MCP uses symbol=\"ctwin\" should list cpp/pair/user.cpp:3: $( printf '%s' "$MB" | head -c 240 )" MQ="$( mcp_uses One::ctwin )" -if [ "$( val_of "$( tag_of "$MQ" uses )" count )" = 0 ] && ! printf '%s' "$MQ" | grep -qE '