mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
feat(type-resolution): per-language cross-file binding tests + resolver fixes
Add cross-file binding propagation integration tests for 6 languages (Python, JavaScript, Java, PHP, C#, Kotlin) with fixture repos and HAS_METHOD edge assertions. Fix 3 language resolver issues uncovered by tests: - Kotlin: class methods now labeled Method (not Function) via shared isKotlinClassMethod() utility; non-aliased imports create NamedImportMap entries; 2-segment package-directory fallback for top-level function imports - PHP: namespace-directory fallback for use-function imports; SuffixIndex preferred over linear scan; path traversal rejection; PSR-4 sort cached - JVM: root-level package path matching; indexOf→lastIndexOf for correctness Address code review findings: - Extract runCrossFileBindingPropagation() from 810-line pipeline function - Replace (importCtx as any) casts with typed dispose() method - Remove Tarjan's SCC (dev-only YAGNI, ~85 lines) - Remove dead PARALLEL_RE_RESOLUTION_THRESHOLD constant - Fix constructor handling divergence in getLabelFromCaptures - Optimize gap pre-scan with early exit once threshold exceeded - Fix findEnclosingFunction any→SyntaxNode type
This commit is contained in:
parent
0736bb23bc
commit
6c972079e0
34 changed files with 882 additions and 398 deletions
|
|
@ -166,7 +166,7 @@ const TYPE_PRESERVING_METHODS = new Set([
|
|||
* Returns null if the call is at module/file level (top-level code).
|
||||
*/
|
||||
const findEnclosingFunction = (
|
||||
node: any,
|
||||
node: SyntaxNode,
|
||||
filePath: string,
|
||||
ctx: ResolutionContext
|
||||
): string | null => {
|
||||
|
|
|
|||
|
|
@ -89,8 +89,10 @@ export interface ImportResolutionContext {
|
|||
allFilePaths: Set<string>;
|
||||
allFileList: string[];
|
||||
normalizedFileList: string[];
|
||||
suffixIndex: SuffixIndex;
|
||||
suffixIndex: SuffixIndex | null;
|
||||
resolveCache: Map<string, string | null>;
|
||||
/** Release heavyweight fields (suffix index, file lists) to free memory after import resolution. */
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export function buildImportResolutionContext(allPaths: string[]): ImportResolutionContext {
|
||||
|
|
@ -98,7 +100,15 @@ export function buildImportResolutionContext(allPaths: string[]): ImportResoluti
|
|||
const normalizedFileList = allFileList.map(p => p.replace(/\\/g, '/'));
|
||||
const allFilePaths = new Set(allFileList);
|
||||
const suffixIndex = buildSuffixIndex(normalizedFileList, allFileList);
|
||||
return { allFilePaths, allFileList, normalizedFileList, suffixIndex, resolveCache: new Map() };
|
||||
const ctx: ImportResolutionContext = {
|
||||
allFilePaths, allFileList, normalizedFileList, suffixIndex, resolveCache: new Map(),
|
||||
dispose() {
|
||||
ctx.suffixIndex = null;
|
||||
ctx.normalizedFileList = [];
|
||||
ctx.resolveCache.clear();
|
||||
},
|
||||
};
|
||||
return ctx;
|
||||
}
|
||||
|
||||
// Config loaders extracted to ./language-config.ts (Phase 2 refactor)
|
||||
|
|
@ -169,6 +179,23 @@ function resolveLanguageImport(
|
|||
memberResolved = resolveJvmMemberImport(rawImportPath, normalizedFileList, allFileList, ['.java'], index);
|
||||
}
|
||||
if (memberResolved) return { kind: 'files', files: [memberResolved] };
|
||||
|
||||
// Kotlin: top-level function imports (e.g. import models.getUser) have only 2 segments,
|
||||
// which resolveJvmMemberImport skips (requires ≥3). Fall back to package-directory scan
|
||||
// for lowercase last segments (function/property imports). Uppercase last segments
|
||||
// (class imports like models.User) fall through to standard suffix resolution.
|
||||
if (language === SupportedLanguages.Kotlin) {
|
||||
const segments = rawImportPath.split('.');
|
||||
const lastSeg = segments[segments.length - 1];
|
||||
if (segments.length >= 2 && lastSeg[0] && lastSeg[0] === lastSeg[0].toLowerCase()) {
|
||||
const pkgWildcard = segments.slice(0, -1).join('.') + '.*';
|
||||
let dirFiles = resolveJvmWildcard(pkgWildcard, normalizedFileList, allFileList, exts, index);
|
||||
if (dirFiles.length === 0) {
|
||||
dirFiles = resolveJvmWildcard(pkgWildcard, normalizedFileList, allFileList, ['.java'], index);
|
||||
}
|
||||
if (dirFiles.length > 0) return { kind: 'files', files: dirFiles };
|
||||
}
|
||||
}
|
||||
// Fall through to standard resolution
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -201,13 +201,19 @@ export function extractKotlinNamedBindings(importNode: any): { local: string; ex
|
|||
}
|
||||
|
||||
// Non-aliased: import com.example.User → local="User", exported="User"
|
||||
// Also handles top-level function imports: import models.getUser → local="getUser"
|
||||
// Skip wildcard imports (ending in *)
|
||||
if (fullText.endsWith('.*') || fullText.endsWith('*')) return undefined;
|
||||
// Skip lowercase last segments — those are member/function imports (e.g.,
|
||||
// import util.OneArg.writeAudit), not class imports. Multiple member imports
|
||||
// Skip class-member imports (e.g., import util.OneArg.writeAudit) where the
|
||||
// second-to-last segment is PascalCase (a class name). Multiple member imports
|
||||
// with the same function name would collide in NamedImportMap, breaking
|
||||
// arity-based disambiguation.
|
||||
if (exportedName[0] && exportedName[0] === exportedName[0].toLowerCase()) return undefined;
|
||||
// arity-based disambiguation. Top-level function imports (import models.getUser)
|
||||
// and class imports (import models.User) have package-only prefixes.
|
||||
const segments = fullText.split('.');
|
||||
if (segments.length >= 3) {
|
||||
const parentSegment = segments[segments.length - 2];
|
||||
if (parentSegment[0] && parentSegment[0] === parentSegment[0].toUpperCase()) return undefined;
|
||||
}
|
||||
return [{ local: exportedName, exported: exportedName }];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { LANGUAGE_QUERIES } from './tree-sitter-queries.js';
|
|||
import { generateId } from '../../lib/utils.js';
|
||||
import { SymbolTable } from './symbol-table.js';
|
||||
import { ASTCache } from './ast-cache.js';
|
||||
import { getLanguageFromFilename, yieldToEventLoop, getDefinitionNodeFromCaptures, findEnclosingClassId, extractMethodSignature } from './utils.js';
|
||||
import { getLanguageFromFilename, yieldToEventLoop, getDefinitionNodeFromCaptures, findEnclosingClassId, extractMethodSignature, isKotlinClassMethod } from './utils.js';
|
||||
import { extractPropertyDeclaredType } from './type-extractors/shared.js';
|
||||
import { isNodeExported } from './export-detection.js';
|
||||
import { detectFrameworkFromAST } from './framework-detection.js';
|
||||
|
|
@ -222,7 +222,13 @@ const processParsingSequential = async (
|
|||
}
|
||||
if (ancestor) return; // inside a class body — handled by @definition.method
|
||||
}
|
||||
nodeLabel = 'Function';
|
||||
|
||||
// Kotlin: function_declaration inside a class_body is a method, not a top-level function.
|
||||
if (language === SupportedLanguages.Kotlin &&
|
||||
isKotlinClassMethod(captureMap['definition.function'])) {
|
||||
nodeLabel = 'Method';
|
||||
}
|
||||
if (nodeLabel !== 'Method') nodeLabel = 'Function';
|
||||
}
|
||||
else if (captureMap['definition.class']) nodeLabel = 'Class';
|
||||
else if (captureMap['definition.interface']) nodeLabel = 'Interface';
|
||||
|
|
|
|||
|
|
@ -80,92 +80,6 @@ export function topologicalLevelSort(
|
|||
return levels;
|
||||
}
|
||||
|
||||
/** Cycle decomposition from Tarjan's SCC algorithm. */
|
||||
export interface ImportCycleInfo {
|
||||
/** Strongly connected components, each containing ≥2 files */
|
||||
sccs: readonly (readonly string[])[];
|
||||
/** Files in cycles (flattened sccs — for backward compat) */
|
||||
cycleFiles: readonly string[];
|
||||
}
|
||||
|
||||
interface TarjanFrame {
|
||||
node: string;
|
||||
neighborIdx: number;
|
||||
neighbors: readonly string[];
|
||||
}
|
||||
|
||||
/** Decompose cycle files into strongly connected components using iterative Tarjan's.
|
||||
* Runs on the cycle subgraph only (cycle nodes from Kahn's output), not the full graph.
|
||||
* Iterative to avoid stack overflow on large repos (V8 stack limit ~10K-15K frames). */
|
||||
export function computeImportCycleSCCs(
|
||||
importMap: ReadonlyMap<string, ReadonlySet<string>>,
|
||||
cycleNodes: readonly string[],
|
||||
): ImportCycleInfo {
|
||||
// Build subgraph of only cycle nodes
|
||||
const cycleSet = new Set(cycleNodes);
|
||||
const subgraph = new Map<string, readonly string[]>();
|
||||
for (const node of cycleNodes) {
|
||||
const deps = importMap.get(node);
|
||||
subgraph.set(node, deps ? [...deps].filter(d => cycleSet.has(d)) : []);
|
||||
}
|
||||
|
||||
// Iterative Tarjan's on subgraph
|
||||
const state = new Map<string, { index: number; lowlink: number; onStack: boolean }>();
|
||||
const stack: string[] = [];
|
||||
const sccs: string[][] = [];
|
||||
let nextIndex = 0;
|
||||
|
||||
for (const startNode of subgraph.keys()) {
|
||||
if (state.has(startNode)) continue;
|
||||
const s = { index: nextIndex, lowlink: nextIndex, onStack: true };
|
||||
nextIndex++;
|
||||
state.set(startNode, s);
|
||||
stack.push(startNode);
|
||||
const workStack: TarjanFrame[] = [
|
||||
{ node: startNode, neighborIdx: 0, neighbors: subgraph.get(startNode)! },
|
||||
];
|
||||
|
||||
while (workStack.length > 0) {
|
||||
const frame = workStack[workStack.length - 1];
|
||||
if (frame.neighborIdx < frame.neighbors.length) {
|
||||
const w = frame.neighbors[frame.neighborIdx];
|
||||
frame.neighborIdx++;
|
||||
const wState = state.get(w);
|
||||
if (!wState) {
|
||||
const ws = { index: nextIndex, lowlink: nextIndex, onStack: true };
|
||||
nextIndex++;
|
||||
state.set(w, ws);
|
||||
stack.push(w);
|
||||
workStack.push({ node: w, neighborIdx: 0, neighbors: subgraph.get(w)! });
|
||||
} else if (wState.onStack) {
|
||||
state.get(frame.node)!.lowlink = Math.min(
|
||||
state.get(frame.node)!.lowlink, wState.index,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
workStack.pop();
|
||||
const vState = state.get(frame.node)!;
|
||||
if (workStack.length > 0) {
|
||||
const pState = state.get(workStack[workStack.length - 1].node)!;
|
||||
pState.lowlink = Math.min(pState.lowlink, vState.lowlink);
|
||||
}
|
||||
if (vState.lowlink === vState.index) {
|
||||
const component: string[] = [];
|
||||
let w: string;
|
||||
do { w = stack.pop()!; state.get(w)!.onStack = false; component.push(w); }
|
||||
while (w !== frame.node);
|
||||
if (component.length >= 2) sccs.push(component);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sccs,
|
||||
cycleFiles: sccs.flat(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Max bytes of source content to load per parse chunk. Each chunk's source +
|
||||
* parsed ASTs + extracted records + worker serialization overhead all live in
|
||||
* memory simultaneously, so this must be conservative. 20MB source ≈ 200-400MB
|
||||
|
|
@ -175,10 +89,147 @@ const CHUNK_BYTE_BUDGET = 20 * 1024 * 1024; // 20MB
|
|||
/** Max AST trees to keep in LRU cache */
|
||||
const AST_CACHE_CAP = 50;
|
||||
|
||||
/** Threshold for parallel re-resolution within topological levels.
|
||||
* When more files need re-resolution than this threshold, process in parallel via workers.
|
||||
* Set to Infinity to disable (current default — enable when metrics justify). */
|
||||
const PARALLEL_RE_RESOLUTION_THRESHOLD = Infinity;
|
||||
/** Minimum percentage of files that must benefit from cross-file seeding to justify the re-resolution pass. */
|
||||
const CROSS_FILE_SKIP_THRESHOLD = 0.03;
|
||||
/** Hard cap on files re-processed during cross-file propagation. */
|
||||
const MAX_CROSS_FILE_REPROCESS = 2000;
|
||||
|
||||
/** Phase 14: Cross-file binding propagation.
|
||||
* Seeds downstream files with resolved type bindings from upstream exports.
|
||||
* Files are processed in topological import order so upstream bindings are
|
||||
* available when downstream files are re-resolved. */
|
||||
async function runCrossFileBindingPropagation(
|
||||
graph: ReturnType<typeof createKnowledgeGraph>,
|
||||
ctx: ReturnType<typeof createResolutionContext>,
|
||||
exportedTypeMap: ExportedTypeMap,
|
||||
allPaths: string[],
|
||||
totalFiles: number,
|
||||
repoPath: string,
|
||||
pipelineStart: number,
|
||||
onProgress: (progress: PipelineProgress) => void,
|
||||
): Promise<void> {
|
||||
// For the worker path, buildTypeEnv runs inside workers without SymbolTable,
|
||||
// so exported bindings must be collected from graph + SymbolTable in main thread.
|
||||
if (exportedTypeMap.size === 0 && graph.nodeCount > 0) {
|
||||
const graphExports = buildExportedTypeMapFromGraph(graph, ctx.symbols);
|
||||
for (const [fp, exports] of graphExports) exportedTypeMap.set(fp, exports);
|
||||
}
|
||||
|
||||
if (exportedTypeMap.size === 0 || ctx.namedImportMap.size === 0) return;
|
||||
|
||||
const allPathSet = new Set(allPaths);
|
||||
const levels = topologicalLevelSort(ctx.importMap);
|
||||
|
||||
// Cycle diagnostic: Kahn's dumps cycle files in the last level (positive in-degree)
|
||||
if (isDev && levels.length > 0) {
|
||||
const lastLevel = levels[levels.length - 1];
|
||||
if (lastLevel.length > 1) {
|
||||
console.log(`🔄 ${lastLevel.length} files in import cycles (skipped for cross-file propagation)`);
|
||||
}
|
||||
}
|
||||
|
||||
// Quick count of files with cross-file binding gaps (early exit once threshold exceeded)
|
||||
let filesWithGaps = 0;
|
||||
const gapThreshold = Math.max(1, Math.ceil(totalFiles * CROSS_FILE_SKIP_THRESHOLD));
|
||||
outer: for (const level of levels) {
|
||||
for (const filePath of level) {
|
||||
const imports = ctx.namedImportMap.get(filePath);
|
||||
if (!imports) continue;
|
||||
for (const [, binding] of imports) {
|
||||
const upstream = exportedTypeMap.get(binding.sourcePath);
|
||||
if (upstream?.has(binding.exportedName)) { filesWithGaps++; break; }
|
||||
const def = ctx.symbols.lookupExactFull(binding.sourcePath, binding.exportedName);
|
||||
if (def?.returnType) { filesWithGaps++; break; }
|
||||
}
|
||||
if (filesWithGaps >= gapThreshold) break outer;
|
||||
}
|
||||
}
|
||||
|
||||
const gapRatio = totalFiles > 0 ? filesWithGaps / totalFiles : 0;
|
||||
if (gapRatio < CROSS_FILE_SKIP_THRESHOLD && filesWithGaps < gapThreshold) {
|
||||
if (isDev) {
|
||||
console.log(`⏭️ Cross-file re-resolution skipped (${filesWithGaps}/${totalFiles} files, ${(gapRatio * 100).toFixed(1)}% < ${CROSS_FILE_SKIP_THRESHOLD * 100}% threshold)`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
onProgress({
|
||||
phase: 'parsing',
|
||||
percent: 82,
|
||||
message: `Cross-file type propagation (${filesWithGaps}+ files)...`,
|
||||
stats: { filesProcessed: totalFiles, totalFiles, nodesCreated: graph.nodeCount },
|
||||
});
|
||||
|
||||
let crossFileResolved = 0;
|
||||
const crossFileStart = Date.now();
|
||||
let astCache = createASTCache(AST_CACHE_CAP);
|
||||
|
||||
for (const level of levels) {
|
||||
const levelCandidates: { filePath: string; seeded: Map<string, string>; importedReturns: ReadonlyMap<string, string> }[] = [];
|
||||
for (const filePath of level) {
|
||||
if (crossFileResolved + levelCandidates.length >= MAX_CROSS_FILE_REPROCESS) break;
|
||||
const imports = ctx.namedImportMap.get(filePath);
|
||||
if (!imports) continue;
|
||||
|
||||
const seeded = new Map<string, string>();
|
||||
for (const [localName, binding] of imports) {
|
||||
const upstream = exportedTypeMap.get(binding.sourcePath);
|
||||
if (upstream) {
|
||||
const type = upstream.get(binding.exportedName);
|
||||
if (type) seeded.set(localName, type);
|
||||
}
|
||||
}
|
||||
|
||||
const importedReturns = buildImportedReturnTypes(filePath, ctx.namedImportMap, ctx.symbols);
|
||||
if (seeded.size === 0 && importedReturns.size === 0) continue;
|
||||
if (!allPathSet.has(filePath)) continue;
|
||||
|
||||
const lang = getLanguageFromFilename(filePath);
|
||||
if (!lang || !isLanguageAvailable(lang)) continue;
|
||||
|
||||
levelCandidates.push({ filePath, seeded, importedReturns });
|
||||
}
|
||||
|
||||
if (levelCandidates.length === 0) continue;
|
||||
|
||||
const levelPaths = levelCandidates.map(c => c.filePath);
|
||||
const contentMap = await readFileContents(repoPath, levelPaths);
|
||||
|
||||
for (const { filePath, seeded, importedReturns } of levelCandidates) {
|
||||
const content = contentMap.get(filePath);
|
||||
if (!content) continue;
|
||||
|
||||
const reFile = [{ path: filePath, content }];
|
||||
const bindings = new Map<string, ReadonlyMap<string, string>>();
|
||||
if (seeded.size > 0) bindings.set(filePath, seeded);
|
||||
|
||||
const importedReturnTypesMap = new Map<string, ReadonlyMap<string, string>>();
|
||||
if (importedReturns.size > 0) {
|
||||
importedReturnTypesMap.set(filePath, importedReturns);
|
||||
}
|
||||
|
||||
await processCalls(graph, reFile, astCache, ctx, undefined, exportedTypeMap, bindings.size > 0 ? bindings : undefined, importedReturnTypesMap.size > 0 ? importedReturnTypesMap : undefined);
|
||||
crossFileResolved++;
|
||||
}
|
||||
|
||||
if (crossFileResolved >= MAX_CROSS_FILE_REPROCESS) {
|
||||
if (isDev) console.log(`⚠️ Cross-file re-resolution capped at ${MAX_CROSS_FILE_REPROCESS} files`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
astCache.clear();
|
||||
|
||||
if (isDev) {
|
||||
const elapsed = Date.now() - crossFileStart;
|
||||
const totalElapsed = Date.now() - pipelineStart;
|
||||
const reResolutionPct = totalElapsed > 0 ? ((elapsed / totalElapsed) * 100).toFixed(1) : '0';
|
||||
console.log(
|
||||
`🔗 Cross-file re-resolution: ${crossFileResolved} candidates re-processed` +
|
||||
` in ${elapsed}ms (${reResolutionPct}% of total ingestion time so far)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export interface PipelineOptions {
|
||||
/** Skip MRO, community detection, and process extraction for faster test runs. */
|
||||
|
|
@ -494,173 +545,15 @@ export const runPipelineFromRepo = async (
|
|||
}
|
||||
|
||||
// ── Phase 14: Cross-file binding propagation ──────────────────────
|
||||
// Seed downstream files with resolved type bindings from upstream files.
|
||||
// Uses namedImportMap (populated during import processing) to determine
|
||||
// which exported bindings each file needs. Files processed in topological
|
||||
// import order so upstream bindings are available when downstream runs.
|
||||
|
||||
// For the worker path, buildTypeEnv runs inside workers without SymbolTable,
|
||||
// so exported bindings must be collected from graph + SymbolTable in main thread.
|
||||
if (exportedTypeMap.size === 0 && graph.nodeCount > 0) {
|
||||
const graphExports = buildExportedTypeMapFromGraph(graph, ctx.symbols);
|
||||
for (const [fp, exports] of graphExports) exportedTypeMap.set(fp, exports);
|
||||
}
|
||||
|
||||
if (exportedTypeMap.size > 0 && ctx.namedImportMap.size > 0) {
|
||||
const allPathSet = new Set(allPaths);
|
||||
const levels = topologicalLevelSort(ctx.importMap);
|
||||
|
||||
// E2: SCC diagnostics for import cycles (dev-mode only)
|
||||
if (isDev && levels.length > 0) {
|
||||
const lastLevel = levels[levels.length - 1];
|
||||
// Kahn's dumps cycle files in the last level — detect via positive in-degree check
|
||||
// If there are cycle files, decompose into SCCs for diagnostics
|
||||
if (lastLevel.length > 1) {
|
||||
const cycleInfo = computeImportCycleSCCs(ctx.importMap, lastLevel);
|
||||
if (cycleInfo.sccs.length > 0) {
|
||||
console.log(`🔄 Detected ${cycleInfo.sccs.length} import cycle(s) (${cycleInfo.cycleFiles.length} files):`);
|
||||
for (const scc of cycleInfo.sccs.slice(0, 5)) {
|
||||
console.log(` Cycle (${scc.length} files): ${scc.slice(0, 3).join(', ')}${scc.length > 3 ? '...' : ''}`);
|
||||
}
|
||||
if (cycleInfo.sccs.length > 5) {
|
||||
console.log(` ... and ${cycleInfo.sccs.length - 5} more cycle(s)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Count files that would benefit from cross-file seeding
|
||||
let filesWithGaps = 0;
|
||||
for (const level of levels) {
|
||||
for (const filePath of level) {
|
||||
const imports = ctx.namedImportMap.get(filePath);
|
||||
if (!imports) continue;
|
||||
let hasGap = false;
|
||||
for (const [, binding] of imports) {
|
||||
// E1/E2: upstream file has a matching exported binding for this specific import
|
||||
const upstream = exportedTypeMap.get(binding.sourcePath);
|
||||
if (upstream?.has(binding.exportedName)) { hasGap = true; break; }
|
||||
// E3: upstream callable has a known return type in SymbolTable
|
||||
const def = ctx.symbols.lookupExactFull(binding.sourcePath, binding.exportedName);
|
||||
if (def?.returnType) { hasGap = true; break; }
|
||||
}
|
||||
if (hasGap) filesWithGaps++;
|
||||
}
|
||||
}
|
||||
|
||||
const CROSS_FILE_SKIP_THRESHOLD = 0.03;
|
||||
const gapRatio = totalFiles > 0 ? filesWithGaps / totalFiles : 0;
|
||||
|
||||
if (gapRatio < CROSS_FILE_SKIP_THRESHOLD) {
|
||||
if (isDev) {
|
||||
console.log(`⏭️ Cross-file re-resolution skipped (${filesWithGaps}/${totalFiles} files, ${(gapRatio * 100).toFixed(1)}% < ${CROSS_FILE_SKIP_THRESHOLD * 100}% threshold)`);
|
||||
}
|
||||
} else {
|
||||
onProgress({
|
||||
phase: 'parsing',
|
||||
percent: 82,
|
||||
message: `Cross-file type propagation (${filesWithGaps} files)...`,
|
||||
stats: { filesProcessed: totalFiles, totalFiles, nodesCreated: graph.nodeCount },
|
||||
});
|
||||
|
||||
let crossFileResolved = 0;
|
||||
const crossFileStart = Date.now();
|
||||
const MAX_CROSS_FILE_REPROCESS = 2000;
|
||||
astCache = createASTCache(AST_CACHE_CAP);
|
||||
|
||||
// ── Worker parallelization design (Phase 14 E4, deferred) ──────────
|
||||
// Files within the same topological level have no mutual dependencies
|
||||
// and could be processed in parallel. When PARALLEL_RE_RESOLUTION_THRESHOLD
|
||||
// is set below Infinity, partition level candidates into worker batches:
|
||||
// 1. Serialize ExportedTypeMap snapshot + per-file seeded bindings
|
||||
// 2. Ship to workers via structured clone (Map<string, Map<string, string>> serializes efficiently)
|
||||
// 3. Workers return updated exports → merge into ExportedTypeMap before next level
|
||||
// Gating: implement when re-resolution pass exceeds 20% of total ingestion time.
|
||||
for (const level of levels) {
|
||||
// Batch: collect files needing re-resolution in this level, then read all at once
|
||||
const levelCandidates: { filePath: string; seeded: Map<string, string>; importedReturns: ReadonlyMap<string, string> }[] = [];
|
||||
for (const filePath of level) {
|
||||
if (crossFileResolved + levelCandidates.length >= MAX_CROSS_FILE_REPROCESS) break;
|
||||
const imports = ctx.namedImportMap.get(filePath);
|
||||
if (!imports) continue;
|
||||
|
||||
// Build seeded bindings from upstream ExportedTypeMap
|
||||
const seeded = new Map<string, string>();
|
||||
for (const [localName, binding] of imports) {
|
||||
const upstream = exportedTypeMap.get(binding.sourcePath);
|
||||
if (upstream) {
|
||||
const type = upstream.get(binding.exportedName);
|
||||
if (type) seeded.set(localName, type);
|
||||
}
|
||||
}
|
||||
|
||||
// E3: Build cross-file return types for imported callables
|
||||
const importedReturns = buildImportedReturnTypes(filePath, ctx.namedImportMap, ctx.symbols);
|
||||
|
||||
// Skip if neither variable bindings nor callable return types are available
|
||||
if (seeded.size === 0 && importedReturns.size === 0) continue;
|
||||
|
||||
// Validate path before re-reading (defense-in-depth)
|
||||
if (!allPathSet.has(filePath)) continue;
|
||||
|
||||
const lang = getLanguageFromFilename(filePath);
|
||||
if (!lang || !isLanguageAvailable(lang)) continue;
|
||||
|
||||
levelCandidates.push({ filePath, seeded, importedReturns });
|
||||
}
|
||||
|
||||
if (levelCandidates.length === 0) continue;
|
||||
|
||||
// Batch read all files in this level at once (avoids per-file I/O overhead)
|
||||
const levelPaths = levelCandidates.map(c => c.filePath);
|
||||
const contentMap = await readFileContents(repoPath, levelPaths);
|
||||
|
||||
for (const { filePath, seeded, importedReturns } of levelCandidates) {
|
||||
const content = contentMap.get(filePath);
|
||||
if (!content) continue;
|
||||
|
||||
// Re-parse and re-resolve calls with cross-file seeded type environment
|
||||
// Reuse the level-scoped AST cache (LRU eviction handles capacity)
|
||||
const reFile = [{ path: filePath, content }];
|
||||
const bindings = new Map<string, ReadonlyMap<string, string>>();
|
||||
if (seeded.size > 0) bindings.set(filePath, seeded);
|
||||
|
||||
const importedReturnTypesMap = new Map<string, ReadonlyMap<string, string>>();
|
||||
if (importedReturns.size > 0) {
|
||||
importedReturnTypesMap.set(filePath, importedReturns);
|
||||
}
|
||||
|
||||
await processCalls(graph, reFile, astCache, ctx, undefined, exportedTypeMap, bindings.size > 0 ? bindings : undefined, importedReturnTypesMap.size > 0 ? importedReturnTypesMap : undefined);
|
||||
|
||||
crossFileResolved++;
|
||||
}
|
||||
|
||||
if (crossFileResolved >= MAX_CROSS_FILE_REPROCESS) {
|
||||
if (isDev) console.log(`⚠️ Cross-file re-resolution capped at ${MAX_CROSS_FILE_REPROCESS} files`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Clear AST cache after re-resolution pass completes
|
||||
astCache.clear();
|
||||
|
||||
if (isDev) {
|
||||
const elapsed = Date.now() - crossFileStart;
|
||||
const totalElapsed = Date.now() - pipelineStart;
|
||||
const reResolutionPct = totalElapsed > 0 ? ((elapsed / totalElapsed) * 100).toFixed(1) : '0';
|
||||
console.log(
|
||||
`🔗 Cross-file re-resolution: ${crossFileResolved}/${filesWithGaps} candidates re-processed` +
|
||||
` in ${elapsed}ms (${reResolutionPct}% of total ingestion time so far)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
await runCrossFileBindingPropagation(
|
||||
graph, ctx, exportedTypeMap, allPaths, totalFiles, repoPath, pipelineStart, onProgress,
|
||||
);
|
||||
|
||||
// Free import resolution context — suffix index + resolve cache no longer needed
|
||||
// (allPathObjects and importCtx hold ~94MB+ for large repos)
|
||||
allPathObjects.length = 0;
|
||||
importCtx.resolveCache.clear();
|
||||
(importCtx as any).suffixIndex = null;
|
||||
(importCtx as any).normalizedFileList = null;
|
||||
importCtx.dispose();
|
||||
|
||||
let communityResult: Awaited<ReturnType<typeof processCommunities>> | undefined;
|
||||
let processResult: Awaited<ReturnType<typeof processProcesses>> | undefined;
|
||||
|
|
|
|||
|
|
@ -39,26 +39,39 @@ export function resolveJvmWildcard(
|
|||
const candidates = extensions.flatMap(ext => index.getFilesInDir(packagePath, ext));
|
||||
// Filter to only direct children (no subdirectories)
|
||||
const packageSuffix = '/' + packagePath + '/';
|
||||
const packagePrefix = packagePath + '/';
|
||||
return candidates.filter(f => {
|
||||
const normalized = f.replace(/\\/g, '/');
|
||||
const idx = normalized.indexOf(packageSuffix);
|
||||
if (idx < 0) return false;
|
||||
const afterPkg = normalized.substring(idx + packageSuffix.length);
|
||||
// Match both nested (src/models/User.kt) and root-level (models/User.kt) packages
|
||||
let afterPkg: string;
|
||||
const idx = normalized.lastIndexOf(packageSuffix);
|
||||
if (idx >= 0) {
|
||||
afterPkg = normalized.substring(idx + packageSuffix.length);
|
||||
} else if (normalized.startsWith(packagePrefix)) {
|
||||
afterPkg = normalized.substring(packagePrefix.length);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
return !afterPkg.includes('/');
|
||||
});
|
||||
}
|
||||
|
||||
// Fallback: linear scan
|
||||
const packageSuffix = '/' + packagePath + '/';
|
||||
const packagePrefix = packagePath + '/';
|
||||
const matches: string[] = [];
|
||||
for (let i = 0; i < normalizedFileList.length; i++) {
|
||||
const normalized = normalizedFileList[i];
|
||||
if (normalized.includes(packageSuffix) &&
|
||||
extensions.some(ext => normalized.endsWith(ext))) {
|
||||
const afterPackage = normalized.substring(normalized.indexOf(packageSuffix) + packageSuffix.length);
|
||||
if (!afterPackage.includes('/')) {
|
||||
matches.push(allFileList[i]);
|
||||
}
|
||||
if (!extensions.some(ext => normalized.endsWith(ext))) continue;
|
||||
// Match both nested (src/models/User.kt) and root-level (models/User.kt) packages
|
||||
let afterPackage: string | null = null;
|
||||
if (normalized.includes(packageSuffix)) {
|
||||
afterPackage = normalized.substring(normalized.lastIndexOf(packageSuffix) + packageSuffix.length);
|
||||
} else if (normalized.startsWith(packagePrefix)) {
|
||||
afterPackage = normalized.substring(packagePrefix.length);
|
||||
}
|
||||
if (afterPackage !== null && !afterPackage.includes('/')) {
|
||||
matches.push(allFileList[i]);
|
||||
}
|
||||
}
|
||||
return matches;
|
||||
|
|
|
|||
|
|
@ -10,11 +10,34 @@ import { suffixResolve } from './utils.js';
|
|||
export interface ComposerConfig {
|
||||
/** Map of namespace prefix -> directory (e.g., "App\\" -> "app/") */
|
||||
psr4: Map<string, string>;
|
||||
/** PSR-4 entries sorted by namespace length descending (longest match wins).
|
||||
* Cached once at config load time to avoid re-sorting on every import. */
|
||||
psr4Sorted?: readonly [string, string][];
|
||||
}
|
||||
|
||||
/** Get or compute the sorted PSR-4 entries (cached after first call). */
|
||||
function getSortedPsr4(config: ComposerConfig): readonly [string, string][] {
|
||||
if (!config.psr4Sorted) {
|
||||
const sorted = [...config.psr4.entries()].sort((a, b) => b[0].length - a[0].length);
|
||||
config.psr4Sorted = sorted;
|
||||
}
|
||||
return config.psr4Sorted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a PHP use-statement import path using PSR-4 mappings.
|
||||
* e.g. "App\Http\Controllers\UserController" -> "app/Http/Controllers/UserController.php"
|
||||
*
|
||||
* For function/constant imports (use function App\Models\getUser), the last
|
||||
* segment is the symbol name, not a class name, so it may not map directly to
|
||||
* a file. When PSR-4 class-style resolution fails, we fall back to scanning
|
||||
* .php files in the namespace directory.
|
||||
*
|
||||
* NOTE: The function-import fallback returns the first matching .php file in the
|
||||
* namespace directory. When multiple files exist in the same namespace directory,
|
||||
* resolution is non-deterministic (depends on Set/index iteration order). This is
|
||||
* a known limitation — PHP function imports cannot be resolved to a specific file
|
||||
* without parsing all candidate files.
|
||||
*/
|
||||
export function resolvePhpImport(
|
||||
importPath: string,
|
||||
|
|
@ -27,20 +50,44 @@ export function resolvePhpImport(
|
|||
// Normalize: replace backslashes with forward slashes
|
||||
const normalized = importPath.replace(/\\/g, '/');
|
||||
|
||||
// Try PSR-4 resolution if composer.json was found
|
||||
// Reject path traversal attempts (defense-in-depth — walker whitelist also prevents this)
|
||||
if (normalized.includes('..')) return null;
|
||||
|
||||
if (composerConfig) {
|
||||
// Sort namespaces by length descending (longest match wins)
|
||||
const sorted = [...composerConfig.psr4.entries()].sort((a, b) => b[0].length - a[0].length);
|
||||
const sorted = getSortedPsr4(composerConfig);
|
||||
for (const [nsPrefix, dirPrefix] of sorted) {
|
||||
const nsPrefixSlash = nsPrefix.replace(/\\/g, '/');
|
||||
if (normalized.startsWith(nsPrefixSlash + '/') || normalized === nsPrefixSlash) {
|
||||
const remainder = normalized.slice(nsPrefixSlash.length).replace(/^\//, '');
|
||||
|
||||
// 1. Try class-style PSR-4: full path → file (e.g. App\Models\User → app/Models/User.php)
|
||||
const filePath = dirPrefix + (remainder ? '/' + remainder : '') + '.php';
|
||||
if (allFiles.has(filePath)) return filePath;
|
||||
if (index) {
|
||||
const result = index.getInsensitive(filePath);
|
||||
if (result) return result;
|
||||
}
|
||||
|
||||
// 2. Function/constant fallback: strip last segment (symbol name), scan namespace directory.
|
||||
// e.g. App\Models\getUser → directory app/Models/, find first .php file in that dir.
|
||||
const lastSlash = remainder.lastIndexOf('/');
|
||||
const nsDir = lastSlash >= 0
|
||||
? dirPrefix + '/' + remainder.slice(0, lastSlash)
|
||||
: dirPrefix;
|
||||
|
||||
// Prefer SuffixIndex directory lookup (O(log n + matches)) over linear scan
|
||||
if (index) {
|
||||
const candidates = index.getFilesInDir(nsDir, '.php');
|
||||
if (candidates.length > 0) return candidates[0];
|
||||
}
|
||||
|
||||
// Fallback: linear scan (only when SuffixIndex unavailable)
|
||||
const nsDirPrefix = nsDir.endsWith('/') ? nsDir : nsDir + '/';
|
||||
for (const f of allFiles) {
|
||||
if (f.startsWith(nsDirPrefix) && f.endsWith('.php') && !f.slice(nsDirPrefix.length).includes('/')) {
|
||||
return f;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -260,6 +260,18 @@ export const BUILT_IN_NAMES = new Set([
|
|||
/** Check if a name is a built-in function or common noise that should be filtered out */
|
||||
export const isBuiltInOrNoise = (name: string): boolean => BUILT_IN_NAMES.has(name);
|
||||
|
||||
/** Check if a Kotlin function_declaration capture is inside a class_body (i.e., a method).
|
||||
* Kotlin grammar uses function_declaration for both top-level functions and class methods.
|
||||
* Returns true when the captured definition node has a class_body ancestor. */
|
||||
export function isKotlinClassMethod(captureNode: { parent?: any } | null | undefined): boolean {
|
||||
let ancestor = captureNode?.parent;
|
||||
while (ancestor) {
|
||||
if (ancestor.type === 'class_body') return true;
|
||||
ancestor = ancestor.parent;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** AST node types that represent a class-like container (for HAS_METHOD edge extraction) */
|
||||
export const CLASS_CONTAINER_TYPES = new Set([
|
||||
'class_declaration', 'abstract_class_declaration',
|
||||
|
|
@ -461,6 +473,12 @@ export const extractFunctionName = (node: SyntaxNode): { funcName: string | null
|
|||
}
|
||||
}
|
||||
funcName = nameNode?.text;
|
||||
|
||||
// Kotlin: function_declaration inside a class_body is a method, not a top-level function.
|
||||
// Must match the label assigned in parse-worker.ts for consistent generateId() output.
|
||||
if (funcName && node.type === 'function_declaration' && isKotlinClassMethod(node)) {
|
||||
label = 'Method';
|
||||
}
|
||||
}
|
||||
} else if (node.type === 'impl_item') {
|
||||
let funcItem: SyntaxNode | null = null;
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import {
|
|||
isBuiltInOrNoise,
|
||||
getDefinitionNodeFromCaptures,
|
||||
findEnclosingClassId,
|
||||
isKotlinClassMethod,
|
||||
extractMethodSignature,
|
||||
countCallArguments,
|
||||
inferCallForm,
|
||||
|
|
@ -256,7 +257,8 @@ const findEnclosingFunctionId = (node: any, filePath: string): string | null =>
|
|||
const getLabelFromCaptures = (captureMap: Record<string, any>): NodeLabel | null => {
|
||||
// Skip imports (handled separately) and calls
|
||||
if (captureMap['import'] || captureMap['call']) return null;
|
||||
if (!captureMap['name']) return null;
|
||||
// Allow constructors without explicit @name capture (e.g. Swift init) — name synthesized downstream
|
||||
if (!captureMap['name'] && !captureMap['definition.constructor']) return null;
|
||||
|
||||
if (captureMap['definition.function']) return 'Function';
|
||||
if (captureMap['definition.class']) return 'Class';
|
||||
|
|
@ -1144,7 +1146,7 @@ const processFileGroup = (
|
|||
}
|
||||
}
|
||||
|
||||
const nodeLabel = getLabelFromCaptures(captureMap);
|
||||
let nodeLabel = getLabelFromCaptures(captureMap);
|
||||
if (!nodeLabel) continue;
|
||||
|
||||
// C/C++: @definition.function is broad and also matches inline class methods (inside
|
||||
|
|
@ -1164,6 +1166,12 @@ const processFileGroup = (
|
|||
if (ancestor) continue; // found a class/struct ancestor → skip
|
||||
}
|
||||
|
||||
// Kotlin: function_declaration inside a class_body is a method, not a top-level function.
|
||||
if (language === SupportedLanguages.Kotlin && nodeLabel === 'Function' &&
|
||||
isKotlinClassMethod(captureMap['definition.function'])) {
|
||||
nodeLabel = 'Method';
|
||||
}
|
||||
|
||||
const nameNode = captureMap['name'];
|
||||
// Synthesize name for constructors without explicit @name capture (e.g. Swift init)
|
||||
if (!nameNode && nodeLabel !== 'Constructor') continue;
|
||||
|
|
|
|||
14
gitnexus/test/fixtures/cross-file-binding/csharp-cross-file/App/Program.cs
vendored
Normal file
14
gitnexus/test/fixtures/cross-file-binding/csharp-cross-file/App/Program.cs
vendored
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
using static CrossFile.Models.UserFactory;
|
||||
|
||||
namespace CrossFile.App
|
||||
{
|
||||
public class Program
|
||||
{
|
||||
public void Run()
|
||||
{
|
||||
var u = GetUser();
|
||||
u.Save();
|
||||
u.GetName();
|
||||
}
|
||||
}
|
||||
}
|
||||
5
gitnexus/test/fixtures/cross-file-binding/csharp-cross-file/CrossFile.csproj
vendored
Normal file
5
gitnexus/test/fixtures/cross-file-binding/csharp-cross-file/CrossFile.csproj
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<RootNamespace>CrossFile</RootNamespace>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
8
gitnexus/test/fixtures/cross-file-binding/csharp-cross-file/Models/User.cs
vendored
Normal file
8
gitnexus/test/fixtures/cross-file-binding/csharp-cross-file/Models/User.cs
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
namespace CrossFile.Models
|
||||
{
|
||||
public class User
|
||||
{
|
||||
public void Save() {}
|
||||
public string GetName() { return ""; }
|
||||
}
|
||||
}
|
||||
7
gitnexus/test/fixtures/cross-file-binding/csharp-cross-file/Models/UserFactory.cs
vendored
Normal file
7
gitnexus/test/fixtures/cross-file-binding/csharp-cross-file/Models/UserFactory.cs
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
namespace CrossFile.Models
|
||||
{
|
||||
public static class UserFactory
|
||||
{
|
||||
public static User GetUser() { return new User(); }
|
||||
}
|
||||
}
|
||||
11
gitnexus/test/fixtures/cross-file-binding/java-cross-file/app/App.java
vendored
Normal file
11
gitnexus/test/fixtures/cross-file-binding/java-cross-file/app/App.java
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
package app;
|
||||
|
||||
import static models.UserFactory.getUser;
|
||||
|
||||
public class App {
|
||||
public void run() {
|
||||
var user = getUser();
|
||||
user.save();
|
||||
user.getName();
|
||||
}
|
||||
}
|
||||
6
gitnexus/test/fixtures/cross-file-binding/java-cross-file/models/User.java
vendored
Normal file
6
gitnexus/test/fixtures/cross-file-binding/java-cross-file/models/User.java
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
package models;
|
||||
|
||||
public class User {
|
||||
public void save() {}
|
||||
public String getName() { return ""; }
|
||||
}
|
||||
7
gitnexus/test/fixtures/cross-file-binding/java-cross-file/models/UserFactory.java
vendored
Normal file
7
gitnexus/test/fixtures/cross-file-binding/java-cross-file/models/UserFactory.java
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
package models;
|
||||
|
||||
public class UserFactory {
|
||||
public static User getUser() {
|
||||
return new User();
|
||||
}
|
||||
}
|
||||
7
gitnexus/test/fixtures/cross-file-binding/js-cross-file/src/app.js
vendored
Normal file
7
gitnexus/test/fixtures/cross-file-binding/js-cross-file/src/app.js
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { getUser } from './models';
|
||||
|
||||
export function run() {
|
||||
const u = getUser();
|
||||
u.save();
|
||||
u.getName();
|
||||
}
|
||||
8
gitnexus/test/fixtures/cross-file-binding/js-cross-file/src/models.js
vendored
Normal file
8
gitnexus/test/fixtures/cross-file-binding/js-cross-file/src/models.js
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
export class User {
|
||||
save() {}
|
||||
getName() { return ''; }
|
||||
}
|
||||
|
||||
export function getUser() {
|
||||
return new User();
|
||||
}
|
||||
11
gitnexus/test/fixtures/cross-file-binding/kotlin-cross-file/app/App.kt
vendored
Normal file
11
gitnexus/test/fixtures/cross-file-binding/kotlin-cross-file/app/App.kt
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
package app
|
||||
|
||||
import models.getUser
|
||||
|
||||
class App {
|
||||
fun run() {
|
||||
val u = getUser()
|
||||
u.save()
|
||||
u.getName()
|
||||
}
|
||||
}
|
||||
8
gitnexus/test/fixtures/cross-file-binding/kotlin-cross-file/models/User.kt
vendored
Normal file
8
gitnexus/test/fixtures/cross-file-binding/kotlin-cross-file/models/User.kt
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
package models
|
||||
|
||||
class User {
|
||||
fun save() {}
|
||||
fun getName(): String = ""
|
||||
}
|
||||
|
||||
fun getUser(): User = User()
|
||||
13
gitnexus/test/fixtures/cross-file-binding/php-cross-file/app/Main.php
vendored
Normal file
13
gitnexus/test/fixtures/cross-file-binding/php-cross-file/app/Main.php
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<?php
|
||||
|
||||
namespace App;
|
||||
|
||||
use function App\Models\getUser;
|
||||
|
||||
class Main {
|
||||
public function run(): void {
|
||||
$u = getUser();
|
||||
$u->save();
|
||||
$u->getName();
|
||||
}
|
||||
}
|
||||
8
gitnexus/test/fixtures/cross-file-binding/php-cross-file/app/Models/User.php
vendored
Normal file
8
gitnexus/test/fixtures/cross-file-binding/php-cross-file/app/Models/User.php
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
class User {
|
||||
public function save(): void {}
|
||||
public function getName(): string { return ''; }
|
||||
}
|
||||
7
gitnexus/test/fixtures/cross-file-binding/php-cross-file/app/Models/UserFactory.php
vendored
Normal file
7
gitnexus/test/fixtures/cross-file-binding/php-cross-file/app/Models/UserFactory.php
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
function getUser(): User {
|
||||
return new User();
|
||||
}
|
||||
7
gitnexus/test/fixtures/cross-file-binding/php-cross-file/composer.json
vendored
Normal file
7
gitnexus/test/fixtures/cross-file-binding/php-cross-file/composer.json
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"App\\": "app/"
|
||||
}
|
||||
}
|
||||
}
|
||||
6
gitnexus/test/fixtures/cross-file-binding/py-cross-file/src/app.py
vendored
Normal file
6
gitnexus/test/fixtures/cross-file-binding/py-cross-file/src/app.py
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
from models import get_user
|
||||
|
||||
def run():
|
||||
u = get_user()
|
||||
u.save()
|
||||
u.get_name()
|
||||
9
gitnexus/test/fixtures/cross-file-binding/py-cross-file/src/models.py
vendored
Normal file
9
gitnexus/test/fixtures/cross-file-binding/py-cross-file/src/models.py
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
class User:
|
||||
def save(self):
|
||||
pass
|
||||
|
||||
def get_name(self) -> str:
|
||||
return ''
|
||||
|
||||
def get_user() -> User:
|
||||
return User()
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import path from 'path';
|
||||
import {
|
||||
FIXTURES, getRelationships, getNodesByLabel, getNodesByLabelFull, edgeSet,
|
||||
FIXTURES, CROSS_FILE_FIXTURES, getRelationships, getNodesByLabel, getNodesByLabelFull, edgeSet,
|
||||
runPipelineFromRepo, type PipelineResult,
|
||||
} from './helpers.js';
|
||||
|
||||
|
|
@ -1517,3 +1517,73 @@ describe('C# optional parameter arity resolution', () => {
|
|||
expect(greetCalls.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 14: Cross-file binding propagation
|
||||
// Models/UserFactory.cs exports static GetUser() returning User
|
||||
// App/Program.cs uses static import, calls var u = GetUser(); u.Save()
|
||||
// → u is typed User via cross-file return type propagation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('C# cross-file binding propagation', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(
|
||||
path.join(CROSS_FILE_FIXTURES, 'csharp-cross-file'),
|
||||
() => {},
|
||||
);
|
||||
}, 60000);
|
||||
|
||||
it('detects User class with Save and GetName methods', () => {
|
||||
expect(getNodesByLabel(result, 'Class')).toContain('User');
|
||||
expect(getNodesByLabel(result, 'Method')).toContain('Save');
|
||||
expect(getNodesByLabel(result, 'Method')).toContain('GetName');
|
||||
});
|
||||
|
||||
it('detects UserFactory class with GetUser method', () => {
|
||||
expect(getNodesByLabel(result, 'Class')).toContain('UserFactory');
|
||||
expect(getNodesByLabel(result, 'Method')).toContain('GetUser');
|
||||
});
|
||||
|
||||
it('detects Program class with Run method', () => {
|
||||
expect(getNodesByLabel(result, 'Class')).toContain('Program');
|
||||
expect(getNodesByLabel(result, 'Method')).toContain('Run');
|
||||
});
|
||||
|
||||
it('emits IMPORTS edge from Program.cs to UserFactory.cs', () => {
|
||||
const imports = getRelationships(result, 'IMPORTS');
|
||||
const edge = imports.find(e =>
|
||||
e.sourceFilePath.includes('Program') && e.targetFilePath.includes('UserFactory'),
|
||||
);
|
||||
expect(edge).toBeDefined();
|
||||
});
|
||||
|
||||
it('resolves u.Save() in Run() to User#Save via cross-file return type propagation', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const saveCall = calls.find(c =>
|
||||
c.target === 'Save' &&
|
||||
c.source === 'Run' &&
|
||||
c.targetFilePath.includes('User.cs'),
|
||||
);
|
||||
expect(saveCall).toBeDefined();
|
||||
});
|
||||
|
||||
it('resolves u.GetName() in Run() to User#GetName via cross-file return type propagation', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const getNameCall = calls.find(c =>
|
||||
c.target === 'GetName' &&
|
||||
c.source === 'Run' &&
|
||||
c.targetFilePath.includes('User.cs'),
|
||||
);
|
||||
expect(getNameCall).toBeDefined();
|
||||
});
|
||||
|
||||
it('emits HAS_METHOD edges linking Save and GetName to User', () => {
|
||||
const hasMethod = getRelationships(result, 'HAS_METHOD');
|
||||
const saveEdge = hasMethod.find(e => e.source === 'User' && e.target === 'Save');
|
||||
const getNameEdge = hasMethod.find(e => e.source === 'User' && e.target === 'GetName');
|
||||
expect(saveEdge).toBeDefined();
|
||||
expect(getNameEdge).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import type { PipelineResult } from '../../../src/types/pipeline.js';
|
|||
import type { GraphRelationship } from '../../../src/core/graph/types.js';
|
||||
|
||||
export const FIXTURES = path.resolve(__dirname, '..', '..', 'fixtures', 'lang-resolution');
|
||||
export const CROSS_FILE_FIXTURES = path.resolve(__dirname, '..', '..', 'fixtures', 'cross-file-binding');
|
||||
|
||||
export type RelEdge = {
|
||||
source: string;
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import path from 'path';
|
||||
import {
|
||||
FIXTURES, getRelationships, getNodesByLabel, getNodesByLabelFull, edgeSet,
|
||||
FIXTURES, CROSS_FILE_FIXTURES, getRelationships, getNodesByLabel, getNodesByLabelFull, edgeSet,
|
||||
runPipelineFromRepo, type PipelineResult,
|
||||
} from './helpers.js';
|
||||
|
||||
|
|
@ -1396,3 +1396,73 @@ describe('Java virtual dispatch via constructor type (same-file)', () => {
|
|||
expect(fetchCalls.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 14: Cross-file binding propagation
|
||||
// models/UserFactory.java exports static getUser() returning User
|
||||
// app/App.java static-imports getUser, calls var user = getUser(); user.save()
|
||||
// → user is typed User via cross-file return type propagation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Java cross-file binding propagation', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(
|
||||
path.join(CROSS_FILE_FIXTURES, 'java-cross-file'),
|
||||
() => {},
|
||||
);
|
||||
}, 60000);
|
||||
|
||||
it('detects User class with save and getName methods', () => {
|
||||
expect(getNodesByLabel(result, 'Class')).toContain('User');
|
||||
expect(getNodesByLabel(result, 'Method')).toContain('save');
|
||||
expect(getNodesByLabel(result, 'Method')).toContain('getName');
|
||||
});
|
||||
|
||||
it('detects UserFactory class with getUser method', () => {
|
||||
expect(getNodesByLabel(result, 'Class')).toContain('UserFactory');
|
||||
expect(getNodesByLabel(result, 'Method')).toContain('getUser');
|
||||
});
|
||||
|
||||
it('detects App class with run method', () => {
|
||||
expect(getNodesByLabel(result, 'Class')).toContain('App');
|
||||
expect(getNodesByLabel(result, 'Method')).toContain('run');
|
||||
});
|
||||
|
||||
it('emits IMPORTS edge from App.java to UserFactory.java', () => {
|
||||
const imports = getRelationships(result, 'IMPORTS');
|
||||
const edge = imports.find(e =>
|
||||
e.sourceFilePath.includes('App') && e.targetFilePath.includes('UserFactory'),
|
||||
);
|
||||
expect(edge).toBeDefined();
|
||||
});
|
||||
|
||||
it('resolves user.save() in run() to User#save via cross-file return type propagation', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const saveCall = calls.find(c =>
|
||||
c.target === 'save' &&
|
||||
c.source === 'run' &&
|
||||
c.targetFilePath.includes('User.java'),
|
||||
);
|
||||
expect(saveCall).toBeDefined();
|
||||
});
|
||||
|
||||
it('resolves user.getName() in run() to User#getName via cross-file return type propagation', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const getNameCall = calls.find(c =>
|
||||
c.target === 'getName' &&
|
||||
c.source === 'run' &&
|
||||
c.targetFilePath.includes('User.java'),
|
||||
);
|
||||
expect(getNameCall).toBeDefined();
|
||||
});
|
||||
|
||||
it('emits HAS_METHOD edges linking save and getName to User', () => {
|
||||
const hasMethod = getRelationships(result, 'HAS_METHOD');
|
||||
const saveEdge = hasMethod.find(e => e.source === 'User' && e.target === 'save');
|
||||
const getNameEdge = hasMethod.find(e => e.source === 'User' && e.target === 'getName');
|
||||
expect(saveEdge).toBeDefined();
|
||||
expect(getNameEdge).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import path from 'path';
|
||||
import {
|
||||
FIXTURES, getRelationships, getNodesByLabel, edgeSet,
|
||||
FIXTURES, CROSS_FILE_FIXTURES, getRelationships, getNodesByLabel, edgeSet,
|
||||
runPipelineFromRepo, type PipelineResult,
|
||||
} from './helpers.js';
|
||||
|
||||
|
|
@ -354,3 +354,68 @@ describe('JavaScript post-fixpoint for-loop replay (Phase A ex-9B)', () => {
|
|||
expect(saveCall).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 14: Cross-file binding propagation
|
||||
// models.js exports getUser() returning User
|
||||
// app.js imports getUser, calls const u = getUser(); u.save(); u.getName()
|
||||
// → u is typed User via cross-file return type propagation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('JavaScript cross-file binding propagation', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(
|
||||
path.join(CROSS_FILE_FIXTURES, 'js-cross-file'),
|
||||
() => {},
|
||||
);
|
||||
}, 60000);
|
||||
|
||||
it('detects User class with save and getName methods', () => {
|
||||
expect(getNodesByLabel(result, 'Class')).toContain('User');
|
||||
expect(getNodesByLabel(result, 'Method')).toContain('save');
|
||||
expect(getNodesByLabel(result, 'Method')).toContain('getName');
|
||||
});
|
||||
|
||||
it('detects getUser and run functions', () => {
|
||||
expect(getNodesByLabel(result, 'Function')).toContain('getUser');
|
||||
expect(getNodesByLabel(result, 'Function')).toContain('run');
|
||||
});
|
||||
|
||||
it('emits IMPORTS edge from app.js to models.js', () => {
|
||||
const imports = getRelationships(result, 'IMPORTS');
|
||||
const edge = imports.find(e =>
|
||||
e.sourceFilePath.includes('app') && e.targetFilePath.includes('models'),
|
||||
);
|
||||
expect(edge).toBeDefined();
|
||||
});
|
||||
|
||||
it('resolves u.save() in run() to User#save via cross-file return type propagation', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const saveCall = calls.find(c =>
|
||||
c.target === 'save' &&
|
||||
c.source === 'run' &&
|
||||
c.targetFilePath.includes('models'),
|
||||
);
|
||||
expect(saveCall).toBeDefined();
|
||||
});
|
||||
|
||||
it('resolves u.getName() in run() to User#getName via cross-file return type propagation', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const getNameCall = calls.find(c =>
|
||||
c.target === 'getName' &&
|
||||
c.source === 'run' &&
|
||||
c.targetFilePath.includes('models'),
|
||||
);
|
||||
expect(getNameCall).toBeDefined();
|
||||
});
|
||||
|
||||
it('emits HAS_METHOD edges linking save and getName to User', () => {
|
||||
const hasMethod = getRelationships(result, 'HAS_METHOD');
|
||||
const saveEdge = hasMethod.find(e => e.source === 'User' && e.target === 'save');
|
||||
const getNameEdge = hasMethod.find(e => e.source === 'User' && e.target === 'getName');
|
||||
expect(saveEdge).toBeDefined();
|
||||
expect(getNameEdge).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import path from 'path';
|
||||
import {
|
||||
FIXTURES, getRelationships, getNodesByLabel, getNodesByLabelFull, edgeSet,
|
||||
FIXTURES, CROSS_FILE_FIXTURES, getRelationships, getNodesByLabel, getNodesByLabelFull, edgeSet,
|
||||
runPipelineFromRepo, type PipelineResult,
|
||||
} from './helpers.js';
|
||||
|
||||
|
|
@ -27,8 +27,9 @@ describe('Kotlin heritage resolution', () => {
|
|||
expect(getNodesByLabel(result, 'Interface')).toEqual(['Serializable', 'Validatable']);
|
||||
});
|
||||
|
||||
it('detects 6 functions (interface declarations + implementations + service)', () => {
|
||||
expect(getNodesByLabel(result, 'Function')).toEqual([
|
||||
it('detects 6 class/interface methods (processUser is inside UserService)', () => {
|
||||
expect(getNodesByLabel(result, 'Function')).toEqual([]);
|
||||
expect(getNodesByLabel(result, 'Method')).toEqual([
|
||||
'processUser', 'save', 'serialize', 'serialize', 'validate', 'validate',
|
||||
]);
|
||||
});
|
||||
|
|
@ -199,10 +200,9 @@ describe('Kotlin member-call resolution', () => {
|
|||
expect(saveCall!.targetFilePath).toBe('models/User.kt');
|
||||
});
|
||||
|
||||
it('detects User class and save function (Kotlin fns are Function nodes)', () => {
|
||||
it('detects User class and save method', () => {
|
||||
expect(getNodesByLabel(result, 'Class')).toContain('User');
|
||||
// Kotlin tree-sitter captures all function_declaration as Function, including class methods
|
||||
expect(getNodesByLabel(result, 'Function')).toContain('save');
|
||||
expect(getNodesByLabel(result, 'Method')).toContain('save');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -220,12 +220,11 @@ describe('Kotlin receiver-constrained resolution', () => {
|
|||
);
|
||||
}, 60000);
|
||||
|
||||
it('detects User and Repo classes, both with save functions', () => {
|
||||
it('detects User and Repo classes, both with save methods', () => {
|
||||
expect(getNodesByLabel(result, 'Class')).toContain('User');
|
||||
expect(getNodesByLabel(result, 'Class')).toContain('Repo');
|
||||
// Kotlin tree-sitter captures all function_declaration as Function
|
||||
const saveFns = getNodesByLabel(result, 'Function').filter(m => m === 'save');
|
||||
expect(saveFns.length).toBe(2);
|
||||
const saveMethods = getNodesByLabel(result, 'Method').filter(m => m === 'save');
|
||||
expect(saveMethods.length).toBe(2);
|
||||
});
|
||||
|
||||
it('resolves user.save() to User.save and repo.save() to Repo.save via receiver typing', () => {
|
||||
|
|
@ -259,9 +258,8 @@ describe('Kotlin alias import resolution', () => {
|
|||
|
||||
it('detects User and Repo classes with their methods', () => {
|
||||
expect(getNodesByLabel(result, 'Class')).toEqual(['Repo', 'User']);
|
||||
// Kotlin tree-sitter captures all function_declaration as Function, including class methods
|
||||
expect(getNodesByLabel(result, 'Function')).toContain('save');
|
||||
expect(getNodesByLabel(result, 'Function')).toContain('persist');
|
||||
expect(getNodesByLabel(result, 'Method')).toContain('save');
|
||||
expect(getNodesByLabel(result, 'Method')).toContain('persist');
|
||||
});
|
||||
|
||||
it('resolves u.save() to models/Models.kt and r.persist() to models/Models.kt via alias', () => {
|
||||
|
|
@ -295,7 +293,7 @@ describe('Kotlin constructor-call resolution', () => {
|
|||
|
||||
it('detects User class with save method and main function', () => {
|
||||
expect(getNodesByLabel(result, 'Class')).toContain('User');
|
||||
expect(getNodesByLabel(result, 'Function')).toContain('save');
|
||||
expect(getNodesByLabel(result, 'Method')).toContain('save');
|
||||
expect(getNodesByLabel(result, 'Function')).toContain('main');
|
||||
});
|
||||
|
||||
|
|
@ -395,11 +393,11 @@ describe('Kotlin constructor-inferred type resolution', () => {
|
|||
);
|
||||
}, 60000);
|
||||
|
||||
it('detects User and Repo classes, both with save functions', () => {
|
||||
it('detects User and Repo classes, both with save methods', () => {
|
||||
expect(getNodesByLabel(result, 'Class')).toContain('User');
|
||||
expect(getNodesByLabel(result, 'Class')).toContain('Repo');
|
||||
const saveFns = getNodesByLabel(result, 'Function').filter(m => m === 'save');
|
||||
expect(saveFns.length).toBe(2);
|
||||
const saveMethods = getNodesByLabel(result, 'Method').filter(m => m === 'save');
|
||||
expect(saveMethods.length).toBe(2);
|
||||
});
|
||||
|
||||
it('resolves user.save() to models/User.kt via constructor-inferred type', () => {
|
||||
|
|
@ -477,8 +475,8 @@ describe('Kotlin return type inference', () => {
|
|||
it('detects User and Repo classes with competing save methods', () => {
|
||||
expect(getNodesByLabel(result, 'Class')).toContain('User');
|
||||
expect(getNodesByLabel(result, 'Class')).toContain('Repo');
|
||||
const saveFns = getNodesByLabel(result, 'Function').filter(f => f === 'save');
|
||||
expect(saveFns.length).toBe(2);
|
||||
const saveMethods = getNodesByLabel(result, 'Method').filter(m => m === 'save');
|
||||
expect(saveMethods.length).toBe(2);
|
||||
});
|
||||
|
||||
it('resolves user.save() to User#save via return type inference', () => {
|
||||
|
|
@ -590,11 +588,11 @@ describe('Kotlin for-each loop type resolution', () => {
|
|||
);
|
||||
}, 60000);
|
||||
|
||||
it('detects User and Repo classes, both with save functions', () => {
|
||||
it('detects User and Repo classes, both with save methods', () => {
|
||||
expect(getNodesByLabel(result, 'Class')).toContain('User');
|
||||
expect(getNodesByLabel(result, 'Class')).toContain('Repo');
|
||||
const saveFns = getNodesByLabel(result, 'Function').filter(f => f === 'save');
|
||||
expect(saveFns.length).toBe(2);
|
||||
const saveMethods = getNodesByLabel(result, 'Method').filter(m => m === 'save');
|
||||
expect(saveMethods.length).toBe(2);
|
||||
});
|
||||
|
||||
it('resolves user.save() inside for-each to models/User.kt', () => {
|
||||
|
|
@ -673,7 +671,7 @@ describe('Kotlin nullable receiver resolution (safe calls)', () => {
|
|||
it('detects User and Repo classes with competing save methods', () => {
|
||||
expect(getNodesByLabel(result, 'Class')).toContain('User');
|
||||
expect(getNodesByLabel(result, 'Class')).toContain('Repo');
|
||||
const saveMethods = getNodesByLabel(result, 'Function').filter((m: string) => m === 'save');
|
||||
const saveMethods = getNodesByLabel(result, 'Method').filter((m: string) => m === 'save');
|
||||
expect(saveMethods.length).toBe(2);
|
||||
});
|
||||
|
||||
|
|
@ -717,11 +715,11 @@ describe('Kotlin assignment chain propagation', () => {
|
|||
);
|
||||
}, 60000);
|
||||
|
||||
it('detects User and Repo classes each with a save function', () => {
|
||||
it('detects User and Repo classes each with a save method', () => {
|
||||
expect(getNodesByLabel(result, 'Class')).toContain('User');
|
||||
expect(getNodesByLabel(result, 'Class')).toContain('Repo');
|
||||
const saveFns = getNodesByLabel(result, 'Function').filter(f => f === 'save');
|
||||
expect(saveFns.length).toBe(2);
|
||||
const saveMethods = getNodesByLabel(result, 'Method').filter(m => m === 'save');
|
||||
expect(saveMethods.length).toBe(2);
|
||||
});
|
||||
|
||||
it('resolves alias.save() to User#save via assignment chain', () => {
|
||||
|
|
@ -780,11 +778,11 @@ describe('Kotlin assignment chain inside class method', () => {
|
|||
);
|
||||
}, 60000);
|
||||
|
||||
it('detects User and Repo classes each with a save function', () => {
|
||||
it('detects User and Repo classes each with a save method', () => {
|
||||
expect(getNodesByLabel(result, 'Class')).toContain('User');
|
||||
expect(getNodesByLabel(result, 'Class')).toContain('Repo');
|
||||
const saveFns = getNodesByLabel(result, 'Function').filter(m => m === 'save');
|
||||
expect(saveFns.length).toBe(2);
|
||||
const saveMethods = getNodesByLabel(result, 'Method').filter(m => m === 'save');
|
||||
expect(saveMethods.length).toBe(2);
|
||||
});
|
||||
|
||||
it('resolves alias.save() to User#save via chain inside function', () => {
|
||||
|
|
@ -843,10 +841,10 @@ describe('Kotlin chained method call resolution (Phase 5 review fix)', () => {
|
|||
expect(classes).toContain('UserService');
|
||||
});
|
||||
|
||||
it('detects getUser and save functions', () => {
|
||||
const fns = getNodesByLabel(result, 'Function');
|
||||
expect(fns).toContain('getUser');
|
||||
expect(fns).toContain('save');
|
||||
it('detects getUser and save methods', () => {
|
||||
const methods = getNodesByLabel(result, 'Method');
|
||||
expect(methods).toContain('getUser');
|
||||
expect(methods).toContain('save');
|
||||
});
|
||||
|
||||
it('resolves svc.getUser().save() to User#save via chain resolution', () => {
|
||||
|
|
@ -928,11 +926,11 @@ describe('Kotlin when/is pattern binding', () => {
|
|||
);
|
||||
}, 60000);
|
||||
|
||||
it('detects User and Repo classes, both with save functions', () => {
|
||||
it('detects User and Repo classes, both with save methods', () => {
|
||||
expect(getNodesByLabel(result, 'Class')).toContain('User');
|
||||
expect(getNodesByLabel(result, 'Class')).toContain('Repo');
|
||||
const saveFns = getNodesByLabel(result, 'Function').filter(f => f === 'save');
|
||||
expect(saveFns.length).toBe(2);
|
||||
const saveMethods = getNodesByLabel(result, 'Method').filter(m => m === 'save');
|
||||
expect(saveMethods.length).toBe(2);
|
||||
});
|
||||
|
||||
it('resolves obj.save() in when/is User arm to models/User.kt', () => {
|
||||
|
|
@ -1182,8 +1180,8 @@ describe('Kotlin for-loop call_expression iterable resolution (Phase 7.3)', () =
|
|||
it('detects User and Repo classes with competing save methods', () => {
|
||||
expect(getNodesByLabel(result, 'Class')).toContain('User');
|
||||
expect(getNodesByLabel(result, 'Class')).toContain('Repo');
|
||||
const saveFns = getNodesByLabel(result, 'Function').filter(f => f === 'save');
|
||||
expect(saveFns.length).toBe(2);
|
||||
const saveMethods = getNodesByLabel(result, 'Method').filter(m => m === 'save');
|
||||
expect(saveMethods.length).toBe(2);
|
||||
});
|
||||
|
||||
it('resolves user.save() in for-loop over getUsers() to User#save', () => {
|
||||
|
|
@ -1545,8 +1543,8 @@ describe('Kotlin overload disambiguation by parameter types', () => {
|
|||
);
|
||||
}, 60000);
|
||||
|
||||
it('detects lookup function with parameterTypes on graph node', () => {
|
||||
const nodes = getNodesByLabelFull(result, 'Function');
|
||||
it('detects lookup method with parameterTypes on graph node', () => {
|
||||
const nodes = getNodesByLabelFull(result, 'Method');
|
||||
const lookupNodes = nodes.filter(m => m.name === 'lookup');
|
||||
expect(lookupNodes.length).toBe(1);
|
||||
expect(lookupNodes[0].properties.parameterTypes).toEqual(['Int']);
|
||||
|
|
@ -1604,3 +1602,69 @@ describe('Kotlin default parameter arity resolution', () => {
|
|||
expect(greetCalls.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 14: Cross-file binding propagation
|
||||
// models/User.kt exports top-level getUser(): User
|
||||
// app/App.kt imports getUser, calls val u = getUser(); u.save(); u.getName()
|
||||
// → u is typed User via cross-file return type propagation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Kotlin cross-file binding propagation', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(
|
||||
path.join(CROSS_FILE_FIXTURES, 'kotlin-cross-file'),
|
||||
() => {},
|
||||
);
|
||||
}, 60000);
|
||||
|
||||
it('detects User class with save and getName methods', () => {
|
||||
expect(getNodesByLabel(result, 'Class')).toContain('User');
|
||||
expect(getNodesByLabel(result, 'Method')).toContain('save');
|
||||
expect(getNodesByLabel(result, 'Method')).toContain('getName');
|
||||
});
|
||||
|
||||
it('detects getUser and App class with run method', () => {
|
||||
expect(getNodesByLabel(result, 'Function')).toContain('getUser');
|
||||
expect(getNodesByLabel(result, 'Class')).toContain('App');
|
||||
expect(getNodesByLabel(result, 'Method')).toContain('run');
|
||||
});
|
||||
|
||||
it('emits IMPORTS edge from App.kt to User.kt', () => {
|
||||
const imports = getRelationships(result, 'IMPORTS');
|
||||
const edge = imports.find(e =>
|
||||
e.sourceFilePath.includes('App') && e.targetFilePath.includes('User'),
|
||||
);
|
||||
expect(edge).toBeDefined();
|
||||
});
|
||||
|
||||
it('resolves u.save() in run() to User#save via cross-file return type propagation', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const saveCall = calls.find(c =>
|
||||
c.target === 'save' &&
|
||||
c.source === 'run' &&
|
||||
c.targetFilePath.includes('User'),
|
||||
);
|
||||
expect(saveCall).toBeDefined();
|
||||
});
|
||||
|
||||
it('resolves u.getName() in run() to User#getName via cross-file return type propagation', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const getNameCall = calls.find(c =>
|
||||
c.target === 'getName' &&
|
||||
c.source === 'run' &&
|
||||
c.targetFilePath.includes('User'),
|
||||
);
|
||||
expect(getNameCall).toBeDefined();
|
||||
});
|
||||
|
||||
it('emits HAS_METHOD edges linking save and getName to User', () => {
|
||||
const hasMethod = getRelationships(result, 'HAS_METHOD');
|
||||
const saveEdge = hasMethod.find(e => e.source === 'User' && e.target === 'save');
|
||||
const getNameEdge = hasMethod.find(e => e.source === 'User' && e.target === 'getName');
|
||||
expect(saveEdge).toBeDefined();
|
||||
expect(getNameEdge).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import path from 'path';
|
||||
import {
|
||||
FIXTURES, getRelationships, getNodesByLabel, edgeSet,
|
||||
FIXTURES, CROSS_FILE_FIXTURES, getRelationships, getNodesByLabel, edgeSet,
|
||||
runPipelineFromRepo, type PipelineResult,
|
||||
} from './helpers.js';
|
||||
|
||||
|
|
@ -1449,3 +1449,69 @@ describe('PHP grandparent method resolution via MRO (Phase B)', () => {
|
|||
expect(greetCall).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 14: Cross-file binding propagation
|
||||
// Models/UserFactory.php exports function getUser(): User
|
||||
// Main.php imports getUser via use function, calls $u = getUser(); $u->save()
|
||||
// → $u is typed User via cross-file return type propagation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('PHP cross-file binding propagation', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(
|
||||
path.join(CROSS_FILE_FIXTURES, 'php-cross-file'),
|
||||
() => {},
|
||||
);
|
||||
}, 60000);
|
||||
|
||||
it('detects User class with save and getName methods', () => {
|
||||
expect(getNodesByLabel(result, 'Class')).toContain('User');
|
||||
expect(getNodesByLabel(result, 'Method')).toContain('save');
|
||||
expect(getNodesByLabel(result, 'Method')).toContain('getName');
|
||||
});
|
||||
|
||||
it('detects getUser function and Main class with run method', () => {
|
||||
expect(getNodesByLabel(result, 'Function')).toContain('getUser');
|
||||
expect(getNodesByLabel(result, 'Class')).toContain('Main');
|
||||
expect(getNodesByLabel(result, 'Method')).toContain('run');
|
||||
});
|
||||
|
||||
it('emits IMPORTS edge from Main.php to UserFactory.php', () => {
|
||||
const imports = getRelationships(result, 'IMPORTS');
|
||||
const edge = imports.find(e =>
|
||||
e.sourceFilePath.includes('Main') && e.targetFilePath.includes('UserFactory'),
|
||||
);
|
||||
expect(edge).toBeDefined();
|
||||
});
|
||||
|
||||
it('resolves $u->save() in run() to User#save via cross-file return type propagation', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const saveCall = calls.find(c =>
|
||||
c.target === 'save' &&
|
||||
c.source === 'run' &&
|
||||
c.targetFilePath.includes('User.php'),
|
||||
);
|
||||
expect(saveCall).toBeDefined();
|
||||
});
|
||||
|
||||
it('resolves $u->getName() in run() to User#getName via cross-file return type propagation', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const getNameCall = calls.find(c =>
|
||||
c.target === 'getName' &&
|
||||
c.source === 'run' &&
|
||||
c.targetFilePath.includes('User.php'),
|
||||
);
|
||||
expect(getNameCall).toBeDefined();
|
||||
});
|
||||
|
||||
it('emits HAS_METHOD edges linking save and getName to User', () => {
|
||||
const hasMethod = getRelationships(result, 'HAS_METHOD');
|
||||
const saveEdge = hasMethod.find(e => e.source === 'User' && e.target === 'save');
|
||||
const getNameEdge = hasMethod.find(e => e.source === 'User' && e.target === 'getName');
|
||||
expect(saveEdge).toBeDefined();
|
||||
expect(getNameEdge).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import path from 'path';
|
||||
import {
|
||||
FIXTURES, getRelationships, getNodesByLabel, edgeSet,
|
||||
FIXTURES, CROSS_FILE_FIXTURES, getRelationships, getNodesByLabel, edgeSet,
|
||||
runPipelineFromRepo, type PipelineResult,
|
||||
} from './helpers.js';
|
||||
|
||||
|
|
@ -1499,3 +1499,68 @@ describe('Python default parameter arity resolution', () => {
|
|||
expect(searchCalls.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 14: Cross-file binding propagation
|
||||
// models.py exports get_user() -> User
|
||||
// app.py imports get_user, calls u = get_user(); u.save(); u.get_name()
|
||||
// → u is typed User via cross-file return type propagation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Python cross-file binding propagation', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(
|
||||
path.join(CROSS_FILE_FIXTURES, 'py-cross-file'),
|
||||
() => {},
|
||||
);
|
||||
}, 60000);
|
||||
|
||||
it('detects User class with save and get_name methods', () => {
|
||||
expect(getNodesByLabel(result, 'Class')).toContain('User');
|
||||
expect(getNodesByLabel(result, 'Function')).toContain('save');
|
||||
expect(getNodesByLabel(result, 'Function')).toContain('get_name');
|
||||
});
|
||||
|
||||
it('detects get_user and run functions', () => {
|
||||
expect(getNodesByLabel(result, 'Function')).toContain('get_user');
|
||||
expect(getNodesByLabel(result, 'Function')).toContain('run');
|
||||
});
|
||||
|
||||
it('emits IMPORTS edge from app.py to models.py', () => {
|
||||
const imports = getRelationships(result, 'IMPORTS');
|
||||
const edge = imports.find(e =>
|
||||
e.sourceFilePath.includes('app') && e.targetFilePath.includes('models'),
|
||||
);
|
||||
expect(edge).toBeDefined();
|
||||
});
|
||||
|
||||
it('resolves u.save() in run() to User#save via cross-file return type propagation', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const saveCall = calls.find(c =>
|
||||
c.target === 'save' &&
|
||||
c.source === 'run' &&
|
||||
c.targetFilePath.includes('models'),
|
||||
);
|
||||
expect(saveCall).toBeDefined();
|
||||
});
|
||||
|
||||
it('resolves u.get_name() in run() to User#get_name via cross-file return type propagation', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const getNameCall = calls.find(c =>
|
||||
c.target === 'get_name' &&
|
||||
c.source === 'run' &&
|
||||
c.targetFilePath.includes('models'),
|
||||
);
|
||||
expect(getNameCall).toBeDefined();
|
||||
});
|
||||
|
||||
it('emits HAS_METHOD edges linking save and get_name to User', () => {
|
||||
const hasMethod = getRelationships(result, 'HAS_METHOD');
|
||||
const saveEdge = hasMethod.find(e => e.source === 'User' && e.target === 'save');
|
||||
const getNameEdge = hasMethod.find(e => e.source === 'User' && e.target === 'get_name');
|
||||
expect(saveEdge).toBeDefined();
|
||||
expect(getNameEdge).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { topologicalLevelSort, computeImportCycleSCCs } from '../../src/core/ingestion/pipeline.js';
|
||||
import { topologicalLevelSort } from '../../src/core/ingestion/pipeline.js';
|
||||
|
||||
describe('topologicalLevelSort', () => {
|
||||
it('returns empty levels for empty graph', () => {
|
||||
|
|
@ -148,80 +148,3 @@ describe('topologicalLevelSort', () => {
|
|||
expect(uniqueFiles.size).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeImportCycleSCCs', () => {
|
||||
it('returns no SCCs for empty cycle nodes', () => {
|
||||
const importMap = new Map<string, Set<string>>();
|
||||
const result = computeImportCycleSCCs(importMap, []);
|
||||
expect(result.sccs).toHaveLength(0);
|
||||
expect(result.cycleFiles).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('detects a simple two-node cycle A→B→A as one SCC', () => {
|
||||
const importMap = new Map<string, Set<string>>([
|
||||
['a.ts', new Set(['b.ts'])],
|
||||
['b.ts', new Set(['a.ts'])],
|
||||
]);
|
||||
const result = computeImportCycleSCCs(importMap, ['a.ts', 'b.ts']);
|
||||
expect(result.sccs).toHaveLength(1);
|
||||
expect(result.sccs[0]).toHaveLength(2);
|
||||
expect(result.sccs[0]).toContain('a.ts');
|
||||
expect(result.sccs[0]).toContain('b.ts');
|
||||
expect(result.cycleFiles).toContain('a.ts');
|
||||
expect(result.cycleFiles).toContain('b.ts');
|
||||
});
|
||||
|
||||
it('detects two independent cycles as two SCCs', () => {
|
||||
// A→B→A and C→D→C are separate cycles
|
||||
const importMap = new Map<string, Set<string>>([
|
||||
['a.ts', new Set(['b.ts'])],
|
||||
['b.ts', new Set(['a.ts'])],
|
||||
['c.ts', new Set(['d.ts'])],
|
||||
['d.ts', new Set(['c.ts'])],
|
||||
]);
|
||||
const result = computeImportCycleSCCs(importMap, ['a.ts', 'b.ts', 'c.ts', 'd.ts']);
|
||||
expect(result.sccs).toHaveLength(2);
|
||||
const allInSccs = result.sccs.flat();
|
||||
expect(allInSccs).toContain('a.ts');
|
||||
expect(allInSccs).toContain('b.ts');
|
||||
expect(allInSccs).toContain('c.ts');
|
||||
expect(allInSccs).toContain('d.ts');
|
||||
});
|
||||
|
||||
it('filters out single-node components (not true cycles)', () => {
|
||||
// A single node with no self-loop is not an SCC of size ≥2
|
||||
const importMap = new Map<string, Set<string>>([
|
||||
['a.ts', new Set()],
|
||||
]);
|
||||
const result = computeImportCycleSCCs(importMap, ['a.ts']);
|
||||
expect(result.sccs).toHaveLength(0);
|
||||
expect(result.cycleFiles).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('detects a three-node cycle A→B→C→A as one SCC alongside an independent two-node cycle', () => {
|
||||
const importMap = new Map<string, Set<string>>([
|
||||
['a.ts', new Set(['b.ts'])],
|
||||
['b.ts', new Set(['c.ts'])],
|
||||
['c.ts', new Set(['a.ts'])],
|
||||
['d.ts', new Set(['e.ts'])],
|
||||
['e.ts', new Set(['d.ts'])],
|
||||
]);
|
||||
const result = computeImportCycleSCCs(importMap, ['a.ts', 'b.ts', 'c.ts', 'd.ts', 'e.ts']);
|
||||
expect(result.sccs).toHaveLength(2);
|
||||
const sizes = result.sccs.map(s => s.length).sort((x, y) => x - y);
|
||||
expect(sizes).toEqual([2, 3]);
|
||||
});
|
||||
|
||||
it('cycleFiles is the union of all SCC members', () => {
|
||||
const importMap = new Map<string, Set<string>>([
|
||||
['a.ts', new Set(['b.ts'])],
|
||||
['b.ts', new Set(['a.ts'])],
|
||||
['c.ts', new Set(['d.ts'])],
|
||||
['d.ts', new Set(['c.ts'])],
|
||||
]);
|
||||
const cycleNodes = ['a.ts', 'b.ts', 'c.ts', 'd.ts'];
|
||||
const result = computeImportCycleSCCs(importMap, cycleNodes);
|
||||
expect(new Set(result.cycleFiles)).toEqual(new Set(result.sccs.flat()));
|
||||
expect(result.cycleFiles).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue