Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/ci-integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
1 change: 1 addition & 0 deletions gitnexus-web/src/core/graph/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export type RelationshipType =
| 'DECORATES'
| 'IMPLEMENTS'
| 'EXTENDS'
| 'HAS_METHOD'
| 'MEMBER_OF'
| 'STEP_IN_PROCESS'

Expand Down
70 changes: 59 additions & 11 deletions gitnexus-web/src/core/ingestion/call-processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 }[],
Expand Down Expand Up @@ -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> = {};
Expand All @@ -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
Expand All @@ -219,17 +259,18 @@ 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,
});
}
}
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({
Expand All @@ -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,
},
});
Expand All @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand All @@ -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';
Expand Down Expand Up @@ -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;
Expand Down
11 changes: 6 additions & 5 deletions gitnexus-web/src/core/ingestion/import-processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
27 changes: 18 additions & 9 deletions gitnexus/src/core/ingestion/call-processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 => {
Expand All @@ -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
Expand All @@ -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({
Expand All @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand All @@ -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';
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading