From 39db520722951b6928cbd91eb7161092e3e7397e Mon Sep 17 00:00:00 2001 From: Michal Papis Date: Thu, 15 Oct 2026 01:58:13 +0200 Subject: [PATCH 1/3] refactor(ingest): read Ruby's mixin directives on the shared base-relations path (slice 4a, #400) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ruby's `include` / `extend` / `prepend` reached the base-relations path through a Ruby-only C++ function, captureRubyMixinBases, called from a Lang::Ruby arm of the captureIncludes walk. The shape now lives in queries/ruby/tags.scm as an unanchored @reference.mixin capture, read by the shared capture consumer (ingest_sidecap.h captureTagsFacts), which emits each constant's inherit reference through the same emitBaseRef every language's base clause already goes through. The tags pass evaluates no predicates, so the query states the SHAPE and the reader keeps the two guards a pattern cannot state: the directive's TEXT (kRubyMixinDirectives — without it `Logger.new( Config )` at class-body level matches the same shape) and its POSITION (class/module body, `class << self`, or a concern's `included do … end`). Both are the readers the retired function used, and no Lang:: test is spent: the capture IS the language gate. The directive's own symbolic Include record and its call reference are unchanged. Byte-identical output over the Rails corpus and the Ruby fixtures; --lego lists the same implementors. kParserVer 171 -> 173 (the extraction path changed; 172 is 171's rich file tag). --- CHANGELOG.md | 10 ++++++++++ CONTRIBUTING.md | 2 +- queries/ruby/tags.scm | 19 +++++++++++++++++++ src/ingest_cache.h | 8 +++++++- src/ingest_relations.h | 30 ++++++++++++++++++++++-------- src/ingest_sidecap.h | 10 ++++++++++ src/quality.h | 7 ++++++- test/qschemetrip.hash | 2 +- test/qschemetripcheck.sh | 6 ++++++ test/rubyinheritcheck.sh | 37 ++++++++++++++++++++++++++++++++++++- 10 files changed, 118 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7a9b5e3f..8bb4f4336 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,16 @@ not published here — see `docs/EVALS.md` for the instruments behind the headli ## [Unreleased] +### Changed — Ruby mixins read the shared base-relations path (#400, slice 4a) + +Ruby's `include`/`extend`/`prepend` are now captured by `queries/ruby/tags.scm` as `@reference.mixin` and turned into +inherit references by the shared capture consumer — the same emitter every language's base clause goes through. The +Ruby-only `captureRubyMixinBases` and its `captureIncludes` call site are gone; the query states the call shape and +the C++ reader keeps the two guards a tags pattern cannot state (the directive's text, and its class-body position). +Output is byte-identical: the map, `--deps`, `--skipped` and the read verbs match the base build over a real Rails +corpus and the Ruby fixtures, and `--lego` lists the same implementors. `kParserVer` moves 171 -> 173 (the extraction +path changed; 172 is 171's rich file tag). + ### Added — Clojure (`.clj`/`.cljs`/`.cljc`/`.bb`) Vendored Yogthos' ABI-15 `tree-sitter-clojure` parser and added syntax-level definitions and direct-call captures for the four Clojure-family extensions. The grammar comparison parsed 4,930 of 4,933 files (99.94%, 55.5 MB) from Metabase, clojure-lsp, clj-kondo and Babashka checkouts in 2.24 seconds; two failures were intentionally invalid clj-kondo fixtures and one was a Metabase test. This measures parser completion on that corpus, not semantic call-resolution accuracy. Namespace/import resolution, macro expansion and exact multi-arity matching remain outside this first pass; see `docs/ARCHITECTURE.md` for the extraction limits. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 889931733..9347a1ae6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -559,7 +559,7 @@ review will credit it, but it is never a condition for merging the language. | 1. Imports | `ingest_importcap.h`: `@import.path` captures, `emitCapturedImport`, one `normaliseImportSpecifier` arm per `DepDialect`. Resolution in `resolve.h`. | partly. C-family, JS/TS, Go and Python read the capture (Python since #382). The rest still go through per-language arms of `directiveTargetOf` (`ingest_relations.h`): `csharpUsingTarget`, `phpUseTarget`, `luaRequireTarget`, `bashSourceTarget`, `rubyRequireTarget`, `elixirDirectiveTarget`, plus Rust `use`/`mod`, Kotlin `import_header` and Swift `import_declaration`. | **1a** one language per PR onto `@import.path` plus its normaliser arm, deleting its arm in `directiveTargetOf`: S each for C#, PHP, Lua, Bash, Kotlin and Swift; M for Rust (`use` trees, `mod`), Ruby (`autoload`) and Elixir (`alias`/`use`), whose directives carry more meaning. Claim on #358. | | 2. Aliases | None. Renames reach the resolver as `RawBind` records (`ingest_cache.h`) from separate producers: Python's `@import.names` / `@import.alias` captures, read in `captureTagsFacts` (`ingest_sidecap.h`) and turned into binds by the Python-only `emitCapturedPythonImportBind` (`ingest_importcap.h`); `captureJsImportFacts`, `captureJsModuleAliases` and `captureGoImportFacts` (`ingest_jsimports.h`), C/C++ aliases in `fnPtrAliasName` (`ingest_binds.h`) and `captureTypeAlias` (`ingest_relations.h`). The readers are separate too: `addTypeAliasBases` and `buildUsingReexports` (`resolve.h`). | duplicated: one "local name → target" concept in about five shapes. | **2a** one shared binds channel (`@import.alias` / `@import.names` → one `RawBind` kind): M, *maintainers*, after the #358 import slices. **2b** after 2a, one language per PR moves its renames onto it: S each (Rust `use a as b`, Kotlin `import x as y`, PHP `use … as`, C# `using X = …`). | | 3. Types | `captureTagsFacts` (`ingest_sidecap.h`) reads `@definition.*` captures for every language. | shared, but language decisions are `Lang::` branches inside the loop: 36 non-comment `Lang::` mentions, 15 of them Elixir. | **3a** #374: one constexpr per-language hook table that `captureTagsFacts` calls once per capture, one language per commit, Elixir first. M. | -| 4. Inheritance | `captureBases` (`ingest_relations.h`): base clauses for C++, TS/JS, Java, Python, Swift, C#, PHP, Kotlin and Ruby, chosen by node kind (`isBaseTypeNodeIn`). | partly. Rust `impl … for` is a separate pass; Ruby mixins have their own `captureRubyMixinBases`. | **4a** Ruby `include`/`extend`/`prepend` as captures read by the shared path, retiring `captureRubyMixinBases`: S–M, Ruby (offered on #339). **4b** the base-clause node kinds move from `isBaseTypeNodeIn` into `tags.scm` captures: M, all of the languages above. **4c** structural conformance (#386): a class or type whose methods cover a structural base's required methods (Python `typing.Protocol`, Go interfaces, possibly TS interfaces) is listed as an implementor marked `structural=1`, never as a nominal subclass, with a disclosed minimum-method threshold; step (c), one shared method-set check, per-language data for which bases are structural. M; Python and Go first. | +| 4. Inheritance | `captureBases` (`ingest_relations.h`): base clauses for C++, TS/JS, Java, Python, Swift, C#, PHP, Kotlin and Ruby, chosen by node kind (`isBaseTypeNodeIn`). | partly. Rust `impl … for` is a separate pass; Ruby mixins read the same path through `@reference.mixin` captures since slice 4a (#400). | **4a** done (#400): Ruby `include`/`extend`/`prepend` read the shared path through `queries/ruby/tags.scm` captures, `captureRubyMixinBases` retired. **4b** the base-clause node kinds move from `isBaseTypeNodeIn` into `tags.scm` captures: M, all of the languages above. **4c** structural conformance (#386): a class or type whose methods cover a structural base's required methods (Python `typing.Protocol`, Go interfaces, possibly TS interfaces) is listed as an implementor marked `structural=1`, never as a nominal subclass, with a disclosed minimum-method threshold; step (c), one shared method-set check, per-language data for which bases are structural. M; Python and Go first. | | 5. Callables | Definitions come from `tags.scm`, read by `captureTagsFacts`. | partly. Keyword-headed definitions keep keyword tables in C++: `elixirFunctionKeyword` and `elixirMetadataAttribute` (`ingest_elixir.h`) repeat `macroBodyKeyword` (`ingest_relations.h`), 0.82 and 0.96 similar by `--clones`. | **5a** #355: the tags pass evaluates `#eq?`/`#any-of?`/`#not-any-of?`, then Elixir's table moves into its `tags.scm`. M; Elixir, then Clojure. | | 6. Dispatch | `buildGraph` (`graph.h`): a SCIP pin, then the canonical tier (`appendCanonicalCandidates`, `resolve.h`), bindings and receiver rules (`receiverevidence.h`), then name tiers. Per-language qualified-call rules: `ElixirResolver` (`elixir_resolve.h`), `keepRustQualifiedCandidates` and `keepStdQualifiedCandidates` (`graph.h`). | partly. Facts about each language are scattered `l == Lang::X \|\| …` predicates (`implicitReceiverLang`, `memberNeverReachesFreeFunction` and `classNameReceiverLang` in `receiverevidence.h`; `langUsesHashComment` in `quality.h`), and `--clones` reports them as one group. | **6a** the shared refusal for an unplaced module qualifier (the known gap above): S, *maintainers*, not yet scheduled. **6b** the **language-neutral qualified-call resolver**: generalise `ElixirResolver`'s module table (alias/import/require facts → `Module::name`) so that Clojure `ns/f`, Elixir and other module-qualified languages use one resolver driven by per-language data. M; Elixir byte-identical, Clojure as the first new user. **6c** those predicates become columns of one constexpr per-`Lang` table: S, all languages. | | 7. Data-driven dispatch | One mechanism per shape: fn-pointer and callback bindings `buildFnPtrBindTables` (`graph.h`, C/C++); HTTP routes `routesVisitNode` (`ingest_sidecap.h`, a JS/TS and Python walk) into `RouteDef`/`RouteUse` (`model.h`); FFI aliases `BindingAlias` (`model.h`). | partly. Calls by a literal method name (`send(:m)`) resolve for Ruby only. | **7a** #375: one capture for a call whose method name is a literal, resolved through the ordinary receiver rules. M; Python `getattr`, JS/TS `obj["m"]`, Go `MethodByName`. **7b** route registration read from captures instead of the walk: M; JS/TS and Python. | diff --git a/queries/ruby/tags.scm b/queries/ruby/tags.scm index d44ff08bf..843b39cbf 100644 --- a/queries/ruby/tags.scm +++ b/queries/ruby/tags.scm @@ -96,3 +96,22 @@ ; never contributes: a class-wrapped create_table (string- or symbol-named) is refused by the ; class/module-nest gate — the schema is the ONE source of column names; add_column/remove_column/ ; change_column are argument data and read nowhere (a column added then dropped never registers). + +; ---- mixin directives (family 4, slice 4a) ---- +; `include M` / `extend M` / `prepend M` make M an ANCESTOR of the enclosing class or module: the class's own +; method lookup reaches M's methods, which is the edge `class C < B` mints. Each constant argument is captured +; here as @name and read by the shared base-relations path (src/ingest_sidecap.h captureTagsFacts -> emitBaseRef, +; the same emitter every language's base clause goes through), so no Ruby-only C++ function walks these +; directives any more (slice 4a retired captureRubyMixinBases). +; +; UNANCHORED on purpose, exactly like the import patterns above: the tags pass evaluates NO predicates +; (#eq?/#match? are silently ignored — test/matchgrammarcheck.sh), so this pattern states the call SHAPE and the +; C++ reader owns the two guards a query cannot state — the directive's own TEXT (without it `Logger.new( Config )` +; and `raise Error` at class-body level match the same shape) and the POSITION (class/module body, `class << self`, +; or a concern's `included do … end`; a method body, another block and the file top level emit nothing). Both guards +; already existed for this lane and stayed where they were; the query is the language gate, so the reader spends no +; Lang:: test on them. The division is the one the shared import vocabulary uses for `require` (issue #358, +; src/ingest_importcap.h). +(call + method: (identifier) + arguments: (argument_list [(constant) (scope_resolution)] @name)) @reference.mixin diff --git a/src/ingest_cache.h b/src/ingest_cache.h index 2b7366370..b20ca555f 100644 --- a/src/ingest_cache.h +++ b/src/ingest_cache.h @@ -320,7 +320,13 @@ constexpr std::uint32_t kCacheVersion = 31; // 31: train 27 — lane l // (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 = 171; // bump on any grammar/.scm/extraction change +constexpr std::uint32_t kParserVer = 173; // bump on any grammar/.scm/extraction change + // 173 = 2026-10-15 (slice 4a, #400): the Ruby mixin directives (`include` / `extend` / `prepend`) + // move off the captureIncludes walk onto the shared base-relations path — + // queries/ruby/tags.scm captures the directive as @reference.mixin and captureTagsFacts + // emits each constant's inherit reference through emitBaseRef (captureRubyMixinBases retired). + // Output byte-identical; the extraction path changed, so the number moves. 173, not 172: 172 is + // 171's rich file tag. Rich file tag 174. kCacheVersion stays 31. // 171 = 2026-10-13 (PR #349 lands, @tag-und-nacht): Clojure grammar, tags.scm and extraction // (ingest_clojure.h) and the qualified-call namespace in Reference::qualifier. The branch built // 156, 158 and 169 (rich file tag 170); main is 167 (rich file tag 168). 171, not 168: a lean 168 diff --git a/src/ingest_relations.h b/src/ingest_relations.h index cbba4cf32..1827a18a8 100644 --- a/src/ingest_relations.h +++ b/src/ingest_relations.h @@ -1988,6 +1988,11 @@ inline std::string_view fieldIdentifierText( TSNode n, NodeField field, std::str // (measured: NoMethodError at class level) — captured for third-party DSLs that define it; the // disclosure lives in the CHANGELOG and the tags.scm header. inline constexpr std::array kRubyConstantDirectives = { "include", "extend", "prepend", "autoload" }; +// The MIXIN subset of the set above (family 4, slice 4a): the three directives that make each constant argument an +// ANCESTOR of the enclosing class. `autoload` is in the set above and not here — it names a file to load, never a base. +// The shared capture consumer (ingest_sidecap.h captureTagsFacts) reads this table for the @reference.mixin captures +// queries/ruby/tags.scm emits; a language whose bases come from a clause emits no such capture and needs no row. +inline constexpr std::array kRubyMixinDirectives = { "include", "extend", "prepend" }; inline constexpr std::array kRubyAttrFamilyNames = { "attribute", "attributes", "attr_reader", "attr_writer", "attr_accessor" }; // `module_function` is NOT a visibility wrapper here: `module_function attr_accessor :x` raises in either position // (measured, Ruby 4.0.7: NoMethodError in a class, which undefines module_function; TypeError in a module, which @@ -2108,6 +2113,10 @@ inline bool rubyAttrAtClassBodyLevel( TSNode n, std::string_view src ) noexcept // else — a method body, another block, a file's top level — emits none. Since lane lego-transitive the reference is also an // IMPLEMENTOR edge: graph.h's implementors builder admits a Ruby module as a base, so --lego=M lists every class (and // module) that mixes M in, and test/rubyinheritcheck.sh floor (b) is closed. +// SLICE 4a moved the READ off the captureIncludes walk: queries/ruby/tags.scm captures the directive's call shape as +// @reference.mixin and the shared capture consumer applies the two guards below — the directive's TEXT (kRubyMixinDirectives) +// and the POSITION — then emits the same reference through the shared emitBaseRef. Both position readers stay here because +// they are the guards a tags pattern cannot state. // Is `call` a statement of an ActiveSupport::Concern `included do … end` block written at a module's body level? When a // class includes the concern the block runs in that class, so a mixin there joins the INCLUDER's ancestors; read as an // ancestor of the concern itself, it reaches the includer through the concern — activerecord's AttributeMethods @@ -2126,17 +2135,21 @@ inline bool rubyInConcernIncludedBlock( TSNode call, std::string_view src ) noex && rubyAttrAtClassBodyLevel( owner, src ); } -inline void captureRubyMixinBases( TSNode call, const std::vector& constants, std::uint32_t fileId, std::string_view src, +// The mixin directive a @reference.mixin capture names (family 4, slice 4a): when `call` is a receiverless +// `include`/`extend`/`prepend` at class-DSL position, its constant argument is an ANCESTOR of the enclosing class, so +// it emits one inherit reference through the shared emitBaseRef — the shape a superclass mints. Both guards are here +// because a tags pattern can state neither: the TEXT (tags predicates do not run, so `Logger.new( Config )` matches +// the same call shape) and the POSITION (a method body, another block and the file top level emit nothing). Kept out +// of captureTagsFacts — the densest dispatch point in that file — behind one call, like the scope readers above. +inline void emitCapturedMixinBase( TSNode call, TSNode constant, std::uint32_t fileId, std::string_view src, std::vector& refs ) { - if( constants.empty() || !( rubyAttrAtClassBodyLevel( call, src ) || rubyInConcernIncludedBlock( call, src ) ) ) + if( rubyNamedDirective( call, src, kRubyMixinDirectives ).empty() + || !( rubyAttrAtClassBodyLevel( call, src ) || rubyInConcernIncludedBlock( call, src ) ) ) { return; } - for( const TSNode a : constants ) - { - emitBaseRef( a, fileId, Lang::Ruby, src, refs ); - } + emitBaseRef( constant, fileId, Lang::Ruby, src, refs ); } // A CONSTANT CHAIN: `Name`, `A::B::C`, `::A::B` — every segment a constant, the head a constant or absent (`::A`). @@ -3013,13 +3026,14 @@ void captureIncludes( TSNode root, Lang lang, std::uint32_t fileId, std::string_ else if( lang == Lang::Ruby && kindIs( t, "call" ) ) { // `include A, B` / `extend M` / `prepend P` (parser version 82): N constants off one directive node, in - // SOURCE order, each a symbolic Include — the Ruby twin of the Elixir alias group below. + // SOURCE order, each a symbolic Include — the Ruby twin of the Elixir alias group below. The ANCESTOR half + // of the same directive (the inherit reference each constant mints) is read by the shared base-relations + // path from queries/ruby/tags.scm's @reference.mixin captures since slice 4a. const std::vector mixins = rubyMixinTargets( n, src ); for( const TSNode member : mixins ) { emitDirective( rubyConstantText( member, src ), true, isLazy, false ); } - captureRubyMixinBases( n, mixins, fileId, src, refs ); // parser version 131: and each is an ancestor } else if( lang == Lang::Ruby && kindIs( t, "argument_list" ) ) { diff --git a/src/ingest_sidecap.h b/src/ingest_sidecap.h index 7983f738c..614dd81db 100644 --- a/src/ingest_sidecap.h +++ b/src/ingest_sidecap.h @@ -2433,6 +2433,16 @@ void captureTagsFacts( TSQueryCursor* cursor, const LangEntry& le, std::uint32_t continue; } + // family 4 / slice 4a: a @reference.mixin capture is Ruby's `include`/`extend`/`prepend` — the shape + // lives in queries/ruby/tags.scm, so the capture IS the language gate and no Lang:: test is spent + // here. The two guards a tags pattern cannot state (the directive's TEXT, and its POSITION) live in + // the helper, beside the emitter. The directive's own CALL reference is a separate match, below. + if( refCapSv == "reference.mixin" ) + { + emitCapturedMixinBase( roleNode, nameNode, fileId, src, refs ); + continue; + } + RawRef r; r.fileId = fileId; r.startByte = ts_node_start_byte( roleNode ); diff --git a/src/quality.h b/src/quality.h index 0a0b746df..3cc0dd208 100644 --- a/src/quality.h +++ b/src/quality.h @@ -2268,7 +2268,12 @@ inline std::string cacheRootKeyHex( const std::string& root ) // not include this header; it relies on ingest.cpp including quality.h (line 13) before ingest_cache.h, and a reorder // that broke that fails the build on the undeclared name rather than passing. constexpr std::uint32_t kIngestCacheVersionMirror = 31; // MUST equal ingest.cpp's kCacheVersion (gated); 31 = train 27: lego-transitive's inherit-ref baseBind (the lane's 30), 29 = FE-B ref memberPath/memberCtor + 4 bind kinds, 28 = FE-A ref memberCall/memberRoot, 27 = corrected fnScope values, 26 = function-local def scope span (25 = #157 + #150) -constexpr std::uint32_t kIngestParserVerMirror = 171; // MUST equal ingest.cpp's kParserVer (gated) +constexpr std::uint32_t kIngestParserVerMirror = 173; // MUST equal ingest.cpp's kParserVer (gated) + // 173 = 2026-10-15 (slice 4a, #400): Ruby's mixin directives onto the shared + // base-relations path — queries/ruby/tags.scm captures them as @reference.mixin + // and captureTagsFacts emits the inherit reference; captureRubyMixinBases + // retired. Output byte-identical, extraction path changed. 173, not 172: 172 + // is 171's rich file tag. // 171 = 2026-10-13 (PR #349 Clojure lands above main's 167 / rich tag 168 and the // branch's own 169 / rich tag 170; see the kParserVer note) // 167 = 2026-10-11 (train 27 release: lane fix27-cpp-subscript's 166 renumbered above the lane build; diff --git a/test/qschemetrip.hash b/test/qschemetrip.hash index f1ce3205d..189e81021 100644 --- a/test/qschemetrip.hash +++ b/test/qschemetrip.hash @@ -1 +1 @@ -23cb6ecf5949981b75965273ff9af22fc68cdfe109bbd7f99b7167d59bdbc0cb +82085ad1d2fbf866750fb7e3f928f611464f8ae1227e4453c9b6e12d6af122de diff --git a/test/qschemetripcheck.sh b/test/qschemetripcheck.sh index c3ad1b7fe..16ef175af 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-10-15, slice 4a (#400, Ruby mixins onto the shared base-relations path): RE-DERIVED with UPDATE_GOLDEN=1. +# The only watched text that moved is the kParserVer declaration (and its mirror): 171 -> 173. Ruby's +# `include`/`extend`/`prepend` are captured by queries/ruby/tags.scm as @reference.mixin and read by +# captureTagsFacts instead of the retired captureRubyMixinBases; output is byte-identical, but the extraction +# path changed, so the number moves. 173, not 172: 172 is 171's rich file tag. +# kCacheVersion 31 and kQSnapCacheScheme 20 are unchanged. # 2026-10-13, PR #349 lands (maintainer renumber commit on the contributor's branch): RE-DERIVED with UPDATE_GOLDEN=1. # The only watched text that moved is the kParserVer declaration (and its mirror): provisional 158 -> 171. The branch # built 156, 158 and 169 (rich file tag 170); main is 167 (rich file tag 168); 171 / rich file tag 172 is above all of diff --git a/test/rubyinheritcheck.sh b/test/rubyinheritcheck.sh index fb782e497..3d719d60f 100755 --- a/test/rubyinheritcheck.sh +++ b/test/rubyinheritcheck.sh @@ -498,7 +498,8 @@ then fi # The expected count: every `class X < Const` in the fixture (a computed superclass mints no inherit reference) and, # since parser version 131, every constant of a class-body `include`/`extend`/`prepend` — a mixin is an ancestor too -# (ingest_relations.h captureRubyMixinBases), so the seam drops its directive from the join as well. +# (queries/ruby/tags.scm @reference.mixin since slice 4a; captureRubyMixinBases before that), so the seam drops its +# directive from the join as well. SUPERS="$( cat "$FIX"/*.rb | grep -cE '^[[:space:]]*class [A-Z][A-Za-z0-9_:]* < (::)?[A-Z][A-Za-z0-9_]*(::[A-Z][A-Za-z0-9_]*)*[[:space:]]*(;|#|$)' )" MIXINS="$( cat "$FIX"/*.rb | grep -E '^[[:space:]]+(include|extend|prepend) ' | grep -oE '(::)?[A-Z][A-Za-z0-9_]*(::[A-Z][A-Za-z0-9_]*)*' | grep -c . )" WANT=$(( SUPERS + MIXINS )) @@ -521,5 +522,39 @@ then || no "seam: --legend=compact carries ruby_bases_unscoped= with no row defining it" fi +# ---- slice 4a: the mixin read moved to queries/ruby/tags.scm (@reference.mixin) ------------------------------- +# The constants of a class-body `include`/`extend`/`prepend` are inherit references. Since slice 4a the tags query +# states the SHAPE and the shared capture consumer owns the two guards a tags pattern cannot: the directive's TEXT +# and its POSITION. The query matches ANY call holding a constant argument, so those two guards are the only thing +# between `Logger.new( MixA )` and an invented ancestor — this fixture pins all three boundaries in one count. +MIXQ="$DIR/mixq"; mkdir -p "$MIXQ" +cat > "$MIXQ/q.rb" <<'RUBY' +module MixA; end +module MixB; end +module Space + module Mod; end +end +class Mixed + include MixA, MixB + include Space::Mod + Logger.new( MixA ) + def f + include MixA + end + class << self + include MixA + end +end +RUBY +if RIPWIRE_TEST_RUBY_BASE_UNSCOPED=1 runq MIXSEAM "$MIXQ" --no-cache --lego=MixA --legend=full +then + echo "$MIXSEAM" | grep -q ' ruby_bases_unscoped="4"' \ + && ok "slice 4a: 4 mixin inherit refs (2 off one directive, 1 scope_resolution, 1 under class << self) — and neither Logger.new( MixA ) nor the method-body include is one" \ + || no "slice 4a: expected ruby_bases_unscoped=\"4\" (2 + 1 + 1, the call and the method body refused), got: $( echo "$MIXSEAM" | grep -o 'ruby_bases_unscoped="[0-9]*"' )" + echo "$MIXSEAM" | sed 's/>\n Date: Thu, 15 Oct 2026 03:16:35 +0200 Subject: [PATCH 2/3] =?UTF-8?q?test+docs(ruby):=20close=20the=20#401=20rev?= =?UTF-8?q?iew=20round=20=E2=80=94=20read-verb=20evidence,=20migration=20g?= =?UTF-8?q?uard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit's two findings. 1. CHANGELOG.md claimed the "read verbs" match the base build while the PR report showed only the map, --deps and --skipped. Rather than narrow the claim, run them: --callers / --callees / --impact / --uses over the Rails corpus, the mixin fixture and the Ruby fixtures, base vs head, --no-cache — 24 invocations, empty diff. The claim now stands on evidence and the argv list goes in the PR body. 2. The new arm passes on main too, so it cannot catch a regression that restores the old extraction path. A behaviour guard cannot exist for this slice — it is byte-identical by contract — so the arm gains an IMPLEMENTATION guard instead: the @reference.mixin capture is present and the retired captureRubyMixinBases definition is gone. Red on main (0 captures / 1 definition), green here (1 / 0). The arm's `&& ok || no` lines became if/then, clearing the shellcheck SC2015 note. Also: the eight comments that still pointed at the deleted function now name the live mechanism (@reference.mixin / emitCapturedMixinBase) — the clean cutover the slice promises. --- src/graph.h | 2 +- src/ingest_cache.h | 2 +- src/ingest_relations.h | 4 ++-- src/ingest_ruby.h | 4 ++-- src/model.h | 2 +- src/resolve.h | 2 +- test/rubyinheritcheck.sh | 30 ++++++++++++++++++++++++------ 7 files changed, 32 insertions(+), 14 deletions(-) diff --git a/src/graph.h b/src/graph.h index 658a09606..720caa5e3 100644 --- a/src/graph.h +++ b/src/graph.h @@ -8788,7 +8788,7 @@ inline Graph buildGraph( const IngestResult& ing, const ScipOverlay* scip = null for( NodeId baseId : *baseIds ) { // lane lego-transitive (M1): a Ruby MODULE is a base here too. `include M` / `extend M` / `prepend M` put it in - // the class's ancestors (captureRubyMixinBases, parser version 131), so the class implements its contract the way + // the class's ancestors (the @reference.mixin capture, parser version 131), so the class implements its contract the way // a subclass does — and --lego=M listed nobody (rake: `include Rake::DSL`). The test is BuiltinMethodGate's // class-like rule, the one that already counts a Ruby module (Ruby && SymKind::Other: @definition.module maps to // Other for every language, and only Ruby's tags have no other Other). Other languages need nothing here: an diff --git a/src/ingest_cache.h b/src/ingest_cache.h index b20ca555f..d56bf7605 100644 --- a/src/ingest_cache.h +++ b/src/ingest_cache.h @@ -500,7 +500,7 @@ constexpr std::uint32_t kParserVer = 173; // bump on any grammar/.sc // 131 = 2026-10-02 (test/rubyreachcheck.sh): a Ruby MIXIN is an // ancestor — `include`/`extend`/`prepend` at class-DSL // position mints one inherit RawRef per constant, at the - // constant's byte (ingest_relations.h captureRubyMixinBases). + // constant's byte (ingest_relations.h emitCapturedMixinBase). // New records, same layout: kCacheVersion unchanged (27). A // Ruby cache written at 130 holds none of them and must re-parse. // 130 = 2026-10-01 (test/rubybarecallcheck.sh): Ruby's BARE-WORD call — an diff --git a/src/ingest_relations.h b/src/ingest_relations.h index 1827a18a8..66916c35d 100644 --- a/src/ingest_relations.h +++ b/src/ingest_relations.h @@ -944,7 +944,7 @@ inline void wrappedBase( TSNode wn, std::uint32_t fileId, Lang lang, std::string // C#/PHP/Kotlin/Ruby. Ruby reuses the `superclass` clause name Java's extends clause already has, and // names its base with (constant)/(scope_resolution) — see isBaseTypeNodeIn. A Ruby MIXIN // (`include M` / `extend M` / `prepend M`) is a call in the class BODY, not a clause, so it is captured where -// the call is read instead: captureRubyMixinBases (parser version 131; test/rubyreachcheck.sh). Lua is deliberately absent and it is a DISCLOSED non-goal, not an omission: Lua inheritance IS +// the call is read instead: the @reference.mixin capture (parser version 131; test/rubyreachcheck.sh). Lua is deliberately absent and it is a DISCLOSED non-goal, not an omission: Lua inheritance IS // `setmetatable( Derived, { __index = Base } )`, an ordinary runtime call over an ordinary table, so there // is no syntax to read and a Lua corpus correctly reports no inheritance edges at all. // @@ -2015,7 +2015,7 @@ inline std::string_view rubyNamedDirective( TSNode n, std::string_view src, std: // `include A, B` / `extend M` / `prepend P` — ONE directive naming N constants and therefore N Include // records, in SOURCE order (the same shape as elixirAliasGroup). A non-constant argument (`include // Object.const_get(:X)`, `include mod`) contributes nothing; the constant ones beside it still do. The constant -// NODES: captureIncludes reads each one's text for its Include record, and captureRubyMixinBases its byte. +// NODES: captureIncludes reads each one's text for its Include record, and the @reference.mixin capture its byte. inline std::vector rubyMixinTargets( TSNode n, std::string_view src ) { std::vector out; diff --git a/src/ingest_ruby.h b/src/ingest_ruby.h index 9dc6c2f02..278254d86 100644 --- a/src/ingest_ruby.h +++ b/src/ingest_ruby.h @@ -615,7 +615,7 @@ inline void captureRubySingletonAccessors( TSNode root, std::uint32_t fileId, st // Parser version 145 (test/rubyclassrecvcheck.sh): each mixin the CLASS OBJECT's lookup reaches rather than its // instances' — the constants of an `extend M`, and of an `include`/`prepend` inside `class << self` — as a -// LocalBindKind::RubyClassMixin binding at the constant's own start byte, where captureRubyMixinBases puts its inherit +// LocalBindKind::RubyClassMixin binding at the constant's own start byte, where emitCapturedMixinBase puts its inherit // reference, read where that reads the directive (class-body level, or a concern's `included do`). One a concern's // `included do` writes extends the includer, not the concern: importedName kRubyIncluderMark. A class body's `extend self` // names no constant: its binding sits at the directive, var "self". A file that never writes `extend` or `<<` is not @@ -627,7 +627,7 @@ inline bool rubyExtendsSelf( TSNode n ) noexcept return !ts_node_is_null( args ) && ts_node_named_child_count( args ) == 1 && kindIs( ts_node_type( ts_node_named_child( args, 0 ) ), "self" ); } -// The RubyClassMixin bindings of the mixin directive `n` (an `extend` when `extend`), read where captureRubyMixinBases reads +// The RubyClassMixin bindings of the mixin directive `n` (an `extend` when `extend`), read where emitCapturedMixinBase reads // it: one per constant a class object's lookup reaches, and a module body's `extend self`. inline void rubyNoteClassMixins( TSNode n, bool extend, std::uint32_t fileId, std::string_view src, std::vector& binds ) { diff --git a/src/model.h b/src/model.h index 970e1897c..4e6bf0dd2 100644 --- a/src/model.h +++ b/src/model.h @@ -1077,7 +1077,7 @@ enum class LocalBindKind : std::uint8_t // ActiveSupport::Concern extends onto the includer (ingest_ruby.h rubyDefSide). RubyClassMixin, // parser version 145: a mixin the CLASS OBJECT's lookup reaches rather than its instances' — the constant // of an `extend M`, or of an `include`/`prepend` inside `class << self` — at startByte, the - // constant's own start, where ingest_relations.h captureRubyMixinBases puts its inherit reference; + // constant's own start, where ingest_relations.h emitCapturedMixinBase puts its inherit reference; // var and typeName empty; importedName=kRubyIncluderMark when the directive sits in a concern's // `included do … end`, which extends the includer, not the concern. A module body's `extend self` // has no constant: var="self", at the directive's own start byte (ingest_ruby.h diff --git a/src/resolve.h b/src/resolve.h index 5221412a5..5cd3edaf6 100644 --- a/src/resolve.h +++ b/src/resolve.h @@ -1954,7 +1954,7 @@ struct RubyDirectiveSite }; // Every symbolic directive that is not a value use, per file in byte order — the records a MIXIN reference is joined to -// (parser version 131: ingest_relations.h captureRubyMixinBases puts the reference at its constant, the directive sits at +// (parser version 131: ingest_relations.h emitCapturedMixinBase puts the reference at its constant, the directive sits at // its `include` call). Stable, so two constants of one `include A, B` keep their source order at their shared byte. inline std::vector> rubyDirectiveSitesByFile( const IngestResult& ing, const RubyConstantIndex& ix ) { diff --git a/test/rubyinheritcheck.sh b/test/rubyinheritcheck.sh index 3d719d60f..cde613d10 100755 --- a/test/rubyinheritcheck.sh +++ b/test/rubyinheritcheck.sh @@ -548,12 +548,30 @@ end RUBY if RIPWIRE_TEST_RUBY_BASE_UNSCOPED=1 runq MIXSEAM "$MIXQ" --no-cache --lego=MixA --legend=full then - echo "$MIXSEAM" | grep -q ' ruby_bases_unscoped="4"' \ - && ok "slice 4a: 4 mixin inherit refs (2 off one directive, 1 scope_resolution, 1 under class << self) — and neither Logger.new( MixA ) nor the method-body include is one" \ - || no "slice 4a: expected ruby_bases_unscoped=\"4\" (2 + 1 + 1, the call and the method body refused), got: $( echo "$MIXSEAM" | grep -o 'ruby_bases_unscoped="[0-9]*"' )" - echo "$MIXSEAM" | sed 's/>\n\n/dev/null \ + && ! grep -qE 'void captureRubyMixinBases *\(' "$ROOT/src/ingest_relations.h" 2>/dev/null +then + ok "slice 4a: the mixin read is queries/ruby/tags.scm's @reference.mixin capture, and the retired captureRubyMixinBases definition is gone" +else + no "slice 4a: the migration is undone — tags.scm @reference.mixin=$( grep -c '@reference.mixin' "$ROOT/queries/ruby/tags.scm" 2>/dev/null ), ingest_relations.h captureRubyMixinBases definition=$( grep -cE 'void captureRubyMixinBases *\(' "$ROOT/src/ingest_relations.h" 2>/dev/null )" fi echo From 94a61643194715b2ab0a2a2dd1c0cedcc929fcad Mon Sep 17 00:00:00 2001 From: Michal Papis Date: Thu, 15 Oct 2026 03:41:39 +0200 Subject: [PATCH 3/3] docs(changelog): name the byte-identity verbs in the file, not only in the PR body MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CHANGELOG.md:25 thread stayed open because the previous round answered it in the PR body while leaving the anchored line untouched: the diff never carried the answer, so the thread never went outdated and CodeRabbit never resolved it (the sibling test-file thread did, because that file was rewritten). The changelog now names what was compared — the map, --deps --limit=100000, --skipped, --callers, --callees, --impact and --uses — the same set the PR body's table covers, so no claim in the file rests on evidence outside it. --- CHANGELOG.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bb4f4336..c6534ed84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,9 +21,9 @@ Ruby's `include`/`extend`/`prepend` are now captured by `queries/ruby/tags.scm` inherit references by the shared capture consumer — the same emitter every language's base clause goes through. The Ruby-only `captureRubyMixinBases` and its `captureIncludes` call site are gone; the query states the call shape and the C++ reader keeps the two guards a tags pattern cannot state (the directive's text, and its class-body position). -Output is byte-identical: the map, `--deps`, `--skipped` and the read verbs match the base build over a real Rails -corpus and the Ruby fixtures, and `--lego` lists the same implementors. `kParserVer` moves 171 -> 173 (the extraction -path changed; 172 is 171's rich file tag). +Output is byte-identical: the map, `--deps --limit=100000`, `--skipped`, `--callers`, `--callees`, `--impact` and +`--uses` all match the base build over a real Rails corpus and the Ruby fixtures, and `--lego` lists the same +implementors. `kParserVer` moves 171 -> 173 (the extraction path changed; 172 is 171's rich file tag). ### Added — Clojure (`.clj`/`.cljs`/`.cljc`/`.bb`)