mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-29 06:32:28 +00:00
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:
parent
a2ecfbfb80
commit
09a3a42ae9
29 changed files with 710 additions and 116 deletions
4
.github/workflows/ci-integration.yml
vendored
4
.github/workflows/ci-integration.yml
vendored
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ export type RelationshipType =
|
|||
| 'DECORATES'
|
||||
| 'IMPLEMENTS'
|
||||
| 'EXTENDS'
|
||||
| 'HAS_METHOD'
|
||||
| 'MEMBER_OF'
|
||||
| 'STEP_IN_PROCESS'
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
7
gitnexus/test/fixtures/lang-resolution/ruby-ambiguous/lib/user_handler.rb
vendored
Normal file
7
gitnexus/test/fixtures/lang-resolution/ruby-ambiguous/lib/user_handler.rb
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
require_relative '../models/handler'
|
||||
|
||||
class UserHandler < Handler
|
||||
def handle_event
|
||||
process_request
|
||||
end
|
||||
end
|
||||
5
gitnexus/test/fixtures/lang-resolution/ruby-ambiguous/models/handler.rb
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/ruby-ambiguous/models/handler.rb
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
class Handler
|
||||
def process_request
|
||||
true
|
||||
end
|
||||
end
|
||||
5
gitnexus/test/fixtures/lang-resolution/ruby-ambiguous/other/handler.rb
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/ruby-ambiguous/other/handler.rb
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
class Handler
|
||||
def process_request
|
||||
false
|
||||
end
|
||||
end
|
||||
17
gitnexus/test/fixtures/lang-resolution/ruby-app/lib/base_model.rb
vendored
Normal file
17
gitnexus/test/fixtures/lang-resolution/ruby-app/lib/base_model.rb
vendored
Normal 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
|
||||
5
gitnexus/test/fixtures/lang-resolution/ruby-app/lib/concerns/cacheable.rb
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/ruby-app/lib/concerns/cacheable.rb
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
module Cacheable
|
||||
def cache_key
|
||||
"key"
|
||||
end
|
||||
end
|
||||
5
gitnexus/test/fixtures/lang-resolution/ruby-app/lib/concerns/loggable.rb
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/ruby-app/lib/concerns/loggable.rb
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
module Loggable
|
||||
def log_action
|
||||
true
|
||||
end
|
||||
end
|
||||
5
gitnexus/test/fixtures/lang-resolution/ruby-app/lib/concerns/serializable.rb
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/ruby-app/lib/concerns/serializable.rb
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
module Serializable
|
||||
def serialize_data
|
||||
"{}"
|
||||
end
|
||||
end
|
||||
9
gitnexus/test/fixtures/lang-resolution/ruby-app/lib/service.rb
vendored
Normal file
9
gitnexus/test/fixtures/lang-resolution/ruby-app/lib/service.rb
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
require_relative './user'
|
||||
|
||||
class UserService
|
||||
def create_user
|
||||
user = User.new
|
||||
user.persist
|
||||
user.greet_user
|
||||
end
|
||||
end
|
||||
18
gitnexus/test/fixtures/lang-resolution/ruby-app/lib/user.rb
vendored
Normal file
18
gitnexus/test/fixtures/lang-resolution/ruby-app/lib/user.rb
vendored
Normal 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
|
||||
5
gitnexus/test/fixtures/lang-resolution/ruby-calls/lib/one_arg.rb
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/ruby-calls/lib/one_arg.rb
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
class OneArg
|
||||
def write_audit(message)
|
||||
message
|
||||
end
|
||||
end
|
||||
8
gitnexus/test/fixtures/lang-resolution/ruby-calls/lib/service.rb
vendored
Normal file
8
gitnexus/test/fixtures/lang-resolution/ruby-calls/lib/service.rb
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
require_relative './one_arg'
|
||||
require_relative './two_args'
|
||||
|
||||
class Service
|
||||
def run_task
|
||||
write_audit("done")
|
||||
end
|
||||
end
|
||||
5
gitnexus/test/fixtures/lang-resolution/ruby-calls/lib/two_args.rb
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/ruby-calls/lib/two_args.rb
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
class TwoArgs
|
||||
def write_audit(message, level)
|
||||
message
|
||||
end
|
||||
end
|
||||
9
gitnexus/test/fixtures/lang-resolution/ruby-local-shadow/lib/app.rb
vendored
Normal file
9
gitnexus/test/fixtures/lang-resolution/ruby-local-shadow/lib/app.rb
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
require_relative './utils'
|
||||
|
||||
def do_work
|
||||
false
|
||||
end
|
||||
|
||||
def run_app
|
||||
do_work
|
||||
end
|
||||
5
gitnexus/test/fixtures/lang-resolution/ruby-local-shadow/lib/utils.rb
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/ruby-local-shadow/lib/utils.rb
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
class Utils
|
||||
def do_work
|
||||
true
|
||||
end
|
||||
end
|
||||
8
gitnexus/test/fixtures/lang-resolution/ruby-member-calls/lib/app.rb
vendored
Normal file
8
gitnexus/test/fixtures/lang-resolution/ruby-member-calls/lib/app.rb
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
require_relative './user'
|
||||
|
||||
class App
|
||||
def process_user
|
||||
user = User.new
|
||||
user.persist_record
|
||||
end
|
||||
end
|
||||
5
gitnexus/test/fixtures/lang-resolution/ruby-member-calls/lib/user.rb
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/ruby-member-calls/lib/user.rb
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
class User
|
||||
def persist_record
|
||||
true
|
||||
end
|
||||
end
|
||||
317
gitnexus/test/integration/resolvers/ruby.test.ts
Normal file
317
gitnexus/test/integration/resolvers/ruby.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
Add table
Reference in a new issue