fix(ruby): method-level call resolution, HAS_METHOD edges, and dispatch table (#278)

* fix(ruby): method-level call resolution, HAS_METHOD edges, and dispatch table refactoring

- Replace all `if (language === Ruby)` checks in processors with a
  `callRouters` dispatch table in call-routing.ts (renamed from
  ruby-call-routing.ts to preserve git history)
- Add Ruby `method` and `singleton_method` to FUNCTION_NODE_TYPES so
  findEnclosingFunction produces Method-level CALLS sources
- Add Ruby `class` and `module` to CLASS_CONTAINER_TYPES for HAS_METHOD
  edge generation
- Add bare call capture via tree-sitter query `(body_statement (identifier))`
  for Ruby methods called without parentheses
- Add Ruby member call detection (`call` node with `receiver` field) to
  inferCallForm and extractReceiverName
- Wire resolveRubyImport into resolveLanguageImport
- Add 24 integration tests across 5 suites: heritage/properties, arity
  filtering, member calls, ambiguous disambiguation, local shadow
- Add ruby.test.ts to CI integration workflow

* fix(ruby): resolve 6 Ruby resolution gaps from PR review

- Fix singleton_method label mismatch: @definition.function → @definition.method
  so CALLS edges from `def self.foo` bodies get correct sourceId
- Add ownerId and HAS_METHOD edges to attr_* Property nodes by calling
  findEnclosingClassId in both parse-worker and call-processor property branches
- Distinguish include/extend/prepend heritage: add heritageKind to
  RubyHeritageItem, propagate through heritage pipeline as IMPLEMENTS reason
- Document bare call over-capture limitation in tree-sitter query comment
- Add bare `require` (non-relative) import test coverage
- Add prepend/extend test coverage with distinct Loggable/Cacheable modules

31 Ruby integration tests passing, no regressions in other language resolvers.

* fix(ruby): web package parity — heritage reasons, property HAS_METHOD, singleton_method label

- Web call-processor: use item.heritageKind as IMPLEMENTS reason instead of
  hardcoded 'trait-impl', add :${kind} suffix to edge ID for uniqueness
- Web call-processor: port findEnclosingClassId, add HAS_METHOD edges for
  attr_* Property nodes to match CLI fix
- Web call-processor: singleton_method label 'Function' → 'Method' to match
  CLI tree-sitter query fix
- CLI parse-worker: update stale ExtractedHeritage.kind JSDoc to include
  'include' | 'extend' | 'prepend'

* fix(web): add HAS_METHOD to RelationshipType union

Web package was missing HAS_METHOD in the RelationshipType union,
causing a type mismatch with the HAS_METHOD edges emitted by the
attr_* property fix in call-processor.ts.
This commit is contained in:
Gergő Magyar 2026-03-14 11:34:30 +00:00 • committed by GitHub
parent a2ecfbfb80
commit 09a3a42ae9
29 changed files with 710 additions and 116 deletions

View file

@ -23,7 +23,7 @@ jobs:
# on Linux, and its C++ destructors segfault during
# process.exit(). Running each file in its own process lets
# the OS reclaim all resources cleanly.
# pipeline — 12 files: ingestion pipeline + csv + 9 resolver tests
# pipeline — 13 files: ingestion pipeline + csv + 10 resolver tests
# e2e — 2 files: child-process only (spawnSync), no in-process kuzu
# standalone — 4 files: pure logic, no kuzu, no child processes
test-matrix:
@ -51,6 +51,7 @@ jobs:
test/integration/resolvers/go.test.ts
test/integration/resolvers/kotlin.test.ts
test/integration/resolvers/php.test.ts
test/integration/resolvers/ruby.test.ts
- test-group: e2e
test-glob: >-
test/integration/cli-e2e.test.ts
@ -159,6 +160,7 @@ jobs:
test/integration/resolvers/go.test.ts
test/integration/resolvers/kotlin.test.ts
test/integration/resolvers/php.test.ts
test/integration/resolvers/ruby.test.ts
- name: Upload integration coverage
if: always()

View file

@ -54,6 +54,7 @@ export type RelationshipType =
| 'DECORATES'
| 'IMPLEMENTS'
| 'EXTENDS'
| 'HAS_METHOD'
| 'MEMBER_OF'
| 'STEP_IN_PROCESS'

View file

@ -6,8 +6,7 @@ import { loadParser, loadLanguage } from '../tree-sitter/parser-loader';
import { LANGUAGE_QUERIES } from './tree-sitter-queries';
import { generateId } from '../../lib/utils';
import { getLanguageFromFilename } from './utils';
import { SupportedLanguages } from '../../config/supported-languages';
import { routeRubyCall } from './ruby-call-routing';
import { callRouters } from './call-routing';
/**
* Node types that represent function/method definitions across languages.
@ -108,7 +107,7 @@ const findEnclosingFunction = (
const nameNode = current.childForFieldName?.('name') ||
current.children?.find((c: any) => c.type === 'identifier');
funcName = nameNode?.text;
label = 'Function';
label = 'Method';
} else if (current.type === 'arrow_function' || current.type === 'function_expression') {
// Arrow/expression: const foo = () => {} - check parent variable declarator
const parent = current.parent;
@ -143,6 +142,47 @@ const findEnclosingFunction = (
return null; // Top-level call (not inside any function)
};
/** AST node types that represent a class-like container */
const CLASS_CONTAINER_TYPES = new Set([
'class_declaration', 'abstract_class_declaration',
'interface_declaration', 'struct_declaration', 'record_declaration',
'class_specifier', 'struct_specifier',
'impl_item', 'trait_item',
'class_definition',
'trait_declaration',
'protocol_declaration',
'class', 'module', // Ruby
]);
const CONTAINER_TYPE_TO_LABEL: Record<string, string> = {
class_declaration: 'Class', abstract_class_declaration: 'Class',
interface_declaration: 'Interface',
struct_declaration: 'Struct', struct_specifier: 'Struct',
class_specifier: 'Class', class_definition: 'Class',
impl_item: 'Impl', trait_item: 'Trait', trait_declaration: 'Trait',
record_declaration: 'Record', protocol_declaration: 'Interface',
class: 'Class', module: 'Module',
};
/** Walk up AST to find enclosing class/struct/interface, return its generateId or null. */
const findEnclosingClassId = (node: any, filePath: string): string | null => {
let current = node.parent;
while (current) {
if (CLASS_CONTAINER_TYPES.has(current.type)) {
const nameNode = current.childForFieldName?.('name')
?? current.children?.find((c: any) =>
c.type === 'type_identifier' || c.type === 'identifier' || c.type === 'name' || c.type === 'constant'
);
if (nameNode) {
const label = CONTAINER_TYPE_TO_LABEL[current.type] || 'Class';
return generateId(label, `${filePath}:${nameNode.text}`);
}
}
current = current.parent;
}
return null;
};
export const processCalls = async (
graph: KnowledgeGraph,
files: { path: string; content: string }[],
@ -188,6 +228,8 @@ export const processCalls = async (
continue;
}
const callRouter = callRouters[language];
// 3. Process each call match
matches.forEach(match => {
const captureMap: Record<string, any> = {};
@ -201,11 +243,9 @@ export const processCalls = async (
const calledName = nameNode.text;
// Ruby: route special calls to heritage or properties (imports handled by import-processor)
if (language === SupportedLanguages.Ruby) {
const callNode = captureMap['call'];
const routed = routeRubyCall(calledName, callNode);
// Dispatch: route language-specific calls (heritage, properties, imports)
const routed = callRouter(calledName, captureMap['call']);
if (routed) {
switch (routed.kind) {
case 'skip':
case 'import': // handled by import-processor
@ -219,10 +259,10 @@ export const processCalls = async (
const parentId = symbolTable.lookupFuzzy(item.mixinName)[0]?.nodeId ||
generateId('Module', `${item.mixinName}`);
if (childId && parentId) {
const relId = generateId('IMPLEMENTS', `${childId}->${parentId}`);
const relId = generateId('IMPLEMENTS', `${childId}->${parentId}:${item.heritageKind}`);
graph.addRelationship({
id: relId, sourceId: childId, targetId: parentId,
type: 'IMPLEMENTS', confidence: 1.0, reason: 'trait-impl',
type: 'IMPLEMENTS', confidence: 1.0, reason: item.heritageKind,
});
}
}
@ -230,6 +270,7 @@ export const processCalls = async (
case 'properties': {
const fileId = generateId('File', file.path);
const propEnclosingClassId = findEnclosingClassId(captureMap['call'], file.path);
for (const item of routed.items) {
const nodeId = generateId('Property', `${file.path}:${item.propName}`);
graph.addNode({
@ -238,7 +279,7 @@ export const processCalls = async (
properties: {
name: item.propName, filePath: file.path,
startLine: item.startLine, endLine: item.endLine,
language: SupportedLanguages.Ruby, isExported: true,
language, isExported: true,
description: item.accessorType,
},
});
@ -248,6 +289,13 @@ export const processCalls = async (
id: relId, sourceId: fileId, targetId: nodeId,
type: 'DEFINES', confidence: 1.0, reason: '',
});
if (propEnclosingClassId) {
graph.addRelationship({
id: generateId('HAS_METHOD', `${propEnclosingClassId}->${nodeId}`),
sourceId: propEnclosingClassId, targetId: nodeId,
type: 'HAS_METHOD', confidence: 1.0, reason: '',
});
}
}
return;
}

View file

@ -11,6 +11,37 @@
* Keep both copies in sync until a shared package is introduced.
*/
import { SupportedLanguages } from '../../config/supported-languages';
// ── Call routing dispatch table ─────────────────────────────────────────────
/** null = this call was not routed; fall through to default call handling */
export type CallRoutingResult = RubyCallRouting | null;
export type CallRouter = (
calledName: string,
callNode: any,
) => CallRoutingResult;
/** No-op router: returns null for every call (passthrough to normal processing) */
const noRouting: CallRouter = () => null;
/** Per-language call routing. noRouting = no special routing (normal call processing) */
export const callRouters: Record<SupportedLanguages, CallRouter> = {
[SupportedLanguages.JavaScript]: noRouting,
[SupportedLanguages.TypeScript]: noRouting,
[SupportedLanguages.Python]: noRouting,
[SupportedLanguages.Java]: noRouting,
[SupportedLanguages.Go]: noRouting,
[SupportedLanguages.Rust]: noRouting,
[SupportedLanguages.CSharp]: noRouting,
[SupportedLanguages.PHP]: noRouting,
[SupportedLanguages.Swift]: noRouting,
[SupportedLanguages.CPlusPlus]: noRouting,
[SupportedLanguages.C]: noRouting,
[SupportedLanguages.Ruby]: routeRubyCall,
};
// ── Result types ────────────────────────────────────────────────────────────
export type RubyCallRouting =
@ -23,6 +54,7 @@ export type RubyCallRouting =
export interface RubyHeritageItem {
enclosingClass: string;
mixinName: string;
heritageKind: 'include' | 'extend' | 'prepend';
}
export type RubyAccessorType = 'attr_accessor' | 'attr_reader' | 'attr_writer';
@ -88,7 +120,7 @@ export function routeRubyCall(calledName: string, callNode: any): RubyCallRoutin
const argList = callNode.childForFieldName?.('arguments');
for (const arg of (argList?.children ?? [])) {
if (arg.type === 'constant' || arg.type === 'scope_resolution') {
items.push({ enclosingClass, mixinName: arg.text });
items.push({ enclosingClass, mixinName: arg.text, heritageKind: calledName as 'include' | 'extend' | 'prepend' });
}
}
return items.length > 0 ? { kind: 'heritage', items } : SKIP_RESULT;

View file

@ -4,7 +4,7 @@ import { loadParser, loadLanguage } from '../tree-sitter/parser-loader';
import { LANGUAGE_QUERIES } from './tree-sitter-queries';
import { generateId } from '../../lib/utils';
import { getLanguageFromFilename } from './utils';
import { routeRubyCall } from './ruby-call-routing';
import { callRouters } from './call-routing';
// Type: Map<FilePath, Set<ResolvedFilePath>>
// Stores all files that a given file imports from
@ -224,12 +224,13 @@ export const processImports = async (
}
}
// ---- Ruby: require/require_relative come through @call, not @import ----
if (language === 'ruby' && captureMap['call']) {
// ---- Language-specific call-as-import routing (Ruby require, etc.) ----
if (captureMap['call']) {
const callNameNode = captureMap['call.name'];
if (callNameNode) {
const routed = routeRubyCall(callNameNode.text, captureMap['call']);
if (routed.kind === 'import') {
const callRouter = callRouters[language];
const routed = callRouter(callNameNode.text, captureMap['call']);
if (routed && routed.kind === 'import') {
totalImportsFound++;
const resolvedPath = resolveImportPath(
file.path, routed.importPath, allFilePaths, allFileList, resolveCache

View file

@ -19,11 +19,12 @@ import {
countCallArguments,
inferCallForm,
extractReceiverName,
findEnclosingClassId,
} from './utils.js';
import { buildTypeEnv, lookupTypeEnv } from './type-env.js';
import { getTreeSitterBufferSize } from './constants.js';
import type { ExtractedCall, ExtractedHeritage, ExtractedRoute } from './workers/parse-worker.js';
import { routeRubyCall } from './ruby-call-routing.js';
import { callRouters } from './call-routing.js';
/**
* Walk up the AST from a node to find the enclosing function/method.
@ -121,6 +122,7 @@ export const processCalls = async (
// Build per-file TypeEnv for receiver resolution
const lang = getLanguageFromFilename(file.path);
const typeEnv = lang ? buildTypeEnv(tree, lang) : new Map();
const callRouter = callRouters[language];
// 3. Process each call match
matches.forEach(match => {
@ -135,11 +137,9 @@ export const processCalls = async (
const calledName = nameNode.text;
// Ruby: route special calls to heritage or properties (imports handled by import-processor)
if (language === SupportedLanguages.Ruby) {
const callNode = captureMap['call'];
const routed = routeRubyCall(calledName, callNode);
// Dispatch: route language-specific calls (heritage, properties, imports)
const routed = callRouter(calledName, captureMap['call']);
if (routed) {
switch (routed.kind) {
case 'skip':
case 'import': // handled by import-processor
@ -151,13 +151,14 @@ export const processCalls = async (
filePath: file.path,
className: item.enclosingClass,
parentName: item.mixinName,
kind: 'trait-impl',
kind: item.heritageKind,
});
}
return;
case 'properties': {
const fileId = generateId('File', file.path);
const propEnclosingClassId = findEnclosingClassId(captureMap['call'], file.path);
for (const item of routed.items) {
const nodeId = generateId('Property', `${file.path}:${item.propName}`);
graph.addNode({
@ -166,16 +167,24 @@ export const processCalls = async (
properties: {
name: item.propName, filePath: file.path,
startLine: item.startLine, endLine: item.endLine,
language: SupportedLanguages.Ruby, isExported: true,
language, isExported: true,
description: item.accessorType,
},
});
symbolTable.add(file.path, item.propName, nodeId, 'Property');
symbolTable.add(file.path, item.propName, nodeId, 'Property',
propEnclosingClassId ? { ownerId: propEnclosingClassId } : undefined);
const relId = generateId('DEFINES', `${fileId}->${nodeId}`);
graph.addRelationship({
id: relId, sourceId: fileId, targetId: nodeId,
type: 'DEFINES', confidence: 1.0, reason: '',
});
if (propEnclosingClassId) {
graph.addRelationship({
id: generateId('HAS_METHOD', `${propEnclosingClassId}->${nodeId}`),
sourceId: propEnclosingClassId, targetId: nodeId,
type: 'HAS_METHOD', confidence: 1.0, reason: '',
});
}
}
return;
}

View file

@ -11,6 +11,38 @@
* Keep both copies in sync until a shared package is introduced.
*/
import { SupportedLanguages } from '../../config/supported-languages.js';
// ── Call routing dispatch table ─────────────────────────────────────────────
/** null = this call was not routed; fall through to default call handling */
export type CallRoutingResult = RubyCallRouting | null;
export type CallRouter = (
calledName: string,
callNode: any,
) => CallRoutingResult;
/** No-op router: returns null for every call (passthrough to normal processing) */
const noRouting: CallRouter = () => null;
/** Per-language call routing. noRouting = no special routing (normal call processing) */
export const callRouters: Record<SupportedLanguages, CallRouter> = {
[SupportedLanguages.JavaScript]: noRouting,
[SupportedLanguages.TypeScript]: noRouting,
[SupportedLanguages.Python]: noRouting,
[SupportedLanguages.Java]: noRouting,
[SupportedLanguages.Kotlin]: noRouting,
[SupportedLanguages.Go]: noRouting,
[SupportedLanguages.Rust]: noRouting,
[SupportedLanguages.CSharp]: noRouting,
[SupportedLanguages.PHP]: noRouting,
[SupportedLanguages.Swift]: noRouting,
[SupportedLanguages.CPlusPlus]: noRouting,
[SupportedLanguages.C]: noRouting,
[SupportedLanguages.Ruby]: routeRubyCall,
};
// ── Result types ────────────────────────────────────────────────────────────
export type RubyCallRouting =
@ -23,6 +55,7 @@ export type RubyCallRouting =
export interface RubyHeritageItem {
enclosingClass: string;
mixinName: string;
heritageKind: 'include' | 'extend' | 'prepend';
}
export type RubyAccessorType = 'attr_accessor' | 'attr_reader' | 'attr_writer';
@ -88,7 +121,7 @@ export function routeRubyCall(calledName: string, callNode: any): RubyCallRoutin
const argList = callNode.childForFieldName?.('arguments');
for (const arg of (argList?.children ?? [])) {
if (arg.type === 'constant' || arg.type === 'scope_resolution') {
items.push({ enclosingClass, mixinName: arg.text });
items.push({ enclosingClass, mixinName: arg.text, heritageKind: calledName as 'include' | 'extend' | 'prepend' });
}
}
return items.length > 0 ? { kind: 'heritage', items } : SKIP_RESULT;

View file

@ -298,7 +298,7 @@ export const processHeritageFromExtracted = async (
reason: '',
});
}
} else if (h.kind === 'trait-impl') {
} else if (h.kind === 'trait-impl' || h.kind === 'include' || h.kind === 'extend' || h.kind === 'prepend') {
const structId = symbolTable.lookupExact(h.filePath, h.className) ||
resolveSymbol(h.className, h.filePath, symbolTable, importMap, packageMap)?.nodeId ||
generateId('Struct', `${h.filePath}:${h.className}`);
@ -308,12 +308,12 @@ export const processHeritageFromExtracted = async (
if (structId && traitId) {
graph.addRelationship({
id: generateId('IMPLEMENTS', `${structId}->${traitId}`),
id: generateId('IMPLEMENTS', `${structId}->${traitId}:${h.kind}`),
sourceId: structId,
targetId: traitId,
type: 'IMPLEMENTS',
confidence: 1.0,
reason: 'trait-impl',
reason: h.kind,
});
}
}

View file

@ -30,8 +30,9 @@ import {
resolveCSharpNamespaceDir,
resolvePhpImport,
resolveRustImport,
resolveRubyImport,
} from './resolvers/index.js';
import { routeRubyCall } from './ruby-call-routing.js';
import { callRouters } from './call-routing.js';
import type {
SuffixIndex,
TsconfigPaths,
@ -223,6 +224,12 @@ function resolveLanguageImport(
return null; // External framework (Foundation, UIKit, etc.)
}
// Ruby: require / require_relative
if (language === SupportedLanguages.Ruby) {
const resolved = resolveRubyImport(rawImportPath, normalizedFileList, allFileList, index);
return resolved ? { kind: 'files', files: [resolved] } : null;
}
// Rust: expand top-level grouped imports: use {crate::a, crate::b}
if (language === SupportedLanguages.Rust && rawImportPath.startsWith('{') && rawImportPath.endsWith('}')) {
const inner = rawImportPath.slice(1, -1);
@ -446,20 +453,16 @@ export const processImports = async (
applyImportResult(result, file.path, importMap, packageMap, addImportEdge, addImportGraphEdge, bindings, namedImportMap);
}
// ---- Ruby: require/require_relative come through @call, not @import ----
if (language === SupportedLanguages.Ruby && captureMap['call']) {
// ---- Language-specific call-as-import routing (Ruby require, etc.) ----
if (captureMap['call']) {
const callNameNode = captureMap['call.name'];
if (callNameNode) {
const routed = routeRubyCall(callNameNode.text, captureMap['call']);
if (routed.kind === 'import') {
const callRouter = callRouters[language];
const routed = callRouter(callNameNode.text, captureMap['call']);
if (routed && routed.kind === 'import') {
totalImportsFound++;
const resolvedPath = resolveImportPath(
file.path, routed.importPath, allFilePaths, allFileList,
normalizedFileList, resolveCache, language, configs.tsconfigPaths, index,
);
if (resolvedPath) {
addImportEdge(file.path, resolvedPath);
}
const result = resolveLanguageImport(file.path, routed.importPath, language, configs, ctx);
applyImportResult(result, file.path, importMap, packageMap, addImportEdge, addImportGraphEdge);
}
}
}

View file

@ -518,12 +518,22 @@ export const RUBY_QUERIES = `
; ── Singleton (class-level) methods ──────────────────────────────────────────
(singleton_method
name: (identifier) @name) @definition.function
name: (identifier) @name) @definition.method
; ── All calls (require, include, attr_*, and regular calls routed in JS) ─────
(call
method: (identifier) @call.name) @call
; ── Bare calls without parens (identifiers at statement level are method calls) ─
; NOTE: This may over-capture variable reads as calls (e.g. 'result' at
; statement level). Ruby's grammar makes bare identifiers ambiguous — they
; could be local variables or zero-arity method calls. Post-processing via
; isBuiltInOrNoise and symbol resolution filtering suppresses most false
; positives, but a variable name that coincidentally matches a method name
; elsewhere may produce a false CALLS edge.
(body_statement
(identifier) @call.name @call)
; ── Heritage: class < SuperClass ─────────────────────────────────────────────
(class
name: (constant) @heritage.class

View file

@ -77,6 +77,9 @@ export const FUNCTION_NODE_TYPES = new Set([
// Swift
'init_declaration',
'deinit_declaration',
// Ruby
'method', // def foo
'singleton_method', // def self.foo
]);
/**
@ -265,6 +268,9 @@ export const CLASS_CONTAINER_TYPES = new Set([
'class_definition',
'trait_declaration',
'protocol_declaration',
// Ruby
'class',
'module',
]);
export const CONTAINER_TYPE_TO_LABEL: Record<string, string> = {
@ -280,6 +286,8 @@ export const CONTAINER_TYPE_TO_LABEL: Record<string, string> = {
trait_declaration: 'Trait',
record_declaration: 'Record',
protocol_declaration: 'Interface',
class: 'Class',
module: 'Module',
};
/** Walk up AST to find enclosing class/struct/interface/impl, return its generateId or null.
@ -322,7 +330,7 @@ export const findEnclosingClassId = (node: any, filePath: string): string | null
}
const nameNode = current.childForFieldName?.('name')
?? current.children?.find((c: any) =>
c.type === 'type_identifier' || c.type === 'identifier' || c.type === 'name'
c.type === 'type_identifier' || c.type === 'identifier' || c.type === 'name' || c.type === 'constant'
);
if (nameNode) {
const label = CONTAINER_TYPE_TO_LABEL[current.type] || 'Class';
@ -421,6 +429,11 @@ export const extractFunctionName = (node: any): { funcName: string | null; label
parent.children?.find((c: any) => c.type === 'identifier');
funcName = nameNode?.text;
}
} else if (node.type === 'method' || node.type === 'singleton_method') {
const nameNode = node.childForFieldName?.('name') ||
node.children?.find((c: any) => c.type === 'identifier');
funcName = nameNode?.text;
label = 'Method';
}
return { funcName, label };
@ -743,6 +756,11 @@ export const inferCallForm = (
return 'member';
}
// 4b. Ruby call with receiver: obj.method
if (callNode.type === 'call' && callNode.childForFieldName('receiver')) {
return 'member';
}
// 5. Scoped calls (Rust Foo::new(), C++ ns::func()): treat as free
// The receiver is a type, not an instance — handled differently in Phase 3
if (nameParent && SCOPED_CALL_NODE_TYPES.has(nameParent.type)) {
@ -801,6 +819,11 @@ export const extractReceiverName = (
receiver = callNode.childForFieldName('object');
}
// Ruby: call node has 'receiver' field
if (!receiver && parent.type === 'call') {
receiver = parent.childForFieldName('receiver');
}
// Kotlin/Swift: navigation_expression target is the first child
if (!receiver && parent.type === 'navigation_suffix') {
const navExpr = parent.parent;

View file

@ -39,7 +39,7 @@ import { detectFrameworkFromAST } from '../framework-detection.js';
import { generateId } from '../../../lib/utils.js';
import { extractNamedBindings } from '../named-binding-extraction.js';
import { appendKotlinWildcard } from '../resolvers/index.js';
import { routeRubyCall } from '../ruby-call-routing.js';
import { callRouters } from '../call-routing.js';
// ============================================================================
// Types for serializable results
@ -107,7 +107,7 @@ export interface ExtractedHeritage {
filePath: string;
className: string;
parentName: string;
/** 'extends' | 'implements' | 'trait-impl' */
/** 'extends' | 'implements' | 'trait-impl' | 'include' | 'extend' | 'prepend' */
kind: string;
}
@ -826,6 +826,7 @@ const processFileGroup = (
// Build per-file TypeEnv from explicit type annotations (for receiver resolution)
const typeEnv = buildTypeEnv(tree, language);
const callRouter = callRouters[language];
let matches;
try {
@ -862,88 +863,81 @@ const processFileGroup = (
if (callNameNode) {
const calledName = callNameNode.text;
// Ruby: route special calls to imports, heritage, or properties
if (language === SupportedLanguages.Ruby) {
const callNode = captureMap['call'];
const routed = routeRubyCall(calledName, callNode);
// Dispatch: route language-specific calls (heritage, properties, imports)
const routed = callRouter(calledName, captureMap['call']);
if (routed) {
if (routed.kind === 'skip') continue;
switch (routed.kind) {
case 'skip':
continue;
if (routed.kind === 'import') {
result.imports.push({
filePath: file.path,
rawImportPath: routed.importPath,
language,
});
continue;
}
case 'import':
result.imports.push({
if (routed.kind === 'heritage') {
for (const item of routed.items) {
result.heritage.push({
filePath: file.path,
rawImportPath: routed.importPath,
language,
className: item.enclosingClass,
parentName: item.mixinName,
kind: item.heritageKind,
});
continue;
}
continue;
}
case 'heritage':
for (const item of routed.items) {
result.heritage.push({
filePath: file.path,
className: item.enclosingClass,
parentName: item.mixinName,
kind: 'trait-impl',
});
}
continue;
case 'properties':
for (const item of routed.items) {
const nodeId = generateId('Property', `${file.path}:${item.propName}`);
result.nodes.push({
id: nodeId,
label: 'Property',
properties: {
name: item.propName,
filePath: file.path,
startLine: item.startLine,
endLine: item.endLine,
language,
isExported: true,
description: item.accessorType,
},
});
result.symbols.push({
filePath: file.path,
if (routed.kind === 'properties') {
const propEnclosingClassId = findEnclosingClassId(captureMap['call'], file.path);
for (const item of routed.items) {
const nodeId = generateId('Property', `${file.path}:${item.propName}`);
result.nodes.push({
id: nodeId,
label: 'Property',
properties: {
name: item.propName,
nodeId,
type: 'Property',
});
const fileId = generateId('File', file.path);
const relId = generateId('DEFINES', `${fileId}->${nodeId}`);
filePath: file.path,
startLine: item.startLine,
endLine: item.endLine,
language,
isExported: true,
description: item.accessorType,
},
});
result.symbols.push({
filePath: file.path,
name: item.propName,
nodeId,
type: 'Property',
...(propEnclosingClassId ? { ownerId: propEnclosingClassId } : {}),
});
const fileId = generateId('File', file.path);
const relId = generateId('DEFINES', `${fileId}->${nodeId}`);
result.relationships.push({
id: relId,
sourceId: fileId,
targetId: nodeId,
type: 'DEFINES',
confidence: 1.0,
reason: '',
});
if (propEnclosingClassId) {
result.relationships.push({
id: relId,
sourceId: fileId,
id: generateId('HAS_METHOD', `${propEnclosingClassId}->${nodeId}`),
sourceId: propEnclosingClassId,
targetId: nodeId,
type: 'DEFINES',
type: 'HAS_METHOD',
confidence: 1.0,
reason: '',
});
}
continue;
case 'call':
if (!isBuiltInOrNoise(calledName)) {
const sourceId = findEnclosingFunctionId(callNode, file.path)
|| generateId('File', file.path);
const callForm = inferCallForm(callNode, callNameNode);
const receiverName = callForm === 'member' ? extractReceiverName(callNameNode) : undefined;
const receiverTypeName = receiverName ? lookupTypeEnv(typeEnv, receiverName, callNode) : undefined;
result.calls.push({
filePath: file.path,
calledName,
sourceId,
argCount: countCallArguments(callNode),
...(callForm !== undefined ? { callForm } : {}),
...(receiverName !== undefined ? { receiverName } : {}),
...(receiverTypeName !== undefined ? { receiverTypeName } : {}),
});
}
continue;
}
continue;
}
// kind === 'call' — fall through to normal call processing below
}
if (!isBuiltInOrNoise(calledName)) {

View file

@ -0,0 +1,7 @@
require_relative '../models/handler'
class UserHandler < Handler
def handle_event
process_request
end
end

View file

@ -0,0 +1,5 @@
class Handler
def process_request
true
end
end

View file

@ -0,0 +1,5 @@
class Handler
def process_request
false
end
end

View file

@ -0,0 +1,17 @@
require 'lib/concerns/serializable'
class BaseModel
attr_accessor :id, :created_at
def persist
run_validations
end
def run_validations
true
end
def self.factory
run_validations
end
end

View file

@ -0,0 +1,5 @@
module Cacheable
def cache_key
"key"
end
end

View file

@ -0,0 +1,5 @@
module Loggable
def log_action
true
end
end

View file

@ -0,0 +1,5 @@
module Serializable
def serialize_data
"{}"
end
end

View file

@ -0,0 +1,9 @@
require_relative './user'
class UserService
def create_user
user = User.new
user.persist
user.greet_user
end
end

View file

@ -0,0 +1,18 @@
require_relative './base_model'
require_relative './concerns/serializable'
require_relative './concerns/loggable'
require_relative './concerns/cacheable'
class User < BaseModel
include Serializable
extend Loggable
prepend Cacheable
attr_reader :name
attr_writer :email
def greet_user
persist
serialize_data
end
end

View file

@ -0,0 +1,5 @@
class OneArg
def write_audit(message)
message
end
end

View file

@ -0,0 +1,8 @@
require_relative './one_arg'
require_relative './two_args'
class Service
def run_task
write_audit("done")
end
end

View file

@ -0,0 +1,5 @@
class TwoArgs
def write_audit(message, level)
message
end
end

View file

@ -0,0 +1,9 @@
require_relative './utils'
def do_work
false
end
def run_app
do_work
end

View file

@ -0,0 +1,5 @@
class Utils
def do_work
true
end
end

View file

@ -0,0 +1,8 @@
require_relative './user'
class App
def process_user
user = User.new
user.persist_record
end
end

View file

@ -0,0 +1,5 @@
class User
def persist_record
true
end
end

View file

@ -0,0 +1,317 @@
/**
* Ruby: require_relative imports, include heritage (mixins), attr_* properties,
* calls, member calls, ambiguous disambiguation, local shadow,
* constructor-inferred type resolution
*/
import { describe, it, expect, beforeAll } from 'vitest';
import path from 'path';
import {
FIXTURES, getRelationships, getNodesByLabel, edgeSet,
runPipelineFromRepo, type PipelineResult,
} from './helpers.js';
// ---------------------------------------------------------------------------
// Heritage: require_relative imports + include heritage + attr_* properties + calls
// ---------------------------------------------------------------------------
describe('Ruby require_relative, heritage & property resolution', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'ruby-app'),
() => {},
);
}, 60000);
// --- Node detection ---
it('detects 3 classes', () => {
expect(getNodesByLabel(result, 'Class')).toEqual([
'BaseModel', 'User', 'UserService',
]);
});
it('detects 3 modules', () => {
expect(getNodesByLabel(result, 'Module')).toEqual(['Cacheable', 'Loggable', 'Serializable']);
});
it('detects methods on classes and modules', () => {
const methods = getNodesByLabel(result, 'Method');
expect(methods).toContain('persist');
expect(methods).toContain('run_validations');
expect(methods).toContain('greet_user');
expect(methods).toContain('serialize_data');
expect(methods).toContain('create_user');
});
it('detects singleton method (def self.factory) as Method', () => {
const methods = getNodesByLabel(result, 'Method');
expect(methods).toContain('factory');
});
it('emits CALLS from singleton method: factory → run_validations', () => {
const calls = getRelationships(result, 'CALLS')
.filter(e => e.source === 'factory' && e.target === 'run_validations');
expect(calls.length).toBe(1);
expect(calls[0].sourceLabel).toBe('Method');
});
// --- Import resolution via require_relative ---
it('resolves 5 require_relative imports to IMPORTS edges', () => {
const imports = getRelationships(result, 'IMPORTS');
const importEdges = edgeSet(imports);
expect(importEdges).toContain('user.rb → base_model.rb');
expect(importEdges).toContain('user.rb → serializable.rb');
expect(importEdges).toContain('user.rb → loggable.rb');
expect(importEdges).toContain('user.rb → cacheable.rb');
expect(importEdges).toContain('service.rb → user.rb');
});
it('resolves bare require to IMPORTS edge', () => {
const imports = getRelationships(result, 'IMPORTS');
const bareRequire = imports.find(e =>
e.sourceFilePath.includes('base_model.rb') &&
e.targetFilePath.includes('serializable.rb')
);
expect(bareRequire).toBeDefined();
});
// --- Heritage: include → IMPLEMENTS ---
it('emits IMPLEMENTS edge for include Serializable with reason "include"', () => {
const implements_ = getRelationships(result, 'IMPLEMENTS');
const edge = implements_.find(e => e.source === 'User' && e.target === 'Serializable');
expect(edge).toBeDefined();
expect(edge!.rel.reason).toBe('include');
});
it('emits IMPLEMENTS edge for extend Loggable with reason "extend"', () => {
const implements_ = getRelationships(result, 'IMPLEMENTS');
const edge = implements_.find(e => e.source === 'User' && e.target === 'Loggable');
expect(edge).toBeDefined();
expect(edge!.rel.reason).toBe('extend');
});
it('emits IMPLEMENTS edge for prepend Cacheable with reason "prepend"', () => {
const implements_ = getRelationships(result, 'IMPLEMENTS');
const edge = implements_.find(e => e.source === 'User' && e.target === 'Cacheable');
expect(edge).toBeDefined();
expect(edge!.rel.reason).toBe('prepend');
});
// --- Extends: class inheritance ---
it('emits EXTENDS edge: User → BaseModel', () => {
const extends_ = getRelationships(result, 'EXTENDS');
expect(extends_.length).toBeGreaterThanOrEqual(1);
const edges = edgeSet(extends_);
expect(edges).toContain('User → BaseModel');
});
// --- Property nodes: attr_accessor, attr_reader, attr_writer ---
it('creates Property nodes for attr_accessor :id and :created_at', () => {
const props = getNodesByLabel(result, 'Property');
expect(props).toContain('id');
expect(props).toContain('created_at');
});
it('creates Property nodes for attr_reader :name and attr_writer :email', () => {
const props = getNodesByLabel(result, 'Property');
expect(props).toContain('name');
expect(props).toContain('email');
});
it('emits HAS_METHOD from User to attr_reader :name', () => {
const hasMethod = getRelationships(result, 'HAS_METHOD');
const edge = hasMethod.find(e => e.source === 'User' && e.target === 'name');
expect(edge).toBeDefined();
});
it('emits HAS_METHOD from BaseModel to attr_accessor :id', () => {
const hasMethod = getRelationships(result, 'HAS_METHOD');
const edge = hasMethod.find(e => e.source === 'BaseModel' && e.target === 'id');
expect(edge).toBeDefined();
});
// --- Call resolution: method-level attribution ---
it('emits method-level CALLS: create_user → persist (member call)', () => {
const calls = getRelationships(result, 'CALLS')
.filter(e => e.source === 'create_user' && e.target === 'persist');
expect(calls.length).toBe(1);
expect(calls[0].sourceLabel).toBe('Method');
expect(calls[0].targetLabel).toBe('Method');
});
it('emits method-level CALLS: create_user → greet_user (member call)', () => {
const calls = getRelationships(result, 'CALLS')
.filter(e => e.source === 'create_user' && e.target === 'greet_user');
expect(calls.length).toBe(1);
expect(calls[0].sourceLabel).toBe('Method');
expect(calls[0].targetLabel).toBe('Method');
});
it('emits method-level CALLS: greet_user → persist (bare call)', () => {
const calls = getRelationships(result, 'CALLS')
.filter(e => e.source === 'greet_user' && e.target === 'persist');
expect(calls.length).toBe(1);
});
it('emits method-level CALLS: greet_user → serialize_data (bare call)', () => {
const calls = getRelationships(result, 'CALLS')
.filter(e => e.source === 'greet_user' && e.target === 'serialize_data');
expect(calls.length).toBe(1);
});
it('emits method-level CALLS: persist → run_validations (bare call)', () => {
const calls = getRelationships(result, 'CALLS')
.filter(e => e.source === 'persist' && e.target === 'run_validations');
expect(calls.length).toBe(1);
});
// --- Heritage edges point to real graph nodes ---
it('all heritage edges point to real graph nodes', () => {
for (const edge of [...getRelationships(result, 'EXTENDS'), ...getRelationships(result, 'IMPLEMENTS')]) {
const target = result.graph.getNode(edge.rel.targetId);
expect(target).toBeDefined();
}
});
// --- No OVERRIDES edges target Property nodes ---
it('no OVERRIDES edges target Property nodes', () => {
const overrides = getRelationships(result, 'OVERRIDES');
for (const edge of overrides) {
const target = result.graph.getNode(edge.rel.targetId);
expect(target).toBeDefined();
expect(target!.label).not.toBe('Property');
}
});
});
// ---------------------------------------------------------------------------
// Calls: arity-based disambiguation
// ---------------------------------------------------------------------------
describe('Ruby call resolution with arity filtering', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'ruby-calls'),
() => {},
);
}, 60000);
it('resolves run_task → write_audit to one_arg.rb via arity narrowing', () => {
const calls = getRelationships(result, 'CALLS');
const auditCall = calls.find(c => c.target === 'write_audit');
expect(auditCall).toBeDefined();
expect(auditCall!.source).toBe('run_task');
expect(auditCall!.targetFilePath).toContain('one_arg.rb');
expect(auditCall!.rel.reason).toBe('import-resolved');
});
});
// ---------------------------------------------------------------------------
// Member-call resolution: obj.method() resolves through pipeline
// ---------------------------------------------------------------------------
describe('Ruby member-call resolution', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'ruby-member-calls'),
() => {},
);
}, 60000);
it('resolves process_user → persist_record as a member call on User', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(c => c.target === 'persist_record');
expect(saveCall).toBeDefined();
expect(saveCall!.source).toBe('process_user');
expect(saveCall!.targetFilePath).toContain('user.rb');
});
it('detects User class and persist_record method', () => {
expect(getNodesByLabel(result, 'Class')).toContain('User');
expect(getNodesByLabel(result, 'Method')).toContain('persist_record');
});
it('emits HAS_METHOD edge from User to persist_record', () => {
const hasMethod = getRelationships(result, 'HAS_METHOD');
const edge = hasMethod.find(e => e.source === 'User' && e.target === 'persist_record');
expect(edge).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// Ambiguous: Handler in two dirs, require_relative disambiguates
// ---------------------------------------------------------------------------
describe('Ruby ambiguous symbol resolution', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'ruby-ambiguous'),
() => {},
);
}, 60000);
it('detects 2 Handler classes', () => {
const classes = getNodesByLabel(result, 'Class');
expect(classes.filter(n => n === 'Handler').length).toBe(2);
expect(classes).toContain('UserHandler');
});
it('resolves EXTENDS to models/handler.rb (not other/handler.rb)', () => {
const extends_ = getRelationships(result, 'EXTENDS');
expect(extends_.length).toBe(1);
expect(extends_[0].source).toBe('UserHandler');
expect(extends_[0].target).toBe('Handler');
expect(extends_[0].targetFilePath).toBe('models/handler.rb');
});
it('import edge points to models/ not other/', () => {
const imports = getRelationships(result, 'IMPORTS');
expect(imports.length).toBe(1);
expect(imports[0].targetFilePath).toBe('models/handler.rb');
});
it('all heritage edges point to real graph nodes', () => {
for (const edge of getRelationships(result, 'EXTENDS')) {
const target = result.graph.getNode(edge.rel.targetId);
expect(target).toBeDefined();
}
});
});
// ---------------------------------------------------------------------------
// Local shadow: same-file definition takes priority over imported name
// ---------------------------------------------------------------------------
describe('Ruby local definition shadows import', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'ruby-local-shadow'),
() => {},
);
}, 60000);
it('resolves run_app → do_work to same-file definition, not the imported one', () => {
const calls = getRelationships(result, 'CALLS');
const doWorkCall = calls.find(c => c.target === 'do_work' && c.source === 'run_app');
expect(doWorkCall).toBeDefined();
expect(doWorkCall!.targetFilePath).toContain('app.rb');
});
});