mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
* 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:
parent
d5b2edddc4
commit
05d269ec28
24 changed files with 2608 additions and 163 deletions
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
45
gitnexus/src/core/ingestion/languages/ruby/arity.ts
Normal file
45
gitnexus/src/core/ingestion/languages/ruby/arity.ts
Normal 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';
|
||||
}
|
||||
18
gitnexus/src/core/ingestion/languages/ruby/cache-stats.ts
Normal file
18
gitnexus/src/core/ingestion/languages/ruby/cache-stats.ts
Normal 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;
|
||||
}
|
||||
619
gitnexus/src/core/ingestion/languages/ruby/captures.ts
Normal file
619
gitnexus/src/core/ingestion/languages/ruby/captures.ts
Normal 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;
|
||||
}
|
||||
107
gitnexus/src/core/ingestion/languages/ruby/import-target.ts
Normal file
107
gitnexus/src/core/ingestion/languages/ruby/import-target.ts
Normal 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);
|
||||
}
|
||||
20
gitnexus/src/core/ingestion/languages/ruby/index.ts
Normal file
20
gitnexus/src/core/ingestion/languages/ruby/index.ts
Normal 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';
|
||||
117
gitnexus/src/core/ingestion/languages/ruby/interpret.ts
Normal file
117
gitnexus/src/core/ingestion/languages/ruby/interpret.ts
Normal 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);
|
||||
}
|
||||
27
gitnexus/src/core/ingestion/languages/ruby/merge-bindings.ts
Normal file
27
gitnexus/src/core/ingestion/languages/ruby/merge-bindings.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
221
gitnexus/src/core/ingestion/languages/ruby/query.ts
Normal file
221
gitnexus/src/core/ingestion/languages/ruby/query.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
235
gitnexus/src/core/ingestion/languages/ruby/scope-resolver.ts
Normal file
235
gitnexus/src/core/ingestion/languages/ruby/scope-resolver.ts
Normal 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,
|
||||
};
|
||||
63
gitnexus/src/core/ingestion/languages/ruby/simple-hooks.ts
Normal file
63
gitnexus/src/core/ingestion/languages/ruby/simple-hooks.ts
Normal 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';
|
||||
}
|
||||
|
|
@ -79,6 +79,7 @@ export const MIGRATED_LANGUAGES: ReadonlySet<SupportedLanguages> = new Set<Suppo
|
|||
SupportedLanguages.Kotlin,
|
||||
SupportedLanguages.Java,
|
||||
SupportedLanguages.Rust,
|
||||
SupportedLanguages.Ruby,
|
||||
]);
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
481
gitnexus/test/integration/resolvers/ruby-scope.test.ts
Normal file
481
gitnexus/test/integration/resolvers/ruby-scope.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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
305
gitnexus/test/integration/ruby-pipeline-benchmark.test.ts
Normal file
305
gitnexus/test/integration/ruby-pipeline-benchmark.test.ts
Normal 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);
|
||||
});
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue