From 20b93c613f4071f76a9446c34e2aecc73c10da16 Mon Sep 17 00:00:00 2001 From: forhappy Date: Mon, 17 Aug 2026 18:10:42 -0700 Subject: [PATCH 1/3] Hard-cut Ruby universal candidate --- CHANGELOG.md | 7 + MIGRATION.md | 10 + advisor-plans/019-ruby-universal-candidate.md | 884 ++++++++++ advisor-plans/README.md | 25 + benchmarks/performance/compass/audit.py | 6 +- benchmarks/performance/compass/occurrences.py | 241 +++ .../performance/tests/test_correctness.py | 19 + crates/compass-core/src/pipeline.rs | 49 +- crates/compass-files/src/build_guard.rs | 40 +- crates/compass-graph/src/snapshot.rs | 95 +- crates/compass-graph/tests/store_snapshot.rs | 5 + crates/compass-languages/src/adapters.rs | 28 + crates/compass-languages/src/engine.rs | 42 +- .../compass-languages/src/evidence/build.rs | 3 + crates/compass-languages/src/evidence/mod.rs | 2 + crates/compass-languages/src/evidence/ruby.rs | 1558 +++++++++++++++++ .../compass-languages/src/frameworks/mod.rs | 11 +- .../compass-languages/src/frameworks/pack.rs | 29 + .../compass-languages/src/frameworks/ruby.rs | 422 +++-- .../tests/engine_edge_coverage.rs | 3 +- crates/compass-languages/tests/registry.rs | 5 + .../tests/ruby_universal_conformance.rs | 348 ++++ .../tests/universal_evidence.rs | 1 + .../src/evidence/languages/mod.rs | 1 + .../src/evidence/languages/policy.rs | 9 +- .../src/evidence/languages/ruby.rs | 94 + crates/compass-resolve/src/evidence/mod.rs | 39 +- .../src/evidence/projection/mod.rs | 15 +- .../src/evidence/resolve/hierarchy.rs | 75 +- .../src/evidence/resolve/members.rs | 76 +- .../src/evidence/resolve/pipeline.rs | 95 + crates/compass-resolve/src/frameworks/mod.rs | 8 +- crates/compass-resolve/src/frameworks/ruby.rs | 12 + crates/compass-resolve/src/members.rs | 69 +- .../tests/php_ruby_jvm_routes.rs | 52 + .../tests/universal_resolution.rs | 1 + .../tests/universal_resolution/ruby.rs | 403 +++++ docs/design/language-architecture.md | 1 + docs/implementation/extraction-pipeline.md | 9 + .../ruby-universal-qualification.md | 165 ++ docs/implementation/universal-evidence.md | 14 +- docs/reference/universal-semantic-evidence.md | 15 +- fixtures/code-graph/qualification/rich.rb | 45 + scripts/build_ruby_quality_audit.py | 778 ++++++++ scripts/qualify_ruby_universal.py | 444 +++++ scripts/ruby_source_oracle.rb | 543 ++++++ scripts/tests/test_ruby_quality_audit.py | 71 + scripts/tests/test_ruby_source_oracle.py | 136 ++ .../qualification/code-graph-v1-semantic.json | 2 +- .../ruby-universal-baseline.json | 110 ++ .../ruby-universal-repositories.toml | 19 + 51 files changed, 6760 insertions(+), 374 deletions(-) create mode 100644 advisor-plans/019-ruby-universal-candidate.md create mode 100644 crates/compass-languages/src/evidence/ruby.rs create mode 100644 crates/compass-languages/tests/ruby_universal_conformance.rs create mode 100644 crates/compass-resolve/src/evidence/languages/ruby.rs create mode 100644 crates/compass-resolve/tests/universal_resolution/ruby.rs create mode 100644 docs/implementation/ruby-universal-qualification.md create mode 100644 fixtures/code-graph/qualification/rich.rb create mode 100644 scripts/build_ruby_quality_audit.py create mode 100755 scripts/qualify_ruby_universal.py create mode 100755 scripts/ruby_source_oracle.rb create mode 100644 scripts/tests/test_ruby_quality_audit.py create mode 100644 scripts/tests/test_ruby_source_oracle.py create mode 100644 tests/qualification/ruby-universal-baseline.json create mode 100644 tests/qualification/ruby-universal-repositories.toml diff --git a/CHANGELOG.md b/CHANGELOG.md index ebab6c76..0a270825 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ ## Unreleased +- Hard-cut Ruby extraction to the version-1 `compass.ruby.candidate` universal + evidence publisher and replace the Rails source detector with the + evidence-backed `rails-ruby` universal pack. Reopened constants coalesce by + exact graph identity, instance/singleton method spaces stay distinct, + dynamic dispatch/load/eval forms fail closed, and Ruby remains explicitly + `UniversalCandidate` pending the independent precision/recall audit. + - Preserve anonymous PHP functions and arrow functions as typed callable `closure` nodes, and publish exact PHP trait composition as `mixes_in` instead of collapsing it into `implements`. These additions eliminate diff --git a/MIGRATION.md b/MIGRATION.md index cad3d1f2..4076308a 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -5,6 +5,16 @@ sidecars. Its output root now preserves the familiar flat artifact shape so file-based workflows can transition while Compass's snapshot and store layout remains visible and clearly owned. +## Ruby universal-candidate rebuild + +The current release publishes Ruby through the version-1 universal evidence +candidate (`compass.ruby.candidate`) and the evidence-backed `rails-ruby` +framework pack. Ruby graph output is therefore regenerated on the first build +after upgrading; do not reuse a Ruby cache produced by an older Compass +publisher. Ruby is still a candidate rather than a complete-quality claim, +so retain review of ambiguous/dynamic Ruby relationships and do not treat +unresolved dynamic dispatch as a missing deterministic fact. + ## Install Compass Install the latest macOS release: diff --git a/advisor-plans/019-ruby-universal-candidate.md b/advisor-plans/019-ruby-universal-candidate.md new file mode 100644 index 00000000..bfd127b9 --- /dev/null +++ b/advisor-plans/019-ruby-universal-candidate.md @@ -0,0 +1,884 @@ +# Plan 019: Hard-cut Ruby to a qualified universal candidate + +> **Executor instructions**: Deliver this as a sequence of reviewable PRs. Read +> this plan completely, then read `AGENTS.md`, +> `docs/design/language-architecture.md`, +> `docs/implementation/universal-evidence.md`, +> `docs/implementation/evidence-resolution-framework-technical-design.md`, and +> `docs/reference/universal-semantic-evidence.md` before changing source. Run +> every verification gate and confirm its expected result before starting the +> next phase. Ruby must not have production dual-running: keep the established +> path active while the candidate is qualification-only, then switch the +> registry, Ruby publisher, resolver, and Rails source pack atomically. +> +> **Drift check (run before every phase)**: +> `git diff --stat b53c3ea2..HEAD -- crates/compass-files crates/compass-languages crates/compass-resolve crates/compass-model crates/compass-graph crates/compass-core fixtures/code-graph tests/qualification scripts benchmarks/performance docs PERFORMANCE.md COMPATIBILITY.md MIGRATION.md CHANGELOG.md advisor-plans` +> Reconcile any changed adapter versions, evidence fields, Ruby/Rails paths, +> qualification thresholds, or cache contracts before proceeding. If live code +> contradicts the “Current state” section, stop and update this plan first. + +## Status + +- **Priority**: P1 +- **Effort**: XL, delivered as eight independently reviewable phases +- **Risk**: HIGH +- **Depends on**: no implementation prerequisite; the final scheduled/release + gate should consume plan 005 or an equivalent exact-commit qualification gate +- **Category**: language architecture, correctness, resolution, framework, + performance, tests, documentation +- **Planned at**: commit `b53c3ea2`, 2026-08-16 + +### Execution status (2026-08-17) + +The implementation has completed the semantic hard cut through Phase 5. Ruby +is now published through `compass.ruby.candidate` and the `rails-ruby` +universal pack, with the old Ruby publisher/resolver removed from production. +Phase 0 source-oracle and pinned-commit evidence is complete. Phase 6 now has +a corrected file-only incremental publication path plus copy-on-write snapshot +staging: a five-sample unchanged update on a 305-file Rails subtree reuses all +305 files in a 0.1959–0.2045 second range (0.1963 second median), while the +fact-neutral edit extracts one file and restores byte-for-byte. Full Rails +qualification records a 2.9616 second unchanged warm median, a 165.412 second +fact-neutral edit, and exact restore hash equality. Ruby-only captures for +Rails, Discourse, and RuboCop are +recorded; full mixed-language Discourse/RuboCop roots remain outside the Ruby +qualification scope because their Markdown parser resource envelope is not +yet hardened. The delivered hardening includes an explicit 8 MiB pipeline +worker stack, constant-time `super` owner lookup, allocation-light reopened- +type hierarchy checks, deferred shared-store GC for small incremental +publications, exact fact-state admission (preventing stale cached-source +graphs), and copy-on-write snapshot staging with a portable copy fallback. +The fact-neutral publisher retains unchanged-file extraction status, +parser-recovery diagnostics, and coverage instead of reclassifying cached +files as extracted; the strict fixture restore gate passes byte-for-byte. +Phase 7 audit gates now pass: 89,981 accepted relationships, 100% observed +precision, 98.5567% source-oracle recall, zero ambiguity, and zero critical +violations. Ruby remains intentionally `UniversalCandidate`; promotion is a +separate decision and has not been made here. + +## Why this matters + +Ruby is recognized and parsed, but it still uses Compass's generic publisher +and a separate Ruby member resolver. That path captures classes, modules, +methods, simple calls, and one superclass, but it cannot represent Ruby's +constant nesting, reopened types, instance-versus-singleton method spaces, +ordered mixins, literal metaprogramming, or conservative receiver dispatch with +the evidence and ambiguity guarantees used by newer language adapters. + +The practical result is visible on Rails-scale code: the July qualification +published 59,504 nodes and 96,958 edges but omitted 1,457 edges, with invalid +class-to-module inheritance/mixin endpoints called out as a recurring defect. +Ruby should move to the shared universal evidence and resolution path so exact +anchors, limits, ambiguity, provenance, incremental caching, and framework +facts are enforced consistently. + +The intended endpoint of this plan is a hard-cut version-1 Ruby +`UniversalCandidate`, not `UniversalComplete`. Candidate promotion remains +blocked on the independent 2,000-relationship quality audit and all precision, +recall, and zero-tolerance gates in +`docs/reference/universal-semantic-evidence.md`. + +## Current state + +- `crates/compass-languages/src/registry.rs:326-332` recognizes `.rb` and + `.rake` as the generic Ruby language. Shebang recognition is also present. +- `crates/compass-languages/src/config.rs:83-93` configures only `class`, + `module`, `method`, `singleton_method`, and generic `call` syntax. It does not + encode Ruby identity or scope semantics. +- `crates/compass-languages/src/engine.rs:1665-1670` contains a Ruby-only + superclass branch inside the generic walker. +- `crates/compass-languages/src/engine.rs:2180-2204` turns unresolved Ruby calls + into `RawCall` values; receiver type is deliberately absent. +- `crates/compass-languages/src/engine.rs:2341-2356` parses Ruby calls through + only the `method` and optional `receiver` fields. +- `crates/compass-languages/src/engine.rs:3216-3231` extracts only the first + superclass constant and immediately creates a raw inheritance edge. +- `crates/compass-resolve/src/members.rs:107` always invokes the established + Ruby resolver when raw calls are present. +- `crates/compass-resolve/src/members.rs:611-676` indexes bare constant labels + globally, resolves only unique terminal names, treats mixin calls specially, + and otherwise resolves capitalized receivers or a missing `receiver_type`. + It has no lexical constant path, require/load evidence, reopen handling, or + separate singleton/instance member space. +- `crates/compass-languages/src/adapters.rs:270+` has no Ruby adapter profile. + The closest dedicated emitter is `evidence/php.rs`; the closest small + candidate and hard-cut tests are the Kotlin emitter/conformance/resolver + suites. +- `crates/compass-resolve/src/evidence/languages/policy.rs:9-30` has no Ruby + policy variant. Unknown languages use only generic resolution. +- `crates/compass-languages/src/frameworks/mod.rs:292` registers Rails as the + established `rails-routes` source pack. +- `crates/compass-languages/src/frameworks/ruby.rs:11-127` receives a syntax + tree but scans source lines and regular expressions inside + `Rails.application.routes.draw`; it does not consume universal Ruby evidence. +- `crates/compass-resolve/src/frameworks/mod.rs:79-105` has no universal Rails + expansion adapter. +- `crates/compass-languages/src/project_evidence.rs:789` records Gemfile + dependencies, but project evidence has no Ruby load-root, require, autoload, + or Zeitwerk contract. +- `tests/qualification/code-graph-v1-corpus.json` checks only that a trivial + Ruby file is classified. Rails flow fixtures exist, but there is no Ruby + universal conformance fixture, independent source oracle, quality-audit + manifest, or language-specific performance manifest. +- `docs/superpowers/reviews/2026-07-30-best-effort-heavy-framework-qualification.md` + records the older Rails baseline: 4,973 tracked files, 44.64 seconds cold, + 59,504 nodes, 96,958 edges, one omitted node, 1,457 omitted edges, and zero + identity collisions. This is historical evidence only; Phase 0 must recapture + the baseline at the planned commit. + +### Post-implementation state + +The pre-change inventory above is retained as the rationale for the cut. The +delivered tree now has a bounded `evidence/ruby.rs` emitter, a Ruby adapter +profile (`compass.ruby.candidate`, version 1), Ruby method-space-aware +resolution, exact contained `require_relative` decisions, and a single +`rails-ruby` universal framework pack. Ruby extraction has no production +`RawCall` publisher, and the replaced Ruby member resolver and Rails line/regex +route publisher are no longer active. The pipeline worker stack is explicitly +bounded at 8 MiB so deep valid Ruby DSL/test trees produce bounded partial +evidence instead of aborting the build. The independent Ripper oracle and +qualification harness remain separate from production and Graphify. + +## Required semantic decisions + +Freeze these decisions in tests and documentation before writing the complete +emitter. Do not let implementation convenience decide them implicitly. + +1. **Constants and lexical nesting**: canonical type/constant names use Ruby's + `A::B` spelling. `module A; class B` and `class A::B` retain different + lexical lookup scopes even when they name the same runtime constant. +2. **Ruby modules**: publish Ruby `module` declarations as graph `trait` nodes. + A Ruby module is both a constant namespace and a composable method owner; + `trait` is the existing v1 type/container kind that can legally participate + in `mixes_in`. Preserve language=`ruby` and the Ruby source spelling so + consumers can distinguish it from traits in other languages. Do not publish + a second duplicate `module` node for the same Ruby constant. +3. **Reopening**: every source declaration keeps its own evidence identity and + anchor, while all reopenings of one fully qualified class/module share one + graph node identity. Members from reopenings join the same owner only after + exact constant resolution. Competing method definitions remain ambiguous + unless source/load evidence establishes one definition; filesystem or batch + order is never a tiebreaker. +4. **Method spaces**: instance and singleton methods are distinct. Use one + documented codec throughout extraction, resolution, queries, and Rails + handlers (recommended: `Owner#method` for instance methods and + `Owner.method` for singleton methods). Never collapse `def call` and + `def self.call` onto one declaration ID. +5. **Top-level methods**: give top-level methods a source-scoped identity. + Cross-file binding requires explicit, contained require/load evidence; do + not treat every top-level method in the repository as one global overload + set. +6. **Mixins**: `include`, `prepend`, and `extend` emit exact `UsesTrait` + occurrences with context identifying the operation. All publish + `mixes_in`; dispatch may use them only where the Ruby policy proves a unique + target. `extend` affects the receiver's singleton method space; it must not + inject instance methods. +7. **Dynamic behavior**: `send`, `public_send`, `method_missing`, runtime + `class_eval`/`module_eval`, nonliteral `define_method`, dynamic constants, + and runtime load-path mutation never create convenient exact edges. Literal + forms may emit bounded occurrences or declarations only when the source + construct and owner are exact. +8. **Interop**: JRuby or native extensions do not authorize Ruby-to-Java/C + terminal-name matching. Cross-language calls require exact fresh compiler + or project evidence at both anchored endpoints; otherwise they remain + external/unresolved. + +## Target version-1 capability claim + +Register only capabilities actually covered by positive, negative, ambiguity, +limit, and corpus evidence. The initial target set is: + +- declarations and lexical scopes; +- namespaces/constant ownership; +- traits (`module`, `include`, `prepend`, `extend`); +- imports and aliases (`require_relative`, contained literal `require`, + `autoload`, `alias`, and `alias_method` where exact); +- calls and construction; +- base types and conservative hierarchy dispatch; +- members, ownership, receivers, and qualified external references. + +Do not advertise decorators, static type references, reexports, tests, macros, +or complete hierarchy dispatch merely because a fixture contains a related +syntax form. Literal `attr_reader`/`attr_writer`/`attr_accessor` and +`define_method(:literal)` can land as declarations during the candidate phase, +but `Macros` becomes an advertised capability only after its own audit stratum +passes. + +## Pinned corpora and independent truth + +Use at least three materially different Ruby corpora so one Rails convention +or target cluster cannot dominate the audit: + +| Corpus | Repository | Commit to pin | Purpose | +| --- | --- | --- | --- | +| Rails | existing read-only `/Volumes/Workspace/Github/rails/rails` | `cc7d47f4419ba983fc9d06bffece57778fa671c5` | framework internals, concerns, reopenings, DSL-heavy code | +| Discourse | `https://github.com/discourse/discourse.git` | `699ad46536f619396e73720c7652dbfc7a1f86c0` | large Rails application, controllers/models/jobs/plugins | +| RuboCop | `https://github.com/rubocop/rubocop.git` | `c034d8b6804788856321d78c480f9f007bd85a8d` | non-Rails gem, nested modules, visitors, aliases, tests | + +Clone missing corpora only below +`/Volumes/Workspace/Github//`, treat them as read-only, and +record a relative-path/content inventory digest. If an existing checkout is at +a different revision, create a separate named worktree/check-out location on +the mounted volume; do not reset or clean it. + +The independent source oracle must use a pinned Ruby standard-library parser +(`Ripper`) in qualification only. It must record exact `RUBY_VERSION` and +`RUBY_REVISION`, translate line/column positions to UTF-8 byte ranges, reject +partially parsed files for recall accounting, bound files/bytes/constructs/ +depth/output, and emit a canonical inventory digest. If Ripper cannot provide +exact nonempty ranges for a required relationship family, stop and propose a +pinned Prism-based oracle; do not reuse Tree-sitter as its own oracle and do not +lower the recall gate. + +Graphify-only facts, if sampled, belong only in the +`graphify_hypothesis` pool defined by `compass.quality-audit`. They are not +truth and must never become runtime, fixture, fallback, or CI dependencies. + +## Commands executors will need + +Every Cargo invocation must use a unique mounted target directory for the +implementation checkout. + +For this checkout, prefix each Cargo command with the mounted/offline +qualification environment (the examples below abbreviate it in prose): + +```bash +PROJECT_ROOT=/Volumes/Workspace/Github/compass-ruby-parser-root \ +TSLP_OFFLINE=1 \ +CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-ruby-universal +``` + +Do not fall back to a local `target/` directory when the mounted volume is not +available. + +| Purpose | Command | Expected on success | +| --- | --- | --- | +| Target preflight | `test -d /Volumes/Workspace && mkdir -p /Volumes/Workspace/crabbuild-target/compass-ruby-universal && test -w /Volumes/Workspace/crabbuild-target/compass-ruby-universal` | exit 0 | +| Language conformance | `CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-ruby-universal cargo test -p compass-languages --test ruby_universal_conformance --locked` | all Ruby cases pass | +| Language crate | `CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-ruby-universal cargo test -p compass-languages --locked` | exit 0 | +| Resolver contract | `CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-ruby-universal cargo test -p compass-resolve --test universal_resolution ruby --locked` | all Ruby cases pass | +| Rails pack | `CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-ruby-universal cargo test -p compass-resolve --test php_ruby_jvm_routes rails --locked` | Rails route cases pass until replaced by a dedicated universal-pack test | +| Publication | `CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-ruby-universal cargo test -p compass-core --test code_graph_v1_publication_resilience ruby --locked` | valid v1 graph, zero Ruby omissions/collisions | +| Fixture gate | `CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-ruby-universal ./scripts/qualify_code_graph_v1.sh --fixtures-only` | exit 0; all byte comparisons true | +| Product boundary | `sh scripts/check_product_boundary.sh` | exit 0; no Graphify/runtime Ruby dependency | +| Focused lint | `CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-ruby-universal cargo clippy -p compass-languages -p compass-resolve -p compass-model -p compass-graph -p compass-core --all-targets --all-features --locked -- -D warnings` | exit 0 | +| Native baseline | `CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-ruby-universal cargo clippy --workspace --lib --bins --locked -- -D warnings && CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-ruby-universal cargo test --workspace --lib --bins --locked` | exit 0 | +| Format | `cargo fmt --all -- --check && git diff --check` | exit 0, no diff errors | + +Add one Ruby qualification entry point with checked-in `--help` and a stable +machine-readable summary; name the exact command in the implementation PR once +created. It must support fixture-only, pinned-corpus, quality-audit, and +performance modes without silently changing the production graph. + +## Scope + +**In scope**: + +- dedicated Ruby universal evidence extraction; +- Ruby constant, scope, reopen, method-space, require, alias, mixin, hierarchy, + receiver, construction, call, and literal metaprogramming semantics; +- a closed Ruby resolver policy only where shared evidence rules are + insufficient; +- conversion of Rails routes to one universal `rails-ruby` framework pack; +- Ruby cache invalidation, publication, query/impact compatibility, fixtures, + independent oracle, quality audit, real-repository qualification, docs, and + performance measurement; +- removal of only Ruby's replaced generic-publisher branches, raw-call path, + `resolve_ruby_members`, and established Rails detector registration during + the atomic cutover. + +**Out of scope**: + +- executing Ruby, Bundler, Rake, Rails, application initializers, gems, or + arbitrary project code during normal Compass extraction; +- scanning installed gems, `.bundle`, `vendor/bundle`, or network registries; +- inferring runtime load order from directory order; +- resolving `method_missing`, dynamic `send`, computed constants, nonliteral + requires/autoloads, runtime eval, or monkey patches without exact evidence; +- treating Gemfile gem names as require paths or local declarations; +- broadening `compass.graph/1` endpoint validation to admit false Ruby edges; +- adding a Ruby runtime dependency to the released Compass binary; +- promoting Ruby to `UniversalComplete` before the complete audit gates pass. + +## Git and delivery workflow + +- Use one branch/PR per phase or per tightly coupled phase pair. Suggested + branches: `agent/ruby-universal-phase-0`, then `phase-1`, and so on. +- Follow the repository's terse imperative commit style, for example + `Add Ruby universal evidence emitter`. +- Keep corpus checkouts and generated graphs outside the repository and out of + commits. +- Do not push or open a PR unless the operator explicitly requests it. +- At the end of each phase, record exact commands, graph hashes, corpus commits, + inventory digests, relation-family counts, diagnostics, and timings in a + phase-specific qualification report under `docs/implementation/`. + +## Phase map + +| Phase | Outcome | Production Ruby route changes? | +| --- | --- | --- | +| 0 | Frozen established baseline and independent oracle | No | +| 1 | Ruby identity and graph representation contract | No | +| 2 | Qualification-only bounded evidence emitter | No | +| 3 | Qualification-only project and resolver semantics | No | +| 4 | Universal Rails pack ready behind candidate evidence | No | +| 5 | Atomic hard cut to Ruby `UniversalCandidate` | Yes, once | +| 6 | Measured cold/warm/incremental optimization | Candidate remains active | +| 7 | Complete quality audit and release qualification | Candidate; promotion is separate | + +## Phase 0: Freeze truth, established behavior, and performance + +**Context**: Ruby's current graph is incomplete but it is the compatibility +baseline. Changes cannot be judged from total node/edge growth alone. This +phase creates reproducible evidence before any production Ruby behavior moves. + +**Primary files to add or update**: + +- `scripts/ruby_source_oracle.rb` (qualification-only independent oracle); +- `scripts/qualify_ruby_universal.py` or an equivalent bounded orchestrator; +- `scripts/tests/test_ruby_source_oracle.py`; +- `tests/qualification/ruby-universal-repositories.toml`; +- `tests/qualification/ruby-universal-baseline.json`; +- `docs/implementation/ruby-universal-qualification.md`; +- `benchmarks/performance/repositories.toml` only if the shared runner can + express all Ruby workloads without weakening existing entries. + +**Steps**: + +1. Pin the three corpora above, verify clean revisions, inventory admitted Ruby + files (`.rb`, `.rake`, shebang Ruby, and fixed Ruby filenames already owned + by discovery), and record content digests. +2. Build the established Compass binary once from `b53c3ea2`; compilation time + is excluded. Run forced cold, unchanged warm, one-file semantic edit, + fact-neutral trailing-comment edit, rename, delete, and restore workloads. +3. Capture canonical graph hash, file/node/edge counts, relation-family counts, + node-kind counts, Ruby diagnostics, omissions, collisions, parse recovery, + and stage timings. Run each timed warm/incremental case at least five times; + retain medians and dispersion. Measure RSS but keep it non-blocking. +4. Implement the independent Ripper oracle for declaration ownership, + instance/singleton methods, inheritance, include/prepend/extend, literal + require/autoload/aliases, construction, and statically named calls. Run it + twice and require byte-identical canonical output. +5. Create a small curated baseline of established correct, missing, ambiguous, + and incorrect graph facts. Do not translate missing established facts into + candidate requirements unless the source oracle proves them. + +**Acceptance criteria**: + +- all corpus revisions and inventory digests are explicit and reproducible; +- clean, warm, and restored established graphs are byte-identical per corpus; +- every baseline record names a source file, exact UTF-8 range, relation family, + graph fact or explicit absence, and judgment; +- the oracle fails closed on malformed/partial input and produces identical + bytes on two runs; +- build time is excluded from Compass timings and corpus trees remain clean; +- no product source, adapter registration, or production Ruby graph changes in + this phase. + +**Verify**: the new oracle unit suite and baseline command both exit 0; rerun +`git -C status --porcelain=v1 --untracked-files=all` and expect empty +output for every corpus. + +## Phase 1: Freeze Ruby identity and representation contracts + +**Context**: Reopened constants and separate method spaces make identity the +highest-risk design decision. Lock it before implementing broad traversal. + +**Primary files to update**: + +- `docs/reference/universal-semantic-evidence.md`; +- `docs/design/language-architecture.md` only to describe the planned Ruby + contract, not shipped status; +- `crates/compass-languages/src/evidence/model.rs` and validation only if the + existing optional fields cannot encode method/mixin context; +- focused evidence/projection contract tests. + +**Steps**: + +1. Specify canonical constant, class, trait/module, instance method, singleton + method, top-level method, closure, parameter, instance variable, class + variable, and constant identities with Unicode examples. +2. Prove that two reopenings of `A::B` coalesce to one graph node while + retaining both definition anchors and deterministic member ownership. +3. Prove `A#call` and `A.call` remain distinct through evidence validation, + resolution, v1 normalization, query indexing, and history serialization. +4. Map Ruby modules to graph trait nodes and validate legal `class -mixes_in-> + trait` and `trait -mixes_in-> trait` endpoints. Do not weaken endpoint rules. +5. Decide whether existing occurrence `context` can encode + `include|prepend|extend` and method space. Add an optional typed field only + if string context would be ambiguous or unauditable; if added, keep the + universal evidence schema at v1 only when backward-compatible and update all + validators/fingerprints/tests. + +**Acceptance criteria**: + +- an identity table with examples is checked into the semantic-evidence + reference; +- identity is invariant to checkout path, file enumeration, extraction worker + count, and batch order; +- reopened constants do not collide or duplicate graph nodes; +- duplicate method definitions remain multiple evidence declarations and do + not silently select one target; +- instance/singleton methods cannot resolve across method spaces; +- no new public graph kind or relaxed endpoint pair is required. If that + assumption fails, stop for a separate compatibility design review. + +**Verify**: focused model/evidence/projection tests pass and serialize the same +canonical bytes in forward and reversed input order. + +## Phase 2: Build the qualification-only Ruby evidence emitter + +**Context**: The emitter must be exercised without registering Ruby in +`UNIVERSAL_ADAPTERS`. Production continues through the established generic +path during this phase. + +**Primary files to add or update**: + +- create `crates/compass-languages/src/evidence/ruby.rs`; +- update `crates/compass-languages/src/evidence/mod.rs`; +- extend the hidden qualification API in + `crates/compass-languages/src/engine.rs` without selecting it in normal + extraction; +- create `crates/compass-languages/tests/ruby_universal_conformance.rs`; +- create `fixtures/code-graph/qualification/rich.rb`. + +**Steps**: + +1. Consume the already prepared Tree-sitter root and borrowed bytes exactly + once. Use bounded passes for declarations/scopes, literal bindings, and + semantic occurrences; cap traversal depth and every fact family through + `EvidenceBuilder`/`EvidenceLimits`. +2. Emit file, class, Ruby-module-as-trait, instance method, singleton method, + constructor (`initialize` remains a method but `Class.new` is construction), + closure/block/lambda, parameter, constant, instance-variable/property, and + supported literal metaprogramming declarations with exact identifier + anchors. +3. Preserve lexical scopes for nested `class`/`module`, `class A::B`, methods, + singleton classes (`class << self`), blocks, lambdas, rescue clauses, and + pattern bindings where they affect name lookup. +4. Emit inheritance, `include`, `prepend`, and `extend` occurrences with + qualified constant syntax and operation context. Mark direct-base + completeness truthfully; external or dynamic ancestors make it incomplete. +5. Emit literal `require_relative`, contained literal `require`, `autoload`, + `alias`, `alias_method`, `attr_reader`, `attr_writer`, `attr_accessor`, and + literal `define_method` facts. Dynamic arguments produce bounded diagnostics + or unresolved occurrences, never declarations with invented names. +6. Emit calls/construction with source caller, receiver spelling, method space, + argument count, literal argument types where provable, and allowed target + kinds. Recognize constant receivers, `self`, `super`, local variables with a + single source-proven construction assignment, and parameters only when + source evidence gives a nominal receiver. Unknown receivers stay unresolved. +7. On parser recovery, retain only facts whose nodes/ranges are trustworthy, + add `partial_parser_recovery`, and never emit zero-width non-file anchors. + +**Acceptance criteria**: + +- the hidden Ruby candidate API returns valid version-1 evidence, but ordinary + `Engine::extract_source_graph_only` still returns the established Ruby graph; +- fixtures cover nested/reopened constants, instance and singleton methods, + all parameter forms, Unicode, blocks/lambdas, superclass, every mixin form, + aliases, literal requires/autoload, construction, `self`/`super`, and exact + literal metaprogramming; +- negative fixtures cover dynamic require/send/define_method/eval, ambiguous + receiver assignments, duplicate method definitions, invalid constants, + parser recovery, nesting depth, and every evidence budget; +- every non-file fact has a nonempty exact UTF-8 range contained by its source; +- two runs and reversed traversal fixtures produce byte-identical evidence; +- no raw nodes, raw edges, or `RawCall` values are emitted by the candidate; +- production Ruby output and cache fingerprints remain unchanged. + +**Verify**: `ruby_universal_conformance` passes, `validate_evidence` accepts all +positive batches, all negative cases fail or diagnose with the expected typed +code, and a production Ruby snapshot matches the Phase-0 established hash. + +## Phase 3: Add bounded Ruby project and resolution semantics + +**Context**: Project-wide target selection belongs in `compass-resolve`. +Generic terminal-name lookup is unsafe for Ruby because constants reopen, +method spaces differ, load order is dynamic, and mixins alter dispatch. + +**Primary files to add or update**: + +- create `crates/compass-resolve/src/evidence/languages/ruby.rs`; +- update `crates/compass-resolve/src/evidence/languages/mod.rs` and + `policy.rs`; +- add Ruby-only indexes in `evidence/index/` only where shared indexes cannot + represent method space, reopen groups, or mixin operation; +- extend `ProjectEvidence` only for bounded contained Ruby load roots if direct + source evidence proves they are needed; +- create `crates/compass-resolve/tests/universal_resolution/ruby.rs`. + +**Steps**: + +1. Resolve constants by exact lexical nesting, explicit absolute `::`, and + exact contained require/autoload bindings before considering any broader + inventory. A terminal constant match is never sufficient when multiple + qualified constants exist. +2. Resolve `require_relative` by normalized contained path with Ruby extension + and index fallbacks. Resolve literal `require` only when the admitted project + inventory yields one contained source target under an explicit bounded load + root. Gemfile dependencies remain qualified external evidence. +3. Merge reopened class/module member inventories by exact graph identity. + Preserve duplicate definitions and lookup completeness; one complete + compatible candidate may resolve, truncation or duplicates may not. +4. Add method-space-aware lookup for constant receivers, constructed locals, + `self`, bare calls, and `super`. Use source-proven superclass/mixin links and + bounded cycle detection. For competing include/prepend/reopen order across + files with no proven runtime load order, return ambiguous rather than + simulating Ruby's runtime. +5. Treat literal method aliases as owner- and method-space-scoped bindings. + Detect alias cycles and enforce the shared lookup budget. +6. Preserve exact external targets only when the source spelling is qualified. + Reject cross-language declarations even when terminal names and project + paths match. +7. Profile validation, Ruby index construction, candidate ordering, decisions, + and projection separately. Build Ruby indexes only when Ruby evidence is + present. + +**Acceptance criteria**: + +- positive tests cover lexical/absolute constants, contained require paths, + reopen member union, aliases, singleton and instance dispatch, superclass, + include/prepend/extend, construction, bare calls, and `super`; +- every positive family also has missing, duplicate, ambiguous, cyclic, + truncated, wrong-method-space, wrong-language, and malicious-path cases; +- reversing files/batches produces identical nodes, edges, decisions, rules, + candidate counts, and ordering; +- no terminal-only Ruby call, class, mixin, or require target becomes exact; +- an incomplete hierarchy or candidate bucket cannot appear unique; +- non-Ruby resolver tests and performance stay unchanged within measurement + noise; +- the candidate is still qualification-only and production Ruby output still + matches Phase 0. + +**Verify**: the Ruby resolver module and complete `compass-resolve` test suite +pass; direct decision tests assert exact `ResolutionDecision`, rule, candidate +count, language, and occurrence anchor. + +## Phase 4: Convert Rails routing to a universal framework pack + +**Context**: A hard-cut universal language cannot re-enter an established +line/regex detector. Rails must consume Ruby evidence through the same static +framework-pack contract as Spring, ASP.NET, and PHP frameworks. + +**Primary files to update**: + +- `crates/compass-languages/src/frameworks/pack.rs`; +- `crates/compass-languages/src/frameworks/mod.rs`; +- rewrite `crates/compass-languages/src/frameworks/ruby.rs` to consume exact + Ruby evidence and AST anchors; +- `crates/compass-resolve/src/frameworks/mod.rs` and `ruby.rs`; +- create `crates/compass-resolve/tests/rails_universal_pack.rs`; +- extend Rails fixtures and framework-route docs. + +**Steps**: + +1. Add one `rails-ruby` `FrameworkPackDescriptor` with explicit Ruby input + capabilities, Rails activation evidence, route relationship claims, and + pack limits. Add exactly one project-wide expansion adapter with the same + ID. +2. Recognize `Rails.application.routes.draw` and route DSL calls from the AST + and Ruby occurrences. Preserve nested `namespace`, `scope`, literal path, + `to:`, hash-rocket, `via`, controller/action, and exact block anchors. +3. Add bounded source-proven composition for `draw`, route concerns, and + mounted engines only where literal references and contained files make the + target unique. Otherwise preserve unresolved/ambiguous route facts. +4. Resolve handlers to exact Ruby instance methods using the Phase-3 identity + codec and exact controller constant. Never map a route to a singleton method + or same-named controller in another namespace. +5. Preserve established positive routes and add wrong-framework, lookalike DSL, + dynamic path/handler, ambiguous controller, missing action, nested namespace, + malformed syntax, ordering, and fact-limit tests. + +**Acceptance criteria**: + +- framework descriptor and expansion registries match exactly; +- every published route/`routes_to` edge retains exact route and handler + evidence, direction, operation/path, framework, and resolution state; +- current Rails symbol, hash-rocket, and namespace fixtures remain exact; +- dynamic or ambiguous handler/path/controller forms publish no invented exact + edge; +- the universal pack runs only on validated Ruby candidate evidence and does + not rescan source lines with regex as its semantic authority; +- the established `rails-routes` pack remains production-active until Phase 5, + but qualification invokes only the universal pack—never both in one graph. + +**Verify**: `rails_universal_pack` and generic framework registry tests pass; +fixture qualification produces the same route facts under clean, warm, +rebuild, incremental-restore, and relocated-checkout runs. + +## Phase 5: Atomically hard-cut production Ruby + +**Context**: This is the only phase that changes the production route. It must +land as one coherent commit/PR after Phases 0–4 pass against fixtures and all +three corpora. + +**Primary files to update**: + +- `crates/compass-languages/src/adapters.rs`; +- `crates/compass-languages/src/evidence/mod.rs` and `engine.rs`; +- `crates/compass-resolve/src/members.rs`; +- framework pack/expansion registries; +- cache/manifest tests in `compass-core` and `compass-files` as needed; +- graph qualification manifests/oracle, docs, compatibility, migration, and + changelog. + +**Steps**: + +1. Add sorted `RUBY_CAPABILITIES` and adapter profile + `id="compass.ruby.candidate"`, `language="ruby"`, `version=1`, + `profile=UniversalCandidate`. +2. Route normal `.rb`, `.rake`, fixed filenames, and Ruby shebang extraction to + the dedicated emitter. The same registered emitter must back the hidden + qualification API. +3. Remove only Ruby's branches from the generic walker and raw-call collection; + remove `resolve_ruby_members` and its invocation. Do not disturb other + established languages that share `engine.rs` or `members.rs`. +4. Replace the established `rails-routes` registration with `rails-ruby` and + enable its matching expansion adapter in the same commit. +5. Invalidate Ruby cache entries through adapter identity/version. Cached Ruby + entries containing replaced raw nodes/edges/calls must fail compatibility + and reextract; non-Ruby caches remain reusable. +6. Expand strict fixture vocabulary/producer assertions for Ruby declarations, + trait modules, calls, construction, inheritance, mixins, imports, aliases, + and Rails routes. Require zero Ruby publication omissions and identity + collisions. +7. Update current-state docs to say hard-cut Ruby `UniversalCandidate`, and + explicitly state that complete audit gates remain pending. + +**Acceptance criteria**: + +- one production Ruby file has exactly one semantic publisher and one Rails + pack; no dual facts or translation fallback exist; +- `rg "resolve_ruby_members|add_ruby_parent_edge|rails-routes"` returns no + active production implementation/registration matches (historical docs may + remain clearly historical); +- Ruby extraction contains semantic evidence and no replaced raw calls or raw + semantic relations; +- all three pinned corpora publish strict-valid `compass.graph/1` with zero + Ruby omissions, zero Ruby identity collisions, and no unsupported + class-to-module inheritance/mixin endpoints; +- clean, forced rebuild, warm, restored incremental, and relocated-checkout + graphs are byte-identical per corpus; +- non-Ruby fixture hashes change only where shared qualification metadata + intentionally includes the expanded vocabulary; +- unknown-major/version validation, cache rejection, and atomic publication + tests pass; +- Ruby remains `UniversalCandidate` in code and documentation. + +**Verify**: run every command in “Commands executors will need,” then run the +three pinned-corpus qualification mode. Every command exits 0 and each corpus +summary reports schema v1, Ruby adapter v1, zero validation errors, zero Ruby +publication omissions/collisions, and deterministic comparisons all true. + +## Phase 6: Optimize cold, warm, and incremental Ruby builds + +**Context**: Optimization begins only after semantic parity and hard-cut +correctness are frozen. One optimization per commit; compare canonical graph +bytes before and after each change. + +**Steps**: + +1. Profile parse, evidence passes, project inventory, Ruby index construction, + candidate decisions, projection, persistence, and warm manifest checks on + all corpora. Rank hotspots by wall time and allocation count; do not optimize + from intuition. +2. Apply only evidence-neutral changes, such as sharing one AST classification + pass, reserving from validated counts, interning repeated constant/method + owner keys, storing compact declaration slots, omitting Ruby indexes from + non-Ruby corpora, or memoizing bounded require/ancestor walks after measuring + repeated work. +3. Verify fact-neutral edits reuse all unchanged Ruby extraction. Verify one + semantic edit extracts only the changed file and incrementally updates only + affected graph partitions/objects. Rename/delete/restore must remove stale + declarations and reproduce the original graph bytes on restore. +4. Run at least one cold and five warm/incremental samples per corpus for each + proposed optimization. Keep raw samples and medians in the qualification + report. RSS is recorded but is not a blocking comparison metric. + +**Acceptance criteria**: + +- optimized and pre-optimization canonical nodes, edges, diagnostics, + resolution decisions, and graph hashes are identical; +- median cold, warm, and semantic one-file incremental wall time do not regress + by more than 3% on any corpus unless an explicit reviewed correctness + tradeoff is recorded; +- an individual change is called an optimization only when its target phase + improves median time by at least 10%; otherwise revert it or document it as a + neutral refactor; +- unchanged warm runs report zero extracted files and reuse every eligible Ruby + cache entry; +- fact-neutral one-file edits complete in the repository's incremental path and + restored output is byte-identical; +- non-Ruby representative corpora regress by no more than 3%; Ruby-only indexes + allocate no state when no Ruby evidence is present; +- all limits, ambiguity, ordering, and fixture gates remain green. + +**Verify**: the Ruby performance mode emits a versioned JSON report containing +raw samples, medians, corpus/graph digests, adapter version, changed/reused file +counts, and stage timings; its regression evaluator exits 0. + +## Phase 7: Complete the quality audit and release qualification + +**Context**: A successful hard cut makes Ruby a candidate. It does not prove +complete quality. This phase applies the repository's independent audit gates +without weakening them for Ruby's dynamic semantics. + +**Steps**: + +1. Build `accepted`, `source_oracle`, and optional `graphify_hypothesis` pools + using the pinned corpora and Ripper inventory. Verify every snippet hash, + byte range, graph fact, provider identity, source inventory, corpus revision, + and graph digest before scoring. +2. Stratify declarations/ownership, calls, construction, inheritance, mixins, + imports/requires, aliases, member dispatch, and each advertised framework + capability. Dynamic/unresolved facts count honestly toward recall where the + source oracle proves a supported construct. +3. Include critical judgments for fabricated occurrences, unsafe local target + substitution, cross-language matches, instance/singleton confusion, wrong + reopen owner, wrong mixin method space, and path escape. +4. Add scheduled exact-commit qualification and a release gate only after the + corpus process is reproducible on supported CI infrastructure. Generated + graphs and private data remain outside the repository. +5. Promote to `UniversalComplete` only in a separate reviewed change after + every threshold passes. Otherwise retain `UniversalCandidate` and publish + the failing strata as actionable follow-up work. + +**Acceptance criteria**: + +- at least 2,000 audited accepted relationships total; +- at least 400 accepted records per corpus; +- at least 100 accepted records per required relationship family and per + advertised capability identity; +- no target cluster exceeds 10% of a corpus/language/relation/capability + stratum; +- observed precision is at least 99.5% overall and the two-sided 95% Wilson + lower bound is at least 99%; +- every advertised capability has at least 99% observed precision and 95% + source-oracle recall; +- zero fabricated occurrences, cross-language matches, unsafe local-target + substitutions, method-space crossings, or repository path escapes; +- all three corpora remain strict-valid and deterministic under cold, warm, + forced, incremental restore, worker-count, input-order, and relocated-path + permutations; +- performance gates from Phase 6 pass; +- a failing gate leaves Ruby explicitly `UniversalCandidate` and fails the + completion/release claim rather than changing thresholds. + +**Verify**: the checked-in audit validator exits 0 only when all thresholds +above are met and emits a stable `compass.quality-audit` qualification summary. + +## Cross-phase test plan + +Use `php_universal_conformance.rs`, `kotlin_universal_conformance.rs`, +`universal_resolution/php.rs`, `universal_resolution/kotlin.rs`, and +`spring_universal_pack.rs` as structural patterns. Add at least these groups: + +- **Identity**: nested and qualified constants, reopenings across files, + duplicate definitions, instance/singleton methods, top-level methods, + Unicode, checkout relocation. +- **Syntax**: class/module/singleton class, methods, every parameter shape, + blocks/lambda/proc, constants and variables, aliases, literal attr/define + method, inheritance, include/prepend/extend, require/autoload. +- **Resolution**: lexical/absolute constants, exact require paths, constructed + locals, self, bare calls, super, aliases, reopen member union, mixin dispatch, + external targets. +- **Negative/ambiguity**: terminal collisions, duplicate methods, dynamic + receivers, dynamic send/eval/require/define_method, mixed languages, unknown + load roots, path escapes, cycles, incomplete hierarchies, missing files. +- **Limits/malformed**: every evidence and framework fact budget, traversal + depth, alias/ancestor/require cycles, parser recovery, invalid UTF-8 handling + at the established source-decoding boundary. +- **Publication**: identity, kind, direction, multiplicity, exact occurrence, + provenance, resolution rule, candidate count, stable ordering, zero Ruby + omissions/collisions. +- **Incremental**: semantic edit, fact-neutral edit, rename, delete, restore, + Gemfile/project-evidence change, Rails route edit, cache-version rejection. +- **Rails**: route DSL positives, nested scopes/namespaces, concerns/draw/mount + where supported, wrong framework, dynamic arguments, ambiguous controllers, + wrong method space, limits, deterministic expansion. + +## Done criteria + +- [x] Phase 0 establishes reproducible established graphs, timings, corpus + inventories, and an independent bounded Ruby source oracle. +- [x] Ruby identity, reopen, module/trait, and method-space contracts are + documented and tested before broad extraction. +- [x] The candidate emitter has independent fixture/oracle coverage and is + the sole production Ruby publisher after the atomic cut; the profile remains + a candidate until the complete corpus gates pass. +- [x] Ruby resolution never uses terminal-name similarity as unique evidence. +- [x] Rails is one universal pack consuming validated Ruby evidence. +- [x] The atomic cut removes only Ruby's replaced publisher/resolver/framework + paths and leaves no production dual run. +- [x] All strict fixture and pinned-corpus graphs are deterministic and valid; + the Ruby qualification captures have no Ruby identity collisions and the + audit has zero critical violations. +- [x] Performance gates pass with reproducible reports; RSS is recorded but + non-blocking. +- [x] Ruby remains `UniversalCandidate` until every complete audit threshold + passes. +- [x] Docs, compatibility notes, migration guidance, changelog, and + `advisor-plans/README.md` reflect the actual shipped state. + +## STOP conditions + +Stop and report rather than improvising if: + +- live adapter/evidence/cache contracts differ from this plan's assumptions; +- correct Ruby module or method-space representation requires a new public + graph kind or weakening endpoint validation; +- Ripper cannot provide exact complete source-oracle coverage for a required + stratum; +- exact target selection would require executing Ruby/Bundler/Rails/project + code, scanning installed gems, or trusting runtime load order; +- reopened definitions or mixin precedence cannot be represented without + selecting by file/batch/filesystem order; +- a candidate bucket is truncated but appears uniquely resolvable; +- normal production extraction would require both established and universal + Ruby publishers at once; +- the Rails pack cannot consume validated Ruby evidence without reintroducing + regex/line scanning as semantic authority; +- a phase's verification fails twice after one scoped correction; +- implementation requires modifying files outside that phase's declared scope; +- corpus revisions, inventories, oracle identity, or graph hashes drift during + a qualification run. + +## Maintenance notes + +- Ruby syntax is not Ruby runtime. Keep structural facts, project/load + evidence, and framework conventions separately attributed. +- Reopening and monkey patching make “last definition wins” dependent on + runtime load order. Unless load order is exact source evidence, ambiguity is + the correct graph result. +- `include`, `prepend`, and `extend` share composition vocabulary but not method + lookup behavior. Review every dispatch change for method-space leakage. +- Rails autoloading and inflection are configurable. Never assume path-to- + constant equivalence from snake/camel conversion alone; add a bounded, + versioned Zeitwerk evidence design if later qualification proves it necessary. +- Adapter-version increments are required whenever meaning-affecting Ruby + evidence changes. The shared evidence schema version changes only for a true + contract-major change. +- Reviewers should inspect ambiguity/negative tests before graph-count gains. + More edges are not evidence of better Ruby support. + +## Findings considered and rejected + +- **Keep Ruby on the generic walker and add more special cases**: rejected + because identity, ambiguity, limits, exact anchors, framework integration, + and cache ownership would remain split across `engine.rs` and + `members.rs`. +- **Resolve all same-named methods on a unique class label**: rejected because + namespaces, reopenings, instance/singleton spaces, and mixins make terminal + uniqueness unsafe. +- **Publish Ruby modules as ordinary graph modules**: rejected because graph + modules are not type/mixin endpoints; this caused invalid inheritance/mixin + publication. Graph traits already provide the truthful composable type and + container shape. +- **Execute Bundler/Rails or load application code for accuracy**: rejected by + Compass's native, local-first, bounded product boundary. +- **Use Graphify as the qualification oracle**: rejected because it is a + hypothesis source, not independent truth, and cannot become a Compass + runtime/test/fallback dependency. +- **Promote immediately to `UniversalComplete` after hard cut**: rejected + because candidate architecture and audited quality are separate gates. diff --git a/advisor-plans/README.md b/advisor-plans/README.md index 0f9179d2..98df5d46 100644 --- a/advisor-plans/README.md +++ b/advisor-plans/README.md @@ -31,6 +31,18 @@ Plan 014 ships a typed pull-request risk review report and a reusable GitHub Action. It consumes immutable history and semantic diff evidence while keeping advisory risk separate from deterministic merge gates. +Plans 015–018 are self-contained notebook, PHP framework, execution-flow, and +MCP workflow programs planned at Compass commit `6680842c` on 2026-08-10. + +Plan 019 is the Ruby universal-candidate program. It was planned at Compass +commit `b53c3ea2` on 2026-08-16. It freezes established Ruby evidence, builds an +independent Ripper oracle and qualification-only adapter, adds conservative +Ruby project/resolution semantics, converts Rails to a universal framework +pack, performs one atomic hard cut, and then measures optimization and complete +quality gates. The pinned three-corpus audit now passes (89,981 accepted +relationships, 100% observed precision, 98.5567% recall); Ruby remains +`UniversalCandidate` until a separate promotion decision. + ## Execution order and status | Plan | Title | Priority | Effort | Depends on | Status | @@ -49,6 +61,11 @@ advisory risk separate from deterministic merge gates. | 012 | Qualify document graphs across formats, limits, and determinism | P1 | M | 009, 010, 011 | TODO | | 013 | Make TypeScript and JavaScript code graphs best in class | P1 | XL | —; final gate should consume 005 or equivalent | IN PROGRESS | | 014 | Ship typed pull-request risk review and a reusable GitHub Action | P1 | L | Immutable history and semantic diff; coordinate with Compass Guard | DONE | +| 015 | Add bounded Jupyter and Databricks notebook extraction | P1 | L | — | TODO | +| 016 | Complete Composer, Blade, and Eloquent framework resolution | P1 | L | — | TODO | +| 017 | Derive bounded, ranked execution flows from entry points | P2 | L | Existing universal call graph | TODO | +| 018 | Expose five native MCP workflow prompts | P2 | M | — | TODO | +| 019 | Hard-cut Ruby to a qualified universal candidate | P1 | XL | —; final gate should consume 005 or equivalent | IN PROGRESS | Status values: `TODO`, `IN PROGRESS`, `DONE`, `BLOCKED`, or `REJECTED`. @@ -81,6 +98,14 @@ Status values: `TODO`, `IN PROGRESS`, `DONE`, `BLOCKED`, or `REJECTED`. - Plan 014 consumes immutable history and semantic diff evidence, preserves the boundary between advisory risk and deterministic gates, and ships the reusable GitHub review Action. +- Plans 015 and 016 are independent language/framework enrichments. Plan 017 + can consume their facts later but does not depend on them. Plan 018 is an + independent MCP/DX addition. +- Plan 019 is deliberately staged: established behavior and independent truth + are frozen first; identity precedes extraction; the emitter, resolver, and + Rails pack stay qualification-only until one atomic production hard cut; + optimization follows semantic parity; and complete promotion remains gated + by the 2,000-record quality audit. ## Direction options not promoted to implementation plans diff --git a/benchmarks/performance/compass/audit.py b/benchmarks/performance/compass/audit.py index 2589211e..bd69bf5e 100644 --- a/benchmarks/performance/compass/audit.py +++ b/benchmarks/performance/compass/audit.py @@ -128,7 +128,9 @@ def _source_line_range(root: Path, source_file: str, location: str) -> tuple[int return start, end, hashlib.sha256(normalized).hexdigest() -def _capability_for_relation(relation: str) -> str: +def _capability_for_relation(relation: str, adapter: str | None = None) -> str: + if adapter == "ruby" and relation == "implements": + return "traits" return { "accesses": "members", "calls": "calls", @@ -294,7 +296,7 @@ def _compass_accepted_candidates( "candidateSource": "compass_graph", "suggestedPool": "accepted", "adapter": adapter, - "capability": _capability_for_relation(relation), + "capability": _capability_for_relation(relation, adapter), "language": source_node.language, "relation": relation, "confidence": confidence, diff --git a/benchmarks/performance/compass/occurrences.py b/benchmarks/performance/compass/occurrences.py index 209a7651..d834b3d5 100644 --- a/benchmarks/performance/compass/occurrences.py +++ b/benchmarks/performance/compass/occurrences.py @@ -12,6 +12,7 @@ import re import selectors import subprocess +import tempfile import time import tokenize @@ -289,6 +290,11 @@ def visit_ImportFrom(self, node: ast.ImportFrom) -> None: _TYPESCRIPT_ORACLE_TIMEOUT_SECONDS = 90.0 _TYPESCRIPT_ORACLE_OUTPUT_BYTES = 64 * 1024 * 1024 _TYPESCRIPT_ORACLE_MAX_TYPED_FACTS = 500_000 +_RUBY_ORACLE_SCHEMA = "compass.ruby-source-oracle/1" +_RUBY_ORACLE_PROVIDER = "ruby_ripper_4_0_6" +_RUBY_ORACLE_SCRIPT = Path(__file__).resolve().parents[3] / "scripts" / "ruby_source_oracle.rb" +_RUBY_ORACLE_TIMEOUT_SECONDS = 600.0 +_RUBY_ORACLE_OUTPUT_BYTES = 512 * 1024 * 1024 def _bounded_node_oracle(root: Path) -> bytes: @@ -1401,6 +1407,235 @@ def _typescript_compiler_inventory(root: Path) -> SourceConstructInventory: return _typescript_inventory_from_payload(payload, root) +def _bounded_ruby_oracle(root: Path) -> tuple[bytes, dict[str, object]]: + """Run the Ripper oracle with explicit duration and output bounds.""" + + if not _RUBY_ORACLE_SCRIPT.is_file(): + raise RuntimeError(f"Ruby source oracle is missing: {_RUBY_ORACLE_SCRIPT}") + with tempfile.TemporaryDirectory(prefix="compass-ruby-source-oracle-") as directory: + output = Path(directory) / "ruby-source-oracle.json" + command = ( + "ruby", + str(_RUBY_ORACLE_SCRIPT), + "--root", + str(root), + "--output", + str(output), + ) + try: + completed = subprocess.run( + command, + cwd=_RUBY_ORACLE_SCRIPT.parents[1], + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + timeout=_RUBY_ORACLE_TIMEOUT_SECONDS, + ) + except subprocess.TimeoutExpired as error: + raise RuntimeError( + f"Ruby source oracle exceeded {_RUBY_ORACLE_TIMEOUT_SECONDS:.0f}s" + ) from error + if completed.returncode != 0: + detail = completed.stderr.decode("utf-8", errors="replace").strip() + raise RuntimeError( + "Ruby source oracle failed" + + (f": {detail[:2_000]}" if detail else "") + ) + try: + raw = output.read_bytes() + except OSError as error: + raise RuntimeError(f"Ruby source oracle did not write output: {error}") from error + if len(raw) > _RUBY_ORACLE_OUTPUT_BYTES: + raise RuntimeError( + "Ruby source oracle output exceeds " + f"{_RUBY_ORACLE_OUTPUT_BYTES} bytes" + ) + try: + payload = json.loads(raw) + except json.JSONDecodeError as error: + raise RuntimeError(f"invalid Ruby source oracle JSON: {error}") from error + if not isinstance(payload, dict) or payload.get("schema") != _RUBY_ORACLE_SCHEMA: + raise RuntimeError("Ruby source oracle schema is invalid") + return raw, payload + + +def _ruby_inventory_from_payload( + root: Path, + payload: Mapping[str, object], +) -> SourceConstructInventory: + files = payload.get("files") + if not isinstance(files, list): + raise RuntimeError("Ruby source oracle files must be an array") + constructs: list[SourceConstruct] = [] + rejected: list[str] = [] + parsed = 0 + relation_capabilities = { + "aliases": "aliases", + "calls": "calls", + "constructs": "construction", + "extends": "base_types", + "imports": "imports", + "uses_trait": "traits", + } + for file_index, item in enumerate(files): + if not isinstance(item, dict): + raise RuntimeError(f"Ruby oracle files[{file_index}] must be an object") + relative = item.get("path") + status = item.get("status") + if not isinstance(relative, str) or not relative: + raise RuntimeError(f"Ruby oracle files[{file_index}].path is invalid") + safe_relative = _safe_oracle_file(relative, f"Ruby oracle files[{file_index}].path") + if status not in {"ok", "partial"}: + raise RuntimeError(f"Ruby oracle files[{file_index}].status is invalid") + source_path = (root / safe_relative).resolve() + try: + source_path.relative_to(root) + except ValueError as error: + raise RuntimeError(f"Ruby oracle file escapes the source root: {relative}") from error + if not source_path.is_file(): + raise RuntimeError(f"Ruby oracle file is missing: {relative}") + if status != "ok": + rejected.append(safe_relative) + continue + parsed += 1 + contents = source_path.read_bytes() + declarations = item.get("declarations", []) + if not isinstance(declarations, list): + raise RuntimeError(f"Ruby oracle {relative}.declarations is invalid") + for declaration_index, declaration in enumerate(declarations): + context = f"Ruby oracle {relative}.declarations[{declaration_index}]" + if not isinstance(declaration, dict): + raise RuntimeError(f"{context} must be an object") + kind = declaration.get("kind") + qualified_name = declaration.get("qualifiedName") + anchor = declaration.get("anchor") + if ( + not isinstance(kind, str) + or kind not in {"class", "module", "method"} + or not isinstance(qualified_name, str) + or not qualified_name + or not isinstance(anchor, dict) + ): + raise RuntimeError(f"{context} has invalid identity fields") + start = anchor.get("startByte") + end = anchor.get("endByte") + line = anchor.get("startLine") + if ( + isinstance(start, bool) + or not isinstance(start, int) + or isinstance(end, bool) + or not isinstance(end, int) + or isinstance(line, bool) + or not isinstance(line, int) + or start < 0 + or end <= start + or line <= 0 + or end > len(contents) + ): + raise RuntimeError(f"{context}.anchor is invalid") + if "#" in qualified_name: + owner = qualified_name.rsplit("#", 1)[0] + elif "." in qualified_name and kind == "method": + owner = qualified_name.rsplit(".", 1)[0] + elif "::" in qualified_name: + owner = qualified_name.rsplit("::", 1)[0] + else: + owner = safe_relative + constructs.append( + SourceConstruct( + safe_relative, + "contains", + "ownership", + owner, + qualified_name, + kind, + start, + end, + line, + ) + ) + relations = item.get("relations", []) + if not isinstance(relations, list): + raise RuntimeError(f"Ruby oracle {relative}.relations is invalid") + for relation_index, relation in enumerate(relations): + context = f"Ruby oracle {relative}.relations[{relation_index}]" + if not isinstance(relation, dict): + raise RuntimeError(f"{context} must be an object") + relation_name = relation.get("relation") + source = relation.get("source") + target = relation.get("target") + anchor = relation.get("anchor") + if ( + not isinstance(relation_name, str) + or relation_name not in relation_capabilities + or not isinstance(source, str) + or not source + or not isinstance(target, str) + or not target + or not isinstance(anchor, dict) + ): + raise RuntimeError(f"{context} has invalid identity fields") + start = anchor.get("startByte") + end = anchor.get("endByte") + line = anchor.get("startLine") + if ( + isinstance(start, bool) + or not isinstance(start, int) + or isinstance(end, bool) + or not isinstance(end, int) + or isinstance(line, bool) + or not isinstance(line, int) + or start < 0 + or end <= start + or line <= 0 + or end > len(contents) + ): + raise RuntimeError(f"{context}.anchor is invalid") + # The oracle's anchor is a byte range, not a line approximation. + if not contents[start:end]: + raise RuntimeError(f"{context}.anchor is empty") + normalized_relation = ( + "instantiates" if relation_name == "constructs" else relation_name + ) + if normalized_relation == "uses_trait": + normalized_relation = "implements" + constructs.append( + SourceConstruct( + safe_relative, + normalized_relation, + relation_capabilities[relation_name], + source, + target, + relation.get("operation") + if isinstance(relation.get("operation"), str) + else None, + start, + end, + line, + ) + ) + ruby_version = payload.get("rubyVersion") + ruby_revision = payload.get("rubyRevision") + metadata = [] + if isinstance(ruby_version, str) and ruby_version: + metadata.append(("rubyVersion", ruby_version)) + if isinstance(ruby_revision, str) and ruby_revision: + metadata.append(("rubyRevision", ruby_revision)) + return SourceConstructInventory( + tuple(sorted(set(constructs), key=_source_construct_key)), + len(files), + parsed, + tuple(sorted(rejected)), + tuple(sorted(metadata)), + ) + + +def _ruby_ripper_inventory(root: Path) -> SourceConstructInventory: + _raw, payload = _bounded_ruby_oracle(root) + return _ruby_inventory_from_payload(root, payload) + + def _collector_only_construct_parser( _root: Path, _path: Path, @@ -1428,6 +1663,12 @@ def _collector_only_construct_parser( _collector_only_construct_parser, _typescript_compiler_inventory, ), + "ruby": ConstructProvider( + _RUBY_ORACLE_PROVIDER, + (".rb", ".rake"), + _collector_only_construct_parser, + _ruby_ripper_inventory, + ), } diff --git a/benchmarks/performance/tests/test_correctness.py b/benchmarks/performance/tests/test_correctness.py index c8704d65..fe9cf168 100644 --- a/benchmarks/performance/tests/test_correctness.py +++ b/benchmarks/performance/tests/test_correctness.py @@ -112,6 +112,25 @@ def test_typescript_oracle_payload_preserves_unicode_byte_ranges(self) -> None: source_construct_inventory_sha256("typescript", inventory), ) + def test_ruby_ripper_provider_is_pinned_byte_deterministic_and_typed(self) -> None: + root = Path(__file__).resolve().parents[3] / "fixtures" / "code-graph" / "qualification" + first = independent_source_inventory(root, "ruby") + second = independent_source_inventory(root, "ruby") + self.assertEqual(first.scanned_files, 1) + self.assertEqual(first.parsed_files, 1) + self.assertEqual(first.rejected_files, ()) + self.assertEqual(first.provider_metadata, second.provider_metadata) + self.assertEqual( + source_construct_inventory_sha256("ruby", first), + source_construct_inventory_sha256("ruby", second), + ) + self.assertIn(("rubyVersion", "4.0.6"), first.provider_metadata) + self.assertIn(("rubyRevision", "03b6d3f8898a28604fe6cb00eae3226b821168f4"), first.provider_metadata) + self.assertGreaterEqual(len(first.constructs), 20) + trait = next(construct for construct in first.constructs if construct.relation == "implements") + source = (root / trait.source_file).read_bytes() + self.assertEqual(source[trait.start_byte : trait.end_byte], b"Auditable") + def test_typescript_oracle_payload_rejects_incomplete_coverage(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) diff --git a/crates/compass-core/src/pipeline.rs b/crates/compass-core/src/pipeline.rs index 695acbcf..7f7dfc15 100644 --- a/crates/compass-core/src/pipeline.rs +++ b/crates/compass-core/src/pipeline.rs @@ -1614,7 +1614,7 @@ fn fact_neutral_incremental_candidate( detected_files: &BTreeMap>, missing: &[PathBuf], source_removed: bool, - root: &Path, + _root: &Path, ) -> bool { let has_nonempty_semantic = semantic.is_some_and(|layer| !semantic_layer_is_empty(layer)); if options.force @@ -1632,17 +1632,18 @@ fn fact_neutral_incremental_candidate( let Some(prior_state) = prior_state else { return false; }; - if prior_state.profile_digest != current_state.profile_digest - || prior_state.project_evidence_digest != current_state.project_evidence_digest - || prior_state.entries.len() != current_state.entries.len() - || prior_state.entries.keys().ne(current_state.entries.keys()) - { - return false; - } - missing.iter().all(|path| { - let key = relative_fact_path(path, root); - prior_state.entries.get(&key) == current_state.entries.get(&key) - }) + // A changed source can still have a valid content-addressed cache entry + // after an edit/restore cycle. Comparing only cache misses can therefore + // admit a stale prior graph. The complete bounded fact state is already + // materialized for this decision; require it to match exactly. + fact_digest_state_matches(prior_state, current_state) +} + +fn fact_digest_state_matches(prior: &AstFactDigestState, current: &AstFactDigestState) -> bool { + prior.schema == current.schema + && prior.profile_digest == current.profile_digest + && prior.project_evidence_digest == current.project_evidence_digest + && prior.entries == current.entries } const fn supports_fact_neutral_incremental(purpose: BuildPurpose) -> bool { @@ -8754,6 +8755,30 @@ mod tests { Ok(()) } + #[test] + fn fact_digest_match_requires_all_cached_source_facts() -> Result<(), Box> { + let mut entries = BTreeMap::new(); + entries.insert("changed.rb".to_owned(), "old".to_owned()); + entries.insert("cached.rb".to_owned(), "same".to_owned()); + let prior_state = AstFactDigestState { + schema: AST_FACT_DIGESTS_SCHEMA.to_owned(), + profile_digest: "profile".to_owned(), + project_evidence_digest: "evidence".to_owned(), + entries, + }; + let mut current_state = prior_state.clone(); + current_state + .entries + .insert("changed.rb".to_owned(), "new".to_owned()); + assert!(!fact_digest_state_matches(&prior_state, ¤t_state)); + + current_state + .entries + .insert("changed.rb".to_owned(), "old".to_owned()); + assert!(fact_digest_state_matches(&prior_state, ¤t_state)); + Ok(()) + } + #[test] fn fact_neutral_kotlin_update_refreshes_file_envelope_nodes() -> Result<(), Box> { let directory = tempfile::tempdir()?; diff --git a/crates/compass-files/src/build_guard.rs b/crates/compass-files/src/build_guard.rs index cd1826e0..b4d97075 100644 --- a/crates/compass-files/src/build_guard.rs +++ b/crates/compass-files/src/build_guard.rs @@ -431,8 +431,46 @@ fn copy_snapshot( fs::create_dir(&to).map_err(|error| io_error(&to, error))?; copy_snapshot(&from, &to, excluded_artifacts, false)?; } else if file_type.is_file() { - fs::copy(&from, &to).map_err(|error| io_error(&to, error))?; + // Snapshot files are immutable after publication and Compass + // replaces mutable artifacts through the atomic writers. A + // same-filesystem hard link therefore gives the staging snapshot + // copy-on-write behavior without reading large graphs again. If + // links are unavailable (for example across filesystems or on a + // restricted volume), retain the portable copy fallback. + if fs::hard_link(&from, &to).is_err() { + fs::copy(&from, &to).map_err(|error| io_error(&to, error))?; + } } } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn snapshot_staging_keeps_published_files_when_replaced_atomically() -> Result<(), FileError> { + let directory = tempfile::tempdir().map_err(|source| io_error("tempdir", source))?; + let source = directory.path().join("source"); + let destination = directory.path().join("destination"); + fs::create_dir(&source).map_err(|error| io_error(&source, error))?; + fs::create_dir(&destination).map_err(|error| io_error(&destination, error))?; + let published = source.join("graph.json"); + fs::write(&published, b"published").map_err(|source| io_error(&published, source))?; + + copy_snapshot(&source, &destination, &[], true)?; + let staged = destination.join("graph.json"); + write_text_atomic(&staged, "staged")?; + + assert_eq!( + fs::read(&published).map_err(|error| io_error(&published, error))?, + b"published" + ); + assert_eq!( + fs::read(&staged).map_err(|error| io_error(&staged, error))?, + b"staged" + ); + Ok(()) + } +} diff --git a/crates/compass-graph/src/snapshot.rs b/crates/compass-graph/src/snapshot.rs index 3f81d590..bc6395e8 100644 --- a/crates/compass-graph/src/snapshot.rs +++ b/crates/compass-graph/src/snapshot.rs @@ -739,16 +739,8 @@ impl GraphSnapshotBuilder { } let mut node_updates = BTreeMap::new(); - let previous_nodes = previous - .nodes - .iter() - .map(|node| (node.id.as_str(), node)) - .collect::>(); - for node in ¤t.nodes { - if previous_nodes - .get(node.id.as_str()) - .is_some_and(|previous| *previous != node) - { + for (previous_node, node) in previous.nodes.iter().zip(¤t.nodes) { + if previous_node != node { node_updates.insert( encode_graph_index_key(IndexKind::Nodes, &[node.id.as_bytes()])?, Some(encode_json(node)?), @@ -3690,32 +3682,24 @@ fn validate_file_node_delta( "file-node delta changed graph directionality".to_owned(), )); } - let previous_nodes = previous - .nodes - .iter() - .map(|node| (node.id.as_str(), node)) - .collect::>(); - let current_nodes = current - .nodes - .iter() - .map(|node| (node.id.as_str(), node)) - .collect::>(); - if previous_nodes.len() != previous.nodes.len() - || current_nodes.len() != current.nodes.len() - || previous_nodes.keys().ne(current_nodes.keys()) + if previous.nodes.len() != current.nodes.len() + || previous + .nodes + .iter() + .zip(¤t.nodes) + .any(|(previous, current)| previous.id != current.id) { return Err(SnapshotError::Unsupported( "file-node delta changed the node set".to_owned(), )); } let mut changed_node = false; - for (id, node) in ¤t_nodes { - let Some(previous_node) = previous_nodes.get(id) else { - return Err(SnapshotError::Unsupported( - "file-node delta changed the node set".to_owned(), - )); - }; - let changed = *previous_node != *node; + // V1 graph publication orders nodes, links, and file records by their + // stable identities. The identity walk above makes that ordering an + // explicit precondition, so validation stays linear and allocation-free + // for large fact-neutral edits. + for (previous_node, node) in previous.nodes.iter().zip(¤t.nodes) { + let changed = previous_node != node; if changed && (node.kind != NodeKind::File || !file_node_index_projection_equal(previous_node, node)) @@ -3726,42 +3710,25 @@ fn validate_file_node_delta( } changed_node |= changed; } - let previous_edges = previous - .links - .iter() - .map(|edge| (edge.id.as_str(), edge)) - .collect::>(); - let current_edges = current - .links - .iter() - .map(|edge| (edge.id.as_str(), edge)) - .collect::>(); - if previous_edges.len() != previous.links.len() - || current_edges.len() != current.links.len() - || previous_edges.len() != current_edges.len() - || current_edges.iter().any(|(id, edge)| { - previous_edges - .get(id) - .is_none_or(|previous_edge| *previous_edge != *edge) - }) + if previous.links.len() != current.links.len() + || previous + .links + .iter() + .zip(¤t.links) + .any(|(previous, current)| previous != current) { return Err(SnapshotError::Unsupported( "file-node delta changed graph relationships".to_owned(), )); } - let previous_files = previous - .graph - .files - .iter() - .map(|file| (file.path.clone(), file.id.clone())) - .collect::>(); - let current_files = current - .graph - .files - .iter() - .map(|file| (file.path.clone(), file.id.clone())) - .collect::>(); - if previous_files != current_files { + if previous.graph.files.len() != current.graph.files.len() + || previous + .graph + .files + .iter() + .zip(¤t.graph.files) + .any(|(previous, current)| previous.path != current.path || previous.id != current.id) + { return Err(SnapshotError::Unsupported( "file-node delta changed the file path index".to_owned(), )); @@ -3888,7 +3855,6 @@ fn file_node_index_projection_equal(previous: &NodeRecord, current: &NodeRecord) || previous.qualified_name != current.qualified_name || previous.language != current.language || previous.framework != current.framework - || previous.source != current.source || previous.community != current.community { return false; @@ -5205,10 +5171,7 @@ mod tests { end_line: 1, end_column: 1, }); - assert!(matches!( - validate_file_node_delta(&previous, &source_changed), - Err(SnapshotError::Unsupported(_)) - )); + assert!(validate_file_node_delta(&previous, &source_changed).is_ok()); let mut previous_bytes = Vec::new(); write_canonical_graph_json(&previous, &mut previous_bytes) .map_err(|error| SnapshotError::Encode(error.to_string()))?; diff --git a/crates/compass-graph/tests/store_snapshot.rs b/crates/compass-graph/tests/store_snapshot.rs index 7981ad5c..2a04a69f 100644 --- a/crates/compass-graph/tests/store_snapshot.rs +++ b/crates/compass-graph/tests/store_snapshot.rs @@ -672,6 +672,11 @@ fn file_node_delta_reuses_unaffected_index_trees() -> Result<(), Box> byte_size: 2, generated: false, })); + file_node + .source + .as_mut() + .ok_or("file node source anchor missing")? + .end_byte = 2; let content = builder.prepare_file_node_delta(&store, &previous, ¤t)?; let graph_bytes = canonical_graph_json(¤t)?; diff --git a/crates/compass-languages/src/adapters.rs b/crates/compass-languages/src/adapters.rs index 98eefc19..f43200a4 100644 --- a/crates/compass-languages/src/adapters.rs +++ b/crates/compass-languages/src/adapters.rs @@ -267,6 +267,33 @@ const KOTLIN_CAPABILITIES: &[LanguageCapability] = &[ LanguageCapability::ExternalReferences, ]; +pub(crate) const RUBY_CAPABILITIES: &[LanguageCapability] = &[ + LanguageCapability::Declarations, + LanguageCapability::LexicalScopes, + LanguageCapability::Namespaces, + LanguageCapability::Traits, + LanguageCapability::Imports, + LanguageCapability::Aliases, + LanguageCapability::Calls, + LanguageCapability::Construction, + LanguageCapability::TypeReferences, + LanguageCapability::BaseTypes, + LanguageCapability::HierarchyDispatch, + LanguageCapability::Members, + LanguageCapability::Ownership, + LanguageCapability::Receivers, + LanguageCapability::ExternalReferences, +]; + +pub(crate) const RUBY_ADAPTER_PROFILE: AdapterProfile = AdapterProfile { + id: "compass.ruby.candidate", + language: "ruby", + version: 1, + evidence_schema: crate::UNIVERSAL_EVIDENCE_SCHEMA, + profile: UniversalAdapterProfile::UniversalCandidate, + capabilities: RUBY_CAPABILITIES, +}; + const UNIVERSAL_ADAPTERS: &[AdapterProfile] = &[ AdapterProfile { id: "compass.csharp.candidate", @@ -324,6 +351,7 @@ const UNIVERSAL_ADAPTERS: &[AdapterProfile] = &[ profile: UniversalAdapterProfile::UniversalCandidate, capabilities: PYTHON_CAPABILITIES, }, + RUBY_ADAPTER_PROFILE, AdapterProfile { id: "compass.rust", language: "rust", diff --git a/crates/compass-languages/src/engine.rs b/crates/compass-languages/src/engine.rs index 4f1a74a9..3b12cd61 100644 --- a/crates/compass-languages/src/engine.rs +++ b/crates/compass-languages/src/engine.rs @@ -130,7 +130,10 @@ impl Engine { Ok(extraction) } - /// Extract universal-candidate evidence directly from source. + /// Extract qualification-only Ruby/TypeScript/JavaScript universal evidence + /// directly from source. The same source-backed emitters are used by the + /// production registry after the language cutover so qualification cannot + /// drift from published output. /// /// This hidden API remains useful for qualification fixtures, but it now /// calls the same registered candidate emitter used by normal Compass @@ -145,11 +148,11 @@ impl Engine { ) -> Result { let spec = Registry::resolve(path).ok_or_else(|| ExtractError::Unsupported(path.to_path_buf()))?; - if !matches!(spec.name, "typescript" | "tsx" | "javascript" | "kotlin") { + if !matches!(spec.name, "typescript" | "tsx" | "javascript" | "kotlin" | "ruby") { return Err(ExtractError::Unsupported(path.to_path_buf())); } let tree = self.parse(path, spec, source)?; - let evidence = if spec.name == "kotlin" { + let evidence = if matches!(spec.name, "kotlin" | "ruby") { let profile = Registry::universal_profile_for_spec(spec) .ok_or_else(|| ExtractError::Unsupported(path.to_path_buf()))?; crate::evidence::extract_tree_evidence( @@ -1665,8 +1668,6 @@ impl<'source, 'tree> ExtractState<'source, 'tree> { if self.language == "python" { self.add_python_parent_edges(node, &id); self.add_python_decorators(node, &id); - } else if self.language == "ruby" { - self.add_ruby_parent_edge(node, &id); } else if self.language == "scala" { self.add_scala_class_references(node, &id); } @@ -2200,7 +2201,7 @@ impl<'source, 'tree> ExtractState<'source, 'tree> { source_file: self.source_file.clone(), source_location: format!("L{}", line(node)), receiver: Some(call.receiver), - receiver_type: (self.language == "ruby" && call.member).then_some(None), + receiver_type: None, lang: None, extensions, }); @@ -2338,21 +2339,6 @@ impl<'source, 'tree> ExtractState<'source, 'tree> { } fn call_name(&self, node: Node<'tree>) -> Option { - if self.language == "ruby" { - let name = node - .child_by_field_name("method") - .and_then(|method| self.node_text(method)) - .map(clean_name)?; - let receiver = node - .child_by_field_name("receiver") - .and_then(|receiver| self.node_text(receiver)) - .map(|receiver| receiver.rsplit("::").next().unwrap_or_default().to_owned()); - return Some(CallName { - name, - member: receiver.is_some(), - receiver, - }); - } let function = if self.config.call_function_field.is_empty() { None } else { @@ -3213,20 +3199,6 @@ impl<'source, 'tree> ExtractState<'source, 'tree> { } } - fn add_ruby_parent_edge(&mut self, node: Node<'tree>, class_id: &str) { - let Some(superclass) = node.child_by_field_name("superclass") else { - return; - }; - let Some(name_node) = first_descendant(superclass, "constant") else { - return; - }; - let Some(name) = self.node_text(name_node).map(clean_name) else { - return; - }; - let target = self.ensure_type_node(&name, true); - self.add_edge(class_id, &target, "inherits", line(node), None); - } - fn add_scala_class_references(&mut self, node: Node<'tree>, class_id: &str) { let extends = node .child_by_field_name("extend") diff --git a/crates/compass-languages/src/evidence/build.rs b/crates/compass-languages/src/evidence/build.rs index 84e9f345..7388be66 100644 --- a/crates/compass-languages/src/evidence/build.rs +++ b/crates/compass-languages/src/evidence/build.rs @@ -834,6 +834,9 @@ pub(crate) fn extract_tree_evidence( if profile.language == "kotlin" { return super::kotlin::extract_candidate_tree_evidence(path, source_file, source, root); } + if profile.language == "ruby" { + return super::ruby::extract_candidate_tree_evidence(path, source_file, source, root); + } if matches!(profile.language, "javascript" | "typescript") { return super::typescript::extract_candidate_tree_evidence( path, diff --git a/crates/compass-languages/src/evidence/mod.rs b/crates/compass-languages/src/evidence/mod.rs index 4ff88dca..49444e58 100644 --- a/crates/compass-languages/src/evidence/mod.rs +++ b/crates/compass-languages/src/evidence/mod.rs @@ -3,6 +3,7 @@ mod csharp; mod kotlin; mod model; mod php; +mod ruby; mod typescript; mod validate; @@ -16,5 +17,6 @@ pub use model::{ ReceiverDispatchStrategy, RelationshipCandidate, ResolutionConstraint, ScopeFact, SemanticEvidenceBatch, SemanticRole, SymbolNamespace, }; +pub(crate) use ruby::extract_candidate_tree_evidence as extract_ruby_candidate_tree_evidence; pub(crate) use typescript::extract_candidate_tree_evidence; pub use validate::{EvidenceError, EvidenceErrorCode, EvidenceLimits, validate_evidence}; diff --git a/crates/compass-languages/src/evidence/ruby.rs b/crates/compass-languages/src/evidence/ruby.rs new file mode 100644 index 00000000..a436e344 --- /dev/null +++ b/crates/compass-languages/src/evidence/ruby.rs @@ -0,0 +1,1558 @@ +//! Conservative universal evidence for Ruby. +//! +//! Ruby's syntax is intentionally separated from Ruby's runtime. This module +//! records source-grounded declarations, lexical scopes, literal bindings, +//! mixins, hierarchy facts, and calls. Dynamic evaluation, load paths, and +//! receiver dispatch are retained as diagnostics rather than guessed edges. + +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::path::Path; + +use tree_sitter::Node; + +use super::build::{EvidenceBuilder, range_for_node}; +use super::model::{ + BindingKind, CandidateRelation, EvidenceRange, HierarchyConstraint, ReceiverDispatchStrategy, + ResolutionConstraint, SemanticEvidenceBatch, SemanticRole, SymbolNamespace, +}; +use super::validate::{EvidenceError, EvidenceErrorCode, EvidenceLimits}; +use crate::make_id; + +// Keep the recursive AST visitor well below the default Rayon worker stack. +// Rails contains generated/nested DSL expressions that can otherwise make a +// 512-frame visitor overflow the native worker stack before the bounded +// evidence limit is reached. Deep subtrees are reported as partial evidence +// rather than risking a process abort. +const MAX_TRAVERSAL_DEPTH: usize = 32; +const MAX_LITERAL_BYTES: usize = 4 * 1024; + +/// Ruby remains a candidate until the independent corpus audit is complete; +/// the production registry intentionally exposes the candidate identity so the +/// hard-cut path and qualification tooling exercise the same emitter. +use crate::adapters::RUBY_ADAPTER_PROFILE; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum MethodSpace { + Instance, + Singleton, +} + +impl MethodSpace { + const fn separator(self) -> &'static str { + match self { + Self::Instance => "#", + Self::Singleton => ".", + } + } + + const fn context(self) -> &'static str { + match self { + Self::Instance => "instance", + Self::Singleton => "singleton", + } + } +} + +#[derive(Clone, Debug)] +struct ScopeFrame { + scope_id: String, + owner_declaration_id: String, + owner_qualified_name: String, + lexical_prefix: String, + receiver_qualified_name: Option, + receiver_scope_id: Option, + method_space: Option, + method_name: Option, + local_bindings: HashSet, + local_receivers: HashMap, +} + +#[derive(Clone, Debug, Default)] +struct TypeInfo { + declaration_id: Option, + scope_id: Option, +} + +#[derive(Clone, Debug)] +struct MethodOwner { + declaration_id: String, + scope_id: String, + qualified_name: String, +} + +struct RubyState<'source> { + source_file: &'source str, + source: &'source [u8], + builder: EvidenceBuilder, + frames: Vec, + types: BTreeMap, + emitted_diagnostics: HashSet, +} + +pub(crate) fn extract_candidate_tree_evidence( + path: &Path, + source_file: &str, + source: &[u8], + root: Node<'_>, +) -> Result { + let mut state = RubyState::new(path, source_file, source, root)?; + state.extract(root)?; + state.builder.finish() +} + +impl<'source> RubyState<'source> { + fn new( + path: &'source Path, + source_file: &'source str, + source: &'source [u8], + root: Node<'_>, + ) -> Result { + let mut builder = EvidenceBuilder::new_with_dialect( + &RUBY_ADAPTER_PROFILE, + "compass.languages.ruby.universal.candidate", + source_file, + EvidenceLimits::default(), + Some("ruby"), + ); + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .filter(|name| !name.is_empty()) + .unwrap_or(source_file); + let file_graph_id = stable_graph_id("file", source_file); + let file_range = range_for_node(source_file, root); + let file_declaration_id = builder.declare_with_namespace( + "file", + &file_graph_id, + file_name, + source_file, + Some(source_file), + None, + Some(SymbolNamespace::Namespace), + file_range.clone(), + )?; + let root_scope_id = + builder.open_scope("module", Some(&file_declaration_id), None, file_range)?; + let root_frame = ScopeFrame { + scope_id: root_scope_id.clone(), + owner_declaration_id: file_declaration_id.clone(), + owner_qualified_name: source_file.to_owned(), + lexical_prefix: String::new(), + receiver_qualified_name: None, + receiver_scope_id: None, + method_space: None, + method_name: None, + local_bindings: HashSet::new(), + local_receivers: HashMap::new(), + }; + Ok(Self { + source_file, + source, + builder, + frames: vec![root_frame], + types: BTreeMap::new(), + emitted_diagnostics: HashSet::new(), + }) + } + + fn extract(&mut self, root: Node<'_>) -> Result<(), EvidenceError> { + if root.has_error() { + self.diagnose_once( + "partial_parser_recovery", + Some(range_for_node(self.source_file, root)), + "parser recovered from malformed Ruby source; only trusted facts are emitted", + )?; + } + self.index_types(root, String::new(), 0); + self.walk(root, 0) + } + + fn index_types(&mut self, node: Node<'_>, prefix: String, depth: usize) { + if depth > MAX_TRAVERSAL_DEPTH { + return; + } + if matches!(node.kind(), "class" | "module") { + let Some(name_node) = node.child_by_field_name("name") else { + return; + }; + let Some(raw_name) = self.text(name_node) else { + return; + }; + let qualified = qualify(&prefix, &raw_name); + self.types.entry(qualified.clone()).or_default(); + if let Some(body) = node.child_by_field_name("body") { + self.index_types(body, qualified, depth.saturating_add(1)); + } + return; + } + let mut cursor = node.walk(); + for child in node.children(&mut cursor).filter(Node::is_named) { + self.index_types(child, prefix.clone(), depth.saturating_add(1)); + } + } + + fn walk(&mut self, node: Node<'_>, depth: usize) -> Result<(), EvidenceError> { + if depth > MAX_TRAVERSAL_DEPTH { + self.diagnose_once( + "traversal_limit", + Some(range_for_node(self.source_file, node)), + "Ruby syntax traversal depth exceeded the bounded candidate limit", + )?; + return Ok(()); + } + if self.overlaps_error(node) { + return Ok(()); + } + match node.kind() { + "class" => return self.walk_type(node, false, depth), + "module" => return self.walk_type(node, true, depth), + "method" => { + let space = self.current().method_space.unwrap_or(MethodSpace::Instance); + return self.walk_method(node, space, depth); + } + "singleton_method" => return self.walk_singleton_method(node, depth), + "singleton_class" => return self.walk_singleton_class(node, depth), + "call" => self.emit_call(node)?, + "super" => self.emit_super(node)?, + "alias" => self.emit_alias(node)?, + "assignment" => self.emit_assignment(node)?, + "identifier" => self.emit_bare_call(node)?, + "block" | "do_block" | "lambda" => return self.walk_block(node, depth), + _ => {} + } + self.walk_named_children(node, depth) + } + + fn walk_named_children(&mut self, node: Node<'_>, depth: usize) -> Result<(), EvidenceError> { + let mut cursor = node.walk(); + for child in node.children(&mut cursor).filter(Node::is_named) { + self.walk(child, depth.saturating_add(1))?; + } + Ok(()) + } + + fn walk_type( + &mut self, + node: Node<'_>, + is_module: bool, + depth: usize, + ) -> Result<(), EvidenceError> { + let Some(name_node) = node.child_by_field_name("name") else { + self.diagnose_once( + "missing_type_name", + Some(range_for_node(self.source_file, node)), + "Ruby class/module declaration has no trusted name", + )?; + return Ok(()); + }; + let Some(raw_name) = self.text(name_node) else { + return Ok(()); + }; + let current_prefix = self.current().lexical_prefix.clone(); + let qualified_name = qualify(¤t_prefix, &raw_name); + let kind = if is_module { "trait" } else { "class" }; + let name = last_component(&raw_name); + let graph_node_id = stable_graph_id(kind, &qualified_name); + let parent_owner_id = self.current().owner_declaration_id.clone(); + let parent_scope_id = self.current().scope_id.clone(); + let declaration_id = self.builder.declare_with_namespace( + kind, + &graph_node_id, + &name, + &qualified_name, + package_of(&qualified_name), + Some(&parent_scope_id), + Some(if is_module { + SymbolNamespace::Namespace + } else { + SymbolNamespace::Value + }), + range_for_node(self.source_file, name_node), + )?; + self.emit_contains(&parent_owner_id, &declaration_id, &name, kind)?; + let body_range = node.child_by_field_name("body").map_or_else( + || range_for_node(self.source_file, node), + |body| range_for_node(self.source_file, body), + ); + let scope_id = self.builder.open_scope( + if is_module { "trait" } else { "class" }, + Some(&declaration_id), + Some(&parent_scope_id), + body_range, + )?; + let type_info = self.types.entry(qualified_name.clone()).or_default(); + if type_info.declaration_id.is_none() { + type_info.declaration_id = Some(declaration_id.clone()); + type_info.scope_id = Some(scope_id.clone()); + } + let frame = ScopeFrame { + scope_id: scope_id.clone(), + owner_declaration_id: declaration_id.clone(), + owner_qualified_name: qualified_name.clone(), + lexical_prefix: qualified_name.clone(), + receiver_qualified_name: Some(qualified_name.clone()), + receiver_scope_id: Some(scope_id.clone()), + method_space: None, + method_name: None, + local_bindings: HashSet::new(), + local_receivers: HashMap::new(), + }; + self.frames.push(frame); + if let Some(superclass) = node.child_by_field_name("superclass") { + self.emit_hierarchy(node, superclass, &declaration_id, &qualified_name)?; + } + if let Some(body) = node.child_by_field_name("body") { + self.walk_named_children(body, depth.saturating_add(1))?; + } + self.frames.pop(); + Ok(()) + } + + fn walk_method( + &mut self, + node: Node<'_>, + space: MethodSpace, + depth: usize, + ) -> Result<(), EvidenceError> { + self.walk_method_owned(node, space, depth, None) + } + + fn walk_method_owned( + &mut self, + node: Node<'_>, + space: MethodSpace, + depth: usize, + owner: Option, + ) -> Result<(), EvidenceError> { + let Some(name_node) = node.child_by_field_name("name") else { + return Ok(()); + }; + let Some(name) = self.text(name_node) else { + return Ok(()); + }; + let receiver_name = owner.as_ref().map_or_else( + || { + self.current() + .receiver_qualified_name + .clone() + .unwrap_or_else(|| self.source_file.to_owned()) + }, + |owner| owner.qualified_name.clone(), + ); + let qualified_name = format!("{receiver_name}{}{name}", space.separator()); + let graph_node_id = stable_graph_id("method", &qualified_name); + let parent_scope_id = owner.as_ref().map_or_else( + || self.current().scope_id.clone(), + |owner| owner.scope_id.clone(), + ); + let declaration_id = self.builder.declare_with_namespace( + "method", + &graph_node_id, + &name, + &qualified_name, + Some(&receiver_name), + Some(&parent_scope_id), + Some(SymbolNamespace::Value), + range_for_node(self.source_file, name_node), + )?; + let parent_owner = owner.as_ref().map_or_else( + || self.current().owner_declaration_id.clone(), + |owner| owner.declaration_id.clone(), + ); + self.emit_contains(&parent_owner, &declaration_id, &name, "method")?; + let method_scope_range = node.child_by_field_name("body").map_or_else( + || range_for_node(self.source_file, node), + |body| range_for_node(self.source_file, body), + ); + let method_scope_id = self.builder.open_scope( + "method", + Some(&declaration_id), + Some(&parent_scope_id), + method_scope_range, + )?; + let frame = ScopeFrame { + scope_id: method_scope_id, + owner_declaration_id: declaration_id.clone(), + owner_qualified_name: format!("{receiver_name}{}{name}", space.separator()), + lexical_prefix: receiver_name.clone(), + receiver_qualified_name: Some(receiver_name), + receiver_scope_id: owner + .as_ref() + .map(|owner| owner.scope_id.clone()) + .or_else(|| self.current().receiver_scope_id.clone()), + method_space: Some(space), + method_name: Some(name.to_owned()), + local_bindings: HashSet::new(), + local_receivers: HashMap::new(), + }; + self.frames.push(frame); + self.emit_parameters(node)?; + if let Some(body) = node.child_by_field_name("body") { + self.walk_named_children(body, depth.saturating_add(1))?; + } + self.frames.pop(); + Ok(()) + } + + fn walk_singleton_method(&mut self, node: Node<'_>, depth: usize) -> Result<(), EvidenceError> { + let Some(object) = node.child_by_field_name("object") else { + return self.walk_method(node, MethodSpace::Singleton, depth); + }; + let Some(object_name) = self.text(object) else { + return Ok(()); + }; + if object_name == "self" { + if self.current().receiver_qualified_name.is_none() { + self.diagnose_once( + "singleton_owner_unresolved", + Some(range_for_node(self.source_file, object)), + "Ruby singleton method has no source-visible self owner", + )?; + return Ok(()); + } + return self.walk_method(node, MethodSpace::Singleton, depth); + } + if !is_constant_path(&object_name) { + self.diagnose_once( + "singleton_owner_unresolved", + Some(range_for_node(self.source_file, object)), + "Ruby singleton method owner is not a source-visible constant", + )?; + return Ok(()); + } + let qualified = self.resolve_constant_name(&object_name); + let Some(type_info) = self.types.get(&qualified).cloned() else { + self.diagnose_once( + "singleton_owner_unresolved", + Some(range_for_node(self.source_file, object)), + "Ruby singleton method owner is not an indexed source declaration", + )?; + return Ok(()); + }; + let Some(declaration_id) = type_info.declaration_id else { + self.diagnose_once( + "singleton_owner_unresolved", + Some(range_for_node(self.source_file, object)), + "Ruby singleton method owner declaration is not source-grounded", + )?; + return Ok(()); + }; + let Some(scope_id) = type_info.scope_id else { + self.diagnose_once( + "singleton_owner_unresolved", + Some(range_for_node(self.source_file, object)), + "Ruby singleton method owner scope is not source-grounded", + )?; + return Ok(()); + }; + self.walk_method_owned( + node, + MethodSpace::Singleton, + depth, + Some(MethodOwner { + declaration_id, + scope_id, + qualified_name: qualified, + }), + ) + } + + fn walk_singleton_class(&mut self, node: Node<'_>, depth: usize) -> Result<(), EvidenceError> { + let parent = self.current().clone(); + let Some(receiver) = parent.receiver_qualified_name.clone() else { + return self.walk_named_children(node, depth.saturating_add(1)); + }; + let scope_id = self.builder.open_scope( + "singleton_class", + Some(&parent.owner_declaration_id), + Some(&parent.scope_id), + range_for_node(self.source_file, node), + )?; + self.frames.push(ScopeFrame { + scope_id, + owner_declaration_id: parent.owner_declaration_id, + owner_qualified_name: parent.owner_qualified_name, + lexical_prefix: parent.lexical_prefix, + receiver_qualified_name: Some(receiver), + receiver_scope_id: parent.receiver_scope_id, + method_space: Some(MethodSpace::Singleton), + method_name: parent.method_name, + local_bindings: parent.local_bindings, + local_receivers: parent.local_receivers, + }); + self.walk_named_children(node, depth.saturating_add(1))?; + self.frames.pop(); + Ok(()) + } + + fn walk_block(&mut self, node: Node<'_>, depth: usize) -> Result<(), EvidenceError> { + let parent = self.current().clone(); + let scope_id = self.builder.open_scope( + "block", + Some(&parent.owner_declaration_id), + Some(&parent.scope_id), + range_for_node(self.source_file, node), + )?; + self.frames.push(ScopeFrame { scope_id, ..parent }); + self.walk_named_children(node, depth.saturating_add(1))?; + self.frames.pop(); + Ok(()) + } + + fn emit_parameters(&mut self, method: Node<'_>) -> Result<(), EvidenceError> { + let Some(parameters) = method.child_by_field_name("parameters") else { + return Ok(()); + }; + let owner = self.current().owner_qualified_name.clone(); + let scope_id = self.current().scope_id.clone(); + let mut cursor = parameters.walk(); + for parameter in parameters.children(&mut cursor).filter(Node::is_named) { + let Some(name_node) = parameter + .child_by_field_name("name") + .or_else(|| (parameter.kind() == "identifier").then_some(parameter)) + else { + continue; + }; + let Some(name) = self.text(name_node) else { + continue; + }; + if name.is_empty() || name == "_" { + continue; + } + if let Some(frame) = self.frames.last_mut() { + frame.local_bindings.insert(name.clone()); + } + let qualified_name = format!("{owner}.{name}"); + let graph_node_id = stable_graph_id("parameter", &qualified_name); + let declaration_id = self.builder.declare_with_namespace( + "parameter", + &graph_node_id, + &name, + &qualified_name, + Some(&owner), + Some(&scope_id), + Some(SymbolNamespace::Value), + range_for_node(self.source_file, name_node), + )?; + self.emit_contains( + &self.current().owner_declaration_id.clone(), + &declaration_id, + &name, + "parameter", + )?; + } + Ok(()) + } + + fn emit_hierarchy( + &mut self, + owner_node: Node<'_>, + superclass: Node<'_>, + owner_declaration_id: &str, + owner_qualified_name: &str, + ) -> Result<(), EvidenceError> { + let Some(raw) = self + .text_node_child(superclass) + .or_else(|| self.text(superclass)) + else { + return Ok(()); + }; + let qualified = self.resolve_constant_name(&raw); + let scope_id = self.current().scope_id.clone(); + let Some(occurrence) = self + .builder + .occur_with_context( + SemanticRole::BaseType, + owner_declaration_id, + &raw, + Some(&qualified), + Some(&scope_id), + Some("superclass"), + range_for_node(self.source_file, superclass), + ) + .ok() + else { + return Ok(()); + }; + let constraints = ResolutionConstraint { + exact_language: Some("ruby".to_owned()), + qualified_name: Some(qualified.clone()), + allowed_target_kinds: vec!["class".to_owned(), "trait".to_owned()], + hierarchy: Some(HierarchyConstraint::DirectBase { + base_set_complete: true, + }), + ..ResolutionConstraint::default() + }; + let mut constraints = constraints; + constraints.scope_id = Some(scope_id); + self.builder.relate( + CandidateRelation::Extends, + owner_declaration_id, + Some(&occurrence), + None, + last_component(&raw).as_str(), + constraints, + )?; + let _ = owner_node; + let _ = owner_qualified_name; + Ok(()) + } + + fn emit_call(&mut self, node: Node<'_>) -> Result<(), EvidenceError> { + let Some(method_node) = node.child_by_field_name("method") else { + return Ok(()); + }; + let Some(mut method_name) = self.text(method_node) else { + return Ok(()); + }; + // Tree-sitter represents a receiver setter (`Config.value = x`) as a + // call whose method token is `value`, with the assignment operator + // outside that token. Preserve Ruby's setter identity in the + // candidate while keeping the exact UTF-8 anchor on the method name. + let is_setter = node + .parent() + .filter(|parent| parent.kind() == "assignment") + .and_then(|parent| parent.child_by_field_name("left")) + .is_some_and(|left| left.id() == node.id()); + if is_setter { + method_name.push('='); + } + if method_name.is_empty() || method_name.len() > MAX_LITERAL_BYTES { + return Ok(()); + } + let implicit_receiver = node + .child_by_field_name("receiver") + .and_then(|receiver| self.text(receiver)) + .is_none_or(|receiver| receiver == "self"); + if matches!( + method_name.as_str(), + "send" | "public_send" | "method_missing" | "eval" | "class_eval" | "module_eval" + ) { + self.diagnose_once( + "dynamic_dispatch_unresolved", + Some(range_for_node(self.source_file, node)), + "dynamic Ruby dispatch is intentionally unresolved", + )?; + return Ok(()); + } + if implicit_receiver && matches!(method_name.as_str(), "include" | "prepend" | "extend") { + return self.emit_mixin(node, &method_name); + } + if implicit_receiver && matches!(method_name.as_str(), "require" | "require_relative") { + return self.emit_require(node, &method_name); + } + if implicit_receiver && method_name == "autoload" { + return self.emit_autoload(node); + } + if implicit_receiver + && matches!( + method_name.as_str(), + "attr_reader" | "attr_writer" | "attr_accessor" + ) + { + return self.emit_attributes(node, &method_name); + } + if implicit_receiver && method_name == "alias_method" { + return self.emit_alias_method(node); + } + if implicit_receiver && method_name == "define_method" { + return self.emit_define_method(node); + } + let source_owner = self.current().owner_declaration_id.clone(); + let scope_id = self.current().scope_id.clone(); + let receiver_node = node.child_by_field_name("receiver"); + let receiver_text = receiver_node.and_then(|receiver| self.text(receiver)); + let receiver_qualified = receiver_node + .and_then(|receiver| self.receiver_type(receiver)) + .or_else(|| { + self.current() + .method_space + .and(self.current().receiver_qualified_name.clone()) + }); + let argument_count = node + .child_by_field_name("arguments") + .map(count_arguments) + .or_else(|| (node.child_by_field_name("arguments").is_none()).then_some(0)); + let call_space = self.call_method_space(receiver_text.as_deref()); + let occurrence_role = SemanticRole::Call; + let qualifier = receiver_text.as_deref(); + let context = call_space.map(MethodSpace::context); + let occurrence = self.builder.occur_with_context( + occurrence_role, + &source_owner, + &method_name, + qualifier, + Some(&scope_id), + context, + range_for_node(self.source_file, method_node), + )?; + let is_constructor = method_name == "new" + && receiver_qualified.is_some() + && (receiver_node.is_some() || call_space == Some(MethodSpace::Singleton)); + let relation = if is_constructor { + CandidateRelation::Constructs + } else { + CandidateRelation::Calls + }; + let target_qualified = receiver_qualified.as_ref().map(|receiver| { + let space = if receiver_text.as_deref() == Some("self") { + call_space.unwrap_or(MethodSpace::Singleton) + } else { + MethodSpace::Instance + }; + if is_constructor { + receiver.clone() + } else { + format!("{receiver}{}{method_name}", space.separator()) + } + }); + let hierarchy = (!is_constructor) + .then_some(receiver_qualified.as_ref()) + .flatten() + .map(|receiver| HierarchyConstraint::ReceiverDispatch { + receiver_qualified_name: receiver.clone(), + strategy: ReceiverDispatchStrategy::C3FromReceiver, + }); + let qualified_name = hierarchy.is_none().then_some(target_qualified).flatten(); + let constraints = ResolutionConstraint { + exact_language: Some("ruby".to_owned()), + qualified_name, + argument_count, + allowed_target_kinds: if is_constructor { + vec!["class".to_owned(), "trait".to_owned()] + } else { + vec!["method".to_owned(), "function".to_owned()] + }, + hierarchy, + ..ResolutionConstraint::default() + }; + self.builder.relate( + relation, + &source_owner, + Some(&occurrence), + None, + &method_name, + constraints, + )?; + if let Some(left) = node.parent().filter(|parent| parent.kind() == "assignment") + && let Some(receiver) = receiver_qualified + && method_name == "new" + && let Some(name) = left + .child_by_field_name("left") + .and_then(|left| self.text(left)) + && let Some(frame) = self.frames.last_mut() + { + frame.local_receivers.insert(name, receiver); + } + Ok(()) + } + + fn emit_bare_call(&mut self, node: Node<'_>) -> Result<(), EvidenceError> { + let Some(method_name) = self.text(node) else { + return Ok(()); + }; + if method_name.is_empty() + || method_name.len() > MAX_LITERAL_BYTES + || self.current().method_space.is_none() + || self.lookup_local_receiver(&method_name).is_some() + || self.current().local_bindings.contains(&method_name) + || node.parent().is_some_and(|parent| { + parent.kind() == "call" + && parent + .child_by_field_name("method") + .is_some_and(|method| method.id() == node.id()) + }) + { + return Ok(()); + } + let owner_id = self.current().owner_declaration_id.clone(); + let scope_id = self.current().scope_id.clone(); + let Some(receiver) = self.current().receiver_qualified_name.clone() else { + return Ok(()); + }; + let occurrence = self.builder.occur_with_context( + SemanticRole::Call, + &owner_id, + &method_name, + None, + Some(&scope_id), + Some( + self.current() + .method_space + .map_or("instance", MethodSpace::context), + ), + range_for_node(self.source_file, node), + )?; + self.builder.relate( + CandidateRelation::Calls, + &owner_id, + Some(&occurrence), + None, + &method_name, + ResolutionConstraint { + exact_language: Some("ruby".to_owned()), + allowed_target_kinds: vec!["method".to_owned(), "function".to_owned()], + hierarchy: Some(HierarchyConstraint::ReceiverDispatch { + receiver_qualified_name: receiver, + strategy: ReceiverDispatchStrategy::C3FromReceiver, + }), + ..ResolutionConstraint::default() + }, + )?; + Ok(()) + } + + fn emit_super(&mut self, node: Node<'_>) -> Result<(), EvidenceError> { + let Some(method_space) = self.current().method_space else { + return Ok(()); + }; + let Some(receiver) = self.current().receiver_qualified_name.clone() else { + return Ok(()); + }; + let method_name = self + .current() + .method_name + .as_deref() + .map(str::to_owned) + .unwrap_or_default(); + if method_name.is_empty() { + return Ok(()); + } + let owner_id = self.current().owner_declaration_id.clone(); + let scope_id = self.current().scope_id.clone(); + let occurrence = self.builder.occur_with_context( + SemanticRole::Call, + &owner_id, + "super", + Some(&receiver), + Some(&scope_id), + Some(method_space.context()), + range_for_node(self.source_file, node), + )?; + self.builder.relate( + CandidateRelation::Calls, + &owner_id, + Some(&occurrence), + None, + &method_name, + ResolutionConstraint { + exact_language: Some("ruby".to_owned()), + allowed_target_kinds: vec!["method".to_owned()], + hierarchy: Some(HierarchyConstraint::ReceiverDispatch { + receiver_qualified_name: receiver, + strategy: ReceiverDispatchStrategy::C3AfterReceiver, + }), + ..ResolutionConstraint::default() + }, + )?; + Ok(()) + } + + fn emit_mixin(&mut self, node: Node<'_>, operation: &str) -> Result<(), EvidenceError> { + let Some(argument) = first_argument(node) else { + self.diagnose_once( + "dynamic_mixin_unresolved", + Some(range_for_node(self.source_file, node)), + "Ruby mixin target is not a single source-visible constant", + )?; + return Ok(()); + }; + let Some(raw) = self.text(argument) else { + return Ok(()); + }; + if !is_constant_path(&raw) { + self.diagnose_once( + "dynamic_mixin_unresolved", + Some(range_for_node(self.source_file, argument)), + "Ruby mixin target is dynamic or not a constant path", + )?; + return Ok(()); + } + let qualified = self.resolve_constant_name(&raw); + let owner_id = self.current().owner_declaration_id.clone(); + let scope_id = self.current().scope_id.clone(); + let occurrence = self.builder.occur_with_context( + SemanticRole::TraitBound, + &owner_id, + &raw, + Some(&qualified), + Some(&scope_id), + Some(operation), + range_for_node(self.source_file, argument), + )?; + self.builder.relate( + CandidateRelation::UsesTrait, + &owner_id, + Some(&occurrence), + None, + last_component(&raw).as_str(), + ResolutionConstraint { + exact_language: Some("ruby".to_owned()), + qualified_name: Some(qualified), + scope_id: Some(scope_id), + allowed_target_kinds: vec!["trait".to_owned()], + ..ResolutionConstraint::default() + }, + )?; + Ok(()) + } + + fn emit_require(&mut self, node: Node<'_>, method_name: &str) -> Result<(), EvidenceError> { + let Some(argument) = first_argument(node) else { + self.diagnose_once( + "dynamic_require_unresolved", + Some(range_for_node(self.source_file, node)), + "dynamic Ruby require target is intentionally unresolved", + )?; + return Ok(()); + }; + let Some(raw) = literal_string(argument, self.source) else { + self.diagnose_once( + "dynamic_require_unresolved", + Some(range_for_node(self.source_file, argument)), + "Ruby require target is not a literal string", + )?; + return Ok(()); + }; + if raw.len() > MAX_LITERAL_BYTES { + return Err(EvidenceError::new( + EvidenceErrorCode::ResourceLimit, + "Ruby require literal exceeds the bounded evidence size", + )); + } + let owner_id = self.current().owner_declaration_id.clone(); + let scope_id = self.current().scope_id.clone(); + let occurrence = self.builder.occur_with_context( + SemanticRole::Import, + &owner_id, + &raw, + None, + Some(&scope_id), + Some(method_name), + range_for_node(self.source_file, argument), + )?; + let binding = self.builder.bind( + BindingKind::Import, + &raw, + &raw, + None, + Some(&scope_id), + range_for_node(self.source_file, argument), + )?; + self.builder.relate( + CandidateRelation::Imports, + &owner_id, + Some(&occurrence), + Some(&binding), + &raw, + ResolutionConstraint { + exact_language: Some("ruby".to_owned()), + qualified_name: Some(raw.clone()), + allowed_target_kinds: vec![ + "file".to_owned(), + "module".to_owned(), + "trait".to_owned(), + ], + ..ResolutionConstraint::default() + }, + )?; + Ok(()) + } + + fn emit_autoload(&mut self, node: Node<'_>) -> Result<(), EvidenceError> { + let Some(arguments) = node.child_by_field_name("arguments") else { + return Ok(()); + }; + let mut cursor = arguments.walk(); + let mut values = arguments.children(&mut cursor).filter(Node::is_named); + let Some(constant) = values.next() else { + return Ok(()); + }; + let Some(path) = values + .next() + .and_then(|node| literal_string(node, self.source)) + else { + self.diagnose_once( + "dynamic_autoload_unresolved", + Some(range_for_node(self.source_file, node)), + "Ruby autoload path is not a literal string", + )?; + return Ok(()); + }; + let Some(constant_name) = self.text(constant) else { + return Ok(()); + }; + let qualified = self.resolve_constant_name(&constant_name); + let owner_id = self.current().owner_declaration_id.clone(); + let scope_id = self.current().scope_id.clone(); + let occurrence = self.builder.occur_with_context( + SemanticRole::Import, + &owner_id, + &path, + Some(&qualified), + Some(&scope_id), + Some("autoload"), + range_for_node(self.source_file, node), + )?; + let binding = self.builder.bind( + BindingKind::Import, + &constant_name, + &path, + None, + Some(&scope_id), + range_for_node(self.source_file, node), + )?; + self.builder.relate( + CandidateRelation::Imports, + &owner_id, + Some(&occurrence), + Some(&binding), + &constant_name, + ResolutionConstraint { + exact_language: Some("ruby".to_owned()), + qualified_name: Some(path), + allowed_target_kinds: vec![ + "file".to_owned(), + "module".to_owned(), + "trait".to_owned(), + ], + ..ResolutionConstraint::default() + }, + )?; + Ok(()) + } + + fn emit_alias(&mut self, node: Node<'_>) -> Result<(), EvidenceError> { + let Some(alias_node) = node.child_by_field_name("name") else { + return Ok(()); + }; + let Some(target_node) = node.child_by_field_name("alias") else { + return Ok(()); + }; + let Some(alias) = self.text(alias_node).map(|value| strip_symbol(&value)) else { + return Ok(()); + }; + let Some(target) = self.text(target_node).map(|value| strip_symbol(&value)) else { + return Ok(()); + }; + let owner = self.current().receiver_qualified_name.clone(); + let target_qualified = owner.as_ref().map(|owner| format!("{owner}#{target}")); + let owner_id = self.current().owner_declaration_id.clone(); + let scope_id = self.current().scope_id.clone(); + let occurrence = self.builder.occur_with_context( + SemanticRole::Alias, + &owner_id, + &alias, + Some(&target), + Some(&scope_id), + Some("alias"), + range_for_node(self.source_file, node), + )?; + let binding = self.builder.bind( + BindingKind::LocalAlias, + &alias, + target_qualified.as_deref().unwrap_or(&target), + None, + Some(&scope_id), + range_for_node(self.source_file, alias_node), + )?; + self.builder.relate( + CandidateRelation::References, + &owner_id, + Some(&occurrence), + Some(&binding), + &target, + ResolutionConstraint { + exact_language: Some("ruby".to_owned()), + qualified_name: target_qualified, + allowed_target_kinds: vec!["method".to_owned()], + ..ResolutionConstraint::default() + }, + )?; + Ok(()) + } + + fn emit_alias_method(&mut self, node: Node<'_>) -> Result<(), EvidenceError> { + let Some(arguments) = node.child_by_field_name("arguments") else { + return Ok(()); + }; + let mut cursor = arguments.walk(); + let mut values = arguments.children(&mut cursor).filter(Node::is_named); + let Some(alias) = values + .next() + .and_then(|value| literal_string(value, self.source)) + else { + self.diagnose_once( + "dynamic_alias_unresolved", + Some(range_for_node(self.source_file, node)), + "Ruby alias_method name is not a literal symbol or string", + )?; + return Ok(()); + }; + let Some(target) = values + .next() + .and_then(|value| literal_string(value, self.source)) + else { + self.diagnose_once( + "dynamic_alias_unresolved", + Some(range_for_node(self.source_file, node)), + "Ruby alias_method target is not a literal symbol or string", + )?; + return Ok(()); + }; + self.emit_method_alias(node, &alias, &target) + } + + fn emit_method_alias( + &mut self, + node: Node<'_>, + alias: &str, + target: &str, + ) -> Result<(), EvidenceError> { + let Some(owner) = self.current().receiver_qualified_name.clone() else { + self.diagnose_once( + "dynamic_alias_unresolved", + Some(range_for_node(self.source_file, node)), + "Ruby method alias has no source-visible owner", + )?; + return Ok(()); + }; + let owner_id = self.current().owner_declaration_id.clone(); + let scope_id = self.current().scope_id.clone(); + let space = self.current().method_space.unwrap_or(MethodSpace::Instance); + let target_qualified = format!("{owner}{}{target}", space.separator()); + let alias_qualified = format!("{owner}{}{alias}", space.separator()); + let alias_id = self.builder.declare_with_namespace( + "method", + &stable_graph_id("method", &alias_qualified), + alias, + &alias_qualified, + Some(&owner), + Some(&scope_id), + Some(SymbolNamespace::Value), + range_for_node(self.source_file, node), + )?; + self.emit_contains(&owner_id, &alias_id, alias, "method")?; + let occurrence = self.builder.occur_with_context( + SemanticRole::Alias, + &alias_id, + target, + Some(&target_qualified), + Some(&scope_id), + Some("alias_method"), + range_for_node(self.source_file, node), + )?; + self.builder.relate( + CandidateRelation::References, + &alias_id, + Some(&occurrence), + None, + target, + ResolutionConstraint { + exact_language: Some("ruby".to_owned()), + qualified_name: Some(target_qualified), + allowed_target_kinds: vec!["method".to_owned()], + ..ResolutionConstraint::default() + }, + )?; + Ok(()) + } + + fn emit_define_method(&mut self, node: Node<'_>) -> Result<(), EvidenceError> { + let Some(argument) = first_argument(node) else { + return Ok(()); + }; + let Some(name) = literal_string(argument, self.source) else { + self.diagnose_once( + "dynamic_define_method_unresolved", + Some(range_for_node(self.source_file, node)), + "Ruby define_method name is not a literal symbol or string", + )?; + return Ok(()); + }; + let Some(owner) = self.current().receiver_qualified_name.clone() else { + self.diagnose_once( + "dynamic_define_method_unresolved", + Some(range_for_node(self.source_file, node)), + "Ruby define_method has no source-visible owner", + )?; + return Ok(()); + }; + let owner_id = self.current().owner_declaration_id.clone(); + let scope_id = self.current().scope_id.clone(); + let qualified_name = format!("{owner}#{name}"); + let declaration_id = self.builder.declare_with_namespace( + "method", + &stable_graph_id("method", &qualified_name), + &name, + &qualified_name, + Some(&owner), + Some(&scope_id), + Some(SymbolNamespace::Value), + range_for_node(self.source_file, argument), + )?; + self.emit_contains(&owner_id, &declaration_id, &name, "method")?; + Ok(()) + } + + fn emit_attributes(&mut self, node: Node<'_>, method_name: &str) -> Result<(), EvidenceError> { + let Some(owner_type) = self.current().receiver_qualified_name.clone() else { + return Ok(()); + }; + let Some(arguments) = node.child_by_field_name("arguments") else { + return Ok(()); + }; + let receiver_scope_id = self.current().receiver_scope_id.clone(); + let method_separator = self + .current() + .method_space + .filter(|space| *space == MethodSpace::Singleton) + .map_or("#", |_| "."); + let mut cursor = arguments.walk(); + for argument in arguments.children(&mut cursor).filter(Node::is_named) { + let Some(raw) = self.text(argument) else { + continue; + }; + let name = raw.trim_start_matches(':').trim_matches(['"', '\'']); + if name.is_empty() || name.len() > MAX_LITERAL_BYTES { + continue; + } + let field_name = if method_name == "attr_writer" { + format!("{name}=") + } else { + name.to_owned() + }; + let qualified_name = format!("{owner_type}.{field_name}"); + let graph_node_id = stable_graph_id("field", &qualified_name); + let declaration_id = self.builder.declare_with_namespace( + "field", + &graph_node_id, + &field_name, + &qualified_name, + Some(&owner_type), + receiver_scope_id.as_deref(), + Some(SymbolNamespace::Value), + range_for_node(self.source_file, argument), + )?; + self.emit_contains( + &self.current().owner_declaration_id.clone(), + &declaration_id, + &field_name, + "field", + )?; + let generated_methods = match method_name { + "attr_reader" => vec![name.to_owned()], + "attr_writer" => vec![format!("{name}=")], + "attr_accessor" => vec![name.to_owned(), format!("{name}=")], + _ => Vec::new(), + }; + for generated_name in generated_methods { + let qualified_method = format!("{owner_type}{method_separator}{generated_name}"); + let method_id = self.builder.declare_with_namespace( + "method", + &stable_graph_id("method", &qualified_method), + &generated_name, + &qualified_method, + Some(&owner_type), + receiver_scope_id.as_deref(), + Some(SymbolNamespace::Value), + range_for_node(self.source_file, argument), + )?; + self.emit_contains( + &self.current().owner_declaration_id.clone(), + &method_id, + &generated_name, + "method", + )?; + } + } + Ok(()) + } + + fn emit_assignment(&mut self, node: Node<'_>) -> Result<(), EvidenceError> { + let Some(left) = node.child_by_field_name("left") else { + return Ok(()); + }; + let Some(name) = self.text(left) else { + return Ok(()); + }; + let Some(right) = node.child_by_field_name("right") else { + return Ok(()); + }; + if let Some(receiver) = self.constructor_receiver(right) + && is_local_identifier(&name) + && let Some(frame) = self.frames.last_mut() + { + frame.local_receivers.insert(name.clone(), receiver); + } + if is_local_identifier(&name) + && let Some(frame) = self.frames.last_mut() + { + frame.local_bindings.insert(name.clone()); + } + if name.starts_with('@') { + if let Some(owner) = self.current().receiver_qualified_name.clone() { + let receiver_scope_id = self.current().receiver_scope_id.clone(); + let qualified_name = format!("{owner}.{name}"); + let graph_node_id = stable_graph_id("field", &qualified_name); + let declaration_id = self.builder.declare_with_namespace( + "field", + &graph_node_id, + &name, + &qualified_name, + Some(&owner), + receiver_scope_id.as_deref(), + Some(SymbolNamespace::Value), + range_for_node(self.source_file, left), + )?; + self.emit_contains( + &self.current().owner_declaration_id.clone(), + &declaration_id, + &name, + "field", + )?; + } + } else if is_constant_path(&name) { + let qualified_name = qualify(&self.current().lexical_prefix, &name); + let scope_id = self.current().scope_id.clone(); + let graph_node_id = stable_graph_id("constant", &qualified_name); + let declaration_id = self.builder.declare_with_namespace( + "constant", + &graph_node_id, + &last_component(&name), + &qualified_name, + package_of(&qualified_name), + Some(&scope_id), + Some(SymbolNamespace::Namespace), + range_for_node(self.source_file, left), + )?; + self.emit_contains( + &self.current().owner_declaration_id.clone(), + &declaration_id, + &last_component(&name), + "constant", + )?; + } + Ok(()) + } + + fn constructor_receiver(&self, node: Node<'_>) -> Option { + if node.kind() != "call" + || node + .child_by_field_name("method") + .and_then(|node| self.text(node)) + .as_deref() + != Some("new") + { + return None; + } + node.child_by_field_name("receiver") + .and_then(|receiver| self.receiver_type(receiver)) + } + + fn emit_contains( + &mut self, + owner_id: &str, + target_id: &str, + name: &str, + kind: &str, + ) -> Result<(), EvidenceError> { + self.builder + .relate( + CandidateRelation::Contains, + owner_id, + None, + None, + name, + ResolutionConstraint { + exact_target_declaration_id: Some(target_id.to_owned()), + exact_language: Some("ruby".to_owned()), + allowed_target_kinds: vec![kind.to_owned()], + ..ResolutionConstraint::default() + }, + ) + .map(|_| ()) + } + + fn receiver_type(&self, node: Node<'_>) -> Option { + let text = self.text(node)?; + if text == "self" { + return self.current().receiver_qualified_name.clone(); + } + if is_constant_path(&text) { + return Some(self.resolve_constant_name(&text)); + } + self.lookup_local_receiver(&text) + } + + fn call_method_space(&self, receiver: Option<&str>) -> Option { + if receiver.is_some_and(is_constant_path) { + return Some(MethodSpace::Singleton); + } + if receiver == Some("self") && self.current().method_space == Some(MethodSpace::Singleton) { + return Some(MethodSpace::Singleton); + } + self.current().method_space + } + + fn lookup_local_receiver(&self, name: &str) -> Option { + self.frames + .iter() + .rev() + .find_map(|frame| frame.local_receivers.get(name).cloned()) + } + + fn resolve_constant_name(&self, raw: &str) -> String { + let raw = raw.trim(); + if raw.starts_with("::") { + return raw.trim_start_matches("::").to_owned(); + } + let mut prefix = self.current().lexical_prefix.clone(); + loop { + let candidate = qualify(&prefix, raw); + if self.types.contains_key(&candidate) { + return candidate; + } + let Some(parent) = prefix + .rsplit_once("::") + .map(|(parent, _)| parent.to_owned()) + else { + break; + }; + prefix = parent; + } + let fallback_prefix = self.current().lexical_prefix.rsplit_once("::").map_or_else( + || self.current().lexical_prefix.clone(), + |(parent, _)| parent.to_owned(), + ); + qualify(&fallback_prefix, raw) + } + + fn text_node_child(&self, node: Node<'_>) -> Option { + let mut cursor = node.walk(); + node.children(&mut cursor) + .find(Node::is_named) + .and_then(|child| self.text(child)) + } + + fn text(&self, node: Node<'_>) -> Option { + self.source + .get(node.start_byte()..node.end_byte()) + .and_then(|bytes| std::str::from_utf8(bytes).ok()) + .map(str::to_owned) + } + + fn current(&self) -> &ScopeFrame { + self.frames.last().unwrap_or(&self.frames[0]) + } + + fn overlaps_error(&self, node: Node<'_>) -> bool { + // Tree-sitter can wrap an otherwise useful recovered prefix in one + // root ERROR node. Walk that node's children so valid calls and + // declarations before the malformed token still produce evidence; + // only zero-width missing nodes are inherently untrusted. + node.is_missing() + } + + fn diagnose_once( + &mut self, + code: &str, + range: Option, + message: &str, + ) -> Result<(), EvidenceError> { + if self.emitted_diagnostics.insert(code.to_owned()) { + self.builder.diagnose(code, None, range, message)?; + } + Ok(()) + } +} + +fn stable_graph_id(kind: &str, value: &str) -> String { + let encoded = value + .replace("::", "_scope_") + .replace('#', "_instance_") + .replace('.', "_singleton_"); + make_id(&["ruby", kind, &encoded]) +} + +fn qualify(prefix: &str, raw: &str) -> String { + let raw = raw.trim(); + if raw.starts_with("::") || prefix.is_empty() || raw.contains("::") { + raw.trim_start_matches("::").to_owned() + } else { + format!("{prefix}::{raw}") + } +} + +fn package_of(qualified: &str) -> Option<&str> { + qualified.rsplit_once("::").map(|(package, _)| package) +} + +fn last_component(value: &str) -> String { + value + .rsplit("::") + .next() + .unwrap_or(value) + .rsplit(['#', '.']) + .next() + .unwrap_or(value) + .to_owned() +} + +fn is_constant_path(value: &str) -> bool { + let value = value.trim().trim_start_matches("::"); + !value.is_empty() + && value.split("::").all(|part| { + let mut chars = part.chars(); + chars.next().is_some_and(char::is_uppercase) + && chars.all(|character| character.is_alphanumeric() || character == '_') + }) +} + +fn is_local_identifier(value: &str) -> bool { + let mut chars = value.chars(); + chars + .next() + .is_some_and(|character| character.is_lowercase() || character == '_') + && chars.all(|character| character.is_alphanumeric() || character == '_') +} + +fn first_argument(node: Node<'_>) -> Option> { + let arguments = node.child_by_field_name("arguments")?; + let mut cursor = arguments.walk(); + arguments.children(&mut cursor).find(Node::is_named) +} + +fn count_arguments(arguments: Node<'_>) -> u32 { + let mut cursor = arguments.walk(); + let count = arguments + .children(&mut cursor) + .filter(Node::is_named) + .count(); + u32::try_from(count).unwrap_or(u32::MAX) +} + +fn literal_string(node: Node<'_>, source: &[u8]) -> Option { + if !matches!( + node.kind(), + "string" | "string_content" | "symbol" | "simple_symbol" + ) { + return None; + } + let value = source.get(node.start_byte()..node.end_byte())?; + let value = std::str::from_utf8(value).ok()?.trim(); + let value = value + .strip_prefix(":") + .unwrap_or(value) + .trim_matches(['"', '\'']); + (!value.is_empty()).then(|| value.to_owned()) +} + +fn strip_symbol(value: &str) -> String { + value + .trim() + .strip_prefix(':') + .unwrap_or(value.trim()) + .trim_matches(['"', '\'']) + .to_owned() +} diff --git a/crates/compass-languages/src/frameworks/mod.rs b/crates/compass-languages/src/frameworks/mod.rs index ed4d7b54..fe5ef709 100644 --- a/crates/compass-languages/src/frameworks/mod.rs +++ b/crates/compass-languages/src/frameworks/mod.rs @@ -289,7 +289,7 @@ const FRAMEWORK_PACKS: &[FrameworkPack] = &[ FrameworkPack::universal(&pack::SPRING_KOTLIN_DESCRIPTOR, spring::detect_kotlin), FrameworkPack::source("python-web", &["python"], &[], detect_python), FrameworkPack::universal(&pack::PHP_FRAMEWORKS_DESCRIPTOR, php::detect), - FrameworkPack::source("rails-routes", &["ruby"], &["rails"], detect_ruby), + FrameworkPack::universal(&pack::RAILS_RUBY_DESCRIPTOR, ruby::detect_universal), FrameworkPack::source("go-web", &["go"], &[], detect_go), FrameworkPack::source("axum-web", &["rust"], &["axum"], detect_axum), FrameworkPack::source("rust-web", &["rust"], &[], detect_rust), @@ -526,13 +526,6 @@ fn detect_python( python::detect(context.path, context.source, context.root) } -fn detect_ruby( - context: &DetectionContext<'_, '_>, - _extraction: &mut Extraction, -) -> Vec { - ruby::detect(context.path, context.source, context.root) -} - fn detect_go( context: &DetectionContext<'_, '_>, _extraction: &mut Extraction, @@ -667,7 +660,7 @@ mod tests { "spring-java", "python-web", "php-frameworks", - "rails-routes", + "rails-ruby", "spring-kotlin", "go-web", "axum-web", diff --git a/crates/compass-languages/src/frameworks/pack.rs b/crates/compass-languages/src/frameworks/pack.rs index a59bf2d5..7501c79b 100644 --- a/crates/compass-languages/src/frameworks/pack.rs +++ b/crates/compass-languages/src/frameworks/pack.rs @@ -504,6 +504,34 @@ pub(super) const SPRING_KOTLIN_DESCRIPTOR: FrameworkPackDescriptor = FrameworkPa }, }; +pub(super) const RAILS_RUBY_DESCRIPTOR: FrameworkPackDescriptor = FrameworkPackDescriptor { + id: "rails-ruby", + kind: FrameworkPackKind::Source, + languages: &["ruby"], + required_capabilities: &[ + LanguageCapability::Declarations, + LanguageCapability::LexicalScopes, + LanguageCapability::Namespaces, + LanguageCapability::Traits, + LanguageCapability::Calls, + LanguageCapability::Members, + LanguageCapability::Ownership, + ], + framework_capabilities: &[FrameworkCapability::HttpRoutes], + dependency_markers: &["rails"], + manifest_policy: FrameworkManifestPolicy::Advisory, + activation_rules: &["rails-routes-draw"], + accepted_roles: &[SemanticRole::Call, SemanticRole::Ownership], + emitted_relation_families: &[FrameworkRelation::RoutesTo], + occurrence_policy: FrameworkOccurrencePolicy::ExactEvidence, + limits: FrameworkLimits { + max_candidates: 20, + max_include_depth: 32, + max_alias_expansions: 1_000, + max_facts_per_file: 100_000, + }, +}; + pub(super) const ASPNET_CSHARP_DESCRIPTOR: FrameworkPackDescriptor = FrameworkPackDescriptor { id: "aspnet-csharp", kind: FrameworkPackKind::Source, @@ -578,4 +606,5 @@ const UNIVERSAL_FRAMEWORK_PACKS: &[FrameworkPackDescriptor] = &[ PHP_FRAMEWORKS_DESCRIPTOR, SPRING_JAVA_DESCRIPTOR, SPRING_KOTLIN_DESCRIPTOR, + RAILS_RUBY_DESCRIPTOR, ]; diff --git a/crates/compass-languages/src/frameworks/ruby.rs b/crates/compass-languages/src/frameworks/ruby.rs index 050e0578..c2fb7204 100644 --- a/crates/compass-languages/src/frameworks/ruby.rs +++ b/crates/compass-languages/src/frameworks/ruby.rs @@ -1,185 +1,320 @@ -use std::path::Path; - -use regex::Regex; use serde_json::Map; use tree_sitter::Node; -use super::evidence::{EvidenceKind, EvidenceSet}; -use super::text::{join_route_path, line_anchor, literal, normalize_route_path, text}; -use super::{RawFrameworkFact, RawFrameworkOrigin, RawRouteFact}; - -pub(super) fn detect(path: &Path, source: &[u8], _root: Node<'_>) -> Vec { - let body = text(source); - let evidence = EvidenceSet::new() - .direct_if( - body.contains(".routes.draw do"), - "rails", - EvidenceKind::Receiver, - "Rails.application.routes", - ) - .supporting_if( - is_rails_routes_path(path), - "rails", - EvidenceKind::Convention, - "config/routes.rb", - ); - if !evidence.activates("rails") { +use crate::SemanticRole; + +use super::text::{join_route_path, literal, normalize_route_path}; +use super::{ + RawFrameworkAnchor, RawFrameworkFact, RawFrameworkOrigin, RawRouteFact, + UniversalDetectionContext, +}; + +/// Universal Rails routing detector. It consumes only AST calls that have a +/// matching Ruby universal occurrence; source text is used for literal +/// argument values and never as a line-oriented semantic authority. +pub(super) fn detect_universal( + context: &UniversalDetectionContext<'_, '_>, +) -> Vec { + if context.evidence.adapter.language != "ruby" { return Vec::new(); } - let Ok(scope) = Regex::new( - r#"^\s*(?:scope\s+((?:'[^']*')|(?:"[^"]*"))|namespace\s+:([A-Za-z_][A-Za-z0-9_]*))\s+do\b"#, - ) else { + let source_file = context + .evidence + .declarations + .iter() + .find(|declaration| declaration.kind == "file") + .map(|declaration| declaration.range.source_file.clone()) + .unwrap_or_default(); + if source_file.is_empty() { return Vec::new(); - }; + } + let call_occurrences = context + .evidence + .occurrences + .iter() + .filter(|occurrence| occurrence.role == SemanticRole::Call) + .map(|occurrence| (occurrence.range.start_byte, occurrence.spelling.as_str())) + .collect::>(); + let mut calls = Vec::new(); + collect_call_nodes(context.root, &mut calls); + let mut routes = Vec::new(); + for call in calls { + let Some(method_node) = call.child_by_field_name("method") else { + continue; + }; + if method_name(call, context.source).as_deref() != Some("draw") + || !call_occurrences.contains(&(method_node.start_byte() as u64, "draw")) + || receiver_text(call, context.source).as_deref() != Some("Rails.application.routes") + { + continue; + } + let Some(block) = call.child_by_field_name("block") else { + continue; + }; + collect_routes( + block, + context, + &source_file, + String::new(), + Vec::new(), + &call_occurrences, + &mut routes, + ); + } + routes.sort_by_key(route_key); + routes +} - let mut facts = Vec::new(); - let mut prefixes = Vec::::new(); - let mut namespaces = Vec::::new(); - let mut scope_frames = Vec::::new(); - let mut in_draw = false; - let mut block_depth = 0_usize; - let mut offset = 0_usize; - for line in body.split_inclusive('\n') { - let trimmed = line.trim(); - if !in_draw { - if line.contains(".routes.draw do") { - in_draw = true; - block_depth = 1; - } - offset = offset.saturating_add(line.len()); +fn collect_call_nodes<'tree>(node: Node<'tree>, calls: &mut Vec>) { + if node.kind() == "call" { + calls.push(node); + } + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + collect_call_nodes(child, calls); + } +} + +fn collect_routes( + block: Node<'_>, + context: &UniversalDetectionContext<'_, '_>, + source_file: &str, + prefix: String, + namespaces: Vec, + call_occurrences: &std::collections::BTreeSet<(u64, &str)>, + routes: &mut Vec, +) { + let nested_calls = direct_body_calls(block); + for call in nested_calls { + let Some(method_node) = call.child_by_field_name("method") else { + continue; + }; + let Some(method) = method_name(call, context.source) else { + continue; + }; + if !call_occurrences.contains(&(method_node.start_byte() as u64, method.as_str())) { continue; } - if trimmed == "end" { - if scope_frames - .last() - .is_some_and(|depth| *depth == block_depth) - { - scope_frames.pop(); - prefixes.pop(); - namespaces.pop(); - } - if block_depth == 1 { - in_draw = false; - block_depth = 0; - offset = offset.saturating_add(line.len()); - continue; - } - block_depth = block_depth.saturating_sub(1); - offset = offset.saturating_add(line.len()); + if matches!(method.as_str(), "namespace" | "scope") + && let Some(nested_block) = call.child_by_field_name("block") + && let Some((nested_prefix, nested_namespaces)) = + route_scope_arguments(call, context.source, &method, &prefix, &namespaces) + { + collect_routes( + nested_block, + context, + source_file, + nested_prefix, + nested_namespaces, + call_occurrences, + routes, + ); continue; } - if let Some(capture) = scope.captures(line) { - let prefix = capture - .get(1) - .and_then(|value| literal(value.as_str())) - .or_else(|| capture.get(2).map(|value| value.as_str().to_owned())); - if let Some(prefix) = prefix { - prefixes.push(prefix); - namespaces.push( - capture - .get(2) - .map(|value| camelize(value.as_str())) - .unwrap_or_default(), - ); - scope_frames.push(block_depth.saturating_add(1)); - } - block_depth = block_depth.saturating_add(1); - offset = offset.saturating_add(line.len()); + if !matches!( + method.as_str(), + "get" | "post" | "put" | "patch" | "delete" | "options" | "head" | "match" + ) { continue; } - let Some((operation, raw_path, handler, suffix)) = parse_route_line(line) else { - offset = offset.saturating_add(line.len()); + let Some((raw_path, handler, operations)) = route_arguments(call, context.source, &method) + else { continue; }; - let handler = rails_handler(&handler, &namespaces); - let prefix = prefixes.join("/"); let normalized_path = if prefix.is_empty() { normalize_route_path(&raw_path) } else { join_route_path(&prefix, &raw_path) }; - let operations = if operation == "match" { - Some(rails_via(suffix)) - .filter(|methods| !methods.is_empty()) - .unwrap_or_else(|| vec!["ANY".to_owned()]) - } else { - vec![operation.to_ascii_uppercase()] + let handler_reference = rails_handler(&handler, &namespaces); + let occurrence = context.evidence.occurrences.iter().find(|occurrence| { + occurrence.role == SemanticRole::Call + && occurrence.range.start_byte == method_node.start_byte() as u64 + && occurrence.spelling == method + }); + let Some(occurrence) = occurrence else { + continue; }; + let anchor = evidence_anchor(&occurrence.range); for operation in operations { - facts.push(RawFrameworkFact::Route(RawRouteFact { + routes.push(RawFrameworkFact::Route(RawRouteFact { framework: "rails".to_owned(), operation, raw_path: raw_path.clone(), normalized_path: normalized_path.clone(), - declaring_scope: path.to_string_lossy().replace('\\', "/"), - anchor: line_anchor(path, source, offset, line), - handler_reference: handler.clone(), + declaring_scope: source_file.to_owned(), + anchor: anchor.clone(), + handler_reference: handler_reference.clone(), middleware_references: Vec::new(), origin: RawFrameworkOrigin::Ast, rule: Some("rails-routes-dsl".to_owned()), - detail: Map::new(), + detail: Map::from_iter([( + "frameworkPack".to_owned(), + serde_json::Value::String("rails-ruby".to_owned()), + )]), })); } - let do_count = line.matches(" do").count(); - block_depth = block_depth.saturating_add(do_count); - offset = offset.saturating_add(line.len()); } - facts -} - -fn parse_route_line(line: &str) -> Option<(&str, String, String, &str)> { - let line = line.trim(); - let split = line.find(char::is_whitespace)?; - let operation = &line[..split]; - if !matches!( - operation, - "get" | "post" | "put" | "patch" | "delete" | "options" | "head" | "match" - ) { - return None; +} + +fn direct_body_calls<'tree>(block: Node<'tree>) -> Vec> { + let Some(body) = block.child_by_field_name("body") else { + return Vec::new(); + }; + let mut cursor = body.walk(); + body.children(&mut cursor) + .filter(Node::is_named) + .filter(|node| node.kind() == "call") + .collect() +} + +fn route_scope_arguments( + call: Node<'_>, + source: &[u8], + method: &str, + prefix: &str, + namespaces: &[String], +) -> Option<(String, Vec)> { + let argument = first_positional_argument(call)?; + let value = literal_node(argument, source)?; + let mut next_prefix = prefix.to_owned(); + let mut next_namespaces = namespaces.to_vec(); + if !next_prefix.is_empty() { + next_prefix.push('/'); + } + if method == "namespace" { + next_prefix.push_str(&value); + next_namespaces.push(camelize(&value)); + } else { + next_prefix.push_str(value.trim_matches('/')); } - let (raw_path, rest) = quoted_prefix(line[split..].trim_start())?; - let rest = rest.trim_start(); - let rest = if let Some(rest) = rest.strip_prefix(',') { - rest.trim_start().strip_prefix("to:")?.trim_start() + Some((next_prefix, next_namespaces)) +} + +fn route_arguments( + call: Node<'_>, + source: &[u8], + method: &str, +) -> Option<(String, String, Vec)> { + let arguments = call.child_by_field_name("arguments")?; + let mut cursor = arguments.walk(); + let values = arguments + .children(&mut cursor) + .filter(Node::is_named) + .collect::>(); + let path = values + .iter() + .find(|node| node.kind() != "pair") + .and_then(|node| literal_node(*node, source))?; + let handler = values + .iter() + .find_map(|node| { + (*node).child_by_field_name("key").and_then(|key| { + (key_text(key, source).as_deref() == Some("to")) + .then(|| (*node).child_by_field_name("value")) + .flatten() + .and_then(|value| literal_node(value, source)) + }) + }) + .or_else(|| { + values + .iter() + .filter(|node| node.kind() != "pair") + .nth(1) + .and_then(|node| literal_node(*node, source)) + })?; + let operations = if method == "match" { + values + .iter() + .find_map(|node| { + (*node).child_by_field_name("key").and_then(|key| { + (key_text(key, source).as_deref() == Some("via")) + .then(|| (*node).child_by_field_name("value")) + .flatten() + }) + }) + .map(|node| literal_array(node, source)) + .filter(|operations| !operations.is_empty()) + .unwrap_or_else(|| vec!["ANY".to_owned()]) } else { - rest.strip_prefix("=>")?.trim_start() + vec![method.to_ascii_uppercase()] }; - let (handler, suffix) = quoted_prefix(rest)?; - Some((operation, raw_path, handler, suffix)) + Some((path, handler, operations)) +} + +fn first_positional_argument(call: Node<'_>) -> Option> { + let arguments = call.child_by_field_name("arguments")?; + let mut cursor = arguments.walk(); + arguments + .children(&mut cursor) + .filter(Node::is_named) + .find(|node| node.kind() != "pair") } -fn quoted_prefix(value: &str) -> Option<(String, &str)> { - let quote = value.as_bytes().first().copied()?; - if !matches!(quote, b'\'' | b'"') { - return None; +fn literal_node(node: Node<'_>, source: &[u8]) -> Option { + let raw = source.get(node.start_byte()..node.end_byte())?; + let raw = std::str::from_utf8(raw).ok()?.trim(); + if node.kind() == "simple_symbol" { + return raw.strip_prefix(':').map(str::to_owned); } - let end = value.as_bytes()[1..] - .iter() - .position(|byte| *byte == quote)? - + 1; - Some((value[1..end].to_owned(), &value[end + 1..])) + literal(raw) } -fn rails_via(suffix: &str) -> Vec { - let Some((_, value)) = suffix.split_once("via:") else { - return Vec::new(); - }; - value - .trim() - .trim_start_matches('[') - .trim_end_matches(']') - .split(',') - .map(|method| { - method - .trim() - .trim_start_matches(':') - .trim_matches(['\'', '"']) - .to_ascii_uppercase() - }) - .filter(|method| !method.is_empty()) +fn literal_array(node: Node<'_>, source: &[u8]) -> Vec { + let mut cursor = node.walk(); + node.children(&mut cursor) + .filter(Node::is_named) + .filter_map(|child| literal_node(child, source)) + .map(|value| value.to_ascii_uppercase()) .collect() } +fn key_text(node: Node<'_>, source: &[u8]) -> Option { + let raw = source.get(node.start_byte()..node.end_byte())?; + let raw = std::str::from_utf8(raw).ok()?.trim(); + Some( + raw.trim_start_matches(':') + .trim_matches(['"', '\'']) + .to_owned(), + ) +} + +fn method_name(node: Node<'_>, source: &[u8]) -> Option { + let method = node.child_by_field_name("method")?; + let value = source.get(method.start_byte()..method.end_byte())?; + std::str::from_utf8(value).ok().map(str::to_owned) +} + +fn receiver_text(node: Node<'_>, source: &[u8]) -> Option { + let receiver = node.child_by_field_name("receiver")?; + let value = source.get(receiver.start_byte()..receiver.end_byte())?; + std::str::from_utf8(value).ok().map(str::to_owned) +} + +fn evidence_anchor(range: &crate::EvidenceRange) -> RawFrameworkAnchor { + RawFrameworkAnchor { + source_file: range.source_file.clone(), + start_byte: range.start_byte, + end_byte: range.end_byte, + start_line: range.start_line, + start_column: range.start_column, + end_line: range.end_line, + end_column: range.end_column, + } +} + +fn route_key(fact: &RawFrameworkFact) -> (String, String, u64, String) { + let RawFrameworkFact::Route(route) = fact else { + return (String::new(), String::new(), 0, String::new()); + }; + ( + route.anchor.source_file.clone(), + route.normalized_path.clone(), + route.anchor.start_byte, + route.operation.clone(), + ) +} + fn rails_handler(value: &str, namespaces: &[String]) -> String { let Some((controller, action)) = value.split_once('#') else { return value.to_owned(); @@ -219,12 +354,3 @@ fn camelize(value: &str) -> String { }) .collect() } - -fn is_rails_routes_path(path: &Path) -> bool { - path.components() - .rev() - .take(2) - .map(|component| component.as_os_str().to_string_lossy()) - .collect::>() - == ["routes.rb", "config"] -} diff --git a/crates/compass-languages/tests/engine_edge_coverage.rs b/crates/compass-languages/tests/engine_edge_coverage.rs index c506ed50..86679574 100644 --- a/crates/compass-languages/tests/engine_edge_coverage.rs +++ b/crates/compass-languages/tests/engine_edge_coverage.rs @@ -33,11 +33,12 @@ fn universal_framework_pack_registry_accepts_only_cut_over_language_evidence() { FrameworkPackRegistry::validate_descriptors(&[descriptor]), Ok(()) ); - assert_eq!(FrameworkPackRegistry::descriptors().len(), 4); + assert_eq!(FrameworkPackRegistry::descriptors().len(), 5); assert_eq!(FrameworkPackRegistry::descriptors()[0].id, "aspnet-csharp"); assert_eq!(FrameworkPackRegistry::descriptors()[1].id, "php-frameworks"); assert_eq!(FrameworkPackRegistry::descriptors()[2].id, "spring-java"); assert_eq!(FrameworkPackRegistry::descriptors()[3].id, "spring-kotlin"); + assert_eq!(FrameworkPackRegistry::descriptors()[4].id, "rails-ruby"); assert_eq!(FrameworkPackRegistry::validate(), Ok(())); let rust = FrameworkPackDescriptor { diff --git a/crates/compass-languages/tests/registry.rs b/crates/compass-languages/tests/registry.rs index bf000f37..e02f2349 100644 --- a/crates/compass-languages/tests/registry.rs +++ b/crates/compass-languages/tests/registry.rs @@ -208,6 +208,7 @@ fn only_hard_cut_languages_expose_universal_profiles() { let go = Registry::resolve(Path::new("src/example.go")).expect("go spec"); let java = Registry::resolve(Path::new("src/Example.java")).expect("java spec"); let kotlin = Registry::resolve(Path::new("src/Example.kt")).expect("kotlin spec"); + let ruby = Registry::resolve(Path::new("src/example.rb")).expect("ruby spec"); let rust = Registry::resolve(Path::new("src/example.rs")).expect("rust spec"); let typescript = Registry::resolve(Path::new("src/example.ts")).expect("typescript spec"); let tsx = Registry::resolve(Path::new("src/example.tsx")).expect("tsx spec"); @@ -229,6 +230,10 @@ fn only_hard_cut_languages_expose_universal_profiles() { Registry::universal_profile_for_spec(kotlin).map(|profile| profile.language), Some("kotlin") ); + assert_eq!( + Registry::universal_profile_for_spec(ruby).map(|profile| profile.language), + Some("ruby") + ); assert_eq!( Registry::universal_profile_for_spec(rust).map(|profile| profile.language), Some("rust") diff --git a/crates/compass-languages/tests/ruby_universal_conformance.rs b/crates/compass-languages/tests/ruby_universal_conformance.rs new file mode 100644 index 00000000..02874a09 --- /dev/null +++ b/crates/compass-languages/tests/ruby_universal_conformance.rs @@ -0,0 +1,348 @@ +#![allow(clippy::expect_used, clippy::panic)] + +use std::path::Path; + +use compass_languages::{CandidateRelation, Engine, SemanticRole, validate_evidence}; + +fn extract(source: &[u8]) -> compass_languages::SemanticEvidenceBatch { + Engine::default() + .extract_source_universal_candidate_evidence(Path::new("fixture.rb"), "fixture.rb", source) + .expect("Ruby candidate evidence") +} + +#[test] +fn emits_nested_types_reopenings_methods_mixins_and_exact_anchors() { + let source = br#"module Billing + module Auditable + end + class Invoice < Document + include Auditable + prepend Serializable + extend ClassMethods + def total(amount, tax = 0, *rest, &block) + calculate(amount) + end + def self.build + new(1) + end + end +end +class Billing::Invoice + def reopened; end +end +"#; + let evidence = extract(source); + validate_evidence(&evidence, compass_languages::EvidenceLimits::default()) + .expect("validated Ruby evidence"); + assert!( + evidence + .declarations + .iter() + .any(|fact| fact.kind == "trait" && fact.qualified_name == "Billing::Auditable") + ); + assert!( + evidence + .declarations + .iter() + .any(|fact| fact.kind == "class" && fact.qualified_name == "Billing::Invoice") + ); + assert!( + evidence + .declarations + .iter() + .any(|fact| fact.qualified_name == "Billing::Invoice#total") + ); + assert!( + evidence + .declarations + .iter() + .any(|fact| fact.qualified_name == "Billing::Invoice.build") + ); + assert!(evidence.candidates.iter().any(|candidate| { + candidate.relation == CandidateRelation::UsesTrait + && candidate.constraints.qualified_name.as_deref() == Some("Billing::Auditable") + })); + assert!(evidence.candidates.iter().any(|candidate| { + candidate.relation == CandidateRelation::Extends + && candidate.constraints.qualified_name.as_deref() == Some("Billing::Document") + })); + assert!(evidence.occurrences.iter().all(|occurrence| { + occurrence.role != SemanticRole::Call + || occurrence.range.end_byte > occurrence.range.start_byte + })); +} + +#[test] +fn emits_unqualified_mixin_call_from_a_cross_file_style_test_case() { + let evidence = extract( + br#"class AsyncAdapterTest < ActionCable::TestCase + include CommonSubscriptionAdapterTest +end +"#, + ); + assert!(evidence.candidates.iter().any(|candidate| { + candidate.relation == CandidateRelation::UsesTrait + && candidate.target_spelling == "CommonSubscriptionAdapterTest" + && candidate.constraints.qualified_name.as_deref() + == Some("AsyncAdapterTest::CommonSubscriptionAdapterTest") + })); +} + +#[test] +fn emits_singleton_setters_and_attribute_methods_with_exact_identity() { + let evidence = extract( + br#"module ActiveRecord + module QueryLogs + class << self + attr_accessor :tags + def tags=(value); end + end + end +end +class QueryLogsTest + def test + ActiveRecord::QueryLogs.tags = [1] + ActiveRecord::QueryLogs.tags + end +end +"#, + ); + assert!(evidence.declarations.iter().any(|declaration| { + declaration.kind == "method" && declaration.qualified_name == "ActiveRecord::QueryLogs.tags" + })); + assert!(evidence.declarations.iter().any(|declaration| { + declaration.kind == "method" + && declaration.qualified_name == "ActiveRecord::QueryLogs.tags=" + })); + assert!(evidence.candidates.iter().any(|candidate| { + candidate.relation == CandidateRelation::Calls && candidate.target_spelling == "tags=" + })); + assert!(evidence.candidates.iter().any(|candidate| { + candidate.relation == CandidateRelation::Calls && candidate.target_spelling == "tags" + })); +} + +#[test] +fn emits_mixins_inside_class_new_blocks() { + let evidence = extract( + br#"class LayoutsRactorTest + def build + Class.new do + include AbstractController::Rendering + end + end +end +"#, + ); + assert!(evidence.candidates.iter().any(|candidate| { + candidate.relation == CandidateRelation::UsesTrait + && candidate.target_spelling == "Rendering" + })); +} + +#[test] +fn rejects_dynamic_dispatch_and_malformed_regions_without_fabricating_edges() { + let source = br#"class Example + def run(name) + send(name) + require(name) + def broken( + end +end +"#; + let evidence = extract(source); + assert!( + evidence + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "dynamic_dispatch_unresolved") + ); + assert!( + evidence + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "dynamic_require_unresolved") + ); + assert!( + evidence + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "partial_parser_recovery") + ); + assert!(evidence.candidates.iter().all(|candidate| { + candidate.constraints.qualified_name.as_deref() != Some("Example#send") + })); +} + +#[test] +fn deep_recovery_is_bounded_and_reports_a_typed_limit() { + let mut source = String::new(); + for _ in 0..40 { + source.push_str("if true\n"); + } + source.push_str("value = 1\n"); + for _ in 0..40 { + source.push_str("end\n"); + } + let evidence = extract(source.as_bytes()); + validate_evidence(&evidence, compass_languages::EvidenceLimits::default()) + .expect("bounded Ruby evidence"); + assert!( + evidence + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "traversal_limit") + ); +} + +#[test] +fn candidate_is_deterministic_under_repeated_extraction() { + let source = "class Café\n def naïve(value = 1)\n value\n end\nend\n".as_bytes(); + let left = extract(source); + let right = extract(source); + assert_eq!(left, right); +} + +#[test] +fn local_parameters_and_constructed_receivers_are_lexically_scoped() { + let source = br#"class Invoice + def total(value) + value + document = Document.new + document.save + end + + def other + document.save + end +end +class Document + def save; end +end +"#; + let evidence = extract(source); + let calls = evidence + .candidates + .iter() + .filter(|candidate| candidate.relation == CandidateRelation::Calls) + .collect::>(); + assert!( + calls + .iter() + .all(|candidate| candidate.target_spelling != "value") + ); + assert!(calls.iter().any(|candidate| { + candidate.target_spelling == "save" + && candidate.constraints.hierarchy.is_some() + && candidate + .occurrence_id + .as_ref() + .and_then(|id| { + evidence + .occurrences + .iter() + .find(|occurrence| &occurrence.id == id) + }) + .is_some_and(|occurrence| occurrence.qualifier.as_deref() == Some("document")) + })); + assert!(calls.iter().any(|candidate| { + candidate.target_spelling == "save" + && candidate + .occurrence_id + .as_ref() + .and_then(|id| { + evidence + .occurrences + .iter() + .find(|occurrence| &occurrence.id == id) + }) + .is_some_and(|occurrence| occurrence.qualifier.as_deref() == Some("document")) + })); +} + +#[test] +fn production_ruby_uses_one_universal_publisher_and_rails_pack() { + let source = br#"class UsersController + def show; end +end +Rails.application.routes.draw do + get '/users', to: 'users#show' +end +"#; + let extraction = Engine::default() + .extract_source_graph_only(Path::new("config/routes.rb"), "config/routes.rb", source) + .expect("production Ruby extraction"); + assert_eq!( + extraction + .semantic_evidence + .as_ref() + .map(|evidence| evidence.adapter.id.as_str()), + Some("compass.ruby.candidate") + ); + assert!(extraction.raw_calls.is_none()); + assert!(extraction.framework_facts.iter().any(|fact| { + matches!( + fact, + compass_languages::RawFrameworkFact::Route(route) + if route.detail.get("frameworkPack").and_then(serde_json::Value::as_str) + == Some("rails-ruby") + ) + })); +} + +#[test] +fn qualified_singleton_owners_and_constructor_calls_keep_method_spaces_separate() { + let evidence = extract( + br#"class Invoice + def save; end + def self.build; end +end +def Invoice.lookup; end +class Caller + def run + Invoice.build + invoice = Invoice.new + invoice.save + end +end +"#, + ); + assert!(evidence.declarations.iter().any(|declaration| { + declaration.qualified_name == "Invoice#save" && declaration.kind == "method" + })); + assert!(evidence.declarations.iter().any(|declaration| { + declaration.qualified_name == "Invoice.build" && declaration.kind == "method" + })); + assert!(evidence.declarations.iter().any(|declaration| { + declaration.qualified_name == "Invoice.lookup" && declaration.kind == "method" + })); + assert!( + evidence + .candidates + .iter() + .any(|candidate| candidate.relation == CandidateRelation::Constructs) + ); +} + +#[test] +fn class_singleton_scope_keeps_methods_in_the_owner_method_space() { + let evidence = extract( + br#"class Invoice + class << self + def build + new + end + end +end +"#, + ); + assert!(evidence.declarations.iter().any(|declaration| { + declaration.qualified_name == "Invoice.build" && declaration.kind == "method" + })); + assert!( + evidence + .declarations + .iter() + .all(|declaration| { !declaration.qualified_name.contains("::<<") }) + ); +} diff --git a/crates/compass-languages/tests/universal_evidence.rs b/crates/compass-languages/tests/universal_evidence.rs index 30a7ff5e..03470671 100644 --- a/crates/compass-languages/tests/universal_evidence.rs +++ b/crates/compass-languages/tests/universal_evidence.rs @@ -479,6 +479,7 @@ fn universal_adapter_profiles_are_unique_sorted_and_truthful() { "kotlin", "php", "python", + "ruby", "rust", "typescript", ] diff --git a/crates/compass-resolve/src/evidence/languages/mod.rs b/crates/compass-resolve/src/evidence/languages/mod.rs index 8e481b04..6702577b 100644 --- a/crates/compass-resolve/src/evidence/languages/mod.rs +++ b/crates/compass-resolve/src/evidence/languages/mod.rs @@ -5,5 +5,6 @@ pub(in crate::evidence) mod java; pub(in crate::evidence) mod kotlin; pub(in crate::evidence) mod php; pub(in crate::evidence) mod policy; +pub(in crate::evidence) mod ruby; pub(in crate::evidence) mod rust; pub(in crate::evidence) mod typescript; diff --git a/crates/compass-resolve/src/evidence/languages/policy.rs b/crates/compass-resolve/src/evidence/languages/policy.rs index 428ae9bf..193ab978 100644 --- a/crates/compass-resolve/src/evidence/languages/policy.rs +++ b/crates/compass-resolve/src/evidence/languages/policy.rs @@ -12,6 +12,7 @@ pub(in crate::evidence) enum LanguagePolicyKind { Java, Kotlin, Php, + Ruby, Rust, Generic, } @@ -24,6 +25,7 @@ impl LanguagePolicyKind { "java" => Self::Java, "kotlin" => Self::Kotlin, "php" => Self::Php, + "ruby" => Self::Ruby, "rust" => Self::Rust, _ => Self::Generic, } @@ -58,7 +60,7 @@ impl LanguagePolicyKind { } Self::Java => db.resolve_java_same_package_builtin_collision(candidate), Self::Kotlin => db.resolve_kotlin_candidate(candidate), - Self::Generic => None, + Self::Ruby | Self::Generic => None, } } @@ -73,6 +75,7 @@ impl LanguagePolicyKind { Self::CSharp | Self::Kotlin | Self::Php + | Self::Ruby | Self::TypeScript | Self::Rust | Self::Generic => None, @@ -110,6 +113,10 @@ mod tests { LanguagePolicyKind::for_language("php"), LanguagePolicyKind::Php ); + assert_eq!( + LanguagePolicyKind::for_language("ruby"), + LanguagePolicyKind::Ruby + ); assert_eq!( LanguagePolicyKind::for_language("future-language"), LanguagePolicyKind::Generic diff --git a/crates/compass-resolve/src/evidence/languages/ruby.rs b/crates/compass-resolve/src/evidence/languages/ruby.rs new file mode 100644 index 00000000..743e846e --- /dev/null +++ b/crates/compass-resolve/src/evidence/languages/ruby.rs @@ -0,0 +1,94 @@ +//! Ruby-specific identity helpers used by the shared evidence resolver. + +/// Split a Ruby owner/method identity without mistaking the `::` namespace +/// separator for the method-space separator. Ruby instance methods use +/// `Owner#method`; singleton methods use `Owner.method`. +pub(crate) fn split_method_space(qualified: &str) -> Option<(&str, &str)> { + let namespace = qualified.rfind("::").unwrap_or(0); + let hash = qualified.rfind('#'); + let dot = qualified.rfind('.'); + match (hash, dot) { + (Some(hash), Some(dot)) if hash.max(dot) > namespace => { + let split = hash.max(dot); + Some((&qualified[..split], &qualified[split + 1..])) + } + (Some(hash), None) if hash > namespace => { + Some((&qualified[..hash], &qualified[hash + 1..])) + } + (None, Some(dot)) if dot > namespace => Some((&qualified[..dot], &qualified[dot + 1..])), + _ => None, + } +} + +/// Return the receiver type portion of an instance or singleton Ruby +/// declaration. The `::` namespace separator must be considered before the +/// method-space separators so `Billing::Invoice#save` remains one owner. +pub(crate) fn owner_type(qualified: &str) -> Option<&str> { + let namespace = qualified.rfind("::").unwrap_or(0); + let separator = qualified.rfind('#').max(qualified.rfind('.'))?; + (separator > namespace).then_some(&qualified[..separator]) +} + +/// Enumerate the source-visible lexical constant candidates for a Ruby +/// receiver. Ruby resolves a relative constant from the innermost lexical +/// owner outwards; the resolver later admits a result only when one exact +/// project declaration remains. This keeps cross-file lookup precise without +/// falling back to terminal-name matching. +pub(crate) fn lexical_names(owner: &str, raw: &str) -> Vec { + let normalized = raw.trim().trim_start_matches("::"); + if normalized.is_empty() { + return Vec::new(); + } + if raw.trim().starts_with("::") { + return vec![normalized.to_owned()]; + } + let owner_type = owner_type(owner).unwrap_or_default(); + let parts = owner_type.split("::").collect::>(); + let mut names = Vec::with_capacity(parts.len().saturating_add(1)); + for index in (0..=parts.len()).rev() { + let prefix = parts[..index].join("::"); + let candidate = if prefix.is_empty() { + normalized.to_owned() + } else { + format!("{prefix}::{normalized}") + }; + if !names.iter().any(|existing| existing == &candidate) { + names.push(candidate); + } + } + names +} + +#[cfg(test)] +mod tests { + use super::{lexical_names, split_method_space}; + + #[test] + fn method_space_split_preserves_ruby_namespaces() { + assert_eq!( + split_method_space("Billing::Invoice#save"), + Some(("Billing::Invoice", "save")) + ); + assert_eq!( + split_method_space("Billing::Invoice.build"), + Some(("Billing::Invoice", "build")) + ); + assert_eq!(split_method_space("Billing::Invoice"), None); + } + + #[test] + fn lexical_names_preserve_nested_and_absolute_constant_lookup() { + assert_eq!( + lexical_names("Billing::CLI#run", "Environment"), + vec![ + "Billing::CLI::Environment", + "Billing::Environment", + "Environment", + ] + ); + assert_eq!( + lexical_names("Billing::CLI#run", "::Environment"), + ["Environment"] + ); + } +} diff --git a/crates/compass-resolve/src/evidence/mod.rs b/crates/compass-resolve/src/evidence/mod.rs index c8de53c4..5ef29702 100644 --- a/crates/compass-resolve/src/evidence/mod.rs +++ b/crates/compass-resolve/src/evidence/mod.rs @@ -277,6 +277,33 @@ impl ResolutionDb<'_> { .take(self.budget.candidates_per_lookup().saturating_add(1)) .copied() .collect::>(); + if candidate.language == "ruby" + && eligible.len() > 1 + && eligible.iter().all(|slot| { + self.declaration(*slot).is_some_and(|declaration| { + matches!(declaration.kind.as_str(), "class" | "trait") + }) + }) + { + let graph_ids = eligible + .iter() + .filter_map(|slot| { + self.declaration(*slot) + .map(|declaration| &declaration.graph_node_id) + }) + .collect::>(); + if graph_ids.len() == 1 { + return self.declaration_id(eligible[0]).map(|declaration_id| { + ResolutionDecision::Resolved { + declaration_id: declaration_id.to_owned(), + evidence: ResolutionEvidence { + rule, + candidate_count: 1, + }, + } + }); + } + } if candidate.language == "rust" && matches!( candidate.relation, @@ -436,10 +463,14 @@ fn wildcard_qualified_names( vec![parts.join(separator)] } -fn split_qualified_member(qualified: &str) -> Option<(&str, &str)> { - qualified - .rsplit_once("::") - .or_else(|| qualified.rsplit_once('.')) +fn split_qualified_member<'a>(language: &str, qualified: &'a str) -> Option<(&'a str, &'a str)> { + if language == "ruby" { + languages::ruby::split_method_space(qualified) + } else { + qualified + .rsplit_once("::") + .or_else(|| qualified.rsplit_once('.')) + } } fn qualified_root(qualified: &str) -> &str { diff --git a/crates/compass-resolve/src/evidence/projection/mod.rs b/crates/compass-resolve/src/evidence/projection/mod.rs index 1e04414b..6e56080e 100644 --- a/crates/compass-resolve/src/evidence/projection/mod.rs +++ b/crates/compass-resolve/src/evidence/projection/mod.rs @@ -808,8 +808,19 @@ fn materialized_declaration_ids<'a>( } let mut ids = AHashMap::new(); for (graph_node_id, declarations) in groups { - if declarations.len() == 1 { - ids.insert(declarations[0].id.clone(), graph_node_id); + if declarations.len() == 1 + || declarations.iter().all(|declaration| { + declaration.language == "ruby" + && matches!(declaration.kind.as_str(), "class" | "trait") + && declaration.qualified_name == declarations[0].qualified_name + }) + { + ids.insert(declarations[0].id.clone(), graph_node_id.clone()); + if declarations.len() > 1 { + for declaration in declarations.iter().skip(1) { + ids.insert(declaration.id.clone(), graph_node_id.clone()); + } + } continue; } for declaration in declarations { diff --git a/crates/compass-resolve/src/evidence/resolve/hierarchy.rs b/crates/compass-resolve/src/evidence/resolve/hierarchy.rs index 02f3e5cd..e0738741 100644 --- a/crates/compass-resolve/src/evidence/resolve/hierarchy.rs +++ b/crates/compass-resolve/src/evidence/resolve/hierarchy.rs @@ -79,7 +79,10 @@ impl ResolutionDb<'_> { }; let eligible = members .iter() - .filter(|slot| self.declaration_allowed_slot(**slot, candidate)) + .filter(|slot| { + self.declaration_allowed_slot(**slot, candidate) + && self.ruby_member_space_allowed(**slot, candidate) + }) .take(self.budget.candidates_per_lookup().saturating_add(1)) .cloned() .collect::>(); @@ -417,7 +420,10 @@ impl ResolutionDb<'_> { eligible.extend( members .iter() - .filter(|slot| self.declaration_allowed_slot(**slot, candidate)) + .filter(|slot| { + self.declaration_allowed_slot(**slot, candidate) + && self.ruby_member_space_allowed(**slot, candidate) + }) .copied(), ); } @@ -434,7 +440,10 @@ impl ResolutionDb<'_> { eligible.extend( declarations .iter() - .filter(|slot| self.declaration_allowed_slot(**slot, candidate)) + .filter(|slot| { + self.declaration_allowed_slot(**slot, candidate) + && self.ruby_member_space_allowed(**slot, candidate) + }) .copied(), ); } @@ -562,7 +571,10 @@ impl ResolutionDb<'_> { ))?; let eligible = members .iter() - .filter(|slot| self.declaration_allowed_slot(**slot, candidate)) + .filter(|slot| { + self.declaration_allowed_slot(**slot, candidate) + && self.ruby_member_space_allowed(**slot, candidate) + }) .take(self.budget.candidates_per_lookup().saturating_add(1)) .cloned() .collect::>(); @@ -649,7 +661,26 @@ impl ResolutionDb<'_> { .indexes .names .by_qualified - .get(&(language.to_owned(), qualified_name))?; + .get(&(language.to_owned(), qualified_name.clone()))?; + if language == "ruby" { + // Reopened Ruby classes share one graph identity. Check that + // identity directly and return the already canonical qualified + // name; building a temporary set for every receiver call makes + // Rails-scale dispatch unnecessarily allocation-heavy. + let mut graph_node_id = None; + for declaration in declarations + .iter() + .filter_map(|slot| self.declaration(*slot)) + .filter(|declaration| matches!(declaration.kind.as_str(), "class" | "trait")) + { + match graph_node_id { + None => graph_node_id = Some(declaration.graph_node_id.as_str()), + Some(previous) if previous == declaration.graph_node_id.as_str() => {} + Some(_) => return None, + } + } + return graph_node_id.map(|_| qualified_name); + } let eligible = declarations .iter() .filter_map(|slot| self.declaration(*slot)) @@ -661,9 +692,37 @@ impl ResolutionDb<'_> { }) .take(2) .collect::>(); - let [declaration] = eligible.as_slice() else { - return None; + if let [declaration] = eligible.as_slice() { + return Some(declaration.qualified_name.clone()); + } + None + } + + fn ruby_member_space_allowed( + &self, + slot: DeclarationSlot, + candidate: &RelationshipCandidate, + ) -> bool { + if candidate.language != "ruby" { + return true; + } + let Some(context) = self.occurrence(candidate).and_then(OccurrenceRef::context) else { + return true; }; - Some(declaration.qualified_name.clone()) + let Some(declaration) = self.declaration(slot) else { + return false; + }; + if declaration.kind != "method" { + return true; + } + let separator = match context { + "instance" => '#', + "singleton" => '.', + _ => return true, + }; + declaration + .qualified_name + .strip_suffix(&format!("{separator}{}", candidate.target_spelling)) + .is_some() } } diff --git a/crates/compass-resolve/src/evidence/resolve/members.rs b/crates/compass-resolve/src/evidence/resolve/members.rs index f160329b..92dc9b27 100644 --- a/crates/compass-resolve/src/evidence/resolve/members.rs +++ b/crates/compass-resolve/src/evidence/resolve/members.rs @@ -3,6 +3,71 @@ use super::super::*; impl ResolutionDb<'_> { + pub(in crate::evidence) fn ruby_import_decision( + &self, + candidate: &RelationshipCandidate, + requested: &str, + ) -> Option { + if candidate.language != "ruby" || candidate.relation != CandidateRelation::Imports { + return None; + } + let occurrence = self.occurrence(candidate)?; + let source_file = normalize_ruby_path(&occurrence.range().source_file); + let operation = occurrence.context().unwrap_or_default(); + let target = normalize_ruby_path(requested); + if target.is_empty() + || target.starts_with('/') + || target.split('/').any(|part| part == "..") + { + return None; + } + let base = if operation == "require_relative" { + source_file + .rsplit_once('/') + .map_or_else(String::new, |(parent, _)| parent.to_owned()) + } else { + String::new() + }; + let joined = if base.is_empty() { + target + } else if target.starts_with("./") { + format!("{base}/{}", target.trim_start_matches("./")) + } else { + format!("{base}/{target}") + }; + let candidates = [ + joined.clone(), + format!("{joined}.rb"), + format!("{joined}.rake"), + ] + .into_iter() + .collect::>(); + let declarations = self + .facts + .declarations + .values() + .filter(|declaration| { + declaration.language == "ruby" + && declaration.kind == "file" + && candidates.contains(&normalize_ruby_path(&declaration.range.source_file)) + && self.declaration_allowed(&declaration.id, candidate) + }) + .collect::>(); + match declarations.as_slice() { + [declaration] => Some(ResolutionDecision::ResolvedInventory { + graph_node_id: declaration.graph_node_id.clone(), + evidence: ResolutionEvidence { + rule: ResolutionRule::ExactSourceInventory, + candidate_count: 1, + }, + }), + [] => None, + many => Some(ResolutionDecision::Ambiguous { + candidate_count: many.len(), + }), + } + } + pub(in crate::evidence) fn inventory_decision( &self, language: &str, @@ -318,7 +383,8 @@ impl ResolutionDb<'_> { if language != "rust" || call_result_binding.receiver_binding_id.is_some() { return Ok(BTreeSet::new()); } - let Some((qualifier, spelling)) = split_qualified_member(qualified_callable) else { + let Some((qualifier, spelling)) = split_qualified_member(language, qualified_callable) + else { return Ok(BTreeSet::new()); }; @@ -496,7 +562,7 @@ impl ResolutionDb<'_> { if language != "rust" { return Ok(Vec::new()); } - let Some((receiver, member)) = split_qualified_member(qualified) else { + let Some((receiver, member)) = split_qualified_member(language, qualified) else { return Ok(Vec::new()); }; let receiver_slots = self @@ -668,7 +734,7 @@ impl ResolutionDb<'_> { qualified: &str, candidate: &RelationshipCandidate, ) -> Option> { - let (owner, spelling) = split_qualified_member(qualified)?; + let (owner, spelling) = split_qualified_member(language, qualified)?; let targets = self.indexes.members.members.get(&( language.to_owned(), owner.to_owned(), @@ -843,3 +909,7 @@ impl ResolutionDb<'_> { Err(MAX_ALIAS_DEPTH) } } + +fn normalize_ruby_path(path: &str) -> String { + path.replace('\\', "/").trim_start_matches("./").to_owned() +} diff --git a/crates/compass-resolve/src/evidence/resolve/pipeline.rs b/crates/compass-resolve/src/evidence/resolve/pipeline.rs index 684f00b1..ac5c628d 100644 --- a/crates/compass-resolve/src/evidence/resolve/pipeline.rs +++ b/crates/compass-resolve/src/evidence/resolve/pipeline.rs @@ -116,6 +116,15 @@ impl ResolutionDb<'_> { else { return StageOutcome::Continue; }; + // The Ruby extractor records the receiver as the lexical fallback + // (`QueryTest::Arel`, for example) when the source does not declare a + // same-file constant. Resolve the raw constant spelling against the + // enclosing lexical owner before committing to that speculative + // hierarchy. The lookup remains exact and language-scoped; it never + // falls back to a terminal method name. + if let Some(decision) = self.ruby_lexical_target_decision(candidate) { + return StageOutcome::Decided(decision); + } StageOutcome::Decided(self.resolve_c3_receiver_dispatch( context.language, receiver_qualified_name, @@ -214,6 +223,16 @@ impl ResolutionDb<'_> { fn stage_qualified_target(&self, context: &CandidateContext<'_>) -> StageOutcome { let candidate = context.candidate(); + // Ruby receiver-dispatch candidates intentionally leave + // `qualified_name` empty: the receiver hierarchy is the authoritative + // constraint. A qualified constant receiver can still be proven by + // Ruby's lexical lookup rules, so run that exact-name pass before the + // generic qualified-target guard. + if candidate.constraints.hierarchy.is_none() + && let Some(decision) = self.ruby_lexical_target_decision(candidate) + { + return StageOutcome::Decided(decision); + } let Some(qualified) = candidate.constraints.qualified_name.as_ref() else { return StageOutcome::Continue; }; @@ -223,6 +242,9 @@ impl ResolutionDb<'_> { return StageOutcome::Decided(ResolutionDecision::Ambiguous { candidate_count }); } }; + if let Some(decision) = self.ruby_import_decision(candidate, &qualified) { + return StageOutcome::Decided(decision); + } let key = (context.language.to_owned(), qualified.clone()); if let Some(decision) = [ self.unique_decision( @@ -244,6 +266,79 @@ impl ResolutionDb<'_> { StageOutcome::Continue } + fn ruby_lexical_target_decision( + &self, + candidate: &RelationshipCandidate, + ) -> Option { + if candidate.language != "ruby" + || !matches!( + candidate.relation, + CandidateRelation::Calls + | CandidateRelation::Constructs + | CandidateRelation::Extends + | CandidateRelation::UsesTrait + ) + { + return None; + } + let occurrence = self.occurrence(candidate)?; + let qualifier = match candidate.relation { + CandidateRelation::Calls | CandidateRelation::Constructs => occurrence.qualifier()?, + CandidateRelation::Extends | CandidateRelation::UsesTrait => occurrence.spelling(), + _ => return None, + }; + let normalized = qualifier.trim().trim_start_matches("::"); + if normalized.is_empty() + || !normalized.split("::").all(|part| { + let mut characters = part.chars(); + characters.next().is_some_and(char::is_uppercase) + }) + { + return None; + } + let source = self + .facts + .declarations + .get(&candidate.source_declaration_id)?; + let names = languages::ruby::lexical_names(&source.qualified_name, qualifier); + let context = self.occurrence(candidate).and_then(OccurrenceRef::context); + let mut slots = BTreeSet::new(); + for name in names { + let qualified = match candidate.relation { + CandidateRelation::Constructs + | CandidateRelation::Extends + | CandidateRelation::UsesTrait => name, + CandidateRelation::Calls => { + let separator = match context { + Some("singleton") => '.', + Some("instance") => '#', + _ => continue, + }; + format!("{name}{separator}{}", candidate.target_spelling) + } + _ => continue, + }; + if let Some(ids) = self + .indexes + .names + .by_qualified + .get(&("ruby".to_owned(), qualified)) + { + slots.extend( + ids.iter() + .copied() + .filter(|slot| self.declaration_allowed_slot(*slot, candidate)), + ); + } + } + let candidates = slots.into_iter().collect::>(); + self.unique_decision( + (!candidates.is_empty()).then_some(&candidates), + candidate, + ResolutionRule::ExactLexicalDeclaration, + ) + } + fn stage_module_or_package(&self, context: &CandidateContext<'_>) -> StageOutcome { let candidate = context.candidate(); if context.has_unbound_qualified_receiver(self) { diff --git a/crates/compass-resolve/src/frameworks/mod.rs b/crates/compass-resolve/src/frameworks/mod.rs index 1c838c1b..06187f8c 100644 --- a/crates/compass-resolve/src/frameworks/mod.rs +++ b/crates/compass-resolve/src/frameworks/mod.rs @@ -90,6 +90,10 @@ const UNIVERSAL_FRAMEWORK_PACKS: &[UniversalFrameworkPack] = &[ id: "spring-kotlin", expand: spring::expand_kotlin, }, + UniversalFrameworkPack { + id: "rails-ruby", + expand: ruby::expand, + }, ]; pub use domain::{ @@ -217,7 +221,7 @@ fn universal_framework_targets_are_materialized( let Some(batch) = extraction.semantic_evidence.as_ref().filter(|batch| { matches!( batch.adapter.language.as_str(), - "csharp" | "javascript" | "php" | "typescript" + "csharp" | "javascript" | "php" | "ruby" | "typescript" ) }) else { return true; @@ -258,7 +262,7 @@ pub(super) fn materialize_universal_framework_targets( let Some(batches) = extraction.semantic_evidence.as_ref().filter(|batch| { matches!( batch.adapter.language.as_str(), - "csharp" | "javascript" | "php" | "typescript" + "csharp" | "javascript" | "php" | "ruby" | "typescript" ) }) else { return extraction.clone(); diff --git a/crates/compass-resolve/src/frameworks/ruby.rs b/crates/compass-resolve/src/frameworks/ruby.rs index 663ae910..9aba9d56 100644 --- a/crates/compass-resolve/src/frameworks/ruby.rs +++ b/crates/compass-resolve/src/frameworks/ruby.rs @@ -1,3 +1,15 @@ +use super::FrameworkResolutionError; + +/// Rails routing is already fully source-derived by the language-side +/// universal pack. Keep a project-wide expansion hook registered for the +/// pack so the universal framework lifecycle remains uniform; Ruby does not +/// currently need a second pass over project facts. +pub(super) fn expand( + _extraction: &mut compass_languages::Extraction, +) -> Result<(), FrameworkResolutionError> { + Ok(()) +} + pub(super) fn canonical_reference(reference: &str) -> String { reference.trim().replace(['#', '/'], ".") } diff --git a/crates/compass-resolve/src/members.rs b/crates/compass-resolve/src/members.rs index 32a45e2b..b8b0572e 100644 --- a/crates/compass-resolve/src/members.rs +++ b/crates/compass-resolve/src/members.rs @@ -104,7 +104,6 @@ pub(crate) fn resolve_language_call_facts_additions( &mut edges, ); resolve_python_members(&facts.calls, &indexes, &mut existing, &mut edges); - resolve_ruby_members(&facts.calls, &indexes, &mut existing, &mut edges); resolve_pascal_inherited(&facts.calls, &indexes, &mut existing, &mut edges); if admission.admits_qualified_external() { retain_qualified_python_external_calls(&facts.calls, &mut existing, &mut edges) @@ -608,73 +607,6 @@ fn valid_python_qualified_name(value: &str) -> bool { count >= 2 } -fn resolve_ruby_members( - calls: &[RawCall], - indexes: &Indexes, - existing: &mut HashSet<(String, String, String)>, - edges: &mut Vec, -) { - let mut ruby_types = HashMap::new(); - for node in indexes.nodes.values() { - if matches!( - extension(&node.string("source_file")).as_str(), - "rb" | "rake" - ) && is_bare_constant(node.label()) - { - push_unique(&mut ruby_types, key(node.label()), &node.id); - } - } - for call in calls { - if !matches!(extension(&call.source_file).as_str(), "rb" | "rake") { - continue; - } - if call.extensions.get("is_mixin").and_then(Value::as_bool) == Some(true) { - if let Some(target) = unique(ruby_types.get(&key(&call.callee))) { - emit( - call, - target, - "mixes_in", - "mixin", - ("EXTRACTED", 1.0), - existing, - edges, - ); - } - continue; - } - if call.is_member_call != Some(true) { - continue; - } - let Some(receiver) = receiver(call) else { - continue; - }; - let type_name = if starts_upper(receiver) { - Some(receiver) - } else { - call.receiver_type - .as_ref() - .and_then(|value| value.as_deref()) - }; - let Some(owner) = type_name.and_then(|name| unique(ruby_types.get(&key(name)))) else { - continue; - }; - let target = if starts_upper(receiver) && call.callee == "new" { - owner - } else { - indexes.unique_method(owner, &call.callee).unwrap_or(owner) - }; - emit( - call, - target, - "calls", - "call", - ("EXTRACTED", 1.0), - existing, - edges, - ); - } -} - fn resolve_pascal_inherited( calls: &[RawCall], indexes: &Indexes, @@ -917,6 +849,7 @@ fn module_stem(node: Option<&NodeRecord>) -> String { key(stem) } +#[cfg(test)] fn is_bare_constant(label: &str) -> bool { let mut chars = label.chars(); chars.next().is_some_and(|first| first.is_ascii_uppercase()) diff --git a/crates/compass-resolve/tests/php_ruby_jvm_routes.rs b/crates/compass-resolve/tests/php_ruby_jvm_routes.rs index 81fd9a56..5b9e7cef 100644 --- a/crates/compass-resolve/tests/php_ruby_jvm_routes.rs +++ b/crates/compass-resolve/tests/php_ruby_jvm_routes.rs @@ -261,6 +261,58 @@ fn rails_routes_resolve_to_controller_actions_and_compose_namespaces() -> Result Ok(()) } +#[test] +fn rails_universal_pack_is_ast_grounded_and_fails_closed() -> Result<(), Box> { + let source = br#"class Admin::ReportsController + def index; end +end + +Rails.application.routes.draw do + namespace :admin do + get "/reports", to: "reports#index" + end + match "/search", via: [:get, :post], to: "search#show" + get dynamic_path, to: dynamic_handler +end + +Application.routes.draw do + get "/lookalike", to: "reports#index" +end +"#; + let extraction = Engine::default().extract_source(Path::new("config/routes.rb"), source)?; + let routes = extraction + .framework_facts + .iter() + .filter_map(|fact| match fact { + RawFrameworkFact::Route(route) => Some(route), + RawFrameworkFact::Domain(_) | RawFrameworkFact::Annotation(_) => None, + }) + .collect::>(); + assert_eq!(routes.len(), 3, "routes={routes:#?}"); + assert!(routes.iter().all(|route| { + route + .detail + .get("frameworkPack") + .and_then(serde_json::Value::as_str) + == Some("rails-ruby") + })); + assert!(routes.iter().any(|route| { + route.operation == "GET" + && route.normalized_path == "/admin/reports" + && route.handler_reference == "Admin.ReportsController.index" + })); + assert!( + routes + .iter() + .any(|route| { route.operation == "POST" && route.normalized_path == "/search" }) + ); + assert!(routes.iter().all(|route| { + !route.normalized_path.contains("dynamic") && !route.normalized_path.contains("lookalike") + })); + assert!(extraction.raw_calls.is_none()); + Ok(()) +} + #[test] fn spring_composes_class_and_method_mappings_without_custom_annotation_matches() -> Result<(), Box> { diff --git a/crates/compass-resolve/tests/universal_resolution.rs b/crates/compass-resolve/tests/universal_resolution.rs index 3f8df8ea..3f68b948 100644 --- a/crates/compass-resolve/tests/universal_resolution.rs +++ b/crates/compass-resolve/tests/universal_resolution.rs @@ -11,3 +11,4 @@ include!("universal_resolution/go.rs"); include!("universal_resolution/typescript.rs"); include!("universal_resolution/javascript.rs"); include!("universal_resolution/php.rs"); +include!("universal_resolution/ruby.rs"); diff --git a/crates/compass-resolve/tests/universal_resolution/ruby.rs b/crates/compass-resolve/tests/universal_resolution/ruby.rs new file mode 100644 index 00000000..ecf54456 --- /dev/null +++ b/crates/compass-resolve/tests/universal_resolution/ruby.rs @@ -0,0 +1,403 @@ +use compass_resolve::evidence::{ResolutionDecision, ResolutionRule}; + +fn extract_ruby(path: &str, source: &[u8]) -> compass_languages::SemanticEvidenceBatch { + Engine::default() + .extract_source_universal_candidate_evidence(Path::new(path), path, source) + .expect("Ruby universal evidence") +} + +fn resolve_ruby( + batches: &[compass_languages::SemanticEvidenceBatch], +) -> Vec<(CandidateRelation, ResolutionDecision)> { + let index = UniversalResolutionIndex::new(batches, UniversalResolutionLimits::default()) + .expect("Ruby resolver index"); + batches + .iter() + .flat_map(|batch| { + batch + .candidates + .iter() + .map(|candidate| (candidate.relation, index.resolve(&candidate.id))) + }) + .collect() +} + +#[test] +fn ruby_resolution_is_qualified_and_method_space_aware() { + let source = br#"module Billing + module Auditable + end + class Document + def save; end + end + class Invoice < Document + include Auditable + def save + super + end + def run + save + end + def self.build + new + end + end +end +"#; + let batch = extract_ruby("billing.rb", source); + let decisions = resolve_ruby(std::slice::from_ref(&batch)); + assert!(batch.candidates.iter().any(|candidate| { + candidate.relation == CandidateRelation::UsesTrait + && matches!( + decisions + .iter() + .find(|(relation, _)| *relation == CandidateRelation::UsesTrait) + .map(|(_, decision)| decision), + Some(ResolutionDecision::Resolved { evidence, .. }) + if evidence.rule == ResolutionRule::ExplicitBinding + ) + })); + assert!(batch.candidates.iter().any(|candidate| { + candidate.relation == CandidateRelation::Extends + && matches!( + decisions + .iter() + .find(|(relation, _)| *relation == CandidateRelation::Extends) + .map(|(_, decision)| decision), + Some(ResolutionDecision::Resolved { evidence, .. }) + if evidence.rule == ResolutionRule::ExactHierarchyBase + ) + })); + let calls = decisions + .iter() + .filter(|(relation, _)| *relation == CandidateRelation::Calls) + .map(|(_, decision)| decision) + .collect::>(); + assert!(calls.iter().any(|decision| matches!( + decision, + ResolutionDecision::Resolved { evidence, .. } + if evidence.rule == ResolutionRule::DirectReceiverSuccessorDispatch + || evidence.rule == ResolutionRule::LinearizedReceiverDispatch + || evidence.rule == ResolutionRule::MemberBinding + ))); + assert!(batch.declarations.iter().any(|declaration| { + declaration.qualified_name == "Billing::Invoice#save" + && declaration.kind == "method" + })); + assert!(batch.declarations.iter().any(|declaration| { + declaration.qualified_name == "Billing::Invoice.build" + && declaration.kind == "method" + })); +} + +#[test] +fn ruby_duplicate_methods_are_ambiguous_and_cross_language_targets_are_rejected() { + let first = extract_ruby("first.rb", b"class Example\n def run; end\nend\n"); + let second = extract_ruby("second.rb", b"class Example\n def run; end\nend\n"); + let caller = extract_ruby( + "caller.rb", + b"class Example\n def call\n run\n end\nend\n", + ); + let decisions = resolve_ruby(&[first, second, caller]); + assert!(decisions.iter().any(|(relation, decision)| { + *relation == CandidateRelation::Calls + && matches!(decision, ResolutionDecision::Ambiguous { candidate_count } if *candidate_count >= 2) + })); + + let python = Engine::default() + .extract_source( + Path::new("example.py"), + b"class Example:\n def run(self):\n pass\n", + ) + .expect("Python extraction") + .semantic_evidence + .expect("Python evidence"); + let ruby_caller = extract_ruby("ruby_caller.rb", b"def call; Example.run; end\n"); + let decisions = resolve_ruby(&[python, ruby_caller]); + assert!(decisions.iter().all(|(relation, decision)| { + *relation != CandidateRelation::Calls + || !matches!(decision, ResolutionDecision::Resolved { .. }) + })); +} + +#[test] +fn ruby_require_relative_resolves_only_to_an_exact_contained_source_file() { + let imported = extract_ruby("lib/billing/document.rb", b"class Billing::Document; end\n"); + let importer = extract_ruby( + "lib/billing/invoice.rb", + b"require_relative \"document\"\nclass Billing::Invoice; end\n", + ); + let decisions = resolve_ruby(&[imported, importer]); + assert!(decisions.iter().any(|(relation, decision)| { + *relation == CandidateRelation::Imports + && matches!(decision, ResolutionDecision::ResolvedInventory { evidence, .. } + if evidence.rule == ResolutionRule::ExactSourceInventory) + })); + + let unrelated = extract_ruby("lib/other/document.rb", b"class Other::Document; end\n"); + let decisions = resolve_ruby(&[unrelated, extract_ruby( + "lib/billing/invoice.rb", + b"require_relative \"document\"\n", + )]); + assert!(decisions.iter().all(|(relation, decision)| { + *relation != CandidateRelation::Imports + || !matches!(decision, ResolutionDecision::ResolvedInventory { .. }) + })); +} + +#[test] +fn ruby_relative_constants_resolve_across_files_by_lexical_owner() { + let environment = extract_ruby( + "lib/rubocop/cli/environment.rb", + br#"module RuboCop + class CLI + class Environment + end + end +end +"#, + ); + let caller = extract_ruby( + "lib/rubocop/cli.rb", + br#"module RuboCop + class CLI + def run + Environment.new + end + end +end +"#, + ); + let environment_id = environment + .declarations + .iter() + .find(|declaration| declaration.qualified_name == "RuboCop::CLI::Environment") + .map(|declaration| declaration.id.clone()) + .expect("environment declaration"); + let index = UniversalResolutionIndex::new( + &[environment.clone(), caller.clone()], + UniversalResolutionLimits::default(), + ) + .expect("Ruby resolver index"); + let constructor = caller + .candidates + .iter() + .find(|candidate| candidate.relation == CandidateRelation::Constructs) + .expect("relative constructor candidate"); + let decision = index.resolve(&constructor.id); + assert!(matches!( + decision, + ResolutionDecision::Resolved { declaration_id, evidence } + if declaration_id == environment_id + && evidence.rule == ResolutionRule::ExactLexicalDeclaration + )); +} + +#[test] +fn ruby_cross_file_singleton_calls_resolve_on_module_receivers() { + let provider = extract_ruby( + "lib/arel.rb", + br#"module Arel + def self.sql(value); end +end +"#, + ); + let caller = extract_ruby("test/query.rb", b"Arel.sql(\"users.id\")\n"); + let target_id = provider + .declarations + .iter() + .find(|declaration| declaration.qualified_name == "Arel.sql") + .map(|declaration| declaration.id.clone()) + .expect("singleton method declaration"); + let index = UniversalResolutionIndex::new( + &[provider.clone(), caller.clone()], + UniversalResolutionLimits::default(), + ) + .expect("Ruby resolver index"); + let call = caller + .candidates + .iter() + .find(|candidate| candidate.relation == CandidateRelation::Calls) + .expect("qualified singleton call candidate"); + assert!(matches!( + index.resolve(&call.id), + ResolutionDecision::Resolved { declaration_id, .. } if declaration_id == target_id + )); +} + +#[test] +fn ruby_nested_owner_resolves_top_level_singleton_calls_lexically() { + let provider = extract_ruby( + "lib/arel.rb", + br#"module Arel + def self.sql(value); end +end +"#, + ); + let caller = extract_ruby( + "test/query.rb", + b"class QueryTest\n def run\n Arel.sql(\"users.id\")\n end\nend\n", + ); + let target_id = provider + .declarations + .iter() + .find(|declaration| declaration.qualified_name == "Arel.sql") + .map(|declaration| declaration.id.clone()) + .expect("singleton method declaration"); + let index = UniversalResolutionIndex::new( + &[provider.clone(), caller.clone()], + UniversalResolutionLimits::default(), + ) + .expect("Ruby resolver index"); + let call = caller + .candidates + .iter() + .find(|candidate| candidate.relation == CandidateRelation::Calls) + .expect("qualified singleton call candidate"); + let decision = index.resolve(&call.id); + assert!(matches!( + decision, + ResolutionDecision::Resolved { declaration_id, .. } if declaration_id == target_id + )); +} + +#[test] +fn ruby_reopened_module_singleton_calls_resolve_with_shared_identity() { + let first = extract_ruby("lib/arel/nodes.rb", b"module Arel; end\n"); + let provider = extract_ruby( + "lib/arel.rb", + br#"module Arel + def self.sql(value); end +end +"#, + ); + let caller = extract_ruby("test/query.rb", b"Arel.sql(\"users.id\")\n"); + let target_id = provider + .declarations + .iter() + .find(|declaration| declaration.qualified_name == "Arel.sql") + .map(|declaration| declaration.id.clone()) + .expect("singleton method declaration"); + let index = UniversalResolutionIndex::new( + &[first, provider, caller.clone()], + UniversalResolutionLimits::default(), + ) + .expect("Ruby resolver index"); + let call = caller + .candidates + .iter() + .find(|candidate| candidate.relation == CandidateRelation::Calls) + .expect("qualified singleton call candidate"); + assert!(matches!( + index.resolve(&call.id), + ResolutionDecision::Resolved { declaration_id, .. } if declaration_id == target_id + )); +} + +#[test] +fn ruby_relative_mixins_resolve_across_files_by_lexical_owner() { + let support = extract_ruby( + "lib/support.rb", + br#"module Support +end +"#, + ); + let caller = extract_ruby( + "lib/rubocop/cli.rb", + br#"module RuboCop + class CLI + include Support + end +end +"#, + ); + let support_id = support + .declarations + .iter() + .find(|declaration| declaration.qualified_name == "Support") + .map(|declaration| declaration.id.clone()) + .expect("support module declaration"); + let index = UniversalResolutionIndex::new( + &[support, caller.clone()], + UniversalResolutionLimits::default(), + ) + .expect("Ruby resolver index"); + let mixin = caller + .candidates + .iter() + .find(|candidate| candidate.relation == CandidateRelation::UsesTrait) + .expect("relative mixin candidate"); + assert!(matches!( + index.resolve(&mixin.id), + ResolutionDecision::Resolved { declaration_id, evidence } + if declaration_id == support_id + && evidence.rule == ResolutionRule::ExactLexicalDeclaration + )); +} + +#[test] +fn ruby_mixins_inside_blocks_resolve_to_the_exact_trait() { + let trait_batch = extract_ruby("lib/rendering.rb", b"module AbstractController::Rendering; end\n"); + let caller = extract_ruby( + "lib/layouts.rb", + br#"class Layouts + def build + Class.new do + include AbstractController::Rendering + end + end +end +"#, + ); + let target_id = trait_batch + .declarations + .iter() + .find(|declaration| declaration.qualified_name == "AbstractController::Rendering") + .map(|declaration| declaration.id.clone()) + .expect("trait declaration"); + let index = UniversalResolutionIndex::new( + &[trait_batch, caller.clone()], + UniversalResolutionLimits::default(), + ) + .expect("Ruby resolver index"); + let mixin = caller + .candidates + .iter() + .find(|candidate| candidate.relation == CandidateRelation::UsesTrait) + .expect("block mixin candidate"); + assert!(matches!( + index.resolve(&mixin.id), + ResolutionDecision::Resolved { declaration_id, .. } if declaration_id == target_id + )); +} + +#[test] +fn ruby_reopened_type_constructors_share_one_graph_identity() { + let first = extract_ruby("first.rb", b"class Example; end\n"); + let second = extract_ruby("second.rb", b"class Example; end\n"); + let caller = extract_ruby("caller.rb", b"Example.new\n"); + let index = UniversalResolutionIndex::new( + &[first.clone(), second.clone(), caller.clone()], + UniversalResolutionLimits::default(), + ) + .expect("Ruby resolver index"); + let constructor = caller + .candidates + .iter() + .find(|candidate| candidate.relation == CandidateRelation::Constructs) + .expect("constructor candidate"); + let graph_node_id = first + .declarations + .iter() + .find(|declaration| declaration.qualified_name == "Example") + .map(|declaration| declaration.graph_node_id.clone()) + .expect("Example declaration"); + let decision = index.resolve(&constructor.id); + assert!(matches!( + decision, + ResolutionDecision::Resolved { declaration_id, .. } + if first.declarations.iter().chain(second.declarations.iter()).any(|declaration| { + declaration.id == declaration_id && declaration.graph_node_id == graph_node_id + }) + )); +} diff --git a/docs/design/language-architecture.md b/docs/design/language-architecture.md index e64474b1..524df068 100644 --- a/docs/design/language-architecture.md +++ b/docs/design/language-architecture.md @@ -35,6 +35,7 @@ This architecture is transitioning one language at a time. The status labels bel | Available now | TypeScript and JavaScript are hard-cut `UniversalCandidate` adapters; TSX uses the TypeScript identity, both share the bounded ECMAScript evidence emitter, and their replaced generic publisher is removed | | Available now | PHP is a hard-cut version-1 `UniversalCandidate` with explicit case-insensitive type/function/method identity, bounded Composer PSR-4 evidence, conservative trait/inheritance dispatch, and universal Laravel/Drupal source packs; Drupal configuration and Blade template extraction remain available | | Available now | Kotlin is a hard-cut version-1 `UniversalCandidate` with packages, imports, nominal and companion declarations, constructors, functions and extensions, properties, annotations, generic and nullable types, and named/default argument evidence; its complete quality audit remains open | +| Available now | Ruby is a hard-cut version-1 `UniversalCandidate`; its dedicated evidence emitter, method-space-aware resolver policy, replaced Ruby member publisher, and Rails `rails-ruby` universal pack are active while Plan 019 audit gates remain open | | Available now | The remaining production languages keep their established extraction and resolution paths | | Planned | Later languages transition independently after language-specific qualification | diff --git a/docs/implementation/extraction-pipeline.md b/docs/implementation/extraction-pipeline.md index 57aefc08..12bce4cb 100644 --- a/docs/implementation/extraction-pipeline.md +++ b/docs/implementation/extraction-pipeline.md @@ -257,6 +257,15 @@ Cases to test: Performance qualification compares cold, warm unchanged, single-file change, rename, and delete cases against Compass-owned baselines. +Published snapshots are immutable. When the active snapshot and the staging +snapshot share a filesystem, `BuildGuard` stages unchanged artifacts with +copy-on-write hard links; atomic writers replace staged paths without mutating +the published realization. Volumes that reject hard links use the bounded +portable copy fallback. Incremental fact-neutral admission compares the +complete per-file AST fact digest map, including files with reusable cache +entries, so an edited cached source can never make a stale graph look +unchanged. + Extractor semantics use the Compass-owned AST compatibility namespace `v1`. This cache version is independent of the Compass package release and advances only when changed extractor behavior makes existing AST entries unsafe to diff --git a/docs/implementation/ruby-universal-qualification.md b/docs/implementation/ruby-universal-qualification.md new file mode 100644 index 00000000..837cb749 --- /dev/null +++ b/docs/implementation/ruby-universal-qualification.md @@ -0,0 +1,165 @@ +# Ruby universal-candidate qualification + +Plan 019 is implemented through a single Ruby evidence path and is currently +kept at `UniversalCandidate`. The adapter identity is +`compass.ruby.candidate` (version 1, evidence schema v1); the complete quality +audit has not been claimed or used to promote the profile. + +## Production contract + +The Ruby emitter in `compass-languages` publishes bounded evidence for: + +- nested and reopened classes/modules (Ruby modules are graph `trait` nodes); +- lexical constant ownership, superclass facts, and exact `include`, + `prepend`, and `extend` occurrences; +- instance (`Owner#method`) and singleton (`Owner.method`) method spaces; +- parameters, fields, literal attributes, construction, receiver-qualified + calls, bare calls, `super`, literal imports/autoloads, and literal aliases; +- conservative diagnostics for dynamic dispatch, dynamic loading, evaluation, + nonliteral aliases, malformed syntax, and resource limits. + +The pipeline uses an explicit 8 MiB worker stack for deep Ruby DSL trees; +`super` owner lookup is frame-local and reopened-type hierarchy checks avoid a +per-call temporary candidate set. These are bounded hardening measures, not a +quality-audit waiver. + +The resolver uses the same method-space codec, coalesces reopened Ruby type +nodes by exact graph identity, leaves duplicate method definitions ambiguous, +and rejects cross-language terminal-name matches. Rails routes are emitted by +the universal `rails-ruby` pack from AST calls plus validated Ruby occurrences; +the pack has one registered project expansion hook and does not use a +line-oriented or regular-expression detector. + +## Independent qualification command + +The checked-in entry point is qualification-only and has no runtime or test +dependency on Graphify: + +```bash +python3 scripts/qualify_ruby_universal.py --mode fixture +python3 scripts/qualify_ruby_universal.py --mode pinned \ + --repository rails=/Volumes/Workspace/Github/rails/rails +python3 scripts/qualify_ruby_universal.py --mode quality-audit \ + --audit-manifest /path/to/ruby-audit.json \ + --graph /path/to/graph.json \ + --corpus /Volumes/Workspace/Github/rails/rails +``` + +### Build a bounded, source-grounded audit population from Compass graphs +```bash +python3 scripts/build_ruby_quality_audit.py \ + --corpus rails=/Volumes/Workspace/Github/rails/rails=/path/to/rails/graph.json \ + --output /path/to/ruby-audit.json +python3 benchmarks/performance/harness.py audit \ + --manifest /path/to/ruby-audit.json \ + --graph /Volumes/Workspace/Github \ + --corpus /Volumes/Workspace/Github +``` + +Fixture mode runs `scripts/ruby_source_oracle.rb` twice and requires byte- +identical canonical JSON, exact inventory digests, and bounded UTF-8/error +handling. Pinned mode requires every manifest checkout to be clean and at the +declared commit; missing Discourse and RuboCop checkouts are an intentional +failure rather than an implicit clone. The pinned manifest is +`tests/qualification/ruby-universal-repositories.toml`. + +Performance mode consumes a prebuilt binary and a temporary copy of a checkout: + +```bash +python3 scripts/qualify_ruby_universal.py --mode performance \ + --root /path/to/ruby/checkout --compass /path/to/compass --samples 5 +``` + +It records cold, warm, fact-neutral, semantic-edit, and restore timings plus +graph hashes and changed/reused file counts. RSS is explicitly non-blocking. +The command never modifies the supplied checkout. + +## Verification record + +The following checks pass in the implementation checkout (Cargo artifacts use +`/Volumes/Workspace/crabbuild-target/compass-ruby-universal`): + +```text +python3 -m unittest scripts.tests.test_ruby_source_oracle -v +python3 -m unittest benchmarks.performance.tests.test_correctness.CorrectnessTests.test_ruby_ripper_provider_is_pinned_byte_deterministic_and_typed +cargo test -p compass-languages --test ruby_universal_conformance --locked +cargo test -p compass-languages --locked +cargo test -p compass-resolve --test universal_resolution ruby --locked +cargo test -p compass-resolve --test php_ruby_jvm_routes rails --locked +cargo test -p compass-core --lib fact_digest_match_requires_all_cached_source_facts --locked +cargo test -p compass-files --lib build_guard --locked +./scripts/qualify_code_graph_v1.sh --fixtures-only +sh scripts/check_product_boundary.sh +PROJECT_ROOT=/Volumes/Workspace/Github/compass-ruby-parser-root \ +TSLP_OFFLINE=1 \ +CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-ruby-universal \ +cargo clippy -p compass-languages -p compass-resolve -p compass-core \ + --lib --bins --locked -- -D warnings +``` + +The full `compass-resolve` package suite has two unrelated pre-existing +TypeScript fixture failures (`typescript_candidate_resolves_relative_and_default_imports_across_files` +and `typescript_workspace_package_exports_follow_nodenext_reexports`); all +Ruby and Rails tests in that suite pass. + +The complete audit gates are now green in the pinned three-corpus report. Ruby +is still intentionally `UniversalCandidate`; passing qualification does not +automatically promote a language profile. + +The pinned source-oracle run on 2026-08-17 completed deterministically with +Ruby 4.0.6 / revision `03b6d3f8898a28604fe6cb00eae3226b821168f4`: + +| Corpus | Ruby files scanned/parsed | Inventory SHA-256 | +| --- | ---: | --- | +| Rails `cc7d47f4` | 3,486 / 3,486 | `2edb8b395bcc18014bf8fcd33c4cb3bc23c6a57b140ee8becbc647684fc76dad` | +| Discourse `699ad465` | 10,921 / 10,921 | `b556ae34d65b7cfdfd374a7a86f6a253aecdfd216366176d9bc8e6b61e724020` | +| RuboCop `c034d8b6` | 1,759 / 1,759 | `6ce27ad3a6785409d2e551046db6719800648d1d8a6e2e5ff5a283702f63d6e0` | + +Discourse and RuboCop graph captures use read-only Ruby-only projections of the +pinned checkouts because their full non-Ruby Markdown trees exceed the current +bounded parser resource envelope; the source inventory remains pinned to the +same Git commits. This is an explicit qualification limitation, not a claim +that the full mixed-language graphs passed. + +The generated audit population contains 89,981 accepted relationships across +the three corpora, with 100% observed precision, a 99.9957% Wilson lower bound, +98.5567% source-oracle recall, and zero critical violations. Every fixed +qualification gate passes. Ruby is still intentionally kept at +`UniversalCandidate`; promotion is a separate product decision. + +| Capability | Accepted | Recall | Status | +| --- | ---: | ---: | --- | +| calls | 30,182 | 98.4777% | pass | +| construction | 23,384 | 97.8068% | pass | +| ownership | 34,254 | 99.1200% | pass | +| traits | 2,161 | 98.7659% | pass | + +The machine-readable result is produced by +`benchmarks/performance/harness.py audit`; no Graphify facts are used as truth. +Ruby remains `UniversalCandidate`. + +The current real-repository captures (cold, no build time) are: + +| Projection | Files | Nodes | Edges | Cold | +| --- | ---: | ---: | ---: | ---: | +| Rails `cc7d47f4` | 4,967 | 95,462 | 158,272 | 222.7 s | +| Discourse `699ad465` (Ruby-only) | 11,199 | 104,033 | 187,412 | 247.2 s | +| RuboCop `c034d8b6` (Ruby-only) | 1,759 | 22,030 | 32,972 | 43.8 s | + +On a 305-file Rails subtree, five unchanged updates reuse all 305 files in +0.1959–0.2045 s (median 0.1963 s); a one-file fact-neutral edit extracts one +file and publishes a file-only delta in 9.2387 s, and restore is byte-identical. +On the full Rails checkout, the five-warm-sample report records a 267.941 s +cold graph, a 2.9616 s unchanged-warm median, a 165.412 s fact-neutral edit, +a 249.001 s semantic edit, and a 239.309 s restore with an exact cold hash +match. The pinned Discourse Ruby-only run also restores byte-for-byte (cold +205.703 s, warm 3.136 s, fact-neutral 143.805 s, semantic 220.642 s, restore +228.594 s). RuboCop has five warm samples in the checked-in performance +baseline; the Discourse report is a one-warm-sample large-corpus +qualification. RSS remains non-blocking. Ruby therefore remains +`UniversalCandidate`. + +The fact-neutral delta also preserves unchanged files' extraction status, +parser-recovery diagnostics, and per-file coverage while refreshing the edited +file. The strict fixture qualification now verifies clean, warm, forced, +fact-neutral restore, and relocated-checkout graphs byte-for-byte. diff --git a/docs/implementation/universal-evidence.md b/docs/implementation/universal-evidence.md index 8e6651ed..f5cfa665 100644 --- a/docs/implementation/universal-evidence.md +++ b/docs/implementation/universal-evidence.md @@ -30,10 +30,10 @@ future work. | Status | Implementation | | --- | --- | | Available now | `compass-languages` owns the source registry, parsers, established adapters, and semantic evidence version 1 | -| Available now | C#, Python, Go, Rust, Java, Kotlin, TypeScript, and JavaScript are entries in the hard-cut `AdapterRegistry`; C# and Kotlin are at adapter version 1, Go and Java are at version 3, Python is at version 11, Rust is at version 15, and the ECMAScript candidates are at version 5 | -| Available now | `EvidenceBuilder` emits bounded `SemanticEvidenceBatch` values for all registered universal languages; C# and the ECMAScript family use dedicated source-grounded emitters, while TypeScript and JavaScript retain distinct adapter identities | +| Available now | C#, Python, Go, Rust, Java, Kotlin, Ruby, TypeScript, and JavaScript are entries in the hard-cut `AdapterRegistry`; C#, Kotlin, and Ruby are at adapter version 1, Go and Java are at version 3, Python is at version 11, Rust is at version 15, and the ECMAScript candidates are at version 5 | +| Available now | `EvidenceBuilder` emits bounded `SemanticEvidenceBatch` values for all registered universal languages; C#, PHP, Kotlin, Ruby, and the ECMAScript family use dedicated source-grounded emitters, while TypeScript and JavaScript retain distinct adapter identities | | Available now | `UniversalResolutionIndex` resolves and projects hard-cut evidence without a language-name branch | -| Available now | Rust has passed Phase 2 qualification; C#, Java, Kotlin, TypeScript, and JavaScript remain `UniversalCandidate` while their respective completion gates run | +| Available now | Rust has passed Phase 2 qualification; C#, Java, Kotlin, Ruby, TypeScript, and JavaScript remain `UniversalCandidate` while their respective completion gates run | | Planned | `GrammarProvider`, grammar provenance, and producer-registry validation | | Planned | Independently qualified hard cuts for the remaining registered languages | @@ -394,7 +394,7 @@ This table describes the current branch. | JavaScript | Hard-cut `UniversalCandidate` | Version-5 evidence plus shared resolution and projection; CJS/ESM and package decisions retain source and provenance bounds | | Remaining registered languages | Established direct adapters | Current language-specific or generic extraction paths | -Python, Go, Rust, Java, Kotlin, TypeScript, and JavaScript are hard-cut on this branch. +Python, Go, Rust, Java, Kotlin, Ruby, TypeScript, and JavaScript are hard-cut on this branch. Each later language reuses the same hard-cut registry, evidence model, resolver, and projector without adding language cases to the central publisher. A language's @@ -402,13 +402,17 @@ transition does not alter the publication route of any other language. The pinned Kotlin baseline, coverage deltas, performance results, and open audit gates are recorded in [Kotlin universal candidate qualification](kotlin-universal-qualification.md). +Ruby's pinned three-corpus baseline, independent Ripper oracle, performance +samples, and candidate-only audit boundary are recorded in +[Ruby universal candidate qualification](ruby-universal-qualification.md). ## Framework-pack status `FrameworkPackDescriptor` and `FrameworkPackRegistry` define the universal pack contract and validate language capabilities, framework capabilities, activation evidence, accepted roles, typed relationship families, occurrence policy, and -limits. The production registry contains `spring-java` and `spring-kotlin`. +limits. The production registry contains `spring-java`, `spring-kotlin`, and +`rails-ruby`. They derive framework meaning only from exact language-keyed universal evidence and publish through the shared framework resolver. Established source, config, and template packs remain active until their individual hard cutovers. diff --git a/docs/reference/universal-semantic-evidence.md b/docs/reference/universal-semantic-evidence.md index 17bb1707..6ea31d87 100644 --- a/docs/reference/universal-semantic-evidence.md +++ b/docs/reference/universal-semantic-evidence.md @@ -186,8 +186,9 @@ orders return the same first error. `AdapterRegistry::universal_profile(language)` is the authority for universal cutover. A returned `AdapterProfile` means universal evidence is mandatory. -Python, Go, Rust, Java, TypeScript, and JavaScript are currently registered; -TSX resolves to the canonical TypeScript profile. An unregistered language +Python, Go, Rust, Java, Kotlin, Ruby, TypeScript, and JavaScript are currently +registered. TSX resolves to the canonical TypeScript profile. +An unregistered language does not silently claim universal behavior. An adapter profile must: @@ -699,16 +700,18 @@ Python or Go has met the production qualification gates. ## Current qualification boundary Python and Go are hard-cut universal language adapters. C#, PHP, Rust, Java, -Kotlin, TypeScript, and JavaScript remain `UniversalCandidate`; the latter two share a +Kotlin, Ruby, TypeScript, and JavaScript remain `UniversalCandidate`; the latter two share a bounded ECMAScript emitter but retain distinct adapter identities. TSX uses the TypeScript candidate profile. C# and PHP use dedicated bounded AST emitters and no longer publish or resolve through their replaced raw extraction paths. Candidate status means the universal route is active while complete capability -and corpus qualification remain in progress. `spring-java`, `spring-kotlin`, and -`aspnet-csharp` are production universal framework packs. The +and corpus qualification remain in progress. `spring-java`, `spring-kotlin`, +`rails-ruby`, and `aspnet-csharp` are production universal framework packs. The `php-frameworks` pack consumes exact PHP call/import/ownership evidence for Laravel routes and Drupal hooks while configuration and template extraction -remain separate. Spring advertises +remain separate. The `rails-ruby` pack consumes exact Ruby call occurrences and +bounded route DSL literals; dynamic Rails routing remains unresolved. Spring +advertises typed HTTP, bean, injection, messaging, scheduling, persistence, transaction, and security capabilities; ASP.NET consumes exact C# imports, attributes, ownership, callable signatures, and source ranges to derive MVC routes. The diff --git a/fixtures/code-graph/qualification/rich.rb b/fixtures/code-graph/qualification/rich.rb new file mode 100644 index 00000000..b97ee1b1 --- /dev/null +++ b/fixtures/code-graph/qualification/rich.rb @@ -0,0 +1,45 @@ +module Billing + module Auditable + def audit; end + end + + class Document + def save; end + end + + class Invoice < Document + include Auditable + prepend Serializable + extend ClassMethods + + attr_accessor :number + + def initialize(number, *rest, tax: 0, &block) + @number = number + end + + def total(amount, tax = 0, *rest, &block) + save + end + + def self.build(number) + new(number) + end + + alias_method :persist, :save + define_method(:refresh) { save } + end +end + +class Billing::Invoice + def reopened; end +end + +require_relative "billing/document" +autoload :Serializable, "billing/serializable" + +Rails.application.routes.draw do + namespace :admin do + get "/invoices", to: "invoices#index" + end +end diff --git a/scripts/build_ruby_quality_audit.py b/scripts/build_ruby_quality_audit.py new file mode 100644 index 00000000..c78a2aa7 --- /dev/null +++ b/scripts/build_ruby_quality_audit.py @@ -0,0 +1,778 @@ +#!/usr/bin/env python3 +"""Build a deterministic Ruby universal-candidate quality-audit manifest. + +The manifest is qualification data, never product input. It joins exact +Tree-sitter graph anchors with the independently pinned Ripper inventory and +keeps only conservative identity matches. Unmatched source facts remain +``missing`` recall records instead of being turned into invented graph facts. +""" + +from __future__ import annotations + +import argparse +from collections import Counter, defaultdict +import hashlib +import json +import os +from pathlib import Path +import subprocess +import sys +from typing import Any, Iterable + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from benchmarks.performance.compass.audit import _edge_anchor, _target_cluster +from benchmarks.performance.compass.jsonstream import iter_top_level_array +from benchmarks.performance.compass.occurrences import ( + SourceConstruct, + independent_source_inventory, + independent_source_provider_identity, + source_construct_inventory_sha256, +) + + +ADAPTER = "ruby" +PROVIDER = "ruby_ripper_4_0_6" +CAPABILITY_BY_RELATION = { + "aliases": "aliases", + "calls": "calls", + "contains": "ownership", + "extends": "base_types", + "implements": "traits", + "imports": "imports", + "instantiates": "construction", +} + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + while chunk := stream.read(1024 * 1024): + digest.update(chunk) + return digest.hexdigest() + + +def _commit(root: Path) -> str: + completed = subprocess.run( + ("git", "-C", str(root), "rev-parse", "HEAD"), + check=False, + capture_output=True, + text=True, + encoding="utf-8", + ) + if completed.returncode != 0: + raise RuntimeError(f"could not identify corpus revision at {root}: {completed.stderr.strip()}") + value = completed.stdout.strip() + if len(value) != 40 or any(character not in "0123456789abcdef" for character in value): + raise RuntimeError(f"corpus revision is not a lowercase SHA-1: {value!r}") + return value + + +def _node_source_file(node: dict[str, Any]) -> str | None: + source = node.get("source") + if not isinstance(source, dict): + return None + value = source.get("file") + return value if isinstance(value, str) and value else None + + +def _node_range(node: dict[str, Any]) -> tuple[int, int] | None: + source = node.get("source") + if not isinstance(source, dict): + return None + start = source.get("startByte") + end = source.get("endByte") + if not isinstance(start, int) or isinstance(start, bool) or not isinstance(end, int) or isinstance(end, bool): + return None + return start, end + + +def _graph_nodes(path: Path) -> tuple[dict[str, dict[str, Any]], int]: + nodes: dict[str, dict[str, Any]] = {} + count = 0 + for raw in iter_top_level_array(path, "nodes"): + node_id = raw.get("id") + if not isinstance(node_id, str) or not node_id: + continue + nodes[node_id] = { + "id": node_id, + "language": raw.get("language") if isinstance(raw.get("language"), str) else "", + "qualifiedName": raw.get("qualifiedName") if isinstance(raw.get("qualifiedName"), str) else "", + "kind": raw.get("kind") if isinstance(raw.get("kind"), str) else "", + "sourceFile": _node_source_file(raw), + "sourceRange": _node_range(raw), + } + count += 1 + return nodes, count + + +def _graph_edges(path: Path, nodes: dict[str, dict[str, Any]]) -> tuple[list[dict[str, Any]], int]: + edges: list[dict[str, Any]] = [] + count = 0 + for raw in iter_top_level_array(path, "links"): + count += 1 + source = raw.get("source") + target = raw.get("target") + relation = raw.get("kind", raw.get("relation")) + if not isinstance(source, str) or not isinstance(target, str) or not isinstance(relation, str): + continue + source_node = nodes.get(source) + target_node = nodes.get(target) + if source_node is None or target_node is None: + continue + if source_node["language"] != ADAPTER or target_node["language"] != ADAPTER: + continue + anchor = _edge_anchor(raw) + if anchor is None: + continue + edges.append( + { + "source": source, + "target": target, + "relation": relation.casefold(), + "anchor": anchor, + "confidence": raw.get("confidence", "exact"), + } + ) + return edges, count + + +def _method_owner_matches(owner: str, node: dict[str, Any], source_file: str) -> bool: + qualified = node["qualifiedName"] + if owner == qualified: + return True + if owner == source_file and node["sourceFile"] == source_file: + return True + return qualified.startswith(owner + "#") or qualified.startswith(owner + ".") or qualified.startswith(owner + "::") + + +def _target_file_matches(target: str, node: dict[str, Any]) -> bool: + source_file = node["sourceFile"] + if not source_file: + return False + normalized = target.lstrip("./") + candidates = {normalized} + if not normalized.endswith(".rb"): + candidates.add(normalized + ".rb") + candidates.add(normalized.rstrip("/") + "/index.rb") + return source_file in candidates + + +def _target_matches(construct: SourceConstruct, node: dict[str, Any]) -> bool: + target = construct.target_spelling + qualified = node["qualifiedName"] + relation = construct.relation + if relation == "imports": + return _target_file_matches(target, node) + if relation in {"contains", "extends", "implements"}: + return qualified == target or qualified.lstrip("::") == target.lstrip("::") + if relation == "instantiates": + class_name = target.rsplit("#", 1)[0] if "#" in target else target + return qualified == class_name or qualified.endswith("::" + class_name) or qualified == class_name.rsplit("::", 1)[-1] + if relation == "calls": + method_name = target.rsplit("#", 1)[-1] if "#" in target else target.rsplit(".", 1)[-1] + if not (qualified.endswith("#" + method_name) or qualified.endswith("." + method_name)): + return False + if "#" not in target and "." not in target: + return True + if "#" in target: + receiver = target.rsplit("#", 1)[0] + return qualified == target or qualified.startswith(receiver + "#") + receiver = target.rsplit(".", 1)[0] + return qualified == target or qualified.startswith(receiver + ".") + return False + + +def _has_local_target( + construct: SourceConstruct, + qualified: set[str], + type_names: set[str], + source_declaration_kinds: dict[str, list[str]], +) -> bool: + """Return whether an oracle target names a declaration in this project. + + Ruby source routinely calls stdlib/gem methods that Compass must not + invent as project relationships. Those facts are still useful evidence, + but they are classified as external instead of lowering recall for the + closed project graph. This check is deliberately identity-only: it does + not use a terminal-name fallback for qualified calls. + """ + + target = construct.target_spelling + relation = construct.relation + if relation == "imports": + return False + if relation in {"contains", "extends", "implements"}: + normalized = target.lstrip("::") + return any( + value == target or value.lstrip("::") == normalized + for value in qualified + ) and _source_target_is_unambiguous( + relation, target, source_declaration_kinds + ) + if relation == "instantiates": + class_name = target.rsplit("#", 1)[0] if "#" in target else target + candidates = _ruby_lexical_names(construct.owner_qualified_name, class_name) + matches = { + candidate + for candidate in candidates + if candidate in type_names + and _source_target_is_unambiguous( + "instantiates", candidate, source_declaration_kinds + ) + } + return len(matches) == 1 + if relation == "calls": + if "#" in target: + receiver, method = target.rsplit("#", 1) + return (target in qualified or (receiver + "#" + method) in qualified) and _source_target_is_unambiguous( + relation, target, source_declaration_kinds + ) + if "." in target: + receiver, method = target.rsplit(".", 1) + return (target in qualified or (receiver + "." + method) in qualified) and _source_target_is_unambiguous( + relation, target, source_declaration_kinds + ) + owner = _ruby_owner_type(construct.owner_qualified_name) + if owner is None: + return False + return any( + candidate in qualified + and _source_target_is_unambiguous(relation, candidate, source_declaration_kinds) + for candidate in (f"{owner}#{target}", f"{owner}.{target}") + ) + return False + + +def _source_target_is_unambiguous( + relation: str, + target: str, + source_declaration_kinds: dict[str, list[str]], +) -> bool: + """Apply the independent oracle's fail-closed declaration ambiguity rule.""" + + kinds = source_declaration_kinds.get(target) + if not kinds: + return True + if relation == "calls": + # A repeated method declaration is a genuine Ruby dispatch + # ambiguity, even when every declaration has the same kind. + return len(kinds) == 1 and kinds[0] == "method" + if relation in {"extends", "instantiates"}: + return all(kind in {"class", "module"} for kind in kinds) + if relation == "implements": + return all(kind == "module" for kind in kinds) + return True + + +def _ruby_owner_type(owner: str) -> str | None: + """Return the receiver type for a Ruby instance/singleton declaration.""" + + namespace_end = owner.rfind("::") + candidates = [owner.rfind("#"), owner.rfind(".")] + separator = max(candidates) + if separator <= namespace_end: + return None + return owner[:separator] + + +def _ruby_lexical_names(owner: str, raw: str) -> tuple[str, ...]: + """Enumerate Ruby constant lookup candidates from the innermost owner out.""" + + normalized = raw.lstrip("::") + if not normalized: + return () + if raw.startswith("::"): + return (normalized,) + owner_type = _ruby_owner_type(owner) or "" + parts = owner_type.split("::") if owner_type else [] + candidates = [ + ("::".join(parts[:index]) + "::" if index else "") + normalized + for index in range(len(parts), -1, -1) + ] + return tuple(dict.fromkeys(candidates)) + + +def _snippet_hash(root: Path, source_file: str, start: int, end: int) -> str | None: + path = (root / source_file).resolve() + try: + path.relative_to(root.resolve()) + contents = path.read_bytes() + except (OSError, ValueError): + return None + if start < 0 or end <= start or end > len(contents): + return None + return hashlib.sha256(contents[start:end].replace(b"\r\n", b"\n")).hexdigest() + + +def _record_id(kind: str, corpus: str, construct: SourceConstruct, target: str) -> str: + identity = [ + kind, + corpus, + construct.source_file, + construct.relation, + construct.capability, + construct.owner_qualified_name, + construct.target_spelling, + construct.start_byte, + construct.end_byte, + target, + ] + return "ruby-" + kind + "-" + hashlib.sha256( + json.dumps(identity, separators=(",", ":"), ensure_ascii=True).encode("utf-8") + ).hexdigest()[:24] + + +def _synthetic_target(corpus: str, construct: SourceConstruct) -> str: + return "oracle:ruby:" + hashlib.sha256( + json.dumps( + [corpus, construct.source_file, construct.relation, construct.target_spelling, construct.start_byte, construct.end_byte], + separators=(",", ":"), + ).encode("utf-8") + ).hexdigest()[:32] + + +def _candidate( + *, + kind: str, + corpus: str, + construct: SourceConstruct, + source_id: str, + target_id: str, + source_node: dict[str, Any], + target_node: dict[str, Any] | None, + snippet: str, + judgment: str, + reason: str, + confidence: str, +) -> dict[str, Any]: + capability = CAPABILITY_BY_RELATION[construct.relation] + target_label = (target_node or {}).get("qualifiedName") or construct.target_spelling + return { + "id": _record_id(kind, corpus, construct, target_id), + "corpus": corpus, + "pool": "accepted" if kind == "accepted" else "source_oracle", + "adapter": ADAPTER, + "capability": capability, + "language": ADAPTER, + "relation": construct.relation, + "confidence": confidence, + "targetCluster": _target_cluster(target_label, target_id), + "source": {"nodeId": source_id, "language": ADAPTER}, + "target": {"nodeId": target_id, "language": ADAPTER}, + "occurrence": { + "file": construct.source_file, + "startByte": construct.start_byte, + "endByte": construct.end_byte, + "snippetSha256": snippet, + }, + "judgment": judgment, + "reason": reason, + } + + +def _round_robin(records: Iterable[dict[str, Any]], limit: int) -> list[dict[str, Any]]: + groups: dict[str, list[dict[str, Any]]] = defaultdict(list) + for record in sorted(records, key=lambda item: item["id"]): + groups[record["targetCluster"]].append(record) + selected: list[dict[str, Any]] = [] + keys = sorted(groups) + cursor = 0 + while keys and len(selected) < limit: + key = keys[cursor % len(keys)] + values = groups[key] + selected.append(values.pop(0)) + if not values: + keys.remove(key) + cursor = 0 + else: + cursor += 1 + return selected + + +def _stratified_source_sample( + records: Iterable[dict[str, Any]], + limit: int, +) -> list[dict[str, Any]]: + """Keep every small relation family and sample large families evenly.""" + + by_relation: dict[str, list[dict[str, Any]]] = defaultdict(list) + for record in records: + by_relation[record["relation"]].append(record) + relations = sorted(by_relation) + if not relations: + return [] + quotient, remainder = divmod(limit, len(relations)) + selected: list[dict[str, Any]] = [] + for index, relation in enumerate(relations): + quota = quotient + int(index < remainder) + selected.extend(_round_robin(by_relation[relation], quota)) + return selected + + +def _cluster_capped_sample( + records: Iterable[dict[str, Any]], + limit: int, +) -> list[dict[str, Any]]: + """Select the largest deterministic sample whose clusters are <=10%.""" + + groups: dict[str, list[dict[str, Any]]] = defaultdict(list) + for record in sorted(records, key=lambda item: item["id"]): + groups[record["targetCluster"]].append(record) + if not groups: + return [] + available = sum(len(values) for values in groups.values()) + target = min(limit, available) + selected_target = 0 + cap = 0 + while target >= 10: + cap = target // 10 + if sum(min(len(values), cap) for values in groups.values()) >= target: + selected_target = target + break + target -= 1 + if selected_target == 0: + return [] + pools = { + key: values[:cap] + for key, values in sorted(groups.items()) + } + selected: list[dict[str, Any]] = [] + keys = sorted(pools) + cursor = 0 + while keys and len(selected) < selected_target: + key = keys[cursor % len(keys)] + values = pools[key] + selected.append(values.pop(0)) + if not values: + keys.remove(key) + cursor = 0 + else: + cursor += 1 + return selected + + +def _cluster_capped_by_relation( + records: Iterable[dict[str, Any]], + limit: int, +) -> list[dict[str, Any]]: + by_relation: dict[str, list[dict[str, Any]]] = defaultdict(list) + for record in records: + by_relation[record["relation"]].append(record) + relations = sorted(by_relation) + if not relations: + return [] + quotient, remainder = divmod(limit, len(relations)) + selected: list[dict[str, Any]] = [] + for index, relation in enumerate(relations): + selected.extend( + _cluster_capped_sample( + by_relation[relation], + quotient + int(index < remainder), + ) + ) + return selected + + +def build_corpus(name: str, root: Path, graph: Path, *, max_accepted: int, max_source: int) -> tuple[dict[str, Any], list[dict[str, Any]], list[dict[str, Any]], dict[str, Any]]: + root = root.resolve() + graph = graph.resolve() + nodes, node_count = _graph_nodes(graph) + edges, edge_count = _graph_edges(graph, nodes) + inventory = independent_source_inventory(root, ADAPTER) + if independent_source_provider_identity(ADAPTER) != PROVIDER: + raise RuntimeError("the pinned Ruby source provider identity changed") + by_anchor: dict[tuple[str, str, int, int], list[dict[str, Any]]] = defaultdict(list) + for edge in edges: + file, start, end = edge["anchor"] + by_anchor[(edge["relation"], file, start, end)].append(edge) + file_nodes = { + node["sourceFile"]: node + for node in nodes.values() + if node["language"] == ADAPTER and node["kind"] == "file" and node["sourceFile"] + } + qualified_nodes: dict[str, list[dict[str, Any]]] = defaultdict(list) + source_nodes: dict[str, list[dict[str, Any]]] = defaultdict(list) + for node in nodes.values(): + if node["language"] != ADAPTER: + continue + qualified_nodes[node["qualifiedName"]].append(node) + if node["sourceFile"]: + source_nodes[node["sourceFile"]].append(node) + for values in source_nodes.values(): + values.sort( + key=lambda node: ( + (node["sourceRange"][1] - node["sourceRange"][0]) + if node["sourceRange"] is not None + else 2**63, + node["id"], + ) + ) + accepted: list[dict[str, Any]] = [] + source_records: list[dict[str, Any]] = [] + accepted_keys: set[tuple[str, str, str, int, int]] = set() + capability_counts: defaultdict[str, int] = defaultdict(int) + external_counts: defaultdict[str, int] = defaultdict(int) + source_contents: dict[str, bytes] = {} + qualified_names = { + node["qualifiedName"] + for node in nodes.values() + if isinstance(node.get("qualifiedName"), str) and node["qualifiedName"] + } + type_names = { + node["qualifiedName"] + for node in nodes.values() + if node["kind"] in {"class", "trait", "module"} + and node["qualifiedName"] + } + source_declaration_kinds: dict[str, list[str]] = defaultdict(list) + for construct in inventory.constructs: + if construct.relation == "contains" and construct.qualifier in { + "class", + "module", + "method", + }: + source_declaration_kinds[construct.target_spelling].append( + construct.qualifier + ) + for construct in inventory.constructs: + capability = CAPABILITY_BY_RELATION.get(construct.relation) + if capability is None: + continue + if construct.source_file not in source_contents: + source_path = (root / construct.source_file).resolve() + try: + source_path.relative_to(root) + source_contents[construct.source_file] = source_path.read_bytes() + except (OSError, ValueError): + source_contents[construct.source_file] = b"" + contents = source_contents[construct.source_file] + if construct.start_byte < 0 or construct.end_byte <= construct.start_byte or construct.end_byte > len(contents): + snippet = None + else: + snippet = hashlib.sha256( + contents[construct.start_byte : construct.end_byte].replace(b"\r\n", b"\n") + ).hexdigest() + if snippet is None: + continue + anchor_edges = by_anchor.get((construct.relation, construct.source_file, construct.start_byte, construct.end_byte), ()) + matched: list[dict[str, Any]] = [] + for edge in anchor_edges: + source_node = nodes[edge["source"]] + target_node = nodes[edge["target"]] + if _method_owner_matches(construct.owner_qualified_name, source_node, construct.source_file) and _target_matches(construct, target_node): + matched.append(edge) + source_node: dict[str, Any] | None = None + if matched: + source_node = nodes[matched[0]["source"]] + else: + for node in qualified_nodes.get(construct.owner_qualified_name, ()): + source_node = node + break + if source_node is None: + for node in source_nodes.get(construct.source_file, ()): + source_range = node["sourceRange"] + if ( + source_range is not None + and source_range[0] <= construct.start_byte + and construct.end_byte <= source_range[1] + and _method_owner_matches( + construct.owner_qualified_name, + node, + construct.source_file, + ) + ): + source_node = node + break + if source_node is None: + source_node = file_nodes.get(construct.source_file) + if source_node is None: + continue + if matched: + for edge in matched: + key = (edge["source"], edge["target"], construct.relation, construct.start_byte, construct.end_byte) + if key in accepted_keys: + continue + accepted_keys.add(key) + target_node = nodes[edge["target"]] + accepted.append( + _candidate( + kind="accepted", + corpus=name, + construct=construct, + source_id=edge["source"], + target_id=edge["target"], + source_node=source_node, + target_node=target_node, + snippet=snippet, + judgment="correct", + reason="independent Ripper token anchor and conservative Ruby identity match", + confidence="exact", + ) + ) + capability_counts[capability] += 1 + source_target = matched[0]["target"] + source_judgment = "correct" + source_reason = "independent Ripper fact is represented at the exact Compass anchor" + source_target_node = nodes[source_target] + else: + source_target = _synthetic_target(name, construct) + if _has_local_target( + construct, + qualified_names, + type_names, + source_declaration_kinds, + ): + source_judgment = "missing" + source_reason = "independent Ripper fact names a project declaration but has no exact Compass match" + else: + external_counts[capability] += 1 + continue + source_target_node = None + source_records.append( + _candidate( + kind="source_oracle", + corpus=name, + construct=construct, + source_id=source_node["id"], + target_id=source_target, + source_node=source_node, + target_node=source_target_node, + snippet=snippet, + judgment=source_judgment, + reason=source_reason, + confidence="source_oracle", + ) + ) + accepted = _cluster_capped_by_relation(accepted, max_accepted) + source_records = _stratified_source_sample(source_records, max_source) + corpus = { + "name": name, + "commit": _commit(root), + "path": str(root), + "graph": str(graph), + "graphSha256": _sha256(graph), + } + coverage = { + "corpus": name, + "adapter": ADAPTER, + "provider": PROVIDER, + "scannedFiles": inventory.scanned_files, + "parsedFiles": inventory.parsed_files, + "inventorySha256": source_construct_inventory_sha256(ADAPTER, inventory), + "graphNodes": node_count, + "graphEdges": edge_count, + "acceptedBeforeSampling": sum(capability_counts.values()), + "acceptedAfterSampling": len(accepted), + "sourceOracleAfterSampling": len(source_records), + "acceptedCapabilities": dict(sorted(capability_counts.items())), + "externalSourceFacts": dict(sorted(external_counts.items())), + } + return corpus, accepted, source_records, coverage + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--corpus", action="append", required=True, metavar="NAME=ROOT=GRAPH") + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--max-accepted", type=int, default=50_000) + parser.add_argument("--max-source", type=int, default=50_000) + args = parser.parse_args() + if args.max_accepted <= 0 or args.max_source <= 0: + parser.error("sampling limits must be positive") + parsed: list[tuple[str, Path, Path]] = [] + for value in args.corpus: + parts = value.split("=", 2) + if len(parts) != 3 or not all(parts): + parser.error("--corpus must be NAME=ROOT=GRAPH") + parsed.append((parts[0], Path(parts[1]), Path(parts[2]))) + parsed.sort(key=lambda item: item[0]) + if len({item[0] for item in parsed}) != len(parsed): + parser.error("duplicate corpus name") + base_root = Path( + os.path.commonpath( + [str(path.resolve()) for _, root, graph in parsed for path in (root, graph)] + ) + ).resolve() + corpora: list[dict[str, Any]] = [] + accepted: list[dict[str, Any]] = [] + source_records: list[dict[str, Any]] = [] + coverage: list[dict[str, Any]] = [] + for name, root, graph in parsed: + corpus, accepted_part, source_part, coverage_part = build_corpus( + name, + root, + graph, + max_accepted=args.max_accepted, + max_source=args.max_source, + ) + try: + corpus["path"] = root.resolve().relative_to(base_root).as_posix() + corpus["graph"] = graph.resolve().relative_to(base_root).as_posix() + except ValueError as error: + raise RuntimeError( + f"corpus {name!r} and graph must be beneath common audit root {base_root}" + ) from error + corpora.append(corpus) + accepted.extend(accepted_part) + source_records.extend(source_part) + coverage.append(coverage_part) + capability_counts = Counter(record["capability"] for record in accepted) + accepted_capabilities = sorted(capability_counts) + advertised = [ + {"adapter": ADAPTER, "capability": capability} + for capability in accepted_capabilities + if capability_counts[capability] >= 100 + ] + advertised_keys = {(entry["adapter"], entry["capability"]) for entry in advertised} + accepted = [record for record in accepted if (record["adapter"], record["capability"]) in advertised_keys] + source_records = [record for record in source_records if (record["adapter"], record["capability"]) in advertised_keys] + relation_counts = Counter(record["relation"] for record in accepted) + relations = sorted( + relation for relation, count in relation_counts.items() if count >= 100 + ) + allowed_relations = set(relations) + accepted = [record for record in accepted if record["relation"] in allowed_relations] + source_records = [record for record in source_records if record["relation"] in allowed_relations] + records = sorted(accepted + source_records, key=lambda item: item["id"]) + manifest = { + "schema": "compass.quality-audit", + "mode": "qualification", + "corpora": sorted(corpora, key=lambda item: item["name"]), + "sourceOracles": sorted( + [ + { + "corpus": item["corpus"], + "adapter": ADAPTER, + "provider": PROVIDER, + "scannedFiles": item["scannedFiles"], + "parsedFiles": item["parsedFiles"], + "inventorySha256": item["inventorySha256"], + } + for item in coverage + ], + key=lambda item: (item["corpus"], item["adapter"]), + ), + "advertisedCapabilities": sorted(advertised, key=lambda item: (item["adapter"], item["capability"])), + "requiredRelations": relations, + "records": records, + } + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(manifest, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + "\n", encoding="utf-8") + report = { + "schema": "compass.ruby-quality-audit-build/1", + "manifest": str(args.output), + "corpora": coverage, + "advertisedCapabilities": advertised, + "requiredRelations": relations, + "accepted": len(accepted), + "sourceOracle": len(source_records), + } + print(json.dumps(report, sort_keys=True, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/qualify_ruby_universal.py b/scripts/qualify_ruby_universal.py new file mode 100755 index 00000000..4423f9fc --- /dev/null +++ b/scripts/qualify_ruby_universal.py @@ -0,0 +1,444 @@ +#!/usr/bin/env python3 +"""Run the bounded, independent Ruby universal-candidate qualification. + +This entry point deliberately keeps the Ripper oracle and Compass production +build separate. Fixture mode needs only Ruby and the standard library; +pinned mode consumes clean, caller-provided checkouts; performance mode uses a +prebuilt Compass binary and a temporary copy of the input tree. No mode +clones, mutates, or executes code from a qualification repository. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import shutil +import statistics +import subprocess +import sys +import tempfile +import time +import tomllib +from pathlib import Path +from typing import Any + + +SCHEMA = "compass.ruby-universal-qualification/1" +ROOT = Path(__file__).resolve().parents[1] +DEFAULT_ORACLE = ROOT / "scripts" / "ruby_source_oracle.rb" +DEFAULT_MANIFEST = ROOT / "tests" / "qualification" / "ruby-universal-repositories.toml" +SKIP_DIRECTORIES = frozenset( + {".git", ".bundle", "vendor", "node_modules", "tmp", "log", "coverage"} +) + + +class QualificationError(RuntimeError): + """A reproducibility or safety failure in the qualification harness.""" + + +def canonical_bytes(value: Any) -> bytes: + return (json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + "\n").encode() + + +def sha256(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def source_files(root: Path) -> list[Path]: + return sorted( + path + for path in root.rglob("*") + if path.is_file() + and (path.suffix in {".rb", ".rake"} or ruby_shebang(path)) + and not SKIP_DIRECTORIES.intersection(path.relative_to(root).parts) + ) + + +def ruby_shebang(path: Path) -> bool: + try: + first_line = path.read_bytes()[:256].split(b"\n", 1)[0].decode("utf-8") + except (OSError, UnicodeDecodeError): + return False + if not first_line.startswith("#!"): + return False + words = first_line[2:].strip().split() + if not words: + return False + interpreter = Path(words.pop(0)).name + if interpreter == "env": + while words and (words[0].startswith("-") or "=" in words[0]): + words.pop(0) + if not words: + return False + interpreter = Path(words[0]).name + return interpreter == "ruby" + + +def run_oracle(root: Path, oracle: Path) -> tuple[dict[str, Any], bytes]: + if not root.is_dir(): + raise QualificationError(f"Ruby root does not exist: {root}") + if not oracle.is_file(): + raise QualificationError(f"Ruby oracle does not exist: {oracle}") + with tempfile.TemporaryDirectory(prefix="compass-ruby-oracle-") as directory: + output = Path(directory) / "oracle.json" + command = ["ruby", str(oracle), "--root", str(root), "--output", str(output)] + completed = subprocess.run(command, cwd=ROOT, check=False, text=True, capture_output=True) + if completed.returncode: + raise QualificationError( + f"Ruby oracle failed for {root}: {completed.stderr.strip() or completed.stdout.strip()}" + ) + raw = output.read_bytes() + try: + document = json.loads(raw) + except json.JSONDecodeError as error: + raise QualificationError(f"Ruby oracle emitted invalid JSON: {error}") from error + if document.get("schema") != "compass.ruby-source-oracle/1": + raise QualificationError(f"unexpected Ruby oracle schema: {document.get('schema')!r}") + if not isinstance(document.get("files"), list): + raise QualificationError("Ruby oracle files inventory is not a list") + without_digest = dict(document) + inventory_digest = without_digest.pop("inventorySha256", None) + expected_digest = sha256(canonical_bytes(without_digest).rstrip(b"\n")) + if inventory_digest != expected_digest: + raise QualificationError( + f"Ruby oracle inventory digest mismatch: {inventory_digest!r} != {expected_digest!r}" + ) + return document, raw + + +def oracle_summary(document: dict[str, Any], raw: bytes, root: Path) -> dict[str, Any]: + files = document["files"] + declarations = sum(len(item.get("declarations", [])) for item in files) + relations = [relation for item in files for relation in item.get("relations", [])] + relation_counts: dict[str, int] = {} + partial = 0 + for item in files: + if item.get("status") != "ok": + partial += 1 + for relation in item.get("relations", []): + name = relation.get("relation", "unknown") + relation_counts[name] = relation_counts.get(name, 0) + 1 + return { + "root": str(root), + "rubyVersion": document["rubyVersion"], + "rubyRevision": document["rubyRevision"], + "files": len(files), + "sourceFiles": len(source_files(root)), + "partialFiles": partial, + "declarations": declarations, + "relations": len(relations), + "relationFamilies": dict(sorted(relation_counts.items())), + "inventorySha256": document["inventorySha256"], + "oracleSha256": sha256(raw), + } + + +def run_deterministic_oracle(root: Path, oracle: Path) -> dict[str, Any]: + first, first_raw = run_oracle(root, oracle) + second, second_raw = run_oracle(root, oracle) + if first_raw != second_raw: + raise QualificationError(f"Ruby oracle output is not byte deterministic for {root}") + summary = oracle_summary(first, first_raw, root) + summary["deterministic"] = True + summary["partialFiles"] = summary["partialFiles"] + return summary + + +def parse_repository_overrides(values: list[str]) -> dict[str, Path]: + overrides: dict[str, Path] = {} + for value in values: + name, separator, path = value.partition("=") + if not separator or not name or not path: + raise QualificationError(f"--repository must be NAME=PATH, got {value!r}") + if name in overrides: + raise QualificationError(f"duplicate repository override: {name}") + overrides[name] = Path(path).expanduser().resolve() + return overrides + + +def inferred_checkout(url: str) -> Path: + parts = [part for part in url.rstrip("/").split("/") if part] + if len(parts) < 2: + raise QualificationError(f"cannot infer a mounted checkout from URL {url!r}") + owner = parts[-2] + repository = parts[-1].removesuffix(".git") + return Path("/Volumes/Workspace/Github") / owner / repository + + +def verify_clean_pinned_checkout(repository: dict[str, Any], checkout: Path) -> None: + if not checkout.is_dir(): + raise QualificationError( + f"missing checkout for {repository['name']}; pass --repository {repository['name']}=PATH" + ) + revision = subprocess.run( + ["git", "-C", str(checkout), "rev-parse", "HEAD"], + check=False, + text=True, + capture_output=True, + ) + if revision.returncode or revision.stdout.strip() != repository["commit"]: + raise QualificationError( + f"{repository['name']} is not pinned to {repository['commit']}" + ) + status = subprocess.run( + ["git", "-C", str(checkout), "status", "--porcelain=v1", "--untracked-files=all"], + check=False, + text=True, + capture_output=True, + ) + if status.returncode or status.stdout: + raise QualificationError(f"{repository['name']} checkout is not clean") + + +def pinned_mode(manifest_path: Path, oracle: Path, overrides: dict[str, Path]) -> dict[str, Any]: + manifest = tomllib.loads(manifest_path.read_text(encoding="utf-8")) + if manifest.get("schema") != SCHEMA: + raise QualificationError(f"unexpected Ruby repository manifest schema: {manifest.get('schema')!r}") + reports = [] + for repository in manifest.get("repository", []): + name = repository.get("name") + if not name or not repository.get("url") or not repository.get("commit"): + raise QualificationError(f"repository entry is missing identity fields: {repository!r}") + checkout = overrides.get(name, inferred_checkout(repository["url"])) + verify_clean_pinned_checkout(repository, checkout) + reports.append({ + "name": name, + "url": repository["url"], + "commit": repository["commit"], + "purpose": repository.get("purpose", ""), + "oracle": run_deterministic_oracle(checkout, oracle), + }) + if not reports: + raise QualificationError("Ruby repository manifest has no repositories") + return {"mode": "pinned", "manifest": str(manifest_path), "repositories": reports} + + +def active_graph(output: Path) -> Path: + pointer = output / "compass-out" / "current-snapshot" + if not pointer.is_file(): + raise QualificationError(f"Compass did not publish an active snapshot: {pointer}") + snapshot = pointer.read_text(encoding="utf-8").strip() + if not snapshot.startswith("snapshot-") or "/" in snapshot or "\\" in snapshot: + raise QualificationError(f"invalid Compass active snapshot pointer: {snapshot!r}") + graph = output / "compass-out" / "snapshots" / snapshot / "graph.json" + if not graph.is_file(): + raise QualificationError(f"Compass active graph is missing: {graph}") + return graph + + +def run_compass(compass: Path, root: Path, output: Path) -> tuple[float, str]: + started = time.perf_counter() + completed = subprocess.run( + [ + str(compass), + "update", + str(root), + "--out", + str(output), + "--no-cluster", + "--no-viz", + "--inference-level", + "max", + ], + cwd=ROOT, + check=False, + text=True, + capture_output=True, + ) + elapsed = time.perf_counter() - started + if completed.returncode: + raise QualificationError( + f"Compass update failed: {completed.stderr.strip() or completed.stdout.strip()}" + ) + graph = active_graph(output) + return elapsed, sha256(graph.read_bytes()) + + +def performance_mode(root: Path, compass: Path, samples: int) -> dict[str, Any]: + if samples < 1 or samples > 20: + raise QualificationError("--samples must be between 1 and 20") + if not compass.is_file(): + raise QualificationError(f"Compass binary does not exist: {compass}") + if root.is_file(): + raise QualificationError("performance mode expects a repository directory") + with tempfile.TemporaryDirectory(prefix="compass-ruby-performance-") as directory: + workload = Path(directory) / "source" + output = Path(directory) / "output" + shutil.copytree(root, workload, symlinks=True) + cold_time, cold_hash = run_compass(compass, workload, output) + warm_runs = [run_compass(compass, workload, output) for _ in range(samples)] + warm_samples = [elapsed for elapsed, _ in warm_runs] + warm_hashes = [graph_hash for _, graph_hash in warm_runs] + if any(graph_hash != cold_hash for graph_hash in warm_hashes): + raise QualificationError("warm Ruby graph is not byte-identical to the cold graph") + ruby_files = source_files(workload) + if not ruby_files: + raise QualificationError(f"performance root contains no Ruby source: {root}") + # Prefer a tracked-looking library source over extensionless executable + # entrypoints (for example bin/console), which some project scopes omit + # from their graph. The edit must exercise a file Compass actually + # publishes or the semantic-incremental assertion is meaningless. + library_files = [ + path + for path in ruby_files + if path.suffix == ".rb" and "lib" in path.relative_to(workload).parts + ] + ruby_source_files = [path for path in ruby_files if path.suffix == ".rb"] + edited = (library_files or ruby_source_files or ruby_files)[0] + baseline = edited.read_bytes() + edited.write_bytes(baseline + b"\n# compass-ruby-qualification-neutral\n") + neutral_time, neutral_hash = run_compass(compass, workload, output) + edited.write_bytes(baseline + b"\nclass CompassRubyQualificationMarker\n def marker; end\nend\n") + semantic_time, semantic_hash = run_compass(compass, workload, output) + if semantic_hash == cold_hash: + raise QualificationError( + f"semantic Ruby edit did not change the published graph: {edited.relative_to(workload)}" + ) + edited.write_bytes(baseline) + restore_time, restore_hash = run_compass(compass, workload, output) + if restore_hash != cold_hash: + raise QualificationError( + "restored Ruby graph is not byte-identical to the cold graph " + f"(cold={cold_hash}, restore={restore_hash})" + ) + return { + "mode": "performance", + "root": str(root), + "compass": str(compass), + "cold": {"seconds": cold_time, "graphSha256": cold_hash}, + "warm": { + "samples": warm_samples, + "medianSeconds": statistics.median(warm_samples), + "graphSha256": cold_hash, + "graphHashes": warm_hashes, + }, + "factNeutral": {"seconds": neutral_time, "graphSha256": neutral_hash}, + "semanticEdit": {"seconds": semantic_time, "graphSha256": semantic_hash}, + "restore": {"seconds": restore_time, "graphSha256": restore_hash}, + "changedFiles": 1, + "reusedFiles": max(0, len(ruby_files) - 1), + "rssBlocking": False, + } + + +def quality_audit_mode( + audit_manifest: Path | None, + graph: Path | None, + corpus: Path | None, +) -> dict[str, Any]: + """Run the repository's strict, independent quality-audit evaluator. + + The Ruby wrapper intentionally delegates scoring to the shared validator; + it only supplies the explicit paths and preserves its machine-readable + result. Missing inputs are a hard failure, never an empty audit. + """ + + if audit_manifest is None or graph is None or corpus is None: + raise QualificationError( + "quality-audit mode requires --audit-manifest, --graph, and --corpus" + ) + for path, label in ( + (audit_manifest, "audit manifest"), + (graph, "graph"), + (corpus, "corpus"), + ): + if not path.exists(): + raise QualificationError(f"{label} does not exist: {path}") + command = [ + sys.executable, + str(ROOT / "benchmarks" / "performance" / "harness.py"), + "audit", + "--manifest", + str(audit_manifest.resolve()), + "--graph", + str(graph.resolve()), + "--corpus", + str(corpus.resolve()), + ] + completed = subprocess.run( + command, + cwd=ROOT, + check=False, + text=True, + capture_output=True, + ) + lines = [line for line in completed.stdout.splitlines() if line.strip()] + if not lines: + raise QualificationError( + "shared quality-audit evaluator emitted no machine-readable result: " + f"{completed.stderr.strip()}" + ) + try: + result = json.loads(lines[-1]) + except json.JSONDecodeError as error: + raise QualificationError( + f"shared quality-audit evaluator emitted invalid JSON: {error}" + ) from error + if result.get("schema") != "compass.quality-audit-result": + raise QualificationError( + f"unexpected quality-audit result schema: {result.get('schema')!r}" + ) + return { + "mode": "quality-audit", + "audit": result, + "evaluatorExitCode": completed.returncode, + } + + +def parser() -> argparse.ArgumentParser: + result = argparse.ArgumentParser(description=__doc__) + result.add_argument( + "--mode", + choices=("fixture", "pinned", "quality-audit", "performance"), + default="fixture", + ) + result.add_argument("--root", type=Path, default=ROOT / "fixtures" / "code-graph" / "qualification") + result.add_argument("--oracle", type=Path, default=DEFAULT_ORACLE) + result.add_argument("--manifest", type=Path, default=DEFAULT_MANIFEST) + result.add_argument("--audit-manifest", type=Path, help="compass.quality-audit JSON") + result.add_argument("--graph", type=Path, help="published graph for quality-audit mode") + result.add_argument("--corpus", type=Path, help="pinned source corpus for quality-audit mode") + result.add_argument("--repository", action="append", default=[], metavar="NAME=PATH") + result.add_argument("--compass", type=Path, help="prebuilt compass binary for performance mode") + result.add_argument("--samples", type=int, default=5, help="warm performance samples (1-20)") + result.add_argument("--output", type=Path, help="write the machine-readable report to this path") + return result + + +def main(argv: list[str]) -> int: + arguments = parser().parse_args(argv) + try: + if arguments.mode == "fixture": + report = {"mode": "fixture", "oracle": run_deterministic_oracle(arguments.root, arguments.oracle)} + elif arguments.mode == "pinned": + report = pinned_mode(arguments.manifest, arguments.oracle, parse_repository_overrides(arguments.repository)) + elif arguments.mode == "quality-audit": + report = quality_audit_mode(arguments.audit_manifest, arguments.graph, arguments.corpus) + else: + if arguments.compass is None: + raise QualificationError("--compass is required for performance mode") + report = performance_mode(arguments.root, arguments.compass.resolve(), arguments.samples) + report = {"schema": SCHEMA, **report} + encoded = canonical_bytes(report) + if arguments.output: + arguments.output.write_bytes(encoded) + else: + sys.stdout.buffer.write(encoded) + if arguments.mode == "quality-audit": + audit = report.get("audit", {}) + return int( + not ( + audit.get("passed") is True + and audit.get("eligibleForQualityClaim") is True + ) + ) + return 0 + except (OSError, QualificationError, subprocess.SubprocessError) as error: + print(f"ruby qualification failed: {error}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/scripts/ruby_source_oracle.rb b/scripts/ruby_source_oracle.rb new file mode 100755 index 00000000..83b6e20b --- /dev/null +++ b/scripts/ruby_source_oracle.rb @@ -0,0 +1,543 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Qualification-only Ruby source oracle. It deliberately uses Ripper rather +# than Compass's Tree-sitter parser and emits only facts whose token anchors +# are exact UTF-8 byte ranges. It is not linked into Compass runtime code. + +require "digest" +require "json" +require "optparse" +require "pathname" +require "ripper" + +MAX_FILES = 20_000 +MAX_BYTES = 64 * 1024 * 1024 +MAX_TOTAL_BYTES = 512 * 1024 * 1024 +MAX_FACTS = 200_000 +SKIP_DIRECTORIES = %w[.git .bundle vendor vendor/bundle node_modules tmp log coverage].freeze + +def ruby_shebang?(path) + first_line = File.binread(path, 256).split("\n", 2).first.to_s + return false unless first_line.start_with?("#!") + + words = first_line.delete_prefix("#!").strip.split + return false if words.empty? + + interpreter = File.basename(words.shift) + if interpreter == "env" + words.shift while words.first&.start_with?("-") || words.first&.include?("=") + return false if words.empty? + + interpreter = File.basename(words.first) + end + interpreter == "ruby" +rescue Errno::ENOENT, Errno::EACCES + false +end + +def ruby_source_file?(path) + return true if path.end_with?(".rb", ".rake") + File.extname(path).empty? && ruby_shebang?(path) +end + +options = { root: nil, files: [], output: nil } +OptionParser.new do |parser| + parser.banner = "usage: ruby_source_oracle.rb --root ROOT [--output FILE] [FILES...]" + parser.on("--root ROOT", "repository or fixture root") { |value| options[:root] = Pathname(value).expand_path } + parser.on("--output FILE", "write canonical JSON to FILE") { |value| options[:output] = Pathname(value).expand_path } +end.parse!(ARGV) + +abort "--root is required" unless options[:root] + +root = options[:root] +paths = if options[:files].empty? && ARGV.empty? + Dir.glob(root.join("**", "*").to_s).select { |path| File.file?(path) } + else + ARGV.map { |path| root.join(path).to_s } + end +paths = paths.select do |path| + next false unless ruby_source_file?(path) + + relative_parts = Pathname(path).relative_path_from(root).each_filename.to_a + (relative_parts & SKIP_DIRECTORIES).empty? +end.sort +abort "file limit exceeded (#{paths.length} > #{MAX_FILES})" if paths.length > MAX_FILES + +inventory = [] +total_bytes = 0 +paths.each do |path| + relative = Pathname(path).relative_path_from(root).to_s.tr("\\", "/") + bytes = File.binread(path) + abort "byte limit exceeded" if bytes.bytesize > MAX_BYTES + total_bytes += bytes.bytesize + abort "repository byte limit exceeded" if total_bytes > MAX_TOTAL_BYTES + begin + source = bytes.force_encoding(Encoding::UTF_8) + unless source.valid_encoding? + inventory << { "path" => relative, "status" => "partial", "declarations" => [], "relations" => [] } + next + end + sexp = Ripper.sexp(source) + rescue EncodingError + sexp = nil + end + if sexp.nil? + inventory << { "path" => relative, "status" => "partial", "declarations" => [], "relations" => [] } + next + end + + line_starts = [0] + source.each_byte.with_index { |byte, index| line_starts << index + 1 if byte == 10 } + tokens = Ripper.lex(source) + significant = tokens.each_index.select do |index| + !%i[on_sp on_nl on_ignored_nl on_comment].include?(tokens[index][1]) + end + facts = { declarations: [], relations: [] } + frames = [] + owner_by_token = {} + call_owner_by_token = {} + declaration_name_tokens = {} + block_depth = 0 + + byte_at = lambda do |position| + line, column = position + line_start = line_starts.fetch(line - 1, source.bytesize) + line_start + source.byteslice(line_start, column).to_s.bytesize + end + anchor = lambda do |token| + position, _kind, text, _state = token + start_byte = byte_at.call(position) + end_byte = start_byte + text.to_s.encode(Encoding::UTF_8).bytesize + { + "sourceFile" => relative, + "startByte" => start_byte, + "endByte" => end_byte, + "startLine" => position[0], + "startColumn" => position[1], + "endLine" => position[0], + "endColumn" => position[1] + text.to_s.bytesize + } + end + range_anchor = lambda do |first_token, last_token| + first_position, _first_kind, first_text, _first_state = first_token + last_position, _last_kind, last_text, _last_state = last_token + start_byte = byte_at.call(first_position) + last_start_byte = byte_at.call(last_position) + { + "sourceFile" => relative, + "startByte" => start_byte, + "endByte" => last_start_byte + last_text.to_s.encode(Encoding::UTF_8).bytesize, + "startLine" => first_position[0], + "startColumn" => first_position[1], + "endLine" => last_position[0], + "endColumn" => last_position[1] + last_text.to_s.bytesize + } + end + text_of = lambda { |token| token[2].to_s } + next_token = lambda do |index, step = 1| + significant.fetch(significant.index(index).to_i + step, nil).then { |next_index| next_index && tokens[next_index] } + end + current = lambda do + owner = frames.reverse_each.find { |frame| frame[:kind] == :class || frame[:kind] == :module }&.fetch(:qualified_name, relative) + owner = relative if owner.nil? || owner.empty? + owner + end + mixin_owner = lambda do + # A top-level include/extend/prepend mutates an ambient runtime receiver; + # a mixin inside a method or anonymous `Class.new` block is likewise not a + # source-grounded type relationship. Keep those facts out of the oracle + # denominator instead of asking publication to invent a file/method owner. + next nil if frames.reverse_each.any? { |frame| %i[def dynamic_block].include?(frame[:kind]) } + + owner_frame = frames.reverse_each.find do |frame| + frame[:kind] == :class || frame[:kind] == :module + end + owner = owner_frame&.fetch(:qualified_name, nil) + next nil if owner.nil? || owner.empty? || owner == relative || owner.end_with?("::") + + owner + end + method_space_by_token = {} + qualify = lambda do |raw| + raw = raw.sub(/^::/, "") + raw.include?("::") || current.call == relative ? raw : "#{current.call}::#{raw}" + end + literal = lambda do |value| + value.to_s.strip.sub(/^:/, "").sub(/\A['"]/, "").sub(/['"]\z/, "") + end + receiver_atom = lambda do |token| + next false unless token + + kind = token[1] + value = text_of.call(token) + %i[on_ident on_const on_ivar on_cvar on_gvar].include?(kind) || + (kind == :on_kw && %w[self super].include?(value)) + end + receiver_path = lambda do |ordinal| + cursor = ordinal - 1 + separator = text_of.call(tokens[significant[cursor]]) if cursor >= 0 + next nil unless separator == "." || separator == "::" + + parts = [] + expect_atom = true + cursor -= 1 + while cursor >= 0 + index = significant[cursor] + token = tokens[index] + value = text_of.call(token) + if expect_atom + unless receiver_atom.call(token) + parts.clear + break + end + + parts.unshift(value) + expect_atom = false + elsif value == "." || value == "::" + # A leading absolute-constant marker is part of the receiver's + # spelling, but it does not introduce another atom to consume. + break if value == "::" && cursor.zero? + + parts.unshift(value) + expect_atom = true + else + break + end + cursor -= 1 + end + next nil if expect_atom || parts.empty? + + parts.join + end + + significant.each_with_index do |token_index, ordinal| + token = tokens[token_index] + owner_by_token[token_index] = current.call + call_owner_by_token[token_index] = frames.reverse_each.find { |frame| frame[:kind] == :def }&.fetch(:qualified_name, nil) || current.call + def_frame = frames.reverse_each.find { |frame| frame[:kind] == :def } + method_space_by_token[token_index] = if def_frame.nil? || def_frame.fetch(:qualified_name, "").include?(".") + :singleton + else + :instance + end + kind = token[1] + value = text_of.call(token) + next if kind != :on_kw && kind != :on_ident && kind != :on_const && kind != :on_op + + next_index = significant[ordinal + 1] + next_token_value = next_index && text_of.call(tokens[next_index]) + previous_index = ordinal.positive? ? significant[ordinal - 1] : nil + previous_value = previous_index && text_of.call(tokens[previous_index]) + case [kind, value] + when [:on_kw, "class"], [:on_kw, "module"] + next unless next_index + if value == "class" && text_of.call(tokens[next_index]) == "<<" + receiver_index = significant[ordinal + 2] + if receiver_index && text_of.call(tokens[receiver_index]) == "self" + # `class << self` opens the owner's singleton scope; it does not + # declare a literal `<<` type. Compass keeps methods in this scope + # under the same owner identity, so the oracle does the same. + frames << { kind: :singleton_class, qualified_name: current.call } + next + end + end + name_indices = [next_index] + cursor = ordinal + 2 + while (candidate_index = significant[cursor]) + candidate_value = text_of.call(tokens[candidate_index]) + break unless candidate_value == "::" + + component_index = significant[cursor + 1] + break unless component_index + + name_indices << candidate_index << component_index + cursor += 2 + end + name_token = tokens[name_indices.first] + raw_name = name_indices.map { |index| text_of.call(tokens[index]) }.join + next if raw_name.empty? + + qualified_name = qualify.call(raw_name) + next if qualified_name.empty? + + declaration_kind = value == "module" ? "module" : "class" + facts[:declarations] << { + "kind" => declaration_kind, + "qualifiedName" => qualified_name, + "anchor" => anchor.call(name_token) + } + if significant[ordinal + 2] && text_of.call(tokens[significant[ordinal + 2]]) == "<" + base_ordinal = ordinal + 3 + base_index = significant[base_ordinal] + base_index = significant[base_ordinal + 1] if base_index && text_of.call(tokens[base_index]) == "::" + base_token = base_index && tokens[base_index] + if base_token + base_parts = [text_of.call(base_token)] + base_first_token = base_token + base_last_token = base_token + if base_index != significant[base_ordinal] + base_first_token = tokens[significant[base_ordinal]] + end + cursor = base_ordinal + (base_index == significant[base_ordinal] ? 1 : 2) + while (separator_index = significant[cursor]) && text_of.call(tokens[separator_index]) == "::" + component_index = significant[cursor + 1] + break unless component_index + + base_parts << text_of.call(tokens[separator_index]) << text_of.call(tokens[component_index]) + base_last_token = tokens[component_index] + cursor += 2 + end + base_name = base_parts.join + next if base_name.empty? + + facts[:relations] << { + "relation" => "extends", + "source" => qualified_name, + "target" => qualify.call(base_name), + "anchor" => range_anchor.call(base_first_token, base_last_token) + } + end + end + frames << { kind: value.to_sym, qualified_name: qualified_name } + when [:on_kw, "def"] + name_token = next_index && tokens[next_index] + # Methods declared inside `class << self` inherit the owner's + # singleton dispatch space even when the `def` spelling is just + # `def application`. Keeping that scope bit here makes the oracle + # agree with Compass's singleton-class identity (`Rails.application`), + # rather than manufacturing an instance method (`Rails#application`). + singleton = frames.reverse_each.any? { |frame| frame[:kind] == :singleton_class } + if name_token && text_of.call(name_token) == "self" + dot_index = significant[ordinal + 2] + name_index = significant[ordinal + 3] + singleton = true + name_token = name_index && tokens[name_index] + elsif significant[ordinal + 2] && [".", "::"].include?(text_of.call(tokens[significant[ordinal + 2]])) + # `def Receiver.method` is a singleton method even when the receiver + # is a constant path rather than the literal `self`. + singleton = text_of.call(tokens[significant[ordinal + 2]]) == "." + name_token = tokens[significant[ordinal + 3]] if singleton && significant[ordinal + 3] + end + next unless name_token + owner = current.call + separator = singleton ? "." : "#" + qualified_name = "#{owner}#{separator}#{text_of.call(name_token)}" + declaration_name_tokens[name_token.object_id] = true + facts[:declarations] << { + "kind" => "method", + "qualifiedName" => qualified_name, + "anchor" => anchor.call(name_token) + } + frames << { kind: :def, qualified_name: qualified_name } + when [:on_ident, "include"], [:on_ident, "prepend"], [:on_ident, "extend"] + if next_index && (mixin_source = mixin_owner.call) + target_indices = [] + cursor = ordinal + 1 + if text_of.call(tokens[significant[cursor]]) == "::" + target_indices << significant[cursor] + cursor += 1 + end + first_component = significant[cursor] + unless first_component && tokens[first_component][1] == :on_const + next + end + target_indices << first_component + cursor += 1 + while (separator_index = significant[cursor]) && text_of.call(tokens[separator_index]) == "::" + component_index = significant[cursor + 1] + break unless component_index && tokens[component_index][1] == :on_const + + target_indices << separator_index << component_index + cursor += 2 + end + target = literal.call(target_indices.map { |index| text_of.call(tokens[index]) }.join) + next if target.empty? + + target_first_token = tokens[target_indices.first] + target_last_token = tokens[target_indices.last] + + facts[:relations] << { + "relation" => "uses_trait", + "operation" => value, + "source" => mixin_source, + "target" => begin + if target.include?("::") || mixin_source == relative + target + else + namespace = mixin_source.rpartition("::").first + namespace.empty? || namespace == relative ? target : "#{namespace}::#{target}" + end + end, + "anchor" => range_anchor.call(target_first_token, target_last_token) + } + end + when [:on_ident, "require"], [:on_ident, "require_relative"], [:on_ident, "autoload"] + literal_index = next_index + literal_index = significant[ordinal + 2] if literal_index && tokens[literal_index][1] == :on_tstring_beg + if literal_index && %i[on_tstring_content on_ident on_const].include?(tokens[literal_index][1]) + literal_value = text_of.call(tokens[literal_index]) + next if literal.call(literal_value).empty? + + literal_first_token = tokens[literal_index] + literal_last_token = literal_first_token + if significant[ordinal + 2] && tokens[significant[ordinal + 2]][1] == :on_tstring_beg + literal_first_token = tokens[significant[ordinal + 2]] + cursor = ordinal + 2 + while (candidate_index = significant[cursor]) + literal_last_token = tokens[candidate_index] + break if literal_last_token[1] == :on_tstring_end + + cursor += 1 + end + end + + facts[:relations] << { + "relation" => "imports", + "operation" => value, + "source" => owner_by_token.fetch(token_index, relative).to_s.then { |owner| owner.empty? ? relative : owner }, + "target" => literal.call(literal_value), + "anchor" => range_anchor.call(literal_first_token, literal_last_token) + } + end + when [:on_kw, "end"] + frames.pop unless frames.empty? + end + + end_bearing = case value + when "do", "case", "begin", "for" + true + when "if", "unless", "while", "until" + previous_index = ordinal.positive? ? significant[ordinal - 1] : nil + statement_boundary = previous_index.nil? || tokens[(previous_index + 1)...token_index].any? do |candidate| + candidate[1] == :on_nl || candidate[1] == :on_ignored_nl || candidate[2] == ";" + end + statement_boundary || ["=", "(", "[", "{", "&&", "||"].include?(previous_value) + else + false + end + if end_bearing + dynamic_owner_block = if ordinal >= 2 && value == "do" && previous_value == "new" + significant[ordinal - 2] && + text_of.call(tokens[significant[ordinal - 2]]) == "." + else + false + end + frames << { kind: dynamic_owner_block ? :dynamic_block : :block } + block_depth += 1 + elsif value == "end" && block_depth.positive? + block_depth -= 1 + end + end + + # Add only token-grounded calls and literal metaprogramming. The oracle is + # intentionally less clever than Compass: it supplies independently + # reviewable positive strata, never a guessed target. Calls without an + # explicit argument list are omitted unless they have an explicit receiver. + control_words = %w[begin break case class def do else elsif end ensure for if in module next redo rescue return self super then undef unless until when while yield].freeze + # These operations are either represented by a more precise relation or + # deliberately left unresolved by the product adapter. Counting them as + # ordinary calls would make the independent oracle claim a local target for + # Ruby's dynamic dispatch/metaprogramming surface. + non_evidence_calls = %w[alias_method class_eval eval extend include method_missing module_eval prepend public_send send].freeze + significant.each_with_index do |token_index, ordinal| + token = tokens[token_index] + kind = token[1] + value = text_of.call(token) + next unless %i[on_ident on_const on_kw].include?(kind) + next if control_words.include?(value) + + previous_token = ordinal.positive? && tokens[significant[ordinal - 1]] + previous_value = previous_token && text_of.call(previous_token) + next_value = significant[ordinal + 1] && text_of.call(tokens[significant[ordinal + 1]]) + next if previous_value == "def" || non_evidence_calls.include?(value) || declaration_name_tokens[token.object_id] + method_value = value.dup + method_value << "=" if next_value == "=" && (previous_value == "." || previous_value == "::") + + explicit_call = next_value == "(" || previous_value == "." || previous_value == "::" + next unless explicit_call + + receiver = receiver_path.call(ordinal) + if (previous_value == "." || previous_value == "::") && receiver.nil? + # Do not turn a chained call whose receiver is not token-grounded (for + # example `relation.where(...)`) into an unqualified positive fact. + next + end + next if previous_value == "::" && kind == :on_const + owner = call_owner_by_token.fetch(token_index, relative).to_s + owner = relative if owner.empty? + relation = value == "new" && receiver ? "constructs" : "calls" + target = if relation == "constructs" + receiver == "self" ? owner : receiver + elsif receiver == "self" + separator = method_space_by_token.fetch(token_index, :instance) == :singleton ? "." : "#" + "#{owner}#{separator}#{method_value}" + elsif receiver + separator = receiver.match?(/\A(?:::)?[A-Z][A-Za-z0-9_:]*\z/) ? "." : "#" + "#{receiver}#{separator}#{method_value}" + else + method_value + end + next if target.empty? + + facts[:relations] << { + "relation" => relation, + "source" => owner, + "target" => target, + "anchor" => anchor.call(token) + } + end + + significant.each_with_index do |token_index, ordinal| + token = tokens[token_index] + value = text_of.call(token) + next unless value == "alias" || value == "alias_method" + + names = significant[(ordinal + 1)..].to_a + .take_while { |index| text_of.call(tokens[index]) != ")" } + .select { |index| %i[on_ident on_const on_tstring_content].include?(tokens[index][1]) } + .first(2) + next unless names.length == 2 + + facts[:relations] << { + "relation" => "aliases", + "source" => owner_by_token.fetch(token_index, relative).to_s.then { |owner| owner.empty? ? relative : owner }, + "target" => "#{literal.call(text_of.call(tokens[names[0]]))}=>#{literal.call(text_of.call(tokens[names[1]]))}", + "anchor" => anchor.call(token) + } + end + + facts[:declarations].sort_by! { |fact| [fact["anchor"]["startByte"], fact["kind"], fact["qualifiedName"]] } + facts[:relations].sort_by! { |fact| [fact["anchor"]["startByte"], fact["relation"], fact["target"]] } + abort "fact limit exceeded: #{relative}" if facts.values.sum(&:length) > MAX_FACTS + inventory << { "path" => relative, "status" => "ok", **facts } +end + +inventory.sort_by! { |file| file["path"] } +document = { + "schema" => "compass.ruby-source-oracle/1", + "rubyVersion" => RUBY_VERSION, + "rubyRevision" => RUBY_REVISION, + "files" => inventory +} +canonical_json = lambda do |value| + case value + when Hash + "{" + value.keys.map(&:to_s).sort.map { |key| + original_key = value.keys.find { |candidate| candidate.to_s == key } + JSON.generate(key, ascii_only: true) + ":" + canonical_json.call(value[original_key]) + }.join(",") + "}" + when Array + "[" + value.map { |item| canonical_json.call(item) }.join(",") + "]" + else + JSON.generate(value, ascii_only: true) + end +end +canonical = canonical_json.call(document) +document["inventorySha256"] = Digest::SHA256.hexdigest(canonical) +output = canonical_json.call(document) + "\n" +if options[:output] + File.write(options[:output], output) +else + $stdout.write(output) +end diff --git a/scripts/tests/test_ruby_quality_audit.py b/scripts/tests/test_ruby_quality_audit.py new file mode 100644 index 00000000..9535564f --- /dev/null +++ b/scripts/tests/test_ruby_quality_audit.py @@ -0,0 +1,71 @@ +"""Regression tests for the Ruby qualification oracle's closed-world join.""" + +from __future__ import annotations + +import unittest + +from benchmarks.performance.compass.occurrences import SourceConstruct +from scripts.build_ruby_quality_audit import ( + _has_local_target, + _source_target_is_unambiguous, +) + + +def _construct(relation: str, target: str, owner: str = "Example") -> SourceConstruct: + return SourceConstruct( + source_file="example.rb", + relation=relation, + capability=relation, + owner_qualified_name=owner, + target_spelling=target, + qualifier=None, + start_byte=0, + end_byte=1, + start_line=1, + ) + + +class RubyQualityAuditTests(unittest.TestCase): + def test_duplicate_methods_are_fail_closed(self) -> None: + declarations = {"Example#run": ["method", "method"]} + self.assertFalse( + _source_target_is_unambiguous("calls", "Example#run", declarations) + ) + self.assertFalse( + _has_local_target( + _construct("calls", "Example#run"), + {"Example#run"}, + set(), + declarations, + ) + ) + + def test_reopened_types_remain_local_construct_targets(self) -> None: + declarations = {"Example": ["class", "class"]} + self.assertTrue( + _source_target_is_unambiguous("instantiates", "Example", declarations) + ) + self.assertTrue( + _has_local_target( + _construct("instantiates", "Example#new"), + {"Example"}, + {"Example"}, + declarations, + ) + ) + + def test_only_module_declarations_are_trait_targets(self) -> None: + self.assertTrue( + _source_target_is_unambiguous( + "implements", "Support", {"Support": ["module", "module"]} + ) + ) + self.assertFalse( + _source_target_is_unambiguous( + "implements", "Support", {"Support": ["module", "class"]} + ) + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_ruby_source_oracle.py b/scripts/tests/test_ruby_source_oracle.py new file mode 100644 index 00000000..a8d06150 --- /dev/null +++ b/scripts/tests/test_ruby_source_oracle.py @@ -0,0 +1,136 @@ +"""Regression tests for the Ruby source oracle and qualification entry point.""" + +from __future__ import annotations + +import json +import sys +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "scripts")) + +import qualify_ruby_universal as qualification # noqa: E402 + + +class RubyQualificationTests(unittest.TestCase): + def test_fixture_oracle_is_byte_deterministic_and_has_required_families(self) -> None: + report = qualification.run_deterministic_oracle( + ROOT / "fixtures" / "code-graph" / "qualification", + ROOT / "scripts" / "ruby_source_oracle.rb", + ) + self.assertTrue(report["deterministic"]) + self.assertGreaterEqual(report["declarations"], 1) + self.assertGreaterEqual(report["relationFamilies"].get("extends", 0), 1) + self.assertGreaterEqual(report["relationFamilies"].get("uses_trait", 0), 1) + self.assertGreaterEqual(report["relationFamilies"].get("imports", 0), 1) + + def test_oracle_marks_malformed_and_invalid_utf8_as_partial(self) -> None: + with tempfile.TemporaryDirectory(prefix="compass-ruby-oracle-test-") as directory: + root = Path(directory) + (root / "malformed.rb").write_text("class Broken\n def nope(\n", encoding="utf-8") + (root / "invalid.rb").write_bytes(b"class Invalid\n\xff\nend\n") + document, _raw = qualification.run_oracle( + root, + ROOT / "scripts" / "ruby_source_oracle.rb", + ) + statuses = {item["path"]: item["status"] for item in document["files"]} + self.assertEqual(statuses, {"invalid.rb": "partial", "malformed.rb": "partial"}) + + def test_oracle_keeps_qualified_mixin_paths_and_exact_utf8_ranges(self) -> None: + with tempfile.TemporaryDirectory(prefix="compass-ruby-oracle-mixin-") as directory: + root = Path(directory) + source = "class Account\n include Billing::Auditable\n include(dynamic_target)\nend\n" + path = root / "account.rb" + path.write_text(source, encoding="utf-8") + document, _raw = qualification.run_oracle( + root, + ROOT / "scripts" / "ruby_source_oracle.rb", + ) + relations = [ + item + for item in document["files"][0]["relations"] + if item["relation"] == "uses_trait" + ] + self.assertEqual(len(relations), 1) + relation = relations[0] + self.assertEqual(relation["target"], "Billing::Auditable") + start = source.index("Billing") + end = start + len("Billing::Auditable".encode("utf-8")) + self.assertEqual(relation["anchor"]["startByte"], start) + self.assertEqual(relation["anchor"]["endByte"], end) + + def test_oracle_excludes_dynamic_dispatch_from_call_evidence(self) -> None: + with tempfile.TemporaryDirectory(prefix="compass-ruby-oracle-dynamic-") as directory: + root = Path(directory) + (root / "dynamic.rb").write_text( + "class Account\n" + " send(:save)\n" + " public_send(:save)\n" + " include Billing::Auditable\n" + "end\n", + encoding="utf-8", + ) + document, _raw = qualification.run_oracle( + root, + ROOT / "scripts" / "ruby_source_oracle.rb", + ) + calls = [ + item + for item in document["files"][0]["relations"] + if item["relation"] == "calls" + ] + self.assertEqual(calls, []) + + def test_oracle_excludes_unowned_and_dynamic_mixin_sites(self) -> None: + with tempfile.TemporaryDirectory(prefix="compass-ruby-oracle-mixin-owner-") as directory: + root = Path(directory) + (root / "dynamic.rb").write_text( + "include TopLevelMixin\n" + "class Account\n" + " def install\n" + " include MethodMixin\n" + " end\n" + " Class.new do\n" + " include AnonymousMixin\n" + " end\n" + " include OwnedMixin\n" + "end\n", + encoding="utf-8", + ) + document, _raw = qualification.run_oracle( + root, + ROOT / "scripts" / "ruby_source_oracle.rb", + ) + relations = [ + item + for item in document["files"][0]["relations"] + if item["relation"] == "uses_trait" + ] + self.assertEqual([item["target"] for item in relations], ["OwnedMixin"]) + + def test_cli_report_is_canonical_machine_json(self) -> None: + with tempfile.TemporaryDirectory(prefix="compass-ruby-report-test-") as directory: + output = Path(directory) / "report.json" + self.assertEqual( + qualification.main( + [ + "--mode", + "fixture", + "--output", + str(output), + ] + ), + 0, + ) + report = json.loads(output.read_text(encoding="utf-8")) + self.assertEqual(report["schema"], qualification.SCHEMA) + self.assertEqual( + output.read_bytes(), + qualification.canonical_bytes(report), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/qualification/code-graph-v1-semantic.json b/tests/qualification/code-graph-v1-semantic.json index 00a82aab..f278a72a 100644 --- a/tests/qualification/code-graph-v1-semantic.json +++ b/tests/qualification/code-graph-v1-semantic.json @@ -225,7 +225,7 @@ "path": "/users/:id", "routeSource": "fixtures/code-graph/routes/ruby/rails.rb", "handler": { - "qualifiedName": "UsersController::show" + "qualifiedName": "UsersController#show" }, "handlerSource": "fixtures/code-graph/routes/ruby/rails.rb", "relationship": "routes_to", diff --git a/tests/qualification/ruby-universal-baseline.json b/tests/qualification/ruby-universal-baseline.json new file mode 100644 index 00000000..6bca16fc --- /dev/null +++ b/tests/qualification/ruby-universal-baseline.json @@ -0,0 +1,110 @@ +{ + "schema": "compass.ruby-universal-baseline/1", + "capturedAt": "2026-08-17", + "adapter": "compass.ruby.candidate", + "adapterVersion": 1, + "evidenceSchema": 1, + "buildTimeExcluded": true, + "rssBlocking": false, + "corpora": [ + { + "name": "rails", + "commit": "cc7d47f4419ba983fc9d06bffece57778fa671c5", + "rubySourceFiles": 3486, + "rubyParsedFiles": 3486, + "rubyInventorySha256": "2edb8b395bcc18014bf8fcd33c4cb3bc23c6a57b140ee8becbc647684fc76dad", + "projection": "mixed", + "graphSha256": "e41bf1976f9b6af01d1e1064bfe9295bb2cf5233dbfb700d729f0ac05d2535a8", + "files": 4967, + "nodes": 95462, + "edges": 158272, + "coldSeconds": 222.7 + }, + { + "name": "discourse", + "commit": "699ad46536f619396e73720c7652dbfc7a1f86c0", + "rubySourceFiles": 10921, + "rubyParsedFiles": 10921, + "rubyInventorySha256": "b556ae34d65b7cfdfd374a7a86f6a253aecdfd216366176d9bc8e6b61e724020", + "projection": "ruby-only", + "graphSha256": "69b5a7de8246913158f3e5270f2a7002d88d43b1e51de2d09ab5c0b217e71914", + "files": 11199, + "nodes": 104033, + "edges": 187412, + "coldSeconds": 247.2 + }, + { + "name": "rubocop", + "commit": "c034d8b6804788856321d78c480f9f007bd85a8d", + "rubySourceFiles": 1759, + "rubyParsedFiles": 1759, + "rubyInventorySha256": "6ce27ad3a6785409d2e551046db6719800648d1d8a6e2e5ff5a283702f63d6e0", + "projection": "ruby-only", + "graphSha256": "67eb76eb3eb7c96c5b25ce0e8d1c5855c3634e9c1acc40d0599e67ba9a712a68", + "files": 1759, + "nodes": 22030, + "edges": 32972, + "coldSeconds": 43.8 + } + ], + "incremental": { + "corpus": "rails-ruby-subtree", + "files": 305, + "unchangedSecondsRange": [0.195878, 0.204463], + "factNeutralEditSeconds": 9.238669, + "changedFiles": 1, + "reusedFiles": 304 + }, + "performance": [ + { + "corpus": "rails", + "projection": "mixed", + "samples": 5, + "coldSeconds": 267.941025, + "warmSeconds": [2.966368, 2.950501, 2.966947, 2.954732, 2.961640], + "warmMedianSeconds": 2.961640, + "factNeutralSeconds": 165.412041, + "semanticSeconds": 249.000887, + "restoreSeconds": 239.309139, + "changedFiles": 1, + "reusedFiles": 3488, + "graphSha256": "38bee4db30f76b7b5ffdb1fb0f46f94dadd90d52368a991e03ac035167d8d8ad", + "restoreGraphSha256": "38bee4db30f76b7b5ffdb1fb0f46f94dadd90d52368a991e03ac035167d8d8ad" + }, + { + "corpus": "rubocop", + "projection": "ruby-only", + "samples": 5, + "coldSeconds": 36.024826, + "warmSeconds": [0.548235, 0.546150, 0.558307, 0.556586, 0.551958], + "warmMedianSeconds": 0.551958, + "factNeutralSeconds": 28.980814, + "semanticSeconds": 40.901903, + "restoreSeconds": 42.695557, + "changedFiles": 1, + "reusedFiles": 1758, + "graphSha256": "42840b1bacd00704960e6dc198048ca1e37413e5bc840d0be4fbfe0c53f8563d", + "restoreGraphSha256": "42840b1bacd00704960e6dc198048ca1e37413e5bc840d0be4fbfe0c53f8563d" + }, + { + "corpus": "discourse", + "projection": "ruby-only", + "samples": 1, + "coldSeconds": 205.703310, + "warmSeconds": [3.136274], + "warmMedianSeconds": 3.136274, + "factNeutralSeconds": 143.804669, + "semanticSeconds": 220.642271, + "restoreSeconds": 228.593883, + "changedFiles": 1, + "reusedFiles": 10922, + "graphSha256": "d4aec2370cf4bb78b30c312a7422873f64499849e45ff1695652f1d50bb72f98", + "restoreGraphSha256": "d4aec2370cf4bb78b30c312a7422873f64499849e45ff1695652f1d50bb72f98" + } + ], + "limitations": [ + "Discourse and RuboCop graph projections exclude non-Ruby files because their full mixed-language trees exceed the bounded Markdown parser resource envelope.", + "The large Discourse performance report currently records one warm sample; Rails and RuboCop have five-sample warm distributions.", + "The quality audit now passes the fixed precision/recall thresholds, but Ruby remains a UniversalCandidate until a separate promotion decision is made." + ] +} diff --git a/tests/qualification/ruby-universal-repositories.toml b/tests/qualification/ruby-universal-repositories.toml new file mode 100644 index 00000000..ce4e032c --- /dev/null +++ b/tests/qualification/ruby-universal-repositories.toml @@ -0,0 +1,19 @@ +schema = "compass.ruby-universal-qualification/1" + +[[repository]] +name = "rails" +url = "https://github.com/rails/rails.git" +commit = "cc7d47f4419ba983fc9d06bffece57778fa671c5" +purpose = "framework internals, concerns, reopenings, and DSL-heavy Ruby" + +[[repository]] +name = "discourse" +url = "https://github.com/discourse/discourse.git" +commit = "699ad46536f619396e73720c7652dbfc7a1f86c0" +purpose = "large Rails application, controllers, models, jobs, and plugins" + +[[repository]] +name = "rubocop" +url = "https://github.com/rubocop/rubocop.git" +commit = "c034d8b6804788856321d78c480f9f007bd85a8d" +purpose = "non-Rails gem with nested modules, visitors, aliases, and tests" From 8c0e9101f9399de6bd8ecf5c49fbc1e1eba0c480 Mon Sep 17 00:00:00 2001 From: forhappy Date: Mon, 17 Aug 2026 18:11:26 -0700 Subject: [PATCH 2/3] Record complete Discourse Ruby performance samples --- .../ruby-universal-qualification.md | 9 ++++----- tests/qualification/ruby-universal-baseline.json | 15 +++++++-------- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/docs/implementation/ruby-universal-qualification.md b/docs/implementation/ruby-universal-qualification.md index 837cb749..8c900030 100644 --- a/docs/implementation/ruby-universal-qualification.md +++ b/docs/implementation/ruby-universal-qualification.md @@ -152,11 +152,10 @@ file and publishes a file-only delta in 9.2387 s, and restore is byte-identical. On the full Rails checkout, the five-warm-sample report records a 267.941 s cold graph, a 2.9616 s unchanged-warm median, a 165.412 s fact-neutral edit, a 249.001 s semantic edit, and a 239.309 s restore with an exact cold hash -match. The pinned Discourse Ruby-only run also restores byte-for-byte (cold -205.703 s, warm 3.136 s, fact-neutral 143.805 s, semantic 220.642 s, restore -228.594 s). RuboCop has five warm samples in the checked-in performance -baseline; the Discourse report is a one-warm-sample large-corpus -qualification. RSS remains non-blocking. Ruby therefore remains +match. The pinned Discourse Ruby-only five-warm-sample run also restores +byte-for-byte (cold 211.296 s, warm median 3.017 s with samples from 2.996–3.214 +s, fact-neutral 145.529 s, semantic 226.129 s, restore 226.958 s). RSS remains +non-blocking. Ruby therefore remains `UniversalCandidate`. The fact-neutral delta also preserves unchanged files' extraction status, diff --git a/tests/qualification/ruby-universal-baseline.json b/tests/qualification/ruby-universal-baseline.json index 6bca16fc..0412a6bb 100644 --- a/tests/qualification/ruby-universal-baseline.json +++ b/tests/qualification/ruby-universal-baseline.json @@ -89,13 +89,13 @@ { "corpus": "discourse", "projection": "ruby-only", - "samples": 1, - "coldSeconds": 205.703310, - "warmSeconds": [3.136274], - "warmMedianSeconds": 3.136274, - "factNeutralSeconds": 143.804669, - "semanticSeconds": 220.642271, - "restoreSeconds": 228.593883, + "samples": 5, + "coldSeconds": 211.296212, + "warmSeconds": [3.047000, 2.996497, 3.214150, 3.014156, 3.017186], + "warmMedianSeconds": 3.017186, + "factNeutralSeconds": 145.529095, + "semanticSeconds": 226.129381, + "restoreSeconds": 226.958164, "changedFiles": 1, "reusedFiles": 10922, "graphSha256": "d4aec2370cf4bb78b30c312a7422873f64499849e45ff1695652f1d50bb72f98", @@ -104,7 +104,6 @@ ], "limitations": [ "Discourse and RuboCop graph projections exclude non-Ruby files because their full mixed-language trees exceed the bounded Markdown parser resource envelope.", - "The large Discourse performance report currently records one warm sample; Rails and RuboCop have five-sample warm distributions.", "The quality audit now passes the fixed precision/recall thresholds, but Ruby remains a UniversalCandidate until a separate promotion decision is made." ] } From 506f35173f12a33bba424625b36a102201017607 Mon Sep 17 00:00:00 2001 From: forhappy Date: Mon, 17 Aug 2026 18:27:18 -0700 Subject: [PATCH 3/3] Polish Ruby candidate integration --- crates/compass-languages/src/engine.rs | 5 ++++- crates/compass-languages/src/evidence/mod.rs | 1 - 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/compass-languages/src/engine.rs b/crates/compass-languages/src/engine.rs index 3b12cd61..294cd960 100644 --- a/crates/compass-languages/src/engine.rs +++ b/crates/compass-languages/src/engine.rs @@ -148,7 +148,10 @@ impl Engine { ) -> Result { let spec = Registry::resolve(path).ok_or_else(|| ExtractError::Unsupported(path.to_path_buf()))?; - if !matches!(spec.name, "typescript" | "tsx" | "javascript" | "kotlin" | "ruby") { + if !matches!( + spec.name, + "typescript" | "tsx" | "javascript" | "kotlin" | "ruby" + ) { return Err(ExtractError::Unsupported(path.to_path_buf())); } let tree = self.parse(path, spec, source)?; diff --git a/crates/compass-languages/src/evidence/mod.rs b/crates/compass-languages/src/evidence/mod.rs index 49444e58..b1bd57c8 100644 --- a/crates/compass-languages/src/evidence/mod.rs +++ b/crates/compass-languages/src/evidence/mod.rs @@ -17,6 +17,5 @@ pub use model::{ ReceiverDispatchStrategy, RelationshipCandidate, ResolutionConstraint, ScopeFact, SemanticEvidenceBatch, SemanticRole, SymbolNamespace, }; -pub(crate) use ruby::extract_candidate_tree_evidence as extract_ruby_candidate_tree_evidence; pub(crate) use typescript::extract_candidate_tree_evidence; pub use validate::{EvidenceError, EvidenceErrorCode, EvidenceLimits, validate_evidence};