Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
13 commits
Select commit Hold shift + click to select a range
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
73 changes: 73 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,79 @@ not published here — see `docs/EVALS.md` for the instruments behind the headli

## [Unreleased]

### Added — Ruby has inheritance edges: `class Child < Parent` reaches the lego view and the resolver's base walk

No Ruby corpus has ever carried an inheritance edge. `captureBases` turns a class's base clause into
inherit refs, which `buildGraph` reads into the CHA-lite name graph the resolver walks after a type's
OWN method set misses — and Ruby reached none of it. The clause kind was never the problem
(`superclass` is already in the table; Java's `extends` clause carries the same node name): the
base-TYPE table held no node kind Ruby uses. Ruby names a base with `(constant)` — `class Child <
Parent` — or `(scope_resolution)` — `class Derived < Space::Base`. Both are now read, under a
language test rather than appended to the shared table, because both kind names are generic enough
to mean something else in another grammar.

This is floor (a) of the constant-receiver round above, lifted — and it is what held that round's
gem numbers down, since a gem reaches its class methods up an `ActiveRecord::Base` hierarchy. Two
things follow at once: `--lego` answers for Ruby, and `Child.build` resolves to `Parent::build`.

A base is found by its final segment, like every language's, and then SCOPED by Ruby's own constant
lookup: the superclass as written is resolved innermost-first along the enclosing `Module.nesting`,
then at the top level (`::X` absolute), against every class and module the tree opens — the #57
constant index, namespace wrappers included — and only the classes that ARE that constant are its
base. So `class Rec < ActiveRecord::Base` is not an implementor of an in-tree `Space::Base`, `class
Inner < Base` inside `module Beta` lands on `Beta::Base` alone, and a base the tree never opens adds
nothing to the CHA name graph, so its class's walk cannot reach an unrelated in-tree `Base`. That
join needs no new extraction: every `class X < Y` already carries its written superclass as the
symbolic directive #57 records. The bases are read by constant rather than through `byName`, whose
C-family decl/def collapse takes a body-less `class Base < StandardError; end` for a forward
declaration and drops it next to any same-named class with a body.

| corpus | `--lego` implementors | edges | ambiguous |
| --- | --- | --- | --- |
| activerecord 8.1.3 `lib`, `--lego=active_record/base.rb:Base` | 0 → 0 | 9,152 → 9,001 | 1,479 → 1,288 |
| activerecord 8.1.3 `lib`, `--lego=active_record/encryption/errors.rb:Base` | 0 → 6 | — | — |
| activesupport 8.1.3 `lib` | — | 3,912 → 3,915 | 434 → 422 |
| actionpack 8.1.3 `lib` | — | 3,140 → 3,127 | 364 → 355 |
| Rails app A, `--lego=ApplicationRecord` | 0 → 129 | 24,376 → 24,392 | 1,263 → 1,268 |
| Rails app A, `--lego=app/controllers/application_controller.rb:ApplicationController` | 0 → 114 | — | — |
| Rails app A, `--lego=app/controllers/admin/application_controller.rb:ApplicationController` | 0 → 19 | — | — |
| Rails app B | — | 16,112 → 16,121 | 440 → 445 |

`ambiguous` falls on the gems because a two-way split collapses into one pinned edge, which is also
why `edges` falls where it does — 151 fewer on activerecord is 151 calls that stopped naming two
candidates. No `ActiveRecord::Base` subclass lives in activerecord's own `lib`, so 0 is its answer;
before the scoping, the final-segment key gave it 11, every one a collision (`ActiveJob::Base`, the
encryption errors' own `Errors::Base`, the generators' `Base`). On the apps the scoping is what
moves `ambiguous` UP: a call that reached an in-tree `Base` only through an out-of-tree one is no
longer pinned there. App A's `self.data` in a model (`< ApplicationRecord < ActiveRecord::Base`) was
pinned to a report handler's `data` through `Reports::ResolutionHandlers::Base`; it is an honest
split now. App B's twelve `SomeModel.polymorphic_name` sites were each split three ways over the
app's own `User::Base`, `Organization::Base` and `BankAccount::Base`; ActiveRecord answers them, and
they mint nothing.

Stated floors, each pinned by an arm of `test/rubyinheritcheck.sh`: a COMPUTED superclass (`class
Dynamic < Struct.new( :a )`) is a call, not a name, and mints nothing — not even an edge to the
call's receiver, which `captureBases`' one-level wrapper descent used to hand over; a MIXIN
(`include Helper`) is NOT an inheritance edge in this round — it is a receiver-less call in the class
BODY, the same shape and the same decision as PHP's in-body `use SomeTrait;`, and Ruby's ancestor
chain really does hold included modules, so it is a stated residue rather than a claim that it is not
inheritance; the base walk's METHOD probe is keyed by the immediate scope (`Base::m`), so two in-tree
bases that share a final name still share one probe — `UsesAlpha.beta_make` pins `Beta::Base`'s
method although `UsesAlpha < Alpha::Base`; and `Built = Class.new( Parent )`, with or without a block,
makes `Built < Parent` at runtime but is a constant assignment whose value is a call, not a `class`
open, so it mints no class and no edge — the same decision as a computed superclass. A class
reopened with its superclass repeated (`class Reop < Parent … end` twice) is two symbols and one
constant, and the lego view lists it once — app A's 129 was 130 before, one model counted twice
because a stub reopens it with the superclass repeated. A fifth floor, of `queries/ruby/tags.scm` rather than of
this round, is pinned beside them: a receiver-less call written with no parentheses and no arguments
parses as `(identifier)`, not `(call)`, and is not a call site at all.

`--deps` is byte-identical on activerecord, and the default map is byte-identical on four Ruby-free
corpora, with this repository's `--report` totals unchanged. `kParserVer` 97 → 99 in two steps (98:
new records, same layout — a Ruby cache written at 97 holds no inheritance refs; 99: fewer records —
a cache written at 98 holds the computed superclass's stray receiver ref; `kCacheVersion` stays 22),
with the `quality.h` mirror and `test/qschemetrip.hash` moved in each step's commit.

### Added — a Ruby constant receiver now pins the call, instead of splitting it across every same-named method

`Calc.add( 1, 2 )`, `Outer::Engine.run( 3 )`, `::Top.ping` and `Util.format( 5 )` resolved to EVERY
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1817,9 +1817,9 @@ wrong, and it has. These are the results that say so, all in-tree, all published
### In the tests

<details>
<summary><b>622 gate scripts</b>, five contracts no unit test can hold, and the house rule: write the gate before the code it measures</summary> <!-- gatecount -->
<summary><b>623 gate scripts</b>, five contracts no unit test can hold, and the house rule: write the gate before the code it measures</summary> <!-- gatecount -->

`test/regression.sh` names **622 gate scripts** and is the authoritative list; <!-- gatecount -->
`test/regression.sh` names **623 gate scripts** and is the authoritative list; <!-- gatecount -->
`python3 test/pargates.py . ./build/ripwire -j 6` runs the same set in parallel. On top of them sit the
contracts that do not fit a unit test: two runs byte-identical, warm output identical to cold, output
that pipes clean through `xmllint --noout`, a sanitizer build with `-fno-sanitize-recover=all`, and a
Expand Down
6 changes: 3 additions & 3 deletions docs/EVALS.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ section, and it is not an afterthought.
| **Co-change / known-item evals** | `--eval`, `--eval-retrieval` (see `bench/ANSWERQUALITY.md`) | Whether the tool surfaces the other files a real historical commit touched; and known-item retrieval across four rankers. |
| **Ensemble calibration harness** | `bench/ensemblecal/` | Whether `--ensemble`'s four evidence families are actually orthogonal, how often each fires, how stable each is across commits — and the preset ladder derived from that (§9). |
| **Differential argv harness** | `test/argvdiffcheck.sh` | That a refactor changed *nothing observable*: two binaries, every argv vector, stdout + stderr + exit code byte-identical. |
| **The gate suite** | `test/regression.sh`, `test/pargates.py` | 622 gate scripts plus the determinism, cache-transparency and golden contracts. <!-- gatecount --> |
| **The gate suite** | `test/regression.sh`, `test/pargates.py` | 623 gate scripts plus the determinism, cache-transparency and golden contracts. <!-- gatecount --> |
| **`--quality-delta`** | `src/quality.h` | Ten measured code-quality failure modes, reported only where a change made them worse. |

### The labeling protocol (why the held-out eval is allowed to disagree with the ranker)
Expand Down Expand Up @@ -5834,7 +5834,7 @@ copy here would be exactly the dialect divergence that gate exists to catch. Com
tags, wrap, stable-order defaults), seven individually invoked standalone gates (`g1freshcheck`,
`skillscan`, `htmlexport`, `compresscheck`, `handoffcheck`, `releaseinstallcheck`,
`taskroutecheck`), and a single loop
naming **622 gate scripts**, all of which exist on disk. <!-- gatecount -->
naming **623 gate scripts**, all of which exist on disk. <!-- gatecount -->

`python3 test/pargates.py . ./build/ripwire -j 6` runs the same scripts in parallel so a full
verification fits in one sitting. It does not modify `regression.sh`.
Expand Down Expand Up @@ -6846,7 +6846,7 @@ Listed because the reason is more useful than the silence.
shipped**. See `bench/locbench/anchorhop_calib.json`. The mention anchor's reproducible numbers are
the ablations in §4.
- **A single round gate-count.** Two in-tree numbers disagree (`test/pargates.py`'s docstring says
~210; `test/argvdiffcheck.sh` says 200+), while the loop in `test/regression.sh` names 622. The <!-- gatecount -->
~210; `test/argvdiffcheck.sh` says 200+), while the loop in `test/regression.sh` names 623. The <!-- gatecount -->
loop is the authority; the stale docstrings are a known drift. Since 2026-09-10 the number is not
written by hand anywhere: `docs/gatecount_build.py` derives it from the loop and rewrites every
published site, `test/gatecountcheck.sh` fails if any of them drifts, and `test/manifestcheck.sh`
Expand Down
6 changes: 3 additions & 3 deletions present/deck5_ripwire_build.js
Original file line number Diff line number Diff line change
Expand Up @@ -1123,7 +1123,7 @@ function storyCards(s, { kick, head, stories, footText }){
kicker(s, "// how it stays true", AMBER);
title(s, "Proven, not promised");
const cards = [
["622 gate scripts", "the suite runs on every push — plus determinism, cache-transparency and golden contracts; the gate count itself is gated against the runner's own loop"], // gatecount
["623 gate scripts", "the suite runs on every push — plus determinism, cache-transparency and golden contracts; the gate count itself is gated against the runner's own loop"], // gatecount
["byte-identical, always", "two runs over the same tree produce the same bytes; warm equals cold. Enforced in CI, twice — Release AND a plain flavour, because NDEBUG once blinded a whole class of checks"],
["differential refactoring", "a refactor must prove it changed nothing observable: two binaries, hundreds of argv vectors, stdout + stderr + exit codes byte-identical"],
["held-out labels, authored blind", "eval labels were written by reading source before the ranker ever ran on them — so the eval is allowed to say the ranker is wrong. It has."],
Expand All @@ -1147,7 +1147,7 @@ function storyCards(s, { kick, head, stories, footText }){
title(s, "Claims you can trust, because we publish what failed", { size: 32 });

card(s, MX, 1.72, 3.86, 1.72);
stat(s, "622", "gate scripts named by test/regression.sh — and the COUNT itself is gated against the runner's own loop, so it cannot go stale quietly", // gatecount
stat(s, "623", "gate scripts named by test/regression.sh — and the COUNT itself is gated against the runner's own loop, so it cannot go stale quietly", // gatecount
MX+0.15, 1.86, 3.56, CYAN, { bsize: 42, bh: 0.66, lsize: 9.5 });
card(s, 4.68, 1.72, 3.86, 1.72, CARD2);
stat(s, "8", "registered NEGATIVES — changes built, gated green, measured against a band written before the code, and reverted rather than tuned",
Expand Down Expand Up @@ -1397,7 +1397,7 @@ function storyCards(s, { kick, head, stories, footText }){
["180 long flags · 34 slides", "bash test/deckclaimcheck.sh"],
["every --flag named here exists", "bash test/deckcheck.sh"],
["74.7% fewer element bytes", "bash test/showcasecapturecheck.sh"],
["622 gate scripts", "bash test/manifestcheck.sh"], // gatecount
["623 gate scripts", "bash test/manifestcheck.sh"], // gatecount
["49 repos · 71 papers · 237 surveyed","bash test/readmedriftcheck.sh"],
["the ten moments, any row", "ripwire . --callers=SYM | wc -c"],
["the head-to-head table", "bench/headtohead/r4-2026-08-06/"],
Expand Down
38 changes: 34 additions & 4 deletions src/graph.h
Original file line number Diff line number Diff line change
Expand Up @@ -2048,6 +2048,10 @@ inline Graph buildGraph( const IngestResult& ing, const ScipOverlay* scip = null
// for `class C(A, B)` Python's MRO puts A's chain before B's, so when both A and B define `m`, `super().m()`
// in C names A::m. chaUp is sorted for its membership uses and cannot say which base came first.
HashMap<std::string, std::vector<std::string>> chaUpDeclared;
// Ruby bases, SCOPED by Ruby's own constant lookup (resolve.h::RubyBaseScope; test/rubyinheritcheck.sh floor
// (c)). Read here — a base the tree never opens (`< ActiveRecord::Base`) adds no CHA edge, so its class's walk
// cannot reach an unrelated in-tree `Base` — and by the implementor pass below. Empty on a Ruby-free corpus.
const RubyBaseScope rubyBases = buildRubyBaseScope( ing );
{
const auto isClassLikeK = []( SymKind k ) noexcept
{ return k == SymKind::Class || k == SymKind::Struct || k == SymKind::Interface; };
Expand All @@ -2058,6 +2062,10 @@ inline Graph buildGraph( const IngestResult& ing, const ScipOverlay* scip = null
{
continue;
}
if( const std::string* scoped = rubyBases.resolvedBase( std::size_t( &ir - ing.references.data() ) ); scoped && scoped->empty() )
{
continue; // a Ruby base the tree never opens: nothing in-tree to walk to
}
std::string_view derivedName;
if( !ir.qualifier.empty() )
{ // Rust impl → derived type name in qualifier
Expand Down Expand Up @@ -3089,14 +3097,36 @@ inline Graph buildGraph( const IngestResult& ing, const ScipOverlay* scip = null
{
continue;
}
if( ing.symbols[ derived ].lang == Lang::Ruby )
{
derived = rubyBases.canonicalClass( ing, derived ); // a reopened class is ONE implementor, not one per open
}

const auto it = byName.find( r.calleeName );
if( it == byName.end() )
// A Ruby base resolved by Ruby's own lookup: the bases are the classes whose constant it IS, read by constant
// (RubyBaseScope::classesByFqn — byName's decl/def collapse would hide a body-less Ruby class). nullptr ⇒ not
// a scoped Ruby base (every other language, or a Ruby site the join could not place) ⇒ byName stands.
const rw::SmallVec<NodeId, 2>* baseIds = nullptr;
const std::string* rubyScoped = rubyBases.resolvedBase( std::size_t( &r - ing.references.data() ) );
if( rubyScoped != nullptr )
{
continue;
const auto sit = rubyScoped->empty() ? rubyBases.classesByFqn.end() : rubyBases.classesByFqn.find( *rubyScoped );
if( sit == rubyBases.classesByFqn.end() )
{
continue; // the tree never opens the written base — no in-tree row to list it under
}
baseIds = &sit->second;
}
else
{
const auto it = byName.find( r.calleeName );
if( it == byName.end() )
{
continue;
}
baseIds = &it->second;
}
baseCand.clear();
for( NodeId baseId : it->second )
for( NodeId baseId : *baseIds )
{
if( !isClassLike( ing.symbols[baseId].kind ) )
{
Expand Down
18 changes: 17 additions & 1 deletion src/ingest_cache.h
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,23 @@ 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 = 97; // bump on any grammar/.scm/extraction change
constexpr std::uint32_t kParserVer = 99; // bump on any grammar/.scm/extraction change
// 99 = 2026-09-23 (a computed Ruby superclass mints nothing,
// test/rubyinheritcheck.sh floor (a)): captureBases stops
// descending into a Ruby `superclass` clause's non-constant
// child, where it landed on the RECEIVER of
// `class Dynamic < Struct.new( :a )` and emitted an inherit
// ref to `Struct`. FEWER records, same layout (kCacheVersion
// stays 22): a Ruby cache written at 98 holds the stray ref.
// quality.h's kIngestParserVerMirror bumped in the SAME commit.
// 98 = 2026-09-17 (Ruby inheritance edges,
// test/rubyinheritcheck.sh): isBaseTypeNode gains a Ruby arm
// ((constant)/(scope_resolution)), so `class Child < Parent`
// emits an inherit RawRef (role Extends) where it emitted
// none. NEW RECORDS from extraction, same record layout
// (kCacheVersion stays 22): a Ruby cache written at 97 holds
// no inheritance refs at all and must be re-parsed.
// quality.h's kIngestParserVerMirror bumped in the SAME commit.
// 97 = 2026-09-17 (Ruby constant receivers narrow calls,
// test/rubyrecvnarrowcheck.sh): classifyReceiver classifies a
// Ruby (constant)/(scope_resolution) receiver as NamedVar with
Expand Down
Loading