feat(ruby): migrate Ruby to scope-based resolution (RFC #909 Ring 3) (#1831)

* feat(ruby): migrate Ruby to scope-based resolution (RFC #909 Ring 3)

Implement the full scope-resolution pipeline for Ruby following the
PR #1639 (Rust migration) standard, targeting registration in
MIGRATED_LANGUAGES with 100% scope parity.

Scope resolver hooks (languages/ruby/):
- query.ts: RUBY_SCOPE_QUERY covering scopes, declarations, imports,
  type-bindings (constructor inference via .new), and references
- captures.ts: emitRubyScopeCaptures orchestrator with import
  decomposition, receiver-binding synthesis, method reclassification,
  and arity metadata for both declarations and calls
- receiver-binding.ts: self type-binding synthesis for instance methods,
  singleton methods, and class << self blocks
- interpret.ts: interpretRubyImport (wildcard semantics) and
  interpretRubyTypeBinding (YARD, constructor, alias sources)
- import-target.ts: resolveRubyImportTarget adapting the existing
  suffix resolver for require/require_relative/load
- merge-bindings.ts: tier-based shadowing (local > namespace > import)
- arity.ts: Ruby arity check with *args/**kwargs/&block support
- scope-resolver.ts: rubyScopeResolver with custom buildRubyMro
  (kind-aware IMPLEMENTS partitioning: prepend > direct > include;
  extend excluded from instance MRO per legacy semantics)
- simple-hooks.ts: bindingScopeFor, importOwningScope, receiverBinding

Wiring:
- ruby.ts provider gains 7 scope-resolution hooks
- Registered in SCOPE_RESOLVERS map and MIGRATED_LANGUAGES
- 127 legacy tests wired with createResolverParityIt('ruby')
- 27 new scope-specific tests in ruby-scope.test.ts

Parity: 89/127 legacy tests pass under registry-primary; 38 are
heritage/property/YARD gaps expected in V1. All 127 pass under legacy.

Closes #931

* feat(ruby): add emitHeritageEdges hook, YARD parsing, bare calls, property emission

Extend the scope-resolution pipeline with a new optional `emitHeritageEdges`
hook (ScopeResolver contract + run.ts wiring) that runs between
`preEmitInheritanceEdges` and `buildMro`. This lets languages whose heritage
declarations are syntactic method calls (Ruby include/extend/prepend) emit
IMPLEMENTS edges from the scope-resolver without touching the legacy pipeline.

Ruby scope-resolution improvements:
- Heritage: intercept include/extend/prepend in captures.ts, encode as
  special imports, emit IMPLEMENTS edges via emitHeritageEdges hook
- Properties: intercept attr_accessor/attr_reader/attr_writer, emit
  Property nodes + HAS_PROPERTY edges via the same hook
- Bare calls: add (body_statement (identifier)) capture to scope query,
  matching the legacy query pattern for zero-arity method calls
- YARD parsing: second-pass comment scanner for @param/@return/@type
  annotations with findFollowingMethod that handles body_statement nesting
- Query fixes: @declaration.trait for modules (was @declaration.module
  which normalizeNodeLabel didn't recognize), constant constructor
  bindings (SERVICE = UserService.new), call-return inference

Parity: 114/127 legacy tests pass under registry-primary (up from 89).
Remaining 13 are advanced type-inference chain resolution (compound
receiver, cross-file return-type propagation, for-in element types).

* feat(ruby): achieve 100% scope-resolution parity (127/127)

Fix all 13 remaining type-inference failures:

- Add expandsWildcardTo hook (expandRubyWildcardNames) so finalize can
  materialize individual bindings from require/require_relative wildcard
  imports, unblocking cross-file return-type propagation
- Add member-call-return type binding synthesis in captures.ts for
  assignments like `x = obj.method()` — enables compound receiver
  chaining through member call return types
- Add YARD @return support for attr_accessor/attr_reader/attr_writer
  calls, creating field-type bindings for chain resolution
- Add @declaration.property captures alongside __property__ imports so
  properties register in localDefs → model.fields → write-access
- Add constructor-return inference for methods ending with Foo.new()
- Add for-loop variable type aliasing in scope query
- Rebuild nodeLookup after emitHeritageEdges in run.ts so Property
  nodes created by the heritage hook are visible to downstream passes
- Extend compound-receiver resolver to handle compound member-call
  rawNames with () and increase max depth from 4 to 8
- Extend receiver-bound-calls Case 3b for compound rawNames

All 127 legacy Ruby tests pass under both REGISTRY_PRIMARY_RUBY=0
(legacy) and =1 (registry-primary). Ruby is now fully registered
in MIGRATED_LANGUAGES with 100% scope parity.

* test(ruby): add pipeline benchmark exercising heritage emission

Synthetic Ruby codebases at 100/250/500 files with include + extend +
prepend mixins, diamond mixin patterns (shared BaseMixin modules),
attr_accessor properties, YARD annotations, and cross-file imports.

Strict equality assertions verify exact IMPLEMENTS and HAS_PROPERTY
edge counts: 4 IMPLEMENTS per class (include x2, extend, prepend)
plus 1 per non-base mixin module, 3 HAS_PROPERTY per class.

Dedup in emitRubyMixinEdges prevents double-counting when the worker
path (repos >= 15 files) already created Property/IMPLEMENTS edges
before scope-resolution runs.

Scaling: 0.76x and 1.40x (both linear, well under 3x threshold).

* ci: retrigger build

* fix(ci): resolve format, registry-primary-flag, and sequential-mixin test failures

- Run prettier on all changed files (captures.ts, run.ts, ruby-scope.test.ts,
  ruby.test.ts, ruby-pipeline-benchmark.test.ts)
- Update registry-primary-flag.test.ts: use Swift (not in MIGRATED_LANGUAGES)
  instead of Ruby for the isolation and env-var mutation tests
- Pin ruby-sequential-mixin.test.ts to REGISTRY_PRIMARY_RUBY=0 (legacy mode)
  since it tests inferImplicitReceiver + selectDispatch hooks that live in the
  legacy call-processor (gated off under registry-primary)

---------

Co-authored-by: Test <test@example.com>
This commit is contained in:
Gergő Magyar 2026-05-26 16:16:49 +01:00 committed by GitHub
parent d5b2edddc4
commit 05d269ec28
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 2608 additions and 163 deletions

View file

@ -33,6 +33,15 @@ import { rubyHeritageConfig } from '../heritage-extractors/configs/ruby.js';
import { maybeRewriteRubyBareCallToSelf } from '../utils/ruby-self-call.js';
import { findEnclosingClassInfo } from '../utils/ast-helpers.js';
import type { DispatchDecision, ImplicitReceiverOverride } from '../call-types.js';
import {
emitRubyScopeCaptures,
rubyArityCompatibility,
rubyBindingScopeFor,
rubyImportOwningScope,
rubyReceiverBinding,
interpretRubyImport,
interpretRubyTypeBinding,
} from './ruby/index.js';
/**
* Ruby label override. Applied to:
@ -258,4 +267,12 @@ export const rubyProvider = defineLanguage({
},
builtInNames: BUILT_INS,
// ── RFC #909 Ring 3: scope-based resolution hooks ──────────
emitScopeCaptures: emitRubyScopeCaptures,
interpretImport: interpretRubyImport,
interpretTypeBinding: interpretRubyTypeBinding,
bindingScopeFor: rubyBindingScopeFor,
importOwningScope: rubyImportOwningScope,
receiverBinding: rubyReceiverBinding,
arityCompatibility: rubyArityCompatibility,
});

View file

@ -0,0 +1,45 @@
/**
* Ruby arity check, accommodating `*args`, `**kwargs`, and defaults.
*
* The `def` metadata we care about (set by the existing Ruby method/
* function extractor):
* - `parameterCount` total positional + keyword params
* - `requiredParameterCount` min required (excludes defaults / `*args` / `**kwargs`)
* - `parameterTypes` present when types are known; we also use it
* as a "we have varargs" hint (`'*args'`,
* `'**kwargs'` literals appear in the array).
*
* Verdicts:
* - `'compatible'` `requiredParameterCount <= argCount <= parameterCount`,
* OR the def takes `*args` (then any `argCount >= required` ok).
* - `'incompatible'` argCount is below required, OR above max with no `*args`.
* - `'unknown'` def metadata is absent / incomplete.
*
* `'incompatible'` is a soft signal in `Registry.lookup` (penalized but
* still considered when no compatible candidate exists), per RFC 4.
*/
import type { Callsite, SymbolDefinition } from 'gitnexus-shared';
export function rubyArityCompatibility(
def: SymbolDefinition,
callsite: Callsite,
): 'compatible' | 'unknown' | 'incompatible' {
const max = def.parameterCount;
const min = def.requiredParameterCount;
if (max === undefined && min === undefined) return 'unknown';
const argCount = callsite.arity;
if (!Number.isFinite(argCount) || argCount < 0) return 'unknown';
// Detect varargs/kwargs from parameterTypes if present (the Ruby
// method extractor stores `'*args'`/`'**kwargs'` in this list).
const hasVarArgs =
def.parameterTypes !== undefined &&
def.parameterTypes.some((t) => t === '*args' || t === '**kwargs' || t.startsWith('*'));
if (min !== undefined && argCount < min) return 'incompatible';
if (max !== undefined && argCount > max && !hasVarArgs) return 'incompatible';
return 'compatible';
}

View file

@ -0,0 +1,18 @@
let hits = 0;
let misses = 0;
export function recordRubyCacheHit(): void {
hits++;
}
export function recordRubyCacheMiss(): void {
misses++;
}
export function getRubyCaptureCacheStats(): { readonly hits: number; readonly misses: number } {
return { hits, misses };
}
export function resetRubyCaptureCacheStats(): void {
hits = 0;
misses = 0;
}

View file

@ -0,0 +1,619 @@
import type { Capture, CaptureMatch } from 'gitnexus-shared';
import {
findNodeAtRange,
nodeToCapture,
syntheticCapture,
type SyntaxNode,
} from '../../utils/ast-helpers.js';
import { getRubyParser, getRubyScopeQuery } from './query.js';
import { recordRubyCacheHit, recordRubyCacheMiss } from './cache-stats.js';
import { synthesizeRubyReceiverBinding, findEnclosingClassOrModule } from './receiver-binding.js';
import { getTreeSitterBufferSize } from '../../constants.js';
import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
const FUNCTION_NODE_TYPES = ['method', 'singleton_method'] as const;
const HERITAGE_CALL_NAMES: ReadonlySet<string> = new Set(['include', 'extend', 'prepend']);
const ATTR_CALL_NAMES: ReadonlySet<string> = new Set([
'attr_accessor',
'attr_reader',
'attr_writer',
]);
export function emitRubyScopeCaptures(
sourceText: string,
_filePath: string,
cachedTree?: unknown,
): readonly CaptureMatch[] {
let tree = cachedTree as ReturnType<ReturnType<typeof getRubyParser>['parse']> | undefined;
if (tree === undefined) {
try {
tree = parseSourceSafe(getRubyParser(), sourceText, undefined, {
bufferSize: getTreeSitterBufferSize(sourceText),
});
} catch (err) {
throw scopeExtractionError('parse', _filePath, err);
}
recordRubyCacheMiss();
} else {
recordRubyCacheHit();
}
let rawMatches: ReturnType<ReturnType<typeof getRubyScopeQuery>['matches']>;
try {
rawMatches = getRubyScopeQuery().matches(tree.rootNode);
} catch (err) {
throw scopeExtractionError('scope query', _filePath, err);
}
const out: CaptureMatch[] = [];
for (const m of rawMatches) {
const grouped: Record<string, Capture> = {};
for (const c of m.captures) {
const tag = '@' + c.name;
if (tag.startsWith('@_')) continue;
grouped[tag] = nodeToCapture(tag, c.node);
}
if (Object.keys(grouped).length === 0) continue;
// Decompose require/require_relative/load into import captures
if (grouped['@import.statement'] !== undefined) {
const anchor = grouped['@import.statement']!;
const callNode = findNodeAtRange(tree.rootNode, anchor.range, 'call');
if (callNode !== null) {
const decomposed = decomposeRubyImport(callNode, anchor);
if (decomposed !== null) {
out.push(decomposed);
continue;
}
}
out.push(grouped);
continue;
}
// Synthesize self receiver bindings for methods inside class/module
if (grouped['@scope.function'] !== undefined) {
const scopeCap = grouped['@scope.function']!;
const fnNode = findFunctionNode(tree.rootNode, scopeCap.range);
if (fnNode !== null) {
const enclosingNode = findEnclosingClassOrModule(fnNode);
const receiver = synthesizeRubyReceiverBinding(fnNode, enclosingNode);
if (receiver !== null) out.push(receiver);
}
out.push(grouped);
continue;
}
// Reclassify declaration.function as declaration.method + attach arity
if (grouped['@declaration.function'] !== undefined) {
const anchorCap = grouped['@declaration.function']!;
const fnNode = findFunctionNode(tree.rootNode, anchorCap.range);
if (fnNode !== null) {
const enclosingNode = findEnclosingClassOrModule(fnNode);
if (enclosingNode !== null) {
const nameCap = grouped['@declaration.name'];
delete (grouped as Record<string, Capture | undefined>)['@declaration.function'];
grouped['@declaration.method'] = syntheticCapture(
'@declaration.method',
fnNode,
fnNode.text,
);
if (nameCap !== undefined) {
grouped['@declaration.name'] = nameCap;
}
}
const arity = computeRubyDeclarationArity(fnNode);
if (arity.parameterCount !== undefined) {
grouped['@declaration.parameter-count'] = syntheticCapture(
'@declaration.parameter-count',
fnNode,
String(arity.parameterCount),
);
}
if (arity.requiredParameterCount !== undefined) {
grouped['@declaration.required-parameter-count'] = syntheticCapture(
'@declaration.required-parameter-count',
fnNode,
String(arity.requiredParameterCount),
);
}
if (arity.parameterTypes !== undefined) {
grouped['@declaration.parameter-types'] = syntheticCapture(
'@declaration.parameter-types',
fnNode,
JSON.stringify(arity.parameterTypes),
);
}
}
out.push(grouped);
continue;
}
// Intercept heritage calls (include/extend/prepend) — encode as
// special imports so emitHeritageEdges can emit IMPLEMENTS edges.
if (grouped['@reference.call.free'] !== undefined && grouped['@reference.name'] !== undefined) {
const callName = grouped['@reference.name']!.text;
if (HERITAGE_CALL_NAMES.has(callName)) {
const callNode = findNodeAtRange(
tree.rootNode,
grouped['@reference.call.free']!.range,
'call',
);
if (callNode !== null) {
const enclosing = findEnclosingClassOrModule(callNode);
const ownerName = enclosing?.childForFieldName('name')?.text;
if (ownerName) {
const argList = callNode.childForFieldName('arguments');
if (argList !== null) {
for (let ai = 0; ai < argList.namedChildCount; ai++) {
const arg = argList.namedChild(ai);
if (arg !== null && (arg.type === 'constant' || arg.type === 'scope_resolution')) {
out.push({
'@import.statement': grouped['@reference.call.free']!,
'@import.kind': syntheticCapture('@import.kind', callNode, 'namespace'),
'@import.source': syntheticCapture(
'@import.source',
callNode,
`__heritage__:${callName}:${arg.text}:${ownerName}`,
),
'@import.name': syntheticCapture('@import.name', callNode, arg.text),
});
}
}
}
}
}
continue;
}
// Intercept attr_accessor/attr_reader/attr_writer — encode as special
// imports so emitHeritageEdges can create Property nodes + HAS_PROPERTY.
// Also emit @declaration.property captures so each property ends up in
// localDefs and gets reconciled into model.fields, enabling write-access
// resolution via receiver-bound-calls (Case 4 → findOwnedMember).
if (ATTR_CALL_NAMES.has(callName)) {
const callNode = findNodeAtRange(
tree.rootNode,
grouped['@reference.call.free']!.range,
'call',
);
if (callNode !== null) {
const enclosing = findEnclosingClassOrModule(callNode);
const ownerName = enclosing?.childForFieldName('name')?.text;
if (ownerName) {
const argList = callNode.childForFieldName('arguments');
if (argList !== null) {
for (let ai = 0; ai < argList.namedChildCount; ai++) {
const arg = argList.namedChild(ai);
if (arg !== null && (arg.type === 'simple_symbol' || arg.type === 'symbol')) {
const propName = arg.text.replace(/^:/, '');
out.push({
'@import.statement': grouped['@reference.call.free']!,
'@import.kind': syntheticCapture('@import.kind', callNode, 'namespace'),
'@import.source': syntheticCapture(
'@import.source',
callNode,
`__property__:${callName}:${propName}:${ownerName}`,
),
'@import.name': syntheticCapture('@import.name', callNode, propName),
});
// Emit a property declaration so the property flows into
// localDefs → model.fields for receiver-bound write access.
out.push({
'@declaration.property': syntheticCapture(
'@declaration.property',
arg,
propName,
),
'@declaration.name': syntheticCapture('@declaration.name', arg, propName),
});
}
}
}
}
}
continue;
}
}
// Attach call arity for call expressions
const callTag = (['@reference.call.free', '@reference.call.member'] as const).find(
(t) => grouped[t] !== undefined,
);
if (callTag !== undefined && grouped['@reference.arity'] === undefined) {
const anchor = grouped[callTag]!;
const callNode = findNodeAtRange(tree.rootNode, anchor.range, 'call');
if (callNode !== null) {
const arity = computeRubyCallArity(callNode);
grouped['@reference.arity'] = syntheticCapture('@reference.arity', callNode, String(arity));
}
}
out.push(grouped);
}
// Second pass: member-call-return type bindings
// Synthesize compound type bindings for `x = recv.method(...)` assignments.
// The query-level @type-binding.call-return pattern only captures free calls
// (!receiver). For member calls we need `x → recv.method()` so the compound
// receiver resolver can chain-follow through the receiver's class scope.
for (const assignNode of tree.rootNode.descendantsOfType('assignment')) {
const left = assignNode.childForFieldName('left');
const right = assignNode.childForFieldName('right');
if (left === null || right === null) continue;
if (left.type !== 'identifier' && left.type !== 'constant') continue;
if (right.type !== 'call') continue;
const recvNode = right.childForFieldName('receiver');
const methodNode = right.childForFieldName('method');
if (recvNode === null || methodNode === null) continue;
// Skip .new calls — already handled by the constructor-inference query patterns
if (methodNode.text === 'new') continue;
const compoundName = `${recvNode.text}.${methodNode.text}()`;
out.push({
'@type-binding.call-return': syntheticCapture(
'@type-binding.call-return',
assignNode,
assignNode.text,
),
'@type-binding.name': syntheticCapture('@type-binding.name', assignNode, left.text),
'@type-binding.type': syntheticCapture('@type-binding.type', assignNode, compoundName),
});
}
// Third pass: YARD comment annotations (@param, @return, @type)
for (const comment of tree.rootNode.descendantsOfType('comment')) {
const text = comment.text;
// @param name [Type]
const paramMatch = text.match(/@param\s+(\w+)\s+\[([^\]]+)\]/);
if (paramMatch) {
const [, paramName, typeName] = paramMatch;
const methodNode = findFollowingMethod(comment);
if (methodNode !== null && paramName && typeName) {
out.push({
'@type-binding.parameter': syntheticCapture('@type-binding.parameter', methodNode, text),
'@type-binding.name': syntheticCapture('@type-binding.name', methodNode, paramName),
'@type-binding.type': syntheticCapture(
'@type-binding.type',
methodNode,
normalizeYardType(typeName),
),
});
}
}
// @param [Type] name (alternate YARD order)
const paramAltMatch = text.match(/@param\s+\[([^\]]+)\]\s+(\w+)/);
if (!paramMatch && paramAltMatch) {
const [, typeName, paramName] = paramAltMatch;
const methodNode = findFollowingMethod(comment);
if (methodNode !== null && paramName && typeName) {
out.push({
'@type-binding.parameter': syntheticCapture('@type-binding.parameter', methodNode, text),
'@type-binding.name': syntheticCapture('@type-binding.name', methodNode, paramName),
'@type-binding.type': syntheticCapture(
'@type-binding.type',
methodNode,
normalizeYardType(typeName),
),
});
}
}
// @return [Type]
const returnMatch = text.match(/@return\s+\[([^\]]+)\]/);
if (returnMatch) {
const [, typeName] = returnMatch;
const methodNode = findFollowingMethod(comment);
if (methodNode !== null && typeName) {
const methodName = methodNode.childForFieldName('name')?.text;
if (methodName) {
out.push({
'@type-binding.return': syntheticCapture('@type-binding.return', methodNode, text),
'@type-binding.name': syntheticCapture('@type-binding.name', methodNode, methodName),
'@type-binding.type': syntheticCapture(
'@type-binding.type',
methodNode,
normalizeYardType(typeName),
),
});
}
} else if (typeName) {
// YARD @return before attr_accessor/attr_reader/attr_writer: the
// comment precedes a `call` node (not a method). Extract the
// property name from the attr call's arguments and bind it to
// the annotated return type. This enables field-type chains
// like `user.address.save → Address#save`.
const attrNode = findFollowingAttrCall(comment);
if (attrNode !== null) {
const argList = attrNode.childForFieldName('arguments');
if (argList !== null) {
for (let ai = 0; ai < argList.namedChildCount; ai++) {
const arg = argList.namedChild(ai);
if (arg !== null && (arg.type === 'simple_symbol' || arg.type === 'symbol')) {
const propName = arg.text.replace(/^:/, '');
out.push({
'@type-binding.return': syntheticCapture('@type-binding.return', attrNode, text),
'@type-binding.name': syntheticCapture('@type-binding.name', attrNode, propName),
'@type-binding.type': syntheticCapture(
'@type-binding.type',
attrNode,
normalizeYardType(typeName),
),
});
}
}
}
}
}
}
// @type [Type]
const typeMatch = text.match(/@type\s+\[([^\]]+)\]/);
if (typeMatch) {
const [, typeName] = typeMatch;
const methodNode = findFollowingMethod(comment);
if (methodNode !== null && typeName) {
out.push({
'@type-binding.parameter': syntheticCapture('@type-binding.parameter', methodNode, text),
'@type-binding.name': syntheticCapture('@type-binding.name', methodNode, ''),
'@type-binding.type': syntheticCapture(
'@type-binding.type',
methodNode,
normalizeYardType(typeName),
),
});
}
}
}
// Fourth pass: constructor-return inference for methods.
// When a method's body ends with `ClassName.new(...)`, synthesize a
// return-type binding `methodName → ClassName` on the method node.
// This enables cross-file return-type propagation for factory methods
// like `def self.get_user; User.new; end` → `get_user → User`.
for (const methodNode of [
...tree.rootNode.descendantsOfType('method'),
...tree.rootNode.descendantsOfType('singleton_method'),
]) {
const methodName = methodNode.childForFieldName('name')?.text;
if (methodName === undefined) continue;
// Skip if a YARD @return already created a return binding for this method
if (
out.some(
(m) =>
m['@type-binding.return'] !== undefined &&
m['@type-binding.name']?.text === methodName &&
m['@type-binding.return']?.range.startLine === methodNode.startPosition.row,
)
) {
continue;
}
const body = methodNode.childForFieldName('body');
if (body === null) continue;
// Find the last expression in the method body
const lastChild = body.namedChildCount > 0 ? body.namedChild(body.namedChildCount - 1) : null;
if (lastChild === null) continue;
// Check if the last expression is a `ClassName.new(...)` call
if (lastChild.type === 'call') {
const recv = lastChild.childForFieldName('receiver');
const meth = lastChild.childForFieldName('method');
if (
recv !== null &&
meth !== null &&
meth.text === 'new' &&
(recv.type === 'constant' || recv.type === 'scope_resolution')
) {
out.push({
'@type-binding.return': syntheticCapture(
'@type-binding.return',
methodNode,
`constructor-return: ${recv.text}.new`,
),
'@type-binding.name': syntheticCapture('@type-binding.name', methodNode, methodName),
'@type-binding.type': syntheticCapture('@type-binding.type', methodNode, recv.text),
});
}
}
}
return out;
}
function decomposeRubyImport(callNode: SyntaxNode, anchor: Capture): CaptureMatch | null {
const methodNode = callNode.childForFieldName('method');
if (methodNode === null) return null;
const methodName = methodNode.text;
if (methodName !== 'require' && methodName !== 'require_relative' && methodName !== 'load') {
return null;
}
const argsNode = callNode.childForFieldName('arguments');
const argNode = argsNode !== null ? argsNode.namedChild(0) : callNode.namedChild(1);
if (argNode === null) return null;
let sourcePath: string;
if (argNode.type === 'string') {
const contentChild = argNode.namedChild(0);
sourcePath =
contentChild !== null && contentChild.type === 'string_content'
? contentChild.text
: argNode.text.replace(/^['"]|['"]$/g, '');
} else {
return null;
}
if (sourcePath === '') return null;
const segments = sourcePath.replace(/\\/g, '/').split('/');
const lastSegment = segments[segments.length - 1]!;
const moduleName = lastSegment.replace(/\.rb$/, '');
return {
'@import.statement': anchor,
'@import.kind': syntheticCapture('@import.kind', callNode, 'wildcard'),
'@import.source': syntheticCapture('@import.source', callNode, sourcePath),
'@import.name': syntheticCapture('@import.name', callNode, moduleName),
};
}
function computeRubyDeclarationArity(fnNode: SyntaxNode): {
parameterCount?: number;
requiredParameterCount?: number;
parameterTypes?: string[];
} {
const params = fnNode.childForFieldName('parameters');
if (params === null) return { parameterCount: 0, requiredParameterCount: 0 };
let totalCount = 0;
let requiredCount = 0;
const paramTypes: string[] = [];
for (let i = 0; i < params.namedChildCount; i++) {
const child = params.namedChild(i);
if (child === null) continue;
switch (child.type) {
case 'identifier':
totalCount++;
requiredCount++;
paramTypes.push('');
break;
case 'optional_parameter':
totalCount++;
paramTypes.push('');
break;
case 'splat_parameter':
totalCount++;
paramTypes.push('*args');
break;
case 'hash_splat_parameter':
totalCount++;
paramTypes.push('**kwargs');
break;
case 'block_parameter':
// &block not counted in arity
break;
case 'keyword_parameter': {
totalCount++;
const hasDefault = child.childForFieldName('value') !== null;
if (!hasDefault) requiredCount++;
paramTypes.push('');
break;
}
default:
totalCount++;
requiredCount++;
paramTypes.push('');
break;
}
}
return {
parameterCount: totalCount,
requiredParameterCount: requiredCount,
parameterTypes: paramTypes.length > 0 ? paramTypes : undefined,
};
}
function computeRubyCallArity(callNode: SyntaxNode): number {
const argList = callNode.childForFieldName('arguments');
if (argList === null) return 0;
let count = 0;
for (let i = 0; i < argList.namedChildCount; i++) {
const child = argList.namedChild(i);
if (child !== null && child.type !== 'block') count++;
}
return count;
}
function findFunctionNode(rootNode: SyntaxNode, range: Capture['range']): SyntaxNode | null {
for (const nodeType of FUNCTION_NODE_TYPES) {
const n = findNodeAtRange(rootNode, range, nodeType);
if (n !== null) return n;
}
return null;
}
function scopeExtractionError(stage: string, filePath: string, err: unknown): Error {
const reason = err instanceof Error ? err.message : String(err);
return new Error(
`[ruby] tree-sitter ${stage} failed for ${filePath}: ${reason}; skipping scope extraction for this file`,
);
}
/**
* Walk forward from a comment node, skipping consecutive comments,
* and return the next `method` or `singleton_method` node (if any).
*/
function findFollowingMethod(commentNode: SyntaxNode): SyntaxNode | null {
let sibling = commentNode.nextNamedSibling;
while (sibling !== null && sibling.type === 'comment') {
sibling = sibling.nextNamedSibling;
}
if (sibling === null) return null;
if (sibling.type === 'method' || sibling.type === 'singleton_method') return sibling;
// In tree-sitter-ruby, YARD comments before a method inside a class body
// are children of `class`, while the method is inside `body_statement`.
// Walk into body_statement to find the method.
if (sibling.type === 'body_statement') {
const first = sibling.firstNamedChild;
if (first !== null && (first.type === 'method' || first.type === 'singleton_method')) {
return first;
}
}
return null;
}
/**
* Walk forward from a comment node, skipping consecutive comments,
* and return the next `call` node whose method is attr_accessor /
* attr_reader / attr_writer (if any). Used to attach YARD `@return`
* annotations to property declarations.
*/
function findFollowingAttrCall(commentNode: SyntaxNode): SyntaxNode | null {
let sibling = commentNode.nextNamedSibling;
while (sibling !== null && sibling.type === 'comment') {
sibling = sibling.nextNamedSibling;
}
if (sibling === null) return null;
if (sibling.type === 'call') {
const methodNode = sibling.childForFieldName('method');
if (methodNode !== null && ATTR_CALL_NAMES.has(methodNode.text)) {
return sibling;
}
}
return null;
}
/**
* Normalize a YARD type string: for single-parameter generics like
* `Array<User>` or `Array[User]` keep the inner type; for multi-param
* generics like `Hash<Symbol, User>` strip the generic and return the
* outer type name only.
*/
function normalizeYardType(raw: string): string {
const trimmed = raw.trim();
// Check for angle-bracket generics: Type<Inner> or Type<A, B>
const angleMatch = trimmed.match(/^(\w+)<(.+)>$/);
if (angleMatch) {
const inner = angleMatch[2]!;
// Single-param generic → return the inner type
if (!inner.includes(',')) return inner.trim();
// Multi-param generic → return the outer type
return angleMatch[1]!;
}
// Check for bracket generics: Type[Inner] (YARD sometimes uses this)
const bracketMatch = trimmed.match(/^(\w+)\[(.+)\]$/);
if (bracketMatch) {
const inner = bracketMatch[2]!;
if (!inner.includes(',')) return inner.trim();
return bracketMatch[1]!;
}
return trimmed;
}

View file

@ -0,0 +1,107 @@
/**
* Resolve a Ruby require/require_relative import path to a repo-relative file.
*
* Ruby import resolution rules:
* - `require_relative './foo'` resolve relative to the importing file's dir
* - `require 'foo'` suffix-match via the existing Ruby import resolver
* - External gems null (unresolvable within the repo)
*/
import { resolveRubyImportInternal } from '../../import-resolvers/ruby.js';
import { buildSuffixIndex } from '../../import-resolvers/utils.js';
export interface RubyResolveContext {
readonly fromFile: string;
readonly allFilePaths: ReadonlySet<string>;
}
// ─── resolveRubyImportTarget ──────────────────────────────────────────────
/**
* ScopeResolver-shaped adapter:
* `(targetRaw, fromFile, allFilePaths, resolutionConfig?) → string | string[] | null`
*
* For relative paths (`./` or `../` require_relative semantics), resolves
* against the importing file's directory, trying `.rb` and `/index.rb`
* suffixes.
*
* For bare requires (gem-style like `'json'`, `'serializable'`), delegates
* to the existing `resolveRubyImportInternal` which uses suffix matching.
*
* Returns `null` for external gems that have no matching file in the repo.
*/
export function resolveRubyImportTarget(
targetRaw: string,
fromFile: string,
allFilePaths: ReadonlySet<string>,
_resolutionConfig?: unknown,
): string | readonly string[] | null {
if (!targetRaw) return null;
if (targetRaw.startsWith('__heritage__:') || targetRaw.startsWith('__property__:')) return null;
const fromNormalized = fromFile.replace(/\\/g, '/');
const fromDir = fromNormalized.includes('/')
? fromNormalized.slice(0, fromNormalized.lastIndexOf('/'))
: '';
// ── require_relative: relative path resolution ──────────────────────
if (targetRaw.startsWith('./') || targetRaw.startsWith('../')) {
const resolved = resolveRelative(targetRaw, fromDir, allFilePaths);
return resolved;
}
// ── require: bare/gem-style suffix matching ─────────────────────────
return resolveBare(targetRaw, allFilePaths);
}
// ─── internal helpers ─────────────────────────────────────────────────────
/**
* Resolve a relative require path (`./foo`, `../bar`) against `fromDir`.
* Tries `${resolved}.rb` then `${resolved}/index.rb`.
*/
function resolveRelative(
targetRaw: string,
fromDir: string,
allFilePaths: ReadonlySet<string>,
): string | null {
// Resolve `./` and `../` segments manually against fromDir
const segments = (fromDir ? fromDir + '/' + targetRaw : targetRaw).split('/');
const resolved: string[] = [];
for (const seg of segments) {
if (seg === '' || seg === '.') continue;
if (seg === '..') {
resolved.pop();
} else {
resolved.push(seg);
}
}
const resolvedPath = resolved.join('/');
// Try direct .rb file
const rbFile = `${resolvedPath}.rb`;
if (allFilePaths.has(rbFile)) return rbFile;
// Try index.rb inside directory
const indexFile = `${resolvedPath}/index.rb`;
if (allFilePaths.has(indexFile)) return indexFile;
// The path might already include .rb extension
if (resolvedPath.endsWith('.rb') && allFilePaths.has(resolvedPath)) return resolvedPath;
return null;
}
/**
* Resolve a bare require path (`'serializable'`, `'json'`, `'net/http'`)
* via suffix matching using the existing Ruby import resolver.
*/
function resolveBare(targetRaw: string, allFilePaths: ReadonlySet<string>): string | null {
const normalizedFileList = [...allFilePaths].map((f) => f.replace(/\\/g, '/'));
const allFileList = [...allFilePaths];
const index = buildSuffixIndex(normalizedFileList, allFileList);
return resolveRubyImportInternal(targetRaw, normalizedFileList, allFileList, index);
}

View file

@ -0,0 +1,20 @@
/**
* Ruby scope-resolution hooks (RFC #909 Ring 3).
*/
export { emitRubyScopeCaptures } from './captures.js';
export { getRubyCaptureCacheStats, resetRubyCaptureCacheStats } from './cache-stats.js';
export {
interpretRubyImport,
interpretRubyTypeBinding,
normalizeRubyTypeName,
} from './interpret.js';
export { rubyArityCompatibility } from './arity.js';
export { rubyMergeBindings } from './merge-bindings.js';
export { synthesizeRubyReceiverBinding, findEnclosingClassOrModule } from './receiver-binding.js';
export {
rubyBindingScopeFor,
rubyImportOwningScope,
rubyReceiverBinding,
rubyFunctionDefinitionLabel,
} from './simple-hooks.js';
export { resolveRubyImportTarget, type RubyResolveContext } from './import-target.js';

View file

@ -0,0 +1,117 @@
import type { CaptureMatch, ParsedImport, ParsedTypeBinding, TypeRef } from 'gitnexus-shared';
// ─── interpretImport ──────────────────────────────────────────────────────
/**
* Interpret a pre-decomposed Ruby import capture into a `ParsedImport`.
*
* Ruby `require` / `require_relative` / `load` bring everything from the
* target file into scope (wildcard semantics). The captures layer (U5)
* pre-decomposes the raw tree-sitter match so that this function receives:
* - `@import.kind` always `'wildcard'` for Ruby
* - `@import.source` the string argument (e.g. `'./user'`, `'serializable'`)
* - `@import.name` derived module name (informational)
*/
export function interpretRubyImport(captures: CaptureMatch): ParsedImport | null {
const kind = captures['@import.kind']?.text;
if (kind === undefined) return null;
const source = captures['@import.source']?.text;
if (source === undefined) return null;
// Heritage-encoded imports (__heritage__:include:Serializable:User)
// are stored as namespace imports so emitHeritageEdges can read them.
if (source.startsWith('__heritage__:') || source.startsWith('__property__:')) {
const name = captures['@import.name']?.text ?? source;
return { kind: 'namespace', localName: name, importedName: name, targetRaw: source };
}
// Ruby imports are always wildcard — everything in the required file
// becomes visible in the importing scope.
return { kind: 'wildcard', targetRaw: source };
}
// ─── interpretTypeBinding ─────────────────────────────────────────────────
/**
* Interpret a Ruby type-binding capture into a `ParsedTypeBinding`.
*
* Type information in Ruby comes from YARD/RBS annotations, `.new` calls,
* and assignment inference. The captures layer tags each match with one of
* several sub-captures (`@type-binding.self`, `@type-binding.constructor`,
* etc.) so this function can determine the `source`.
*/
export function interpretRubyTypeBinding(captures: CaptureMatch): ParsedTypeBinding | null {
const name = captures['@type-binding.name']?.text;
const type = captures['@type-binding.type']?.text;
if (name === undefined || type === undefined) return null;
let source: TypeRef['source'];
let normalizedType: string;
if (captures['@type-binding.self'] !== undefined) {
source = 'self';
normalizedType = normalizeRubyTypeName(type);
} else if (captures['@type-binding.constructor'] !== undefined) {
source = 'constructor-inferred';
normalizedType = normalizeRubyConstructorType(type);
} else if (captures['@type-binding.call-return'] !== undefined) {
source = 'constructor-inferred';
normalizedType = normalizeRubyTypeName(type);
} else if (captures['@type-binding.return'] !== undefined) {
source = 'return-annotation';
normalizedType = normalizeRubyTypeName(type);
} else if (captures['@type-binding.parameter'] !== undefined) {
source = 'parameter-annotation';
normalizedType = normalizeRubyTypeName(type);
} else if (captures['@type-binding.alias'] !== undefined) {
source = 'assignment-inferred';
normalizedType = normalizeRubyTypeName(type);
} else {
source = 'annotation';
normalizedType = normalizeRubyTypeName(type);
}
return { boundName: name, rawTypeName: normalizedType, source };
}
// ─── normalizeRubyTypeName ────────────────────────────────────────────────
/**
* Normalize a Ruby type name to its simple form:
* 1. Strip leading `::` (root-qualified)
* 2. Take last segment of qualified paths (`Foo::Bar::Baz` `Baz`)
* 3. Strip generic angle brackets for consistency (`Array<User>` `Array`)
* 4. Trim whitespace
*/
export function normalizeRubyTypeName(text: string): string {
let t = text.trim();
// Strip leading root-qualifier
if (t.startsWith('::')) t = t.slice(2);
// Strip generic angle brackets (e.g. `Array<User>` → `Array`)
const angleBracket = t.indexOf('<');
if (angleBracket !== -1) t = t.slice(0, angleBracket);
// Take last segment of qualified paths (Foo::Bar::Baz → Baz)
const lastColon = t.lastIndexOf('::');
if (lastColon !== -1) t = t.slice(lastColon + 2);
return t.trim();
}
// ─── internal helpers ─────────────────────────────────────────────────────
/**
* Normalize a constructor-inferred type from a `.new` call.
* Handles `Foo::Bar.new` `Bar` and plain `Foo.new` `Foo`.
*/
function normalizeRubyConstructorType(text: string): string {
let t = text.trim();
// Strip `.new` suffix if present (e.g. `Foo::Bar.new` → `Foo::Bar`)
if (t.endsWith('.new')) t = t.slice(0, -4);
return normalizeRubyTypeName(t);
}

View file

@ -0,0 +1,27 @@
import type { BindingRef } from 'gitnexus-shared';
const TIER: Record<BindingRef['origin'], number> = {
local: 0,
namespace: 1,
import: 2,
reexport: 3,
wildcard: 4,
};
export function rubyMergeBindings(
existing: readonly BindingRef[],
incoming: readonly BindingRef[],
_scopeId: string,
): BindingRef[] {
const seen = new Set<string>();
return [...existing, ...incoming]
.sort(
(a, b) =>
(TIER[a.origin] ?? 99) - (TIER[b.origin] ?? 99) || a.def.nodeId.localeCompare(b.def.nodeId),
)
.filter((binding) => {
if (seen.has(binding.def.nodeId)) return false;
seen.add(binding.def.nodeId);
return true;
});
}

View file

@ -0,0 +1,221 @@
/**
* Tree-sitter query for Ruby scope captures (U1 scope-resolution migration).
*
* Captures the structural skeleton the generic scope-resolution pipeline
* consumes: scopes (program/class/module/method/block), declarations
* (class, module, method, singleton_method, variable), imports
* (require/require_relative/load), type bindings (constructor-inferred
* locals via `.new`), and references (free calls, member calls).
*
* Ruby specifics that shape this query:
*
* - Ruby modules are class-like scopes (they hold methods and can be
* mixed in via include/extend/prepend).
*
* - `singleton_method` (`def self.foo`) is a class-level method
* declaration, captured as @scope.function + @declaration.function.
*
* - Ruby has no static type annotations. Constructor inference via
* `x = User.new` is handled here; YARD `@param`/`@return` comments
* are handled programmatically in captures.ts.
*
* - In Ruby, field access IS a method call (attr_reader generates
* methods). Member calls cover both method calls and field reads.
*
* - `require`, `require_relative`, and `load` are plain method calls
* in the grammar matched by name via `#match?`.
*
* - `do_block` and `block` (`{ }`) are both block scopes that can
* introduce closures.
*
* Exposes lazy `Parser` and `Query` singletons so callers don't pay
* tree-sitter init cost per file.
*/
import Parser from 'tree-sitter';
import Ruby from 'tree-sitter-ruby';
const RUBY_SCOPE_QUERY = `
;; Scopes
(program) @scope.module
(class) @scope.class
(module) @scope.class
(method) @scope.function
(singleton_method) @scope.function
(do_block) @scope.block
(block) @scope.block
;; Declarations class
(class
name: (constant) @declaration.name) @declaration.class
;; Declarations module (labeled Trait for class-like registry lookup)
(module
name: (constant) @declaration.name) @declaration.trait
;; Declarations method (instance)
(method
name: (identifier) @declaration.name) @declaration.function
;; Declarations singleton method (class-level: def self.foo)
(singleton_method
name: (identifier) @declaration.name) @declaration.function
;; Declarations variable assignment
(assignment
left: (identifier) @declaration.name) @declaration.variable
;; Imports require / require_relative / load
;;
;; All three are plain \`call\` nodes in tree-sitter-ruby with no receiver.
;; The import-decomposer in captures.ts fans out the argument to a path.
(call
method: (identifier) @_method
(#match? @_method "^(require|require_relative|load)$")) @import.statement
;; Type bindings constructor inference: x = User.new
;;
;; tree-sitter-ruby parses \`x = User.new\` as:
;; (assignment
;; left: (identifier) ;; "x"
;; right: (call
;; receiver: (constant) ;; "User"
;; method: (identifier))) ;; "new"
;;
;; Captures the receiver constant as the type.
(assignment
left: (identifier) @type-binding.name
right: (call
receiver: (constant) @type-binding.type
method: (identifier) @_new_method
(#eq? @_new_method "new"))) @type-binding.constructor
;; Qualified constructor: x = Foo::Bar.new (scope_resolution receiver)
(assignment
left: (identifier) @type-binding.name
right: (call
receiver: (scope_resolution) @type-binding.type
method: (identifier) @_new_method2
(#eq? @_new_method2 "new"))) @type-binding.constructor
;; Constant constructor: SERVICE = UserService.new (left is constant, not identifier)
(assignment
left: (constant) @type-binding.name
right: (call
receiver: (constant) @type-binding.type
method: (identifier) @_new_method3
(#eq? @_new_method3 "new"))) @type-binding.constructor
(assignment
left: (constant) @type-binding.name
right: (call
receiver: (scope_resolution) @type-binding.type
method: (identifier) @_new_method4
(#eq? @_new_method4 "new"))) @type-binding.constructor
;; Call-return inference: x = build_service() (factory pattern)
(assignment
left: (identifier) @type-binding.name
right: (call
!receiver
method: (identifier) @type-binding.type)) @type-binding.call-return
;; Constant call-return: SERVICE = build_service()
(assignment
left: (constant) @type-binding.name
right: (call
!receiver
method: (identifier) @type-binding.type)) @type-binding.call-return
;; Type bindings for-in loop: for x in collection
;;
;; The loop variable \`x\` gets the element type of the collection.
;; We bind \`x → collection\` as an alias; the chain-follow pass
;; resolves \`collection → ElementType\` via YARD \`@param\` annotations.
;; tree-sitter-ruby wraps the collection in an \`in\` node:
;; (for pattern: (identifier) value: (in (identifier)))
(for
pattern: (identifier) @type-binding.name
value: (in
(identifier) @type-binding.type)) @type-binding.alias
;; Type bindings variable alias: x = y
(assignment
left: (identifier) @type-binding.name
right: (identifier) @type-binding.type) @type-binding.alias
;; References free calls (no receiver)
(call
!receiver
method: (identifier) @reference.name) @reference.call.free
;; References bare calls (zero-arity calls without parentheses)
;;
;; Ruby allows calling methods without parentheses. When no arguments are
;; passed, tree-sitter-ruby parses them as plain \`identifier\` nodes inside
;; \`body_statement\`. This mirrors the legacy query pattern. The scope-
;; resolution pipeline filters false positives via builtInNames and
;; arity-based overload narrowing.
(body_statement
(identifier) @reference.name) @reference.call.free
;; References member calls (with receiver): obj.method()
(call
receiver: (_) @reference.receiver
method: (identifier) @reference.name) @reference.call.member
;; References field writes: obj.field = value
;;
;; Ruby setter syntax: \`obj.name = x\` is an assignment whose left-hand
;; side is a \`call\` node with a receiver.
(assignment
left: (call
receiver: (_) @reference.receiver
method: (identifier) @reference.name)) @reference.write
;; References field writes (compound assignment: obj.field += value)
(operator_assignment
left: (call
receiver: (_) @reference.receiver
method: (identifier) @reference.name)) @reference.write
`;
let _parser: Parser | null = null;
let _query: Parser.Query | null = null;
export function getRubyParser(): Parser {
if (_parser === null) {
_parser = new Parser();
_parser.setLanguage(Ruby as Parameters<Parser['setLanguage']>[0]);
}
return _parser;
}
export function getRubyScopeQuery(): Parser.Query {
if (_query === null) {
_query = new Parser.Query(Ruby as Parameters<Parser['setLanguage']>[0], RUBY_SCOPE_QUERY);
}
return _query;
}

View file

@ -0,0 +1,72 @@
/**
* Synthesize `@type-binding.self` captures for Ruby methods.
*
* Every instance method and singleton method inside a class or module body
* gets `self` bound to the enclosing class/module name. Ruby has no
* `@staticmethod` concept even `def self.foo` dispatches on the class.
*
* For `class << self` (singleton_class) blocks, we walk past the
* singleton_class to the real owning class/module, matching the
* `rubyResolveEnclosingOwner` logic from the legacy provider.
*/
import type { CaptureMatch } from 'gitnexus-shared';
import { syntheticCapture } from '../../utils/ast-helpers.js';
import type { SyntaxNode } from '../../utils/ast-helpers.js';
/**
* Walk up the parent chain from `node` to find the first enclosing
* `class` or `module` ancestor.
*
* - `singleton_class` (`class << self`) is not itself a real type, so we
* skip past it and continue walking to find the true owner.
* - Stops at `program` (never walks past the file root).
* - Returns `null` when no enclosing class/module is found.
*/
export function findEnclosingClassOrModule(node: SyntaxNode): SyntaxNode | null {
let cur: SyntaxNode | null = node.parent;
while (cur !== null) {
if (cur.type === 'program') return null;
if (cur.type === 'class' || cur.type === 'module') return cur;
// singleton_class (`class << self`) is not the real owner —
// keep walking to find the enclosing class/module.
cur = cur.parent;
}
return null;
}
/**
* Extract the class/module name from the `name` field of a `class` or
* `module` node. The field holds a `constant` node whose text is the
* simple name (e.g. "MyClass").
*/
function extractClassName(classOrModuleNode: SyntaxNode): string | null {
const nameNode = classOrModuleNode.childForFieldName('name');
if (nameNode === null) return null;
return nameNode.text;
}
/**
* Given a method node (`method` or `singleton_method`) and its enclosing
* `class`/`module` node (or null for top-level defs), synthesize a
* `@type-binding.self` capture that binds `self` to the class/module name.
*
* Returns `null` when:
* - `enclosingNode` is null (top-level def has no implicit receiver)
* - the enclosing node's name cannot be extracted
*/
export function synthesizeRubyReceiverBinding(
fnNode: SyntaxNode,
enclosingNode: SyntaxNode | null,
): CaptureMatch | null {
if (enclosingNode === null) return null;
const className = extractClassName(enclosingNode);
if (className === null) return null;
return {
'@type-binding.self': syntheticCapture('@type-binding.self', fnNode, 'self'),
'@type-binding.name': syntheticCapture('@type-binding.name', fnNode, 'self'),
'@type-binding.type': syntheticCapture('@type-binding.type', fnNode, className),
};
}

View file

@ -0,0 +1,235 @@
import type { ParsedFile, ScopeId } from 'gitnexus-shared';
import { SupportedLanguages } from 'gitnexus-shared';
import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js';
import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js';
import { rubyProvider } from '../ruby.js';
import { rubyArityCompatibility, rubyMergeBindings, resolveRubyImportTarget } from './index.js';
import { populateClassOwnedMembers, isClassLike } from '../../scope-resolution/scope/walkers.js';
import { resolveDefGraphId } from '../../scope-resolution/graph-bridge/ids.js';
import type { GraphNodeLookup } from '../../scope-resolution/graph-bridge/node-lookup.js';
import type { KnowledgeGraph } from '../../../graph/types.js';
import { generateId } from '../../../../lib/utils.js';
const HERITAGE_PREFIX = '__heritage__:';
function emitRubyMixinEdges(
graph: KnowledgeGraph,
parsedFiles: readonly ParsedFile[],
nodeLookup: GraphNodeLookup,
): void {
const graphIdByName = new Map<string, string>();
for (const parsed of parsedFiles) {
for (const def of parsed.localDefs) {
if (!isClassLike(def.type)) continue;
const graphId = resolveDefGraphId(parsed.filePath, def, nodeLookup);
if (graphId !== undefined) {
const simpleName = def.qualifiedName?.split('.').pop() ?? def.qualifiedName ?? '';
graphIdByName.set(simpleName, graphId);
}
}
}
const emitted = new Set<string>();
// Pre-seed with existing IMPLEMENTS edges to avoid duplicates when the
// parse-worker path already produced heritage (worker path for repos
// with >= 15 files).
for (const rel of graph.iterRelationshipsByType('IMPLEMENTS')) {
emitted.add(`${rel.sourceId}->${rel.targetId}:${rel.reason}`);
}
for (const parsed of parsedFiles) {
for (const imp of parsed.parsedImports) {
if (!imp.targetRaw.startsWith(HERITAGE_PREFIX)) continue;
const parts = imp.targetRaw.slice(HERITAGE_PREFIX.length).split(':');
if (parts.length < 3) continue;
const [kind, mixinName, className] = parts;
const classGraphId = graphIdByName.get(className!);
const mixinGraphId = graphIdByName.get(mixinName!);
if (classGraphId === undefined || mixinGraphId === undefined) continue;
const edgeKey = `${classGraphId}->${mixinGraphId}:${kind}`;
if (emitted.has(edgeKey)) continue;
emitted.add(edgeKey);
graph.addRelationship({
id: generateId('IMPLEMENTS', edgeKey),
sourceId: classGraphId,
targetId: mixinGraphId,
type: 'IMPLEMENTS',
confidence: 0.85,
reason: kind!,
});
}
}
// Emit Property nodes + HAS_PROPERTY edges from __property__:... imports.
// Skip if the parse-worker already created the property (worker path merges
// Property nodes into the graph before scope-resolution runs).
const existingProps = new Set<string>();
for (const rel of graph.iterRelationshipsByType('HAS_PROPERTY')) {
const targetNode = graph.getNode(rel.targetId);
if (targetNode !== undefined) {
existingProps.add(`${rel.sourceId}->prop:${targetNode.properties.name}`);
}
}
const PROPERTY_PREFIX = '__property__:';
for (const parsed of parsedFiles) {
for (const imp of parsed.parsedImports) {
if (!imp.targetRaw.startsWith(PROPERTY_PREFIX)) continue;
const parts = imp.targetRaw.slice(PROPERTY_PREFIX.length).split(':');
if (parts.length < 3) continue;
const [_attrKind, propName, className] = parts;
const classGraphId = graphIdByName.get(className!);
if (classGraphId === undefined || propName === undefined) continue;
const edgeKey = `${classGraphId}->prop:${propName}`;
if (emitted.has(edgeKey) || existingProps.has(edgeKey)) continue;
emitted.add(edgeKey);
const propId = generateId('Property', `${parsed.filePath}:${className}.${propName}`);
graph.addNode({
id: propId,
label: 'Property',
properties: { name: propName, filePath: parsed.filePath },
});
graph.addRelationship({
id: generateId('HAS_PROPERTY', edgeKey),
sourceId: classGraphId,
targetId: propId,
type: 'HAS_PROPERTY',
confidence: 0.9,
reason: 'attr',
});
}
}
}
function buildRubyMro(
graph: Parameters<ScopeResolver['buildMro']>[0],
parsedFiles: readonly ParsedFile[],
nodeLookup: Parameters<ScopeResolver['buildMro']>[2],
): Map<string, string[]> {
// Step 1: EXTENDS chain via the generic MRO builder (direct class inheritance).
const baseMro = buildMro(graph, parsedFiles, nodeLookup, defaultLinearize);
// Step 2: Build defId ↔ graphId bridge for class-like defs.
const defIdByGraphId = new Map<string, string>();
for (const parsed of parsedFiles) {
for (const def of parsed.localDefs) {
if (!isClassLike(def.type)) continue;
const graphId = resolveDefGraphId(parsed.filePath, def, nodeLookup);
if (graphId !== undefined) defIdByGraphId.set(graphId, def.nodeId);
}
}
// Step 3: Collect IMPLEMENTS edges, partitioned by reason.
const prependByChild = new Map<string, string[]>();
const includeByChild = new Map<string, string[]>();
for (const rel of graph.iterRelationshipsByType('IMPLEMENTS')) {
const childDefId = defIdByGraphId.get(rel.sourceId);
const parentDefId = defIdByGraphId.get(rel.targetId);
if (childDefId === undefined || parentDefId === undefined) continue;
const reason = rel.reason;
if (reason === 'prepend') {
let list = prependByChild.get(childDefId);
if (list === undefined) {
list = [];
prependByChild.set(childDefId, list);
}
list.push(parentDefId);
} else if (reason === 'include') {
let list = includeByChild.get(childDefId);
if (list === undefined) {
list = [];
includeByChild.set(childDefId, list);
}
list.push(parentDefId);
}
}
// Step 4: Reorder MRO per Ruby semantics.
// Order: prepend (reversed) → direct extends chain → include (reversed).
// `extend` is excluded — it belongs to singleton dispatch only (legacy
// `getInstanceAncestry` in heritage-map.ts explicitly drops extend entries).
// Reversed because Ruby declaration order means last-declared wins
// (prepend B; prepend A → B checked before A).
for (const defId of defIdByGraphId.values()) {
const extendsChain = baseMro.get(defId) ?? [];
const prepends = prependByChild.get(defId);
const includes = includeByChild.get(defId);
if (prepends === undefined && includes === undefined) continue;
const reordered: string[] = [];
if (prepends !== undefined) {
for (let i = prepends.length - 1; i >= 0; i--) reordered.push(prepends[i]);
}
reordered.push(...extendsChain);
if (includes !== undefined) {
for (let i = includes.length - 1; i >= 0; i--) reordered.push(includes[i]);
}
baseMro.set(defId, reordered);
}
return baseMro;
}
/**
* Enumerate all names exported from a target module scope's file.
* Ruby's `require` / `require_relative` are wildcard imports they bring
* every top-level def (class, module, method, constant) from the target
* file into the importer's scope. Without this hook the finalize pass
* cannot materialize individual bindings from wildcard imports, which
* blocks `propagateImportedReturnTypes` from mirroring return-type
* typeBindings across files.
*/
function expandRubyWildcardNames(
targetModuleScope: ScopeId,
parsedFiles: readonly ParsedFile[],
): readonly string[] {
const target = parsedFiles.find((p) => p.moduleScope === targetModuleScope);
if (target === undefined) return [];
const seen = new Set<string>();
const names: string[] = [];
for (const def of target.localDefs) {
const qn = def.qualifiedName;
if (qn === undefined || qn.length === 0) continue;
const name = qn.split('.').pop() ?? qn;
if (name === '') continue;
if (seen.has(name)) continue;
seen.add(name);
names.push(name);
}
return names;
}
export const rubyScopeResolver: ScopeResolver = {
language: SupportedLanguages.Ruby,
languageProvider: rubyProvider,
importEdgeReason: 'ruby-scope: import',
resolveImportTarget: (targetRaw, fromFile, allFilePaths, resolutionConfig) =>
resolveRubyImportTarget(targetRaw, fromFile, allFilePaths, resolutionConfig),
expandsWildcardTo: (targetModuleScope, parsedFiles) =>
expandRubyWildcardNames(targetModuleScope, parsedFiles),
mergeBindings: (existing, incoming, scopeId) => rubyMergeBindings(existing, incoming, scopeId),
arityCompatibility: (callsite, def) => rubyArityCompatibility(def, callsite),
buildMro: (graph, parsedFiles, nodeLookup) => buildRubyMro(graph, parsedFiles, nodeLookup),
populateOwners: (parsed) => populateClassOwnedMembers(parsed),
isSuperReceiver: (text) => text.trim() === 'super',
emitHeritageEdges: (graph, parsedFiles, nodeLookup) =>
emitRubyMixinEdges(graph, parsedFiles, nodeLookup),
fieldFallbackOnMethodLookup: true,
propagatesReturnTypesAcrossImports: true,
allowGlobalFreeCallFallback: true,
};

View file

@ -0,0 +1,63 @@
import type {
CaptureMatch,
ParsedImport,
Scope,
ScopeId,
ScopeTree,
TypeRef,
NodeLabel,
} from 'gitnexus-shared';
import type { SyntaxNode } from '../../utils/ast-helpers.js';
export function rubyBindingScopeFor(
decl: CaptureMatch,
innermost: Scope,
_tree: ScopeTree,
): ScopeId | null {
// Keep self typeBindings in the method's Function scope so
// populateClassOwnedMembers can match Method defs to their receiver types.
if (decl['@type-binding.self'] !== undefined) {
return innermost.id;
}
return null;
}
/**
* Ruby `require` / `include` inside a function or class body should attach
* at that scope, not module scope.
*/
export function rubyImportOwningScope(
_imp: ParsedImport,
innermost: Scope,
_tree: ScopeTree,
): ScopeId | null {
if (innermost.kind === 'Function' || innermost.kind === 'Class') {
return innermost.id;
}
return null;
}
export function rubyReceiverBinding(functionScope: Scope): TypeRef | null {
if (functionScope.kind !== 'Function') return null;
return functionScope.typeBindings.get('self') ?? null;
}
/**
* Reclassify top-level `def` as `'Method'` when it appears inside a
* `class` or `module` body. Stand-alone defs remain `'Function'`.
*/
export function rubyFunctionDefinitionLabel(
functionNode: SyntaxNode,
defaultLabel: NodeLabel,
): NodeLabel {
if (defaultLabel !== 'Function') return defaultLabel;
let ancestor: SyntaxNode | null = functionNode.parent;
while (ancestor) {
if (ancestor.type === 'program') break;
if (ancestor.type === 'class' || ancestor.type === 'module') {
return 'Method';
}
ancestor = ancestor.parent;
}
return 'Function';
}

View file

@ -79,6 +79,7 @@ export const MIGRATED_LANGUAGES: ReadonlySet<SupportedLanguages> = new Set<Suppo
SupportedLanguages.Kotlin,
SupportedLanguages.Java,
SupportedLanguages.Rust,
SupportedLanguages.Ruby,
]);
/**

View file

@ -437,6 +437,28 @@ export interface ScopeResolver {
nodeLookup: GraphNodeLookup,
) => Map<string /* DefId */, string[] /* ancestor DefIds */>;
/**
* Optional pre-MRO hook to emit heritage edges (IMPLEMENTS) that the
* generic `preEmitInheritanceEdges` pass cannot produce. Runs AFTER
* `preEmitInheritanceEdges` (which emits EXTENDS from `@reference.inherits`
* sites) and BEFORE `buildMro` (which reads the graph for EXTENDS +
* IMPLEMENTS). Languages whose heritage declarations are syntactic method
* calls rather than grammar-level heritage clauses (e.g., Ruby
* `include`/`extend`/`prepend`) use this hook to emit IMPLEMENTS edges
* from parsed import or reference data.
*
* Receives the graph (writable), parsedFiles, and nodeLookup same
* surface as `buildMro`. Must be idempotent (the orchestrator may call
* it more than once during re-resolution).
*
* Default: undefined (no extra heritage edges needed).
*/
readonly emitHeritageEdges?: (
graph: KnowledgeGraph,
parsedFiles: readonly ParsedFile[],
nodeLookup: GraphNodeLookup,
) => void;
/**
* Mutate `parsed.localDefs[i].ownerId` to point at the structural
* owner. Python's rule: methods (Function defs whose parent scope

View file

@ -30,9 +30,14 @@ import {
} from '../scope/walkers.js';
/** Max depth for compound-receiver chain resolution (`a().b().c().d()`).
* Practical code rarely exceeds 3-4 hops; the cap prevents
* pathological recursion if the receiver text is malformed. */
const COMPOUND_RECEIVER_MAX_DEPTH = 4;
* Practical code rarely exceeds 3-4 _syntactic_ hops, but languages
* with type-binding-mediated chains (Ruby's `x = obj.method()` binds
* `x → obj.method()` and recurses through the compound resolver) can
* triple the depth count because each intermediate step contributes
* two recursions (bare-ident compound rawName call-expr parse).
* 8 covers 3-level chains with headroom while still capping
* pathological recursion. */
const COMPOUND_RECEIVER_MAX_DEPTH = 8;
const MAP_TUPLE_SENTINEL_RE = /^__MAP_TUPLE_(\d+)__:(.+)$/;
@ -144,6 +149,23 @@ export function resolveCompoundReceiverClass(
);
if (callAlias !== undefined) return callAlias;
}
// Compound member-call alias: rawName has both `.` and `()`
// (`user = Factory.get_user()` → rawName `Factory.get_user()`).
// Recurse into the compound resolver with the raw compound
// expression so the mixed-chain parser can split at top-level
// `.` and resolve the receiver + method return type.
if (tb.rawName.includes('.') && tb.rawName.includes('(')) {
const compound = resolveCompoundReceiverClass(
tb.rawName,
inScope,
scopes,
index,
options,
depth + 1,
);
if (compound !== undefined) return compound;
}
}
return findClassBindingInScope(inScope, text, scopes);
}

View file

@ -620,15 +620,21 @@ export function emitReceiverBoundCalls(
}
// ── Case 3b: chain-typebinding (`city → user.get_city`) ──────
// Also handles compound member-call rawNames (`city → addr.get_city()`)
// where the rawName includes both `.` and `()` — Ruby's
// member-call-return captures produce this shape.
const chainHead =
typeRef !== undefined && typeRef.rawName.includes('.') && !typeRef.rawName.includes('(')
typeRef !== undefined && typeRef.rawName.includes('.')
? (typeRef.rawName.split('.', 1)[0] ?? '')
: undefined;
if (typeRef !== undefined && chainHead !== undefined && !namespaceTargets.has(chainHead)) {
// Try the plain dotted-field walk first — covers property /
// collection-accessor shapes (`.Values`, Kotlin `.size`) and
// field chains. Fall back to call-form (`x()`) which treats
// the last segment as a method invocation.
// the last segment as a method invocation. For rawNames that
// already contain `()` (Ruby member-call-return captures),
// pass through directly — the compound resolver handles the
// full expression including the call syntax.
let ownerDef = resolveCompoundReceiverClass(
typeRef.rawName,
typeRef.declaredAtScope,
@ -636,7 +642,7 @@ export function emitReceiverBoundCalls(
index,
compoundOpts,
);
if (ownerDef === undefined) {
if (ownerDef === undefined && !typeRef.rawName.includes('(')) {
ownerDef = resolveCompoundReceiverClass(
typeRef.rawName + '()',
typeRef.declaredAtScope,

View file

@ -22,6 +22,7 @@ import { phpScopeResolver } from '../../languages/php/scope-resolver.js';
import { rustScopeResolver } from '../../languages/rust/scope-resolver.js';
import { javascriptScopeResolver } from '../../languages/javascript/scope-resolver.js';
import { kotlinScopeResolver } from '../../languages/kotlin/scope-resolver.js';
import { rubyScopeResolver } from '../../languages/ruby/scope-resolver.js';
/** Map of `SupportedLanguages` `ScopeResolver`. The phase iterates
* this map intersected with `MIGRATED_LANGUAGES` (the per-language
@ -42,4 +43,5 @@ export const SCOPE_RESOLVERS: ReadonlyMap<SupportedLanguages, ScopeResolver> = n
[SupportedLanguages.Rust, rustScopeResolver],
[SupportedLanguages.JavaScript, javascriptScopeResolver],
[SupportedLanguages.Kotlin, kotlinScopeResolver],
[SupportedLanguages.Ruby, rubyScopeResolver],
]);

View file

@ -308,8 +308,26 @@ export function runScopeResolution(
},
});
const preEmittedInheritanceSites = preEmitInheritanceEdges(graph, finalized, nodeLookup);
const mroByClassDefId = provider.buildMro(graph, parsedFiles, nodeLookup);
const extendsOnlyMroByClassDefId = provider.buildExtendsOnlyMro?.(graph, parsedFiles, nodeLookup);
// Call-based heritage hook (e.g., Ruby include/extend/prepend) — emits
// IMPLEMENTS edges that `preEmitInheritanceEdges` cannot produce because
// the heritage declarations are syntactic method calls, not grammar-level
// heritage clauses. Must run BEFORE `buildMro` so MRO construction sees
// the freshly-emitted IMPLEMENTS edges.
provider.emitHeritageEdges?.(graph, parsedFiles, nodeLookup);
// Rebuild the node lookup after heritage-edge emission. Languages like
// Ruby create Property graph nodes inside `emitHeritageEdges`; those
// nodes must be visible to downstream passes (`emitReceiverBoundCalls`
// resolves write-access targets via `resolveDefGraphId` which consults
// `nodeLookup`). Without this rebuild, Property nodes added by the
// heritage hook are invisible and ACCESSES edges silently fail to emit.
const postHeritageNodeLookup =
provider.emitHeritageEdges !== undefined ? buildGraphNodeLookup(graph) : nodeLookup;
const mroByClassDefId = provider.buildMro(graph, parsedFiles, postHeritageNodeLookup);
const extendsOnlyMroByClassDefId = provider.buildExtendsOnlyMro?.(
graph,
parsedFiles,
postHeritageNodeLookup,
);
// Replace the empty MethodDispatchIndex that finalizeScopeModel
// builds by design with the populated one derived from the
@ -395,7 +413,7 @@ export function runScopeResolution(
graph,
indexes,
parsedFiles,
nodeLookup,
postHeritageNodeLookup,
handledSites,
provider,
workspaceIndex,
@ -410,7 +428,7 @@ export function runScopeResolution(
graph,
indexes,
parsedFiles,
nodeLookup,
postHeritageNodeLookup,
handledSites,
readonlyModel,
)
@ -419,7 +437,7 @@ export function runScopeResolution(
graph,
indexes,
parsedFiles,
nodeLookup,
postHeritageNodeLookup,
referenceIndex,
handledSites,
readonlyModel,
@ -438,7 +456,7 @@ export function runScopeResolution(
graph,
indexes,
referenceIndex,
nodeLookup,
postHeritageNodeLookup,
handledSites,
);
const importsEmitted = emitImportEdges(

View file

@ -200,6 +200,12 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly<Record<string, Readonly
'useChainTypeBindingCrossover: services.first().build() emits NO CALLS edge to build',
'useValueReceiverCrossover: l.create("nope") emits NO CALLS edge to create',
]),
ruby: new Set<string>([
// Ruby scope-resolution currently achieves 89/127 parity.
// Tests listed here are scope-resolver-only correctness wins
// (pass under registry-primary, fail under legacy). Currently
// empty — all 127 tests pass under legacy mode.
]),
cpp: new Set<string>([
// The legacy DAG path has no scope-aware filtering on the global
// free-call fallback, so `#include`d headers still leak class

View file

@ -0,0 +1,481 @@
/**
* Ruby scope-resolution integration tests (U8).
*
* These tests run with REGISTRY_PRIMARY_RUBY=true to exercise the
* scope-based resolution path. They validate class methods, module mixins,
* singleton methods, require_relative imports, constructor inference,
* block scope, class inheritance, and super resolution.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import path from 'path';
import fs from 'node:fs';
import os from 'node:os';
import {
getRelationships,
getNodesByLabel,
runPipelineFromRepo,
type PipelineResult,
} from './helpers.js';
function writeFixtureRepo(root: string, files: Record<string, string>): void {
for (const [rel, content] of Object.entries(files)) {
const abs = path.join(root, rel);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, content, 'utf8');
}
}
let savedEnv: string | undefined;
beforeAll(() => {
savedEnv = process.env['REGISTRY_PRIMARY_RUBY'];
process.env['REGISTRY_PRIMARY_RUBY'] = 'true';
});
afterAll(() => {
if (savedEnv === undefined) delete process.env['REGISTRY_PRIMARY_RUBY'];
else process.env['REGISTRY_PRIMARY_RUBY'] = savedEnv;
});
// ---------------------------------------------------------------------------
// 1. Basic class method resolution
// ---------------------------------------------------------------------------
describe('Ruby scope: basic class method resolution', () => {
let result: PipelineResult;
let tmpDir: string;
beforeAll(async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ruby-scope-basic-'));
writeFixtureRepo(tmpDir, {
'models/user.rb': `
class User
def save
true
end
def greet
"hello"
end
end
`,
'app.rb': `
require_relative 'models/user'
def main
u = User.new
u.save
u.greet
end
`,
});
result = await runPipelineFromRepo(tmpDir, () => {});
}, 60000);
afterAll(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('detects User class', () => {
expect(getNodesByLabel(result, 'Class')).toContain('User');
});
it('detects save and greet as Method nodes', () => {
const methods = getNodesByLabel(result, 'Method');
expect(methods).toContain('save');
expect(methods).toContain('greet');
});
it('emits HAS_METHOD edges from User to save and greet', () => {
const edges = getRelationships(result, 'HAS_METHOD');
const userSave = edges.find((e) => e.source === 'User' && e.target === 'save');
const userGreet = edges.find((e) => e.source === 'User' && e.target === 'greet');
expect(userSave).toBeDefined();
expect(userGreet).toBeDefined();
});
it('resolves main → u.save() as CALLS edge', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(
(c) => c.target === 'save' && c.source === 'main' && c.targetFilePath?.includes('user.rb'),
);
expect(saveCall).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// 2. Module mixin with include
// ---------------------------------------------------------------------------
describe('Ruby scope: module mixin with include', () => {
let result: PipelineResult;
let tmpDir: string;
beforeAll(async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ruby-scope-mixin-'));
writeFixtureRepo(tmpDir, {
'serializable.rb': `
module Serializable
def serialize
to_json
end
end
`,
'user.rb': `
require_relative 'serializable'
class User
include Serializable
def save
serialize
end
end
`,
});
result = await runPipelineFromRepo(tmpDir, () => {});
}, 60000);
afterAll(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('detects User as Class and Serializable as Trait', () => {
expect(getNodesByLabel(result, 'Class')).toContain('User');
expect(getNodesByLabel(result, 'Trait')).toContain('Serializable');
});
it('emits IMPLEMENTS edge from User to Serializable', () => {
const impls = getRelationships(result, 'IMPLEMENTS');
const edge = impls.find((e) => e.source === 'User' && e.target === 'Serializable');
expect(edge).toBeDefined();
});
it('resolves save → serialize as CALLS edge', () => {
const calls = getRelationships(result, 'CALLS');
const serializeCall = calls.find((c) => c.target === 'serialize' && c.source === 'save');
expect(serializeCall).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// 3. Singleton method (def self.foo)
// ---------------------------------------------------------------------------
describe('Ruby scope: singleton method (def self.foo)', () => {
let result: PipelineResult;
let tmpDir: string;
beforeAll(async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ruby-scope-singleton-'));
writeFixtureRepo(tmpDir, {
'config.rb': `
class Config
def self.load
new
end
def validate
true
end
end
`,
});
result = await runPipelineFromRepo(tmpDir, () => {});
}, 60000);
afterAll(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('detects Config class', () => {
expect(getNodesByLabel(result, 'Class')).toContain('Config');
});
it('detects load (singleton) and validate (instance) as Method nodes', () => {
const methods = getNodesByLabel(result, 'Method');
expect(methods).toContain('load');
expect(methods).toContain('validate');
});
it('emits HAS_METHOD edges from Config to both methods', () => {
const edges = getRelationships(result, 'HAS_METHOD');
const configLoad = edges.find((e) => e.source === 'Config' && e.target === 'load');
const configValidate = edges.find((e) => e.source === 'Config' && e.target === 'validate');
expect(configLoad).toBeDefined();
expect(configValidate).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// 4. Require/require_relative import resolution
// ---------------------------------------------------------------------------
describe('Ruby scope: require_relative import resolution', () => {
let result: PipelineResult;
let tmpDir: string;
beforeAll(async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ruby-scope-imports-'));
writeFixtureRepo(tmpDir, {
'lib/utils.rb': `
class Utils
def format(text)
text.strip
end
end
`,
'app.rb': `
require_relative 'lib/utils'
def run
u = Utils.new
u.format("hello")
end
`,
});
result = await runPipelineFromRepo(tmpDir, () => {});
}, 60000);
afterAll(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('emits IMPORTS edge from app.rb to lib/utils.rb', () => {
const imports = getRelationships(result, 'IMPORTS');
const imp = imports.find(
(e) => e.sourceFilePath?.includes('app.rb') && e.targetFilePath?.includes('utils.rb'),
);
expect(imp).toBeDefined();
});
it('resolves run → u.format() as CALLS edge to utils.rb', () => {
const calls = getRelationships(result, 'CALLS');
const formatCall = calls.find(
(c) => c.target === 'format' && c.source === 'run' && c.targetFilePath?.includes('utils.rb'),
);
expect(formatCall).toBeDefined();
});
it('detects Utils class and format method', () => {
expect(getNodesByLabel(result, 'Class')).toContain('Utils');
expect(getNodesByLabel(result, 'Method')).toContain('format');
});
});
// ---------------------------------------------------------------------------
// 5. Constructor inference (User.new)
// ---------------------------------------------------------------------------
describe('Ruby scope: constructor inference via .new', () => {
let result: PipelineResult;
let tmpDir: string;
beforeAll(async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ruby-scope-ctor-'));
writeFixtureRepo(tmpDir, {
'formatter.rb': `
class Formatter
def format(text)
text.upcase
end
end
def main
f = Formatter.new
f.format("hello")
end
`,
});
result = await runPipelineFromRepo(tmpDir, () => {});
}, 60000);
afterAll(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('detects Formatter class and format method', () => {
expect(getNodesByLabel(result, 'Class')).toContain('Formatter');
expect(getNodesByLabel(result, 'Method')).toContain('format');
});
it('emits HAS_METHOD edge from Formatter to format', () => {
const edges = getRelationships(result, 'HAS_METHOD');
const fmtEdge = edges.find((e) => e.source === 'Formatter' && e.target === 'format');
expect(fmtEdge).toBeDefined();
});
it('resolves main → f.format() to Formatter#format via constructor inference', () => {
const calls = getRelationships(result, 'CALLS');
const formatCall = calls.find(
(c) =>
c.target === 'format' && c.source === 'main' && c.targetFilePath?.includes('formatter.rb'),
);
expect(formatCall).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// 6. Block scope (do...end with params)
// ---------------------------------------------------------------------------
describe('Ruby scope: block scope with do...end', () => {
let result: PipelineResult;
let tmpDir: string;
beforeAll(async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ruby-scope-block-'));
writeFixtureRepo(tmpDir, {
'processor.rb': `
class Processor
def run
items = [1, 2, 3]
items.each do |item|
process(item)
end
end
def process(x)
x * 2
end
end
`,
});
result = await runPipelineFromRepo(tmpDir, () => {});
}, 60000);
afterAll(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('detects Processor class', () => {
expect(getNodesByLabel(result, 'Class')).toContain('Processor');
});
it('detects run and process as Method nodes on Processor', () => {
const methods = getNodesByLabel(result, 'Method');
expect(methods).toContain('run');
expect(methods).toContain('process');
});
it('emits HAS_METHOD edges from Processor to run and process', () => {
const edges = getRelationships(result, 'HAS_METHOD');
const procRun = edges.find((e) => e.source === 'Processor' && e.target === 'run');
const procProcess = edges.find((e) => e.source === 'Processor' && e.target === 'process');
expect(procRun).toBeDefined();
expect(procProcess).toBeDefined();
});
it('resolves run → process() as CALLS edge inside block scope', () => {
const calls = getRelationships(result, 'CALLS');
const processCall = calls.find((c) => c.target === 'process' && c.source === 'run');
expect(processCall).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// 7. Class inheritance (EXTENDS)
// ---------------------------------------------------------------------------
describe('Ruby scope: class inheritance via <', () => {
let result: PipelineResult;
let tmpDir: string;
beforeAll(async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ruby-scope-inherit-'));
writeFixtureRepo(tmpDir, {
'animals.rb': `
class Animal
def speak
"..."
end
end
class Dog < Animal
def bark
speak
end
end
`,
});
result = await runPipelineFromRepo(tmpDir, () => {});
}, 60000);
afterAll(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('detects Animal and Dog as Class nodes', () => {
const classes = getNodesByLabel(result, 'Class');
expect(classes).toContain('Animal');
expect(classes).toContain('Dog');
});
it('emits EXTENDS edge from Dog to Animal', () => {
const extends_ = getRelationships(result, 'EXTENDS');
const edge = extends_.find((e) => e.source === 'Dog' && e.target === 'Animal');
expect(edge).toBeDefined();
});
it('resolves bark → speak as CALLS edge', () => {
const calls = getRelationships(result, 'CALLS');
const speakCall = calls.find((c) => c.target === 'speak' && c.source === 'bark');
expect(speakCall).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// 8. Super resolution
// ---------------------------------------------------------------------------
describe('Ruby scope: super resolution in subclass', () => {
let result: PipelineResult;
let tmpDir: string;
beforeAll(async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ruby-scope-super-'));
writeFixtureRepo(tmpDir, {
'hierarchy.rb': `
class Base
def greet
"hello"
end
end
class Child < Base
def greet
super
end
end
def main
c = Child.new
c.greet
end
`,
});
result = await runPipelineFromRepo(tmpDir, () => {});
}, 60000);
afterAll(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('detects Base and Child as Class nodes', () => {
const classes = getNodesByLabel(result, 'Class');
expect(classes).toContain('Base');
expect(classes).toContain('Child');
});
it('emits EXTENDS edge from Child to Base', () => {
const extends_ = getRelationships(result, 'EXTENDS');
const edge = extends_.find((e) => e.source === 'Child' && e.target === 'Base');
expect(edge).toBeDefined();
});
it('resolves main → c.greet() as CALLS edge', () => {
const calls = getRelationships(result, 'CALLS');
const greetCall = calls.find((c) => c.target === 'greet' && c.source === 'main');
expect(greetCall).toBeDefined();
});
});

View file

@ -102,8 +102,15 @@ function resolvedMethodOwners(
describe('Ruby mixin heritage: sequential vs worker parity', () => {
let sequential: PipelineResult;
let workers: PipelineResult;
let savedEnv: string | undefined;
beforeAll(async () => {
// Force legacy mode — these tests exercise inferImplicitReceiver +
// selectDispatch hooks which live in the legacy call-processor.
// With Ruby in MIGRATED_LANGUAGES the call-processor is gated off
// under registry-primary, so pin legacy for this suite.
savedEnv = process.env['REGISTRY_PRIMARY_RUBY'];
process.env['REGISTRY_PRIMARY_RUBY'] = '0';
sequential = await runMode({ skipWorkers: true });
// Force the worker pool to spawn even though the fixture is tiny.
// Without this override, the pipeline's MIN_FILES_FOR_WORKERS / MIN_BYTES_FOR_WORKERS
@ -115,6 +122,11 @@ describe('Ruby mixin heritage: sequential vs worker parity', () => {
});
}, 120000);
afterAll(() => {
if (savedEnv === undefined) delete process.env['REGISTRY_PRIMARY_RUBY'];
else process.env['REGISTRY_PRIMARY_RUBY'] = savedEnv;
});
it('exercises both pipeline paths (sequential and worker)', () => {
// If either of these assertions fails, every downstream parity check
// below is meaningless — both modes would be running the same path.

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,305 @@
/**
* Ruby ingestion pipeline benchmark.
*
* Generates synthetic Ruby codebases at increasing scales and measures
* wall-clock time and peak heap through the full pipeline parsing,
* scope extraction, heritage (include/extend/prepend), MRO construction,
* and call resolution via the registry-primary scope-resolution path.
*
* Run: GITNEXUS_BENCH=1 npx vitest run test/integration/ruby-pipeline-benchmark.test.ts
*
* The benchmark uses workers (production path) by default. Set
* skipWorkers to test the sequential fallback path.
*/
import { describe, it, expect } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
const BENCH_ENABLED = process.env.GITNEXUS_BENCH === '1';
interface BenchResult {
fileCount: number;
classCount: number;
moduleCount: number;
mixinModuleCount: number;
elapsedMs: number;
peakHeapMB: number;
nodeCount: number;
edgeCount: number;
implementsCount: number;
hasPropertyCount: number;
extendsCount: number;
}
function generateRubyFixture(
fileCount: number,
modulesPerLevel: number,
): { dir: string; classCount: number; moduleCount: number; mixinModuleCount: number } {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), `ruby-bench-${fileCount}-`));
// Three families of mixins: one for include, one for extend, one for prepend.
// Each family has modulesPerLevel² modules so the MRO partitioning logic is
// exercised with all three heritage kinds and varied orderings.
const includeMixins: string[] = [];
const extendMixins: string[] = [];
const prependMixins: string[] = [];
for (let i = 0; i < modulesPerLevel; i++) {
for (let j = 0; j < modulesPerLevel; j++) {
includeMixins.push(`Includable${i}x${j}`);
extendMixins.push(`Extendable${i}x${j}`);
prependMixins.push(`Prependable${i}x${j}`);
}
}
const allMixins = [...includeMixins, ...extendMixins, ...prependMixins];
const moduleCount = allMixins.length;
const classCount = fileCount;
// Generate mixin module files — each module includes a shared base module
// to create diamond mixin patterns (class includes A and B, both include Base).
const concernsDir = path.join(dir, 'lib', 'concerns');
fs.mkdirSync(concernsDir, { recursive: true });
// Shared base modules that other mixins include (diamond pattern)
const baseModuleCount = Math.max(2, Math.floor(modulesPerLevel / 2));
for (let b = 0; b < baseModuleCount; b++) {
const baseName = `BaseMixin${b}`;
const content = [
`module ${baseName}`,
` def base${b}_check`,
' true',
' end',
'end',
'',
].join('\n');
fs.writeFileSync(path.join(concernsDir, `${baseName.toLowerCase()}.rb`), content);
}
for (let m = 0; m < allMixins.length; m++) {
const moduleName = allMixins[m];
const baseIdx = m % baseModuleCount;
const baseName = `BaseMixin${baseIdx}`;
const content = [
`require_relative '${baseName.toLowerCase()}'`,
'',
`module ${moduleName}`,
` include ${baseName}`,
'',
` def ${moduleName.toLowerCase()}_action`,
` base${baseIdx}_check`,
' end',
'end',
'',
].join('\n');
fs.writeFileSync(path.join(concernsDir, `${moduleName.toLowerCase()}.rb`), content);
}
// Generate class files — each class uses include + extend + prepend with
// different modules, creating a rich MRO that exercises all three
// heritage-kind partitions in buildRubyMro.
const modelsDir = path.join(dir, 'lib', 'models');
fs.mkdirSync(modelsDir, { recursive: true });
for (let f = 0; f < fileCount; f++) {
const className = `Model${f}`;
// Pick one mixin of each kind (rotating through the pools)
const incMixin = includeMixins[f % includeMixins.length];
const extMixin = extendMixins[f % extendMixins.length];
const preMixin = prependMixins[f % prependMixins.length];
// Second include mixin for diamond-overlap testing
const incMixin2 = includeMixins[(f + 1) % includeMixins.length];
const siblingIdx = (f + 1) % fileCount;
const siblingClass = `Model${siblingIdx}`;
const crossIdx = (f + Math.floor(fileCount / 3)) % fileCount;
const crossClass = `Model${crossIdx}`;
const requireLines = [
`require_relative '../concerns/${incMixin.toLowerCase()}'`,
`require_relative '../concerns/${incMixin2.toLowerCase()}'`,
`require_relative '../concerns/${extMixin.toLowerCase()}'`,
`require_relative '../concerns/${preMixin.toLowerCase()}'`,
f !== siblingIdx ? `require_relative '${siblingClass.toLowerCase()}'` : '',
f !== crossIdx ? `require_relative '${crossClass.toLowerCase()}'` : '',
].filter(Boolean);
const content = [
...requireLines,
'',
`class ${className}`,
` include ${incMixin}`,
` include ${incMixin2}`,
` extend ${extMixin}`,
` prepend ${preMixin}`,
'',
` attr_accessor :id, :name, :status`,
'',
` # @param other [${siblingClass}]`,
` # @return [${siblingClass}]`,
` def process(other)`,
` other.save`,
` ${incMixin.toLowerCase()}_action`,
` other`,
' end',
'',
' def save',
' true',
' end',
'',
` # @return [${crossClass}]`,
` def build_cross`,
` ${crossClass}.new`,
' end',
'',
` def self.class_action`,
` ${extMixin.toLowerCase()}_action`,
' end',
'end',
'',
].join('\n');
fs.writeFileSync(path.join(modelsDir, `${className.toLowerCase()}.rb`), content);
}
return {
dir,
classCount,
moduleCount: moduleCount + baseModuleCount,
mixinModuleCount: moduleCount,
};
}
async function runBenchmark(
fileCount: number,
moduleLevels: number,
budgetMs: number,
): Promise<BenchResult> {
const { dir, classCount, moduleCount, mixinModuleCount } = generateRubyFixture(
fileCount,
moduleLevels,
);
let peakHeapMB = 0;
const heapSampler = setInterval(() => {
const heap = process.memoryUsage().heapUsed / 1024 / 1024;
if (heap > peakHeapMB) peakHeapMB = heap;
}, 50);
try {
const start = Date.now();
const result = await Promise.race([
runPipelineFromRepo(dir, () => {}, { skipGraphPhases: true }),
new Promise<never>((_, reject) =>
setTimeout(
() => reject(new Error(`Pipeline exceeded ${budgetMs}ms at ${fileCount} files`)),
budgetMs,
),
),
]);
const elapsedMs = Date.now() - start;
let implementsCount = 0;
let hasPropertyCount = 0;
let extendsCount = 0;
for (const rel of result.graph.iterRelationshipsByType('IMPLEMENTS')) {
implementsCount++;
void rel;
}
for (const rel of result.graph.iterRelationshipsByType('HAS_PROPERTY')) {
hasPropertyCount++;
void rel;
}
for (const rel of result.graph.iterRelationshipsByType('EXTENDS')) {
extendsCount++;
void rel;
}
return {
fileCount,
classCount,
moduleCount,
mixinModuleCount,
elapsedMs,
peakHeapMB: Math.round(peakHeapMB),
nodeCount: result.graph.nodeCount,
edgeCount: result.graph.relationshipCount,
implementsCount,
hasPropertyCount,
extendsCount,
};
} finally {
clearInterval(heapSampler);
fs.rmSync(dir, { recursive: true, force: true });
}
}
function printResults(label: string, results: BenchResult[]) {
console.log(`\n${label}`);
console.log(
'┌──────────┬─────────┬──────────┬───────────┬──────────┬───────┬───────┬──────┬───────┬─────┐',
);
console.log(
'│ Files │ Classes │ Modules │ Time (ms) │ Heap MB │ Nodes │ Edges │ IMPL │ PROPS │ EXT │',
);
console.log(
'├──────────┼─────────┼──────────┼───────────┼──────────┼───────┼───────┼──────┼───────┼─────┤',
);
for (const r of results) {
console.log(
`${String(r.fileCount).padStart(8)}${String(r.classCount).padStart(7)}${String(r.moduleCount).padStart(8)}${String(r.elapsedMs).padStart(9)}${String(r.peakHeapMB).padStart(8)}${String(r.nodeCount).padStart(5)}${String(r.edgeCount).padStart(5)}${String(r.implementsCount).padStart(4)}${String(r.hasPropertyCount).padStart(5)}${String(r.extendsCount).padStart(3)}`,
);
}
console.log(
'└──────────┴─────────┴──────────┴───────────┴──────────┴───────┴───────┴──────┴───────┴─────┘',
);
if (results.length >= 2) {
console.log('\nScaling ratios (time_ratio / file_ratio):');
for (let i = 1; i < results.length; i++) {
const fileRatio = results[i].fileCount / results[i - 1].fileCount;
const timeRatio = results[i].elapsedMs / results[i - 1].elapsedMs;
const scaling = timeRatio / fileRatio;
console.log(
` ${results[i - 1].fileCount}${results[i].fileCount}: ${scaling.toFixed(2)}x (${scaling < 1.5 ? 'linear' : scaling < 3 ? 'superlinear' : 'WARNING: quadratic'})`,
);
}
}
}
describe.skipIf(!BENCH_ENABLED)('Ruby pipeline benchmark', () => {
it('scales with file count (workers enabled)', async () => {
const scales = [100, 250, 500];
const results: BenchResult[] = [];
for (const fileCount of scales) {
const moduleLevels = Math.max(2, Math.ceil(Math.sqrt(fileCount / 4)));
const result = await runBenchmark(fileCount, moduleLevels, 180_000);
results.push(result);
console.log(
` ${fileCount} files: ${result.elapsedMs}ms, ${result.peakHeapMB}MB heap, ${result.nodeCount} nodes, ${result.edgeCount} edges`,
);
}
printResults('Ruby Pipeline — Workers Enabled', results);
for (let i = 1; i < results.length; i++) {
const fileRatio = results[i].fileCount / results[i - 1].fileCount;
const timeRatio = results[i].elapsedMs / results[i - 1].elapsedMs;
expect(timeRatio / fileRatio).toBeLessThan(3);
}
// Verify heritage emission produces exact expected counts.
// Each class: 2x include + 1x extend + 1x prepend = 4 IMPLEMENTS.
// Each mixin module (non-base) includes one BaseMixin = 1 IMPLEMENTS.
// Each class: attr_accessor :id, :name, :status = 3 HAS_PROPERTY.
for (const r of results) {
expect(r.implementsCount).toBe(r.classCount * 4 + r.mixinModuleCount);
expect(r.hasPropertyCount).toBe(r.classCount * 3);
}
}, 300_000);
});

View file

@ -108,20 +108,20 @@ describe('isRegistryPrimary', () => {
it('isolates flags per-language (one on does not affect others)', () => {
process.env['REGISTRY_PRIMARY_PYTHON'] = 'true';
expect(isRegistryPrimary(SupportedLanguages.Python)).toBe(true);
// Ruby is not in MIGRATED_LANGUAGES — default false stays
// Swift is not in MIGRATED_LANGUAGES — default false stays
// false regardless of Python's flag.
expect(isRegistryPrimary(SupportedLanguages.Ruby)).toBe(false);
expect(isRegistryPrimary(SupportedLanguages.Swift)).toBe(false);
});
it('respects a mid-process env-var mutation (no stale cache)', () => {
// Use Ruby — not in MIGRATED_LANGUAGES — so the unset default is
// Use Swift — not in MIGRATED_LANGUAGES — so the unset default is
// deterministically `false`, independent of which languages have
// been flipped to registry-primary.
expect(isRegistryPrimary(SupportedLanguages.Ruby)).toBe(false);
process.env['REGISTRY_PRIMARY_RUBY'] = 'true';
expect(isRegistryPrimary(SupportedLanguages.Ruby)).toBe(true);
delete process.env['REGISTRY_PRIMARY_RUBY'];
expect(isRegistryPrimary(SupportedLanguages.Ruby)).toBe(false);
expect(isRegistryPrimary(SupportedLanguages.Swift)).toBe(false);
process.env['REGISTRY_PRIMARY_SWIFT'] = 'true';
expect(isRegistryPrimary(SupportedLanguages.Swift)).toBe(true);
delete process.env['REGISTRY_PRIMARY_SWIFT'];
expect(isRegistryPrimary(SupportedLanguages.Swift)).toBe(false);
});
it('handles the CPlusPlus → REGISTRY_PRIMARY_CPP mapping correctly', () => {