mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-30 06:42:43 +00:00
Merge pull request #397 from abhigyanpatwari/feat/phase14-cross-file-binding-propagation
feat(type-resolution): cross-file binding propagation
This commit is contained in:
commit
ef8316e3fb
72 changed files with 2656 additions and 145 deletions
|
|
@ -1,7 +1,7 @@
|
|||
<!-- gitnexus:start -->
|
||||
# GitNexus — Code Intelligence
|
||||
|
||||
This project is indexed by GitNexus as **GitNexus** (2184 symbols, 5245 relationships, 167 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
This project is indexed by GitNexus as **GitNexus** (2227 symbols, 5390 relationships, 170 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
|
||||
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<!-- gitnexus:start -->
|
||||
# GitNexus — Code Intelligence
|
||||
|
||||
This project is indexed by GitNexus as **GitNexus** (2184 symbols, 5245 relationships, 167 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
This project is indexed by GitNexus as **GitNexus** (2227 symbols, 5390 relationships, 170 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
|
||||
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { KnowledgeGraph } from '../graph/types.js';
|
||||
import { ASTCache } from './ast-cache.js';
|
||||
import type { SymbolDefinition } from './symbol-table.js';
|
||||
import type { SymbolDefinition, SymbolTable } from './symbol-table.js';
|
||||
import Parser from 'tree-sitter';
|
||||
import type { ResolutionContext } from './resolution-context.js';
|
||||
import { TIER_CONFIDENCE, type ResolutionTier } from './resolution-context.js';
|
||||
|
|
@ -33,6 +33,145 @@ import { typeConfigs } from './type-extractors/index.js';
|
|||
import type { LiteralTypeInferrer } from './type-extractors/types.js';
|
||||
import type { SyntaxNode } from './utils.js';
|
||||
|
||||
/** Per-file resolved type bindings for exported symbols.
|
||||
* Populated during call processing, consumed by Phase 14 re-resolution pass. */
|
||||
export type ExportedTypeMap = Map<string, Map<string, string>>;
|
||||
|
||||
const MAX_EXPORTS_PER_FILE = 500;
|
||||
const MAX_TYPE_NAME_LENGTH = 256;
|
||||
|
||||
/** Build a map of imported callee names → return types for cross-file call-result binding.
|
||||
* Consulted ONLY when SymbolTable has no unambiguous local match (local-first principle). */
|
||||
export function buildImportedReturnTypes(
|
||||
filePath: string,
|
||||
namedImportMap: ReadonlyMap<string, ReadonlyMap<string, { sourcePath: string; exportedName: string }>>,
|
||||
symbolTable: { lookupExactFull(filePath: string, name: string): { returnType?: string } | undefined },
|
||||
): ReadonlyMap<string, string> {
|
||||
const result = new Map<string, string>();
|
||||
const fileImports = namedImportMap.get(filePath);
|
||||
if (!fileImports) return result;
|
||||
|
||||
for (const [localName, binding] of fileImports) {
|
||||
const def = symbolTable.lookupExactFull(binding.sourcePath, binding.exportedName);
|
||||
if (!def?.returnType) continue;
|
||||
const simpleReturn = extractReturnTypeName(def.returnType);
|
||||
if (simpleReturn) result.set(localName, simpleReturn);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Build cross-file RAW return types for imported callables.
|
||||
* Unlike buildImportedReturnTypes (which stores extractReturnTypeName output),
|
||||
* this stores the raw declared return type string (e.g., 'User[]', 'List<User>').
|
||||
* Used by lookupRawReturnType for for-loop element extraction via extractElementTypeFromString. */
|
||||
export function buildImportedRawReturnTypes(
|
||||
filePath: string,
|
||||
namedImportMap: ReadonlyMap<string, ReadonlyMap<string, { sourcePath: string; exportedName: string }>>,
|
||||
symbolTable: { lookupExactFull(filePath: string, name: string): { returnType?: string } | undefined },
|
||||
): ReadonlyMap<string, string> {
|
||||
const result = new Map<string, string>();
|
||||
const fileImports = namedImportMap.get(filePath);
|
||||
if (!fileImports) return result;
|
||||
|
||||
for (const [localName, binding] of fileImports) {
|
||||
const def = symbolTable.lookupExactFull(binding.sourcePath, binding.exportedName);
|
||||
if (!def?.returnType) continue;
|
||||
result.set(localName, def.returnType);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Collect resolved type bindings for exported file-scope symbols.
|
||||
* Uses graph node isExported flag — does NOT require isExported on SymbolDefinition. */
|
||||
function collectExportedBindings(
|
||||
typeEnv: { readonly env: ReadonlyMap<string, ReadonlyMap<string, string>> },
|
||||
filePath: string,
|
||||
symbolTable: { lookupExact(filePath: string, name: string): string | undefined },
|
||||
graph: { getNode(id: string): { properties?: { isExported?: boolean } } | undefined },
|
||||
): Map<string, string> | null {
|
||||
const fileScope = typeEnv.env.get('');
|
||||
if (!fileScope || fileScope.size === 0) return null;
|
||||
|
||||
const exported = new Map<string, string>();
|
||||
for (const [varName, typeName] of fileScope) {
|
||||
if (exported.size >= MAX_EXPORTS_PER_FILE) break;
|
||||
if (!typeName || typeName.length > MAX_TYPE_NAME_LENGTH) continue;
|
||||
const nodeId = symbolTable.lookupExact(filePath, varName);
|
||||
if (!nodeId) continue;
|
||||
const node = graph.getNode(nodeId);
|
||||
if (node?.properties?.isExported) {
|
||||
exported.set(varName, typeName);
|
||||
}
|
||||
}
|
||||
return exported.size > 0 ? exported : null;
|
||||
}
|
||||
|
||||
/** Build ExportedTypeMap from graph nodes — used for worker path where TypeEnv
|
||||
* is not available in the main thread. Collects returnType/declaredType from
|
||||
* exported symbols that have callables with known return types. */
|
||||
export function buildExportedTypeMapFromGraph(
|
||||
graph: KnowledgeGraph,
|
||||
symbolTable: SymbolTable,
|
||||
): ExportedTypeMap {
|
||||
const result: ExportedTypeMap = new Map();
|
||||
graph.forEachNode(node => {
|
||||
if (!node.properties?.isExported) return;
|
||||
if (!node.properties?.filePath || !node.properties?.name) return;
|
||||
const filePath = node.properties.filePath as string;
|
||||
const name = node.properties.name as string;
|
||||
if (!name || name.length > MAX_TYPE_NAME_LENGTH) return;
|
||||
// For callable symbols, use returnType; for properties/variables, use declaredType
|
||||
const def = symbolTable.lookupExactFull(filePath, name);
|
||||
if (!def) return;
|
||||
const typeName = def.returnType ?? def.declaredType;
|
||||
if (!typeName || typeName.length > MAX_TYPE_NAME_LENGTH) return;
|
||||
// Extract simple type name (strip Promise<>, etc.) — reuse shared utility
|
||||
const simpleType = extractReturnTypeName(typeName) ?? typeName;
|
||||
if (!simpleType) return;
|
||||
let fileExports = result.get(filePath);
|
||||
if (!fileExports) { fileExports = new Map(); result.set(filePath, fileExports); }
|
||||
if (fileExports.size < MAX_EXPORTS_PER_FILE) {
|
||||
fileExports.set(name, simpleType);
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Seed cross-file receiver types into pre-extracted call records.
|
||||
* Fills missing receiverTypeName for single-hop imported variables
|
||||
* using ExportedTypeMap + namedImportMap — zero disk I/O, zero AST re-parsing.
|
||||
* Mutates calls in-place. Runs BEFORE processCallsFromExtracted. */
|
||||
export function seedCrossFileReceiverTypes(
|
||||
calls: ExtractedCall[],
|
||||
namedImportMap: ReadonlyMap<string, ReadonlyMap<string, { sourcePath: string; exportedName: string }>>,
|
||||
exportedTypeMap: ReadonlyMap<string, ReadonlyMap<string, string>>,
|
||||
): { enrichedCount: number } {
|
||||
if (namedImportMap.size === 0 || exportedTypeMap.size === 0) {
|
||||
return { enrichedCount: 0 };
|
||||
}
|
||||
let enrichedCount = 0;
|
||||
for (const call of calls) {
|
||||
if (call.receiverTypeName || !call.receiverName) continue;
|
||||
if (call.callForm !== 'member') continue;
|
||||
|
||||
const fileImports = namedImportMap.get(call.filePath);
|
||||
if (!fileImports) continue;
|
||||
|
||||
const binding = fileImports.get(call.receiverName);
|
||||
if (!binding) continue;
|
||||
|
||||
const upstream = exportedTypeMap.get(binding.sourcePath);
|
||||
if (!upstream) continue;
|
||||
|
||||
const type = upstream.get(binding.exportedName);
|
||||
if (type) {
|
||||
call.receiverTypeName = type;
|
||||
enrichedCount++;
|
||||
}
|
||||
}
|
||||
return { enrichedCount };
|
||||
}
|
||||
|
||||
// Stdlib methods that preserve the receiver's type identity. When TypeEnv already
|
||||
// strips nullable wrappers (Option<User> → User), these chain steps are no-ops
|
||||
// for type resolution — the current type passes through unchanged.
|
||||
|
|
@ -48,7 +187,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 => {
|
||||
|
|
@ -138,6 +277,14 @@ export const processCalls = async (
|
|||
astCache: ASTCache,
|
||||
ctx: ResolutionContext,
|
||||
onProgress?: (current: number, total: number) => void,
|
||||
exportedTypeMap?: ExportedTypeMap,
|
||||
/** Phase 14: pre-resolved cross-file bindings to seed into buildTypeEnv. Keyed by filePath → Map<localName, typeName>. */
|
||||
importedBindingsMap?: ReadonlyMap<string, ReadonlyMap<string, string>>,
|
||||
/** Phase 14 E3: cross-file return types for imported callables. Keyed by filePath → Map<calleeName, returnType>.
|
||||
* Consulted ONLY when SymbolTable has no unambiguous match (local-first principle). */
|
||||
importedReturnTypesMap?: ReadonlyMap<string, ReadonlyMap<string, string>>,
|
||||
/** Phase 14 E3: cross-file RAW return types for for-loop element extraction. Keyed by filePath → Map<calleeName, rawReturnType>. */
|
||||
importedRawReturnTypesMap?: ReadonlyMap<string, ReadonlyMap<string, string>>,
|
||||
): Promise<ExtractedHeritage[]> => {
|
||||
const parser = await loadParser();
|
||||
const collectedHeritage: ExtractedHeritage[] = [];
|
||||
|
|
@ -223,7 +370,14 @@ export const processCalls = async (
|
|||
}
|
||||
}
|
||||
|
||||
const typeEnv = lang ? buildTypeEnv(tree, lang, { symbolTable: ctx.symbols, parentMap }) : null;
|
||||
const importedBindings = importedBindingsMap?.get(file.path);
|
||||
const importedReturnTypes = importedReturnTypesMap?.get(file.path);
|
||||
const importedRawReturnTypes = importedRawReturnTypesMap?.get(file.path);
|
||||
const typeEnv = lang ? buildTypeEnv(tree, lang, { symbolTable: ctx.symbols, parentMap, importedBindings, importedReturnTypes, importedRawReturnTypes }) : null;
|
||||
if (typeEnv && exportedTypeMap) {
|
||||
const fileExports = collectExportedBindings(typeEnv, file.path, ctx.symbols, graph);
|
||||
if (fileExports) exportedTypeMap.set(file.path, fileExports);
|
||||
}
|
||||
const callRouter = callRouters[language];
|
||||
|
||||
const verifiedReceivers = typeEnv && typeEnv.constructorBindings.length > 0
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -293,13 +320,22 @@ function applyImportResult(
|
|||
addImportEdge(filePath, resolvedFile);
|
||||
}
|
||||
|
||||
// Record named bindings for precise Tier 2a resolution
|
||||
// Record named bindings for precise Tier 2a resolution.
|
||||
// If the same local name is imported from multiple files (e.g., Java static imports
|
||||
// of overloaded methods), remove the entry so resolution falls through to Tier 2a
|
||||
// import-scoped which sees all candidates and can apply arity narrowing.
|
||||
if (namedBindings && namedImportMap && files.length === 1) {
|
||||
const resolvedFile = files[0];
|
||||
if (!namedImportMap.has(filePath)) namedImportMap.set(filePath, new Map());
|
||||
const fileBindings = namedImportMap.get(filePath)!;
|
||||
for (const binding of namedBindings) {
|
||||
fileBindings.set(binding.local, { sourcePath: resolvedFile, exportedName: binding.exported });
|
||||
const existing = fileBindings.get(binding.local);
|
||||
if (existing && existing.sourcePath !== resolvedFile) {
|
||||
// Ambiguous: same name imported from different files — remove to fall through
|
||||
fileBindings.delete(binding.local);
|
||||
} else {
|
||||
fileBindings.set(binding.local, { sourcePath: resolvedFile, exportedName: binding.exported });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -89,6 +89,15 @@ export function extractNamedBindings(
|
|||
if (language === SupportedLanguages.Java) {
|
||||
return extractJavaNamedBindings(importNode);
|
||||
}
|
||||
// Languages below use whole-module import semantics — the import AST node does not
|
||||
// name specific symbols. namedImportMap entries are synthesized post-parse by
|
||||
// synthesizeWildcardImportBindings() in pipeline.ts, which expands ImportMap edges
|
||||
// into per-symbol bindings using graph-exported symbols.
|
||||
//
|
||||
// Go: `import "pkg"` — all PascalCase symbols available as pkg.Symbol
|
||||
// Ruby: `require 'file'` — all top-level classes/modules available
|
||||
// C/C++: `#include "file.h"` — textual inclusion, all non-static declarations available
|
||||
// Swift: `import Module` — entire module imported (Phase S blocked on tree-sitter-swift Node 22)
|
||||
return undefined;
|
||||
}
|
||||
|
||||
|
|
@ -201,13 +210,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 }];
|
||||
}
|
||||
|
||||
|
|
@ -331,23 +346,41 @@ export function extractPhpNamedBindings(importNode: any): { local: string; expor
|
|||
}
|
||||
|
||||
export function extractCsharpNamedBindings(importNode: any): { local: string; exported: string }[] | undefined {
|
||||
// using_directive with identifier (alias) + qualified_name (target)
|
||||
// using_directive — three forms:
|
||||
// using Alias = NS.Type; → aliasIdent + qualifiedName
|
||||
// using static NS.Type; → static + qualifiedName (no alias)
|
||||
// using NS; → qualifiedName only (namespace, not capturable)
|
||||
if (importNode.type !== 'using_directive') return undefined;
|
||||
|
||||
let aliasIdent: any = null;
|
||||
let qualifiedName: any = null;
|
||||
let isStatic = false;
|
||||
for (let i = 0; i < importNode.childCount; i++) {
|
||||
const child = importNode.child(i);
|
||||
if (child?.text === 'static') isStatic = true;
|
||||
}
|
||||
for (let i = 0; i < importNode.namedChildCount; i++) {
|
||||
const child = importNode.namedChild(i);
|
||||
if (child?.type === 'identifier' && !aliasIdent) aliasIdent = child;
|
||||
else if (child?.type === 'qualified_name') qualifiedName = child;
|
||||
}
|
||||
|
||||
if (!aliasIdent || !qualifiedName) return undefined;
|
||||
// Form 1: using Alias = NS.Type;
|
||||
if (aliasIdent && qualifiedName) {
|
||||
const fullText = qualifiedName.text;
|
||||
const exportedName = fullText.includes('.') ? fullText.split('.').pop()! : fullText;
|
||||
return [{ local: aliasIdent.text, exported: exportedName }];
|
||||
}
|
||||
|
||||
const fullText = qualifiedName.text;
|
||||
const exportedName = fullText.includes('.') ? fullText.split('.').pop()! : fullText;
|
||||
// Form 2: using static NS.Type; — last segment is the class name
|
||||
if (isStatic && qualifiedName) {
|
||||
const fullText = qualifiedName.text;
|
||||
const lastSegment = fullText.includes('.') ? fullText.split('.').pop()! : fullText;
|
||||
return [{ local: lastSegment, exported: lastSegment }];
|
||||
}
|
||||
|
||||
return [{ local: aliasIdent.text, exported: exportedName }];
|
||||
// Form 3: using NS; — namespace import, can't resolve to per-symbol bindings
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function extractJavaNamedBindings(importNode: any): { local: string; exported: string }[] | undefined {
|
||||
|
|
@ -355,10 +388,12 @@ export function extractJavaNamedBindings(importNode: any): { local: string; expo
|
|||
// Wildcard imports (.*) don't produce named bindings
|
||||
if (importNode.type !== 'import_declaration') return undefined;
|
||||
|
||||
// Check for asterisk (wildcard import) — skip those
|
||||
// Check for asterisk (wildcard import) and static modifier
|
||||
let isStatic = false;
|
||||
for (let i = 0; i < importNode.childCount; i++) {
|
||||
const child = importNode.child(i);
|
||||
if (child?.type === 'asterisk') return undefined;
|
||||
if (child?.text === 'static') isStatic = true;
|
||||
}
|
||||
|
||||
const scopedId = findChild(importNode, 'scoped_identifier');
|
||||
|
|
@ -368,11 +403,12 @@ export function extractJavaNamedBindings(importNode: any): { local: string; expo
|
|||
const lastDot = fullText.lastIndexOf('.');
|
||||
if (lastDot === -1) return undefined;
|
||||
|
||||
const className = fullText.slice(lastDot + 1);
|
||||
// Skip lowercase names — those are package imports, not class imports
|
||||
if (className[0] && className[0] === className[0].toLowerCase()) return undefined;
|
||||
const name = fullText.slice(lastDot + 1);
|
||||
// Non-static: skip lowercase names — those are package imports, not class imports.
|
||||
// Static: allow lowercase — `import static models.UserFactory.getUser` imports a method.
|
||||
if (!isStatic && name[0] && name[0] === name[0].toLowerCase()) return undefined;
|
||||
|
||||
return [{ local: className, exported: className }];
|
||||
return [{ local: name, exported: name }];
|
||||
}
|
||||
|
||||
function findChild(node: any, type: string): any {
|
||||
|
|
|
|||
|
|
@ -5,14 +5,14 @@ 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';
|
||||
import { typeConfigs } from './type-extractors/index.js';
|
||||
import { SupportedLanguages } from '../../config/supported-languages.js';
|
||||
import { WorkerPool } from './workers/worker-pool.js';
|
||||
import type { ParseWorkerResult, ParseWorkerInput, ExtractedImport, ExtractedCall, ExtractedAssignment, ExtractedHeritage, ExtractedRoute, FileConstructorBindings } from './workers/parse-worker.js';
|
||||
import type { ParseWorkerResult, ParseWorkerInput, ExtractedImport, ExtractedCall, ExtractedAssignment, ExtractedHeritage, ExtractedRoute, FileConstructorBindings, FileTypeEnvBindings } from './workers/parse-worker.js';
|
||||
import { getTreeSitterBufferSize, TREE_SITTER_MAX_BUFFER } from './constants.js';
|
||||
|
||||
export type FileProgressCallback = (current: number, total: number, filePath: string) => void;
|
||||
|
|
@ -24,6 +24,7 @@ export interface WorkerExtractedData {
|
|||
heritage: ExtractedHeritage[];
|
||||
routes: ExtractedRoute[];
|
||||
constructorBindings: FileConstructorBindings[];
|
||||
typeEnvBindings: FileTypeEnvBindings[];
|
||||
}
|
||||
|
||||
// isNodeExported imported from ./export-detection.js (shared module)
|
||||
|
|
@ -49,7 +50,7 @@ const processParsingWithWorkers = async (
|
|||
if (lang) parseableFiles.push({ path: file.path, content: file.content });
|
||||
}
|
||||
|
||||
if (parseableFiles.length === 0) return { imports: [], calls: [], assignments: [], heritage: [], routes: [], constructorBindings: [] };
|
||||
if (parseableFiles.length === 0) return { imports: [], calls: [], assignments: [], heritage: [], routes: [], constructorBindings: [], typeEnvBindings: [] };
|
||||
|
||||
const total = files.length;
|
||||
|
||||
|
|
@ -68,6 +69,7 @@ const processParsingWithWorkers = async (
|
|||
const allHeritage: ExtractedHeritage[] = [];
|
||||
const allRoutes: ExtractedRoute[] = [];
|
||||
const allConstructorBindings: FileConstructorBindings[] = [];
|
||||
const allTypeEnvBindings: FileTypeEnvBindings[] = [];
|
||||
for (const result of chunkResults) {
|
||||
for (const node of result.nodes) {
|
||||
graph.addNode({
|
||||
|
|
@ -98,6 +100,7 @@ const processParsingWithWorkers = async (
|
|||
allHeritage.push(...result.heritage);
|
||||
allRoutes.push(...result.routes);
|
||||
allConstructorBindings.push(...result.constructorBindings);
|
||||
allTypeEnvBindings.push(...result.typeEnvBindings);
|
||||
}
|
||||
|
||||
// Merge and log skipped languages from workers
|
||||
|
|
@ -116,7 +119,7 @@ const processParsingWithWorkers = async (
|
|||
|
||||
// Final progress
|
||||
onFileProgress?.(total, total, 'done');
|
||||
return { imports: allImports, calls: allCalls, assignments: allAssignments, heritage: allHeritage, routes: allRoutes, constructorBindings: allConstructorBindings };
|
||||
return { imports: allImports, calls: allCalls, assignments: allAssignments, heritage: allHeritage, routes: allRoutes, constructorBindings: allConstructorBindings, typeEnvBindings: allTypeEnvBindings };
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
|
|
@ -222,7 +225,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';
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import {
|
|||
processImportsFromExtracted,
|
||||
buildImportResolutionContext
|
||||
} from './import-processor.js';
|
||||
import { processCalls, processCallsFromExtracted, processAssignmentsFromExtracted, processRoutesFromExtracted } from './call-processor.js';
|
||||
import { processCalls, processCallsFromExtracted, processAssignmentsFromExtracted, processRoutesFromExtracted, seedCrossFileReceiverTypes, buildImportedReturnTypes, buildImportedRawReturnTypes, type ExportedTypeMap, buildExportedTypeMapFromGraph } from './call-processor.js';
|
||||
import { processHeritage, processHeritageFromExtracted } from './heritage-processor.js';
|
||||
import { computeMRO } from './mro-processor.js';
|
||||
import { processCommunities } from './community-processor.js';
|
||||
|
|
@ -18,6 +18,7 @@ import { PipelineProgress, PipelineResult } from '../../types/pipeline.js';
|
|||
import { walkRepositoryPaths, readFileContents } from './filesystem-walker.js';
|
||||
import { getLanguageFromFilename } from './utils.js';
|
||||
import { isLanguageAvailable } from '../tree-sitter/parser-loader.js';
|
||||
import { SupportedLanguages } from '../../config/supported-languages.js';
|
||||
import { createWorkerPool, WorkerPool } from './workers/worker-pool.js';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
|
@ -25,6 +26,62 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
|
|||
|
||||
const isDev = process.env.NODE_ENV === 'development';
|
||||
|
||||
/** A group of files with no mutual dependencies, safe to process in parallel. */
|
||||
type IndependentFileGroup = readonly string[];
|
||||
|
||||
/** Kahn's algorithm: returns files grouped by topological level.
|
||||
* Files in the same level have no mutual dependencies — safe to process in parallel.
|
||||
* Files in cycles are returned as a final group (no cross-cycle propagation). */
|
||||
export function topologicalLevelSort(
|
||||
importMap: ReadonlyMap<string, ReadonlySet<string>>,
|
||||
): { levels: readonly IndependentFileGroup[]; cycleCount: number } {
|
||||
// Build in-degree map and reverse dependency map
|
||||
const inDegree = new Map<string, number>();
|
||||
const reverseDeps = new Map<string, string[]>();
|
||||
|
||||
for (const [file, deps] of importMap) {
|
||||
if (!inDegree.has(file)) inDegree.set(file, 0);
|
||||
for (const dep of deps) {
|
||||
if (!inDegree.has(dep)) inDegree.set(dep, 0);
|
||||
// file imports dep, so dep must be processed before file
|
||||
// In Kahn's terms: dep → file (dep is a prerequisite of file)
|
||||
inDegree.set(file, (inDegree.get(file) ?? 0) + 1);
|
||||
let rev = reverseDeps.get(dep);
|
||||
if (!rev) { rev = []; reverseDeps.set(dep, rev); }
|
||||
rev.push(file);
|
||||
}
|
||||
}
|
||||
|
||||
// BFS from zero-in-degree nodes, grouping by level
|
||||
const levels: string[][] = [];
|
||||
let currentLevel = [...inDegree.entries()]
|
||||
.filter(([, d]) => d === 0)
|
||||
.map(([f]) => f);
|
||||
|
||||
while (currentLevel.length > 0) {
|
||||
levels.push(currentLevel);
|
||||
const nextLevel: string[] = [];
|
||||
for (const file of currentLevel) {
|
||||
for (const dependent of reverseDeps.get(file) ?? []) {
|
||||
const newDeg = (inDegree.get(dependent) ?? 1) - 1;
|
||||
inDegree.set(dependent, newDeg);
|
||||
if (newDeg === 0) nextLevel.push(dependent);
|
||||
}
|
||||
}
|
||||
currentLevel = nextLevel;
|
||||
}
|
||||
|
||||
// Files still with positive in-degree are in cycles — add as final group
|
||||
const cycleFiles = [...inDegree.entries()]
|
||||
.filter(([, d]) => d > 0)
|
||||
.map(([f]) => f);
|
||||
if (cycleFiles.length > 0) {
|
||||
levels.push(cycleFiles);
|
||||
}
|
||||
|
||||
return { levels, cycleCount: cycleFiles.length };
|
||||
}
|
||||
|
||||
/** 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
|
||||
|
|
@ -34,6 +91,261 @@ const CHUNK_BYTE_BUDGET = 20 * 1024 * 1024; // 20MB
|
|||
/** Max AST trees to keep in LRU cache */
|
||||
const AST_CACHE_CAP = 50;
|
||||
|
||||
/** 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;
|
||||
|
||||
/** Node labels that represent top-level importable symbols.
|
||||
* Excludes Method, Property, Constructor (accessed via receiver, not directly imported),
|
||||
* and structural labels (File, Folder, Package, Module, Project, etc.). */
|
||||
const IMPORTABLE_SYMBOL_LABELS = new Set([
|
||||
'Function', 'Class', 'Interface', 'Struct', 'Enum', 'Trait',
|
||||
'TypeAlias', 'Const', 'Static', 'Record', 'Union', 'Typedef', 'Macro',
|
||||
]);
|
||||
|
||||
/** Max synthetic bindings per importing file — prevents memory bloat for
|
||||
* C/C++ files that include many large headers. */
|
||||
const MAX_SYNTHETIC_BINDINGS_PER_FILE = 1000;
|
||||
|
||||
/** Languages with whole-module import semantics (no per-symbol named imports).
|
||||
* For these languages, namedImportMap entries are synthesized from graph-exported
|
||||
* symbols after parsing, enabling Phase 14 cross-file binding propagation. */
|
||||
const WILDCARD_IMPORT_LANGUAGES = new Set([
|
||||
SupportedLanguages.Go,
|
||||
SupportedLanguages.Ruby,
|
||||
SupportedLanguages.C,
|
||||
SupportedLanguages.CPlusPlus,
|
||||
SupportedLanguages.Swift,
|
||||
]);
|
||||
|
||||
/** Synthesize namedImportMap entries for languages with whole-module imports.
|
||||
* These languages (Go, Ruby, C/C++, Swift) import all exported symbols from a file,
|
||||
* not specific named symbols. After parsing, we know which symbols each file exports
|
||||
* (via graph isExported), so we can expand ImportMap edges into per-symbol bindings
|
||||
* that Phase 14 can use for cross-file type propagation. */
|
||||
function synthesizeWildcardImportBindings(
|
||||
graph: ReturnType<typeof createKnowledgeGraph>,
|
||||
ctx: ReturnType<typeof createResolutionContext>,
|
||||
): number {
|
||||
// Pre-compute exported symbols per file from graph (single pass)
|
||||
const exportedSymbolsByFile = new Map<string, { name: string; filePath: string }[]>();
|
||||
graph.forEachNode(node => {
|
||||
if (!node.properties?.isExported) return;
|
||||
if (!IMPORTABLE_SYMBOL_LABELS.has(node.label)) return;
|
||||
const fp = node.properties.filePath;
|
||||
const name = node.properties.name;
|
||||
if (!fp || !name) return;
|
||||
let symbols = exportedSymbolsByFile.get(fp);
|
||||
if (!symbols) { symbols = []; exportedSymbolsByFile.set(fp, symbols); }
|
||||
symbols.push({ name, filePath: fp });
|
||||
});
|
||||
|
||||
if (exportedSymbolsByFile.size === 0) return 0;
|
||||
|
||||
// Build a merged import map: ctx.importMap has file-based imports (Ruby, C/C++),
|
||||
// but Go/C# package imports use graph IMPORTS edges + PackageMap instead.
|
||||
// Collect graph-level IMPORTS edges for wildcard languages missing from ctx.importMap.
|
||||
const FILE_PREFIX = 'File:';
|
||||
const graphImports = new Map<string, Set<string>>();
|
||||
graph.forEachRelationship(rel => {
|
||||
if (rel.type !== 'IMPORTS') return;
|
||||
if (!rel.sourceId.startsWith(FILE_PREFIX) || !rel.targetId.startsWith(FILE_PREFIX)) return;
|
||||
const srcFile = rel.sourceId.slice(FILE_PREFIX.length);
|
||||
const tgtFile = rel.targetId.slice(FILE_PREFIX.length);
|
||||
const lang = getLanguageFromFilename(srcFile);
|
||||
if (!lang || !WILDCARD_IMPORT_LANGUAGES.has(lang)) return;
|
||||
// Only add if not already in ctx.importMap (avoid duplicates)
|
||||
if (ctx.importMap.get(srcFile)?.has(tgtFile)) return;
|
||||
let set = graphImports.get(srcFile);
|
||||
if (!set) { set = new Set(); graphImports.set(srcFile, set); }
|
||||
set.add(tgtFile);
|
||||
});
|
||||
|
||||
let totalSynthesized = 0;
|
||||
|
||||
// Helper: synthesize bindings for a file given its imported files
|
||||
const synthesizeForFile = (filePath: string, importedFiles: Iterable<string>) => {
|
||||
let fileBindings = ctx.namedImportMap.get(filePath);
|
||||
let fileCount = fileBindings?.size ?? 0;
|
||||
|
||||
for (const importedFile of importedFiles) {
|
||||
const exportedSymbols = exportedSymbolsByFile.get(importedFile);
|
||||
if (!exportedSymbols) continue;
|
||||
|
||||
for (const sym of exportedSymbols) {
|
||||
if (fileCount >= MAX_SYNTHETIC_BINDINGS_PER_FILE) return;
|
||||
if (fileBindings?.has(sym.name)) continue;
|
||||
|
||||
if (!fileBindings) {
|
||||
fileBindings = new Map();
|
||||
ctx.namedImportMap.set(filePath, fileBindings);
|
||||
}
|
||||
fileBindings.set(sym.name, {
|
||||
sourcePath: importedFile,
|
||||
exportedName: sym.name,
|
||||
});
|
||||
fileCount++;
|
||||
totalSynthesized++;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Process files from ctx.importMap (Ruby, C/C++, Swift file-based imports)
|
||||
for (const [filePath, importedFiles] of ctx.importMap) {
|
||||
const lang = getLanguageFromFilename(filePath);
|
||||
if (!lang || !WILDCARD_IMPORT_LANGUAGES.has(lang)) continue;
|
||||
synthesizeForFile(filePath, importedFiles);
|
||||
}
|
||||
|
||||
// Process files from graph IMPORTS edges (Go package imports)
|
||||
for (const [filePath, importedFiles] of graphImports) {
|
||||
synthesizeForFile(filePath, importedFiles);
|
||||
}
|
||||
|
||||
return totalSynthesized;
|
||||
}
|
||||
|
||||
/** 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, cycleCount } = topologicalLevelSort(ctx.importMap);
|
||||
|
||||
// Cycle diagnostic: only log when actual cycles detected (cycleCount from Kahn's BFS)
|
||||
if (isDev && cycleCount > 0) {
|
||||
console.log(`🔄 ${cycleCount} 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>; importedRawReturns: 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);
|
||||
const importedRawReturns = buildImportedRawReturnTypes(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, importedRawReturns });
|
||||
}
|
||||
|
||||
if (levelCandidates.length === 0) continue;
|
||||
|
||||
const levelPaths = levelCandidates.map(c => c.filePath);
|
||||
const contentMap = await readFileContents(repoPath, levelPaths);
|
||||
|
||||
for (const { filePath, seeded, importedReturns, importedRawReturns } 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);
|
||||
}
|
||||
|
||||
const importedRawReturnTypesMap = new Map<string, ReadonlyMap<string, string>>();
|
||||
if (importedRawReturns.size > 0) {
|
||||
importedRawReturnTypesMap.set(filePath, importedRawReturns);
|
||||
}
|
||||
|
||||
await processCalls(graph, reFile, astCache, ctx, undefined, exportedTypeMap, bindings.size > 0 ? bindings : undefined, importedReturnTypesMap.size > 0 ? importedReturnTypesMap : undefined, importedRawReturnTypesMap.size > 0 ? importedRawReturnTypesMap : 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. */
|
||||
skipGraphPhases?: boolean;
|
||||
|
|
@ -48,6 +360,7 @@ export const runPipelineFromRepo = async (
|
|||
const ctx = createResolutionContext();
|
||||
const symbolTable = ctx.symbols;
|
||||
let astCache = createASTCache(AST_CACHE_CAP);
|
||||
const pipelineStart = Date.now();
|
||||
|
||||
const cleanup = () => {
|
||||
astCache.clear();
|
||||
|
|
@ -217,6 +530,10 @@ export const runPipelineFromRepo = async (
|
|||
// are already registered). This trades ~5% cross-chunk resolution accuracy for
|
||||
// 200-400MB less memory — critical for Linux-kernel-scale repos.
|
||||
const sequentialChunkPaths: string[][] = [];
|
||||
// Phase 14: Collect exported type bindings for cross-file propagation
|
||||
const exportedTypeMap: ExportedTypeMap = new Map();
|
||||
// Accumulate file-scope TypeEnv bindings from workers (closes worker/sequential quality gap)
|
||||
const workerTypeEnvBindings: { filePath: string; bindings: [string, string][] }[] = [];
|
||||
|
||||
try {
|
||||
for (let chunkIdx = 0; chunkIdx < numChunks; chunkIdx++) {
|
||||
|
|
@ -258,6 +575,19 @@ export const runPipelineFromRepo = async (
|
|||
stats: { filesProcessed: filesParsedSoFar, totalFiles: totalParseable, nodesCreated: graph.nodeCount },
|
||||
});
|
||||
}, repoPath, importCtx);
|
||||
// Phase 14 E1: Seed cross-file receiver types from ExportedTypeMap
|
||||
// before call resolution — eliminates re-parse for single-hop imported receivers.
|
||||
// NOTE: In the worker path, exportedTypeMap is empty during chunk processing
|
||||
// (populated later in runCrossFileBindingPropagation). This block is latent —
|
||||
// it activates only if incremental export collection is added per-chunk.
|
||||
if (exportedTypeMap.size > 0 && ctx.namedImportMap.size > 0) {
|
||||
const { enrichedCount } = seedCrossFileReceiverTypes(
|
||||
chunkWorkerData.calls, ctx.namedImportMap, exportedTypeMap,
|
||||
);
|
||||
if (isDev && enrichedCount > 0) {
|
||||
console.log(`🔗 E1: Seeded ${enrichedCount} cross-file receiver types (chunk ${chunkIdx + 1})`);
|
||||
}
|
||||
}
|
||||
// Calls + Heritage + Routes — resolve in parallel (no shared mutable state between them)
|
||||
// This is safe because each writes disjoint relationship types into idempotent id-keyed Maps,
|
||||
// and the single-threaded event loop prevents races between synchronous addRelationship calls.
|
||||
|
|
@ -310,6 +640,10 @@ export const runPipelineFromRepo = async (
|
|||
if (chunkWorkerData.assignments?.length) {
|
||||
processAssignmentsFromExtracted(graph, chunkWorkerData.assignments, ctx, chunkWorkerData.constructorBindings);
|
||||
}
|
||||
// Collect TypeEnv file-scope bindings for exported type enrichment
|
||||
if (chunkWorkerData.typeEnvBindings?.length) {
|
||||
workerTypeEnvBindings.push(...chunkWorkerData.typeEnvBindings);
|
||||
}
|
||||
} else {
|
||||
await processImports(graph, chunkFiles, astCache, ctx, undefined, repoPath, allPaths);
|
||||
sequentialChunkPaths.push(chunkPaths);
|
||||
|
|
@ -332,7 +666,7 @@ export const runPipelineFromRepo = async (
|
|||
.filter(p => chunkContents.has(p))
|
||||
.map(p => ({ path: p, content: chunkContents.get(p)! }));
|
||||
astCache = createASTCache(chunkFiles.length);
|
||||
const rubyHeritage = await processCalls(graph, chunkFiles, astCache, ctx);
|
||||
const rubyHeritage = await processCalls(graph, chunkFiles, astCache, ctx, undefined, exportedTypeMap);
|
||||
await processHeritage(graph, chunkFiles, astCache, ctx);
|
||||
if (rubyHeritage.length > 0) {
|
||||
await processHeritageFromExtracted(graph, rubyHeritage, ctx);
|
||||
|
|
@ -348,12 +682,54 @@ export const runPipelineFromRepo = async (
|
|||
console.log(`🔍 Resolution cache: ${rcStats.cacheHits} hits, ${rcStats.cacheMisses} misses (${hitRate}% hit rate)`);
|
||||
}
|
||||
|
||||
// ── Worker path quality enrichment: merge TypeEnv file-scope bindings into ExportedTypeMap ──
|
||||
// Workers return file-scope bindings from their TypeEnv fixpoint (includes inferred types
|
||||
// like `const config = getConfig()` → Config). Filter by graph isExported to match
|
||||
// the sequential path's collectExportedBindings behavior.
|
||||
if (workerTypeEnvBindings.length > 0) {
|
||||
let enriched = 0;
|
||||
for (const { filePath, bindings } of workerTypeEnvBindings) {
|
||||
for (const [name, type] of bindings) {
|
||||
// Verify the symbol is exported via graph node
|
||||
const nodeId = `Function:${filePath}:${name}`;
|
||||
const varNodeId = `Variable:${filePath}:${name}`;
|
||||
const constNodeId = `Const:${filePath}:${name}`;
|
||||
const node = graph.getNode(nodeId) ?? graph.getNode(varNodeId) ?? graph.getNode(constNodeId);
|
||||
if (!node?.properties?.isExported) continue;
|
||||
|
||||
let fileExports = exportedTypeMap.get(filePath);
|
||||
if (!fileExports) { fileExports = new Map(); exportedTypeMap.set(filePath, fileExports); }
|
||||
// Don't overwrite existing entries (Tier 0 from SymbolTable is authoritative)
|
||||
if (!fileExports.has(name)) {
|
||||
fileExports.set(name, type);
|
||||
enriched++;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isDev && enriched > 0) {
|
||||
console.log(`🔗 Worker TypeEnv enrichment: ${enriched} fixpoint-inferred exports added to ExportedTypeMap`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Phase 14 pre-pass: Synthesize namedImportMap for whole-module-import languages ──
|
||||
// Go, Ruby, C/C++, Swift import all exported symbols from a file.
|
||||
// Expand ImportMap edges into per-symbol namedImportMap entries so Phase 14 can
|
||||
// propagate types cross-file for these languages.
|
||||
const synthesized = synthesizeWildcardImportBindings(graph, ctx);
|
||||
if (isDev && synthesized > 0) {
|
||||
console.log(`🔗 Synthesized ${synthesized} wildcard import bindings (Go/Ruby/C++/Swift)`);
|
||||
}
|
||||
|
||||
// ── Phase 14: Cross-file binding propagation ──────────────────────
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -420,8 +420,15 @@ export const CPP_QUERIES = `
|
|||
declarator: (reference_declarator
|
||||
(field_identifier) @name)) @definition.property
|
||||
|
||||
; Inline class method declarations (inside class body, no body: void Foo();)
|
||||
(field_declaration declarator: (function_declarator declarator: (identifier) @name)) @definition.method
|
||||
; Inline class method declarations (inside class body, no body: void save();)
|
||||
; tree-sitter-cpp uses field_identifier (not identifier) for names inside class bodies
|
||||
(field_declaration declarator: (function_declarator declarator: [(field_identifier) (identifier)] @name)) @definition.method
|
||||
|
||||
; Inline class method declarations returning a pointer (User* lookup();)
|
||||
(field_declaration declarator: (pointer_declarator declarator: (function_declarator declarator: [(field_identifier) (identifier)] @name))) @definition.method
|
||||
|
||||
; Inline class method declarations returning a reference (User& lookup();)
|
||||
(field_declaration declarator: (reference_declarator (function_declarator declarator: [(field_identifier) (identifier)] @name))) @definition.method
|
||||
|
||||
; Inline class method definitions (inside class body, with body: void Foo() { ... })
|
||||
(field_declaration_list
|
||||
|
|
|
|||
|
|
@ -647,6 +647,34 @@ const resolveFixpointBindings = (
|
|||
export interface BuildTypeEnvOptions {
|
||||
symbolTable?: SymbolTable;
|
||||
parentMap?: ReadonlyMap<string, readonly string[]>;
|
||||
/** Pre-resolved bindings from upstream files (Phase 14).
|
||||
* Seeded into FILE_SCOPE after walk() for names with no local binding.
|
||||
* Local declarations always take precedence (first-writer-wins). */
|
||||
importedBindings?: ReadonlyMap<string, string>;
|
||||
/** Cross-file return type fallback for imported callables (Phase 14 E3).
|
||||
* Consulted ONLY when SymbolTable has no unambiguous match.
|
||||
* Local definitions always take precedence (local-first principle). */
|
||||
importedReturnTypes?: ReadonlyMap<string, string>;
|
||||
/** Cross-file RAW return types for imported callables (Phase 14 E3).
|
||||
* Stores raw declared return type strings (e.g., 'User[]', 'List<User>').
|
||||
* Used by lookupRawReturnType for for-loop element extraction. */
|
||||
importedRawReturnTypes?: ReadonlyMap<string, string>;
|
||||
}
|
||||
|
||||
/** Seed cross-file type bindings into the file scope.
|
||||
* MUST be called AFTER walk() completes so that local declarations
|
||||
* (Tier 0/1) always take precedence over imported bindings (first-writer-wins). */
|
||||
function seedImportedBindings(
|
||||
env: TypeEnv,
|
||||
importedBindings: ReadonlyMap<string, string>,
|
||||
): void {
|
||||
let fileEnv = env.get(FILE_SCOPE);
|
||||
if (!fileEnv) { fileEnv = new Map(); env.set(FILE_SCOPE, fileEnv); }
|
||||
for (const [name, type] of importedBindings) {
|
||||
if (!fileEnv.has(name)) {
|
||||
fileEnv.set(name, type);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const buildTypeEnv = (
|
||||
|
|
@ -667,24 +695,36 @@ export const buildTypeEnv = (
|
|||
const config = typeConfigs[language];
|
||||
const bindings: ConstructorBinding[] = [];
|
||||
|
||||
// Build ReturnTypeLookup from optional SymbolTable.
|
||||
// Conservative: returns undefined when callee is ambiguous (0 or 2+ matches).
|
||||
// Build ReturnTypeLookup: SymbolTable is authoritative when it has an unambiguous match.
|
||||
// Cross-file importedReturnTypes are consulted ONLY when SymbolTable has 0 matches.
|
||||
// Ambiguous (2+) → undefined, no cross-file fallback (conservative, local-first principle).
|
||||
const returnTypeLookup: ReturnTypeLookup = {
|
||||
lookupReturnType(callee: string): string | undefined {
|
||||
if (!symbolTable) return undefined;
|
||||
if (isBuiltInOrNoise(callee)) return undefined;
|
||||
const callables = symbolTable.lookupFuzzyCallable(callee);
|
||||
if (callables.length !== 1) return undefined;
|
||||
const rawReturn = callables[0].returnType;
|
||||
if (!rawReturn) return undefined;
|
||||
return extractReturnTypeName(rawReturn);
|
||||
// SymbolTable is authoritative when it has an unambiguous match
|
||||
if (symbolTable) {
|
||||
if (isBuiltInOrNoise(callee)) return undefined;
|
||||
const callables = symbolTable.lookupFuzzyCallable(callee);
|
||||
if (callables.length === 1) {
|
||||
const rawReturn = callables[0].returnType;
|
||||
if (rawReturn) return extractReturnTypeName(rawReturn);
|
||||
}
|
||||
// Ambiguous (2+) → return undefined (conservative, no cross-file fallback)
|
||||
if (callables.length > 1) return undefined;
|
||||
}
|
||||
// No match (0 results or no symbolTable) → fall back to cross-file
|
||||
return options?.importedReturnTypes?.get(callee);
|
||||
},
|
||||
lookupRawReturnType(callee: string): string | undefined {
|
||||
if (!symbolTable) return undefined;
|
||||
if (isBuiltInOrNoise(callee)) return undefined;
|
||||
const callables = symbolTable.lookupFuzzyCallable(callee);
|
||||
if (callables.length !== 1) return undefined;
|
||||
return callables[0].returnType;
|
||||
if (symbolTable) {
|
||||
if (isBuiltInOrNoise(callee)) return undefined;
|
||||
const callables = symbolTable.lookupFuzzyCallable(callee);
|
||||
if (callables.length === 1) return callables[0].returnType;
|
||||
// Ambiguous (2+) → return undefined (conservative, no cross-file fallback)
|
||||
if (callables.length > 1) return undefined;
|
||||
}
|
||||
// Cross-file fallback uses importedRawReturnTypes (raw declared types, e.g., 'User[]')
|
||||
// NOT importedReturnTypes (which contains processed/simple types via extractReturnTypeName)
|
||||
return options?.importedRawReturnTypes?.get(callee);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -996,6 +1036,12 @@ export const buildTypeEnv = (
|
|||
|
||||
walk(tree.rootNode, FILE_SCOPE);
|
||||
|
||||
// Phase 14: Seed cross-file bindings from upstream files AFTER walk
|
||||
// (local declarations from walk() take precedence — first-writer-wins)
|
||||
if (options?.importedBindings && options.importedBindings.size > 0) {
|
||||
seedImportedBindings(env, options.importedBindings);
|
||||
}
|
||||
|
||||
resolveFixpointBindings(pendingItems, env, returnTypeLookup, symbolTable, parentMap);
|
||||
|
||||
// Post-fixpoint for-loop replay (Phase 10 / ex-9B loop-fixpoint bridge):
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -164,6 +165,13 @@ export interface FileConstructorBindings {
|
|||
bindings: ConstructorBinding[];
|
||||
}
|
||||
|
||||
/** File-scope type bindings from TypeEnv fixpoint — used for cross-file ExportedTypeMap. */
|
||||
export interface FileTypeEnvBindings {
|
||||
filePath: string;
|
||||
/** [varName, typeName] pairs from file scope (scope = '') */
|
||||
bindings: [string, string][];
|
||||
}
|
||||
|
||||
export interface ParseWorkerResult {
|
||||
nodes: ParsedNode[];
|
||||
relationships: ParsedRelationship[];
|
||||
|
|
@ -174,6 +182,8 @@ export interface ParseWorkerResult {
|
|||
heritage: ExtractedHeritage[];
|
||||
routes: ExtractedRoute[];
|
||||
constructorBindings: FileConstructorBindings[];
|
||||
/** File-scope type bindings from TypeEnv fixpoint for exported symbol collection. */
|
||||
typeEnvBindings: FileTypeEnvBindings[];
|
||||
skippedLanguages: Record<string, number>;
|
||||
fileCount: number;
|
||||
}
|
||||
|
|
@ -256,7 +266,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';
|
||||
|
|
@ -301,6 +312,7 @@ const processBatch = (files: ParseWorkerInput[], onProgress?: (filesProcessed: n
|
|||
heritage: [],
|
||||
routes: [],
|
||||
constructorBindings: [],
|
||||
typeEnvBindings: [],
|
||||
skippedLanguages: {},
|
||||
fileCount: 0,
|
||||
};
|
||||
|
|
@ -934,6 +946,16 @@ const processFileGroup = (
|
|||
result.constructorBindings.push({ filePath: file.path, bindings: [...typeEnv.constructorBindings] });
|
||||
}
|
||||
|
||||
// Extract file-scope bindings for ExportedTypeMap (closes worker/sequential quality gap).
|
||||
// Sequential path uses collectExportedBindings(typeEnv) directly; worker path serializes
|
||||
// these bindings so the main thread can merge them into ExportedTypeMap.
|
||||
const fileScope = typeEnv.env.get('');
|
||||
if (fileScope && fileScope.size > 0) {
|
||||
const bindings: [string, string][] = [];
|
||||
for (const [name, type] of fileScope) bindings.push([name, type]);
|
||||
result.typeEnvBindings.push({ filePath: file.path, bindings });
|
||||
}
|
||||
|
||||
for (const match of matches) {
|
||||
const captureMap: Record<string, any> = {};
|
||||
for (const c of match.captures) {
|
||||
|
|
@ -1144,7 +1166,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 +1186,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;
|
||||
|
|
@ -1292,7 +1320,7 @@ const processFileGroup = (
|
|||
/** Accumulated result across sub-batches */
|
||||
let accumulated: ParseWorkerResult = {
|
||||
nodes: [], relationships: [], symbols: [],
|
||||
imports: [], calls: [], assignments: [], heritage: [], routes: [], constructorBindings: [], skippedLanguages: {}, fileCount: 0,
|
||||
imports: [], calls: [], assignments: [], heritage: [], routes: [], constructorBindings: [], typeEnvBindings: [], skippedLanguages: {}, fileCount: 0,
|
||||
};
|
||||
let cumulativeProcessed = 0;
|
||||
|
||||
|
|
@ -1306,6 +1334,7 @@ const mergeResult = (target: ParseWorkerResult, src: ParseWorkerResult) => {
|
|||
target.heritage.push(...src.heritage);
|
||||
target.routes.push(...src.routes);
|
||||
target.constructorBindings.push(...src.constructorBindings);
|
||||
target.typeEnvBindings.push(...src.typeEnvBindings);
|
||||
for (const [lang, count] of Object.entries(src.skippedLanguages)) {
|
||||
target.skippedLanguages[lang] = (target.skippedLanguages[lang] || 0) + count;
|
||||
}
|
||||
|
|
@ -1330,7 +1359,7 @@ parentPort!.on('message', (msg: any) => {
|
|||
if (msg && msg.type === 'flush') {
|
||||
parentPort!.postMessage({ type: 'result', data: accumulated });
|
||||
// Reset for potential reuse
|
||||
accumulated = { nodes: [], relationships: [], symbols: [], imports: [], calls: [], assignments: [], heritage: [], routes: [], constructorBindings: [], skippedLanguages: {}, fileCount: 0 };
|
||||
accumulated = { nodes: [], relationships: [], symbols: [], imports: [], calls: [], assignments: [], heritage: [], routes: [], constructorBindings: [], typeEnvBindings: [], skippedLanguages: {}, fileCount: 0 };
|
||||
cumulativeProcessed = 0;
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
7
gitnexus/test/fixtures/cross-file-binding/cpp-cross-file/app/main.cpp
vendored
Normal file
7
gitnexus/test/fixtures/cross-file-binding/cpp-cross-file/app/main.cpp
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
#include "../models/user_factory.h"
|
||||
|
||||
void process() {
|
||||
User user = get_user();
|
||||
user.save();
|
||||
user.get_name();
|
||||
}
|
||||
7
gitnexus/test/fixtures/cross-file-binding/cpp-cross-file/models/user.cpp
vendored
Normal file
7
gitnexus/test/fixtures/cross-file-binding/cpp-cross-file/models/user.cpp
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
#include "user.h"
|
||||
|
||||
void User::save() {}
|
||||
|
||||
std::string User::get_name() {
|
||||
return "";
|
||||
}
|
||||
9
gitnexus/test/fixtures/cross-file-binding/cpp-cross-file/models/user.h
vendored
Normal file
9
gitnexus/test/fixtures/cross-file-binding/cpp-cross-file/models/user.h
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
#pragma once
|
||||
|
||||
#include <string>
|
||||
|
||||
class User {
|
||||
public:
|
||||
void save();
|
||||
std::string get_name();
|
||||
};
|
||||
5
gitnexus/test/fixtures/cross-file-binding/cpp-cross-file/models/user_factory.cpp
vendored
Normal file
5
gitnexus/test/fixtures/cross-file-binding/cpp-cross-file/models/user_factory.cpp
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
#include "user_factory.h"
|
||||
|
||||
User get_user() {
|
||||
return User();
|
||||
}
|
||||
5
gitnexus/test/fixtures/cross-file-binding/cpp-cross-file/models/user_factory.h
vendored
Normal file
5
gitnexus/test/fixtures/cross-file-binding/cpp-cross-file/models/user_factory.h
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
#pragma once
|
||||
|
||||
#include "user.h"
|
||||
|
||||
User get_user();
|
||||
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(); }
|
||||
}
|
||||
}
|
||||
9
gitnexus/test/fixtures/cross-file-binding/go-cross-file/app/main.go
vendored
Normal file
9
gitnexus/test/fixtures/cross-file-binding/go-cross-file/app/main.go
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
package main
|
||||
|
||||
import "go-cross-file/models"
|
||||
|
||||
func main() {
|
||||
user := models.GetUser()
|
||||
user.Save()
|
||||
user.GetName()
|
||||
}
|
||||
3
gitnexus/test/fixtures/cross-file-binding/go-cross-file/go.mod
vendored
Normal file
3
gitnexus/test/fixtures/cross-file-binding/go-cross-file/go.mod
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
module go-cross-file
|
||||
|
||||
go 1.21
|
||||
5
gitnexus/test/fixtures/cross-file-binding/go-cross-file/models/factory.go
vendored
Normal file
5
gitnexus/test/fixtures/cross-file-binding/go-cross-file/models/factory.go
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
package models
|
||||
|
||||
func GetUser() User {
|
||||
return User{}
|
||||
}
|
||||
7
gitnexus/test/fixtures/cross-file-binding/go-cross-file/models/user.go
vendored
Normal file
7
gitnexus/test/fixtures/cross-file-binding/go-cross-file/models/user.go
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
package models
|
||||
|
||||
type User struct{}
|
||||
|
||||
func (u User) Save() {}
|
||||
|
||||
func (u User) GetName() string { return "" }
|
||||
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()
|
||||
7
gitnexus/test/fixtures/cross-file-binding/rb-cross-file/app.rb
vendored
Normal file
7
gitnexus/test/fixtures/cross-file-binding/rb-cross-file/app.rb
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
require_relative 'models/user_factory'
|
||||
|
||||
def process
|
||||
user = UserFactory.get_user
|
||||
user.save
|
||||
user.get_name
|
||||
end
|
||||
9
gitnexus/test/fixtures/cross-file-binding/rb-cross-file/models/user.rb
vendored
Normal file
9
gitnexus/test/fixtures/cross-file-binding/rb-cross-file/models/user.rb
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
class User
|
||||
def save
|
||||
puts "saving"
|
||||
end
|
||||
|
||||
def get_name
|
||||
"Alice"
|
||||
end
|
||||
end
|
||||
7
gitnexus/test/fixtures/cross-file-binding/rb-cross-file/models/user_factory.rb
vendored
Normal file
7
gitnexus/test/fixtures/cross-file-binding/rb-cross-file/models/user_factory.rb
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
require_relative 'user'
|
||||
|
||||
class UserFactory
|
||||
def self.get_user
|
||||
User.new
|
||||
end
|
||||
end
|
||||
5
gitnexus/test/fixtures/cross-file-binding/rs-cross-file/src/factory.rs
vendored
Normal file
5
gitnexus/test/fixtures/cross-file-binding/rs-cross-file/src/factory.rs
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
use crate::models::User;
|
||||
|
||||
pub fn get_user() -> User {
|
||||
User
|
||||
}
|
||||
10
gitnexus/test/fixtures/cross-file-binding/rs-cross-file/src/main.rs
vendored
Normal file
10
gitnexus/test/fixtures/cross-file-binding/rs-cross-file/src/main.rs
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
mod factory;
|
||||
mod models;
|
||||
|
||||
use crate::factory::get_user;
|
||||
|
||||
pub fn process() {
|
||||
let u = get_user();
|
||||
u.save();
|
||||
u.get_name();
|
||||
}
|
||||
9
gitnexus/test/fixtures/cross-file-binding/rs-cross-file/src/models.rs
vendored
Normal file
9
gitnexus/test/fixtures/cross-file-binding/rs-cross-file/src/models.rs
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
pub struct User;
|
||||
|
||||
impl User {
|
||||
pub fn save(&self) {}
|
||||
|
||||
pub fn get_name(&self) -> String {
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
8
gitnexus/test/fixtures/cross-file-binding/ts-circular/src/a.ts
vendored
Normal file
8
gitnexus/test/fixtures/cross-file-binding/ts-circular/src/a.ts
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { getB } from './b';
|
||||
export class A {
|
||||
doA(): void {}
|
||||
}
|
||||
export function processA() {
|
||||
const b = getB();
|
||||
b.doB();
|
||||
}
|
||||
7
gitnexus/test/fixtures/cross-file-binding/ts-circular/src/b.ts
vendored
Normal file
7
gitnexus/test/fixtures/cross-file-binding/ts-circular/src/b.ts
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { A } from './a';
|
||||
export class B {
|
||||
doB(): void {}
|
||||
}
|
||||
export function getB(): B {
|
||||
return new B();
|
||||
}
|
||||
5
gitnexus/test/fixtures/cross-file-binding/ts-reexport/src/app.ts
vendored
Normal file
5
gitnexus/test/fixtures/cross-file-binding/ts-reexport/src/app.ts
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { getConfig } from './index';
|
||||
export function init() {
|
||||
const config = getConfig();
|
||||
config.validate();
|
||||
}
|
||||
6
gitnexus/test/fixtures/cross-file-binding/ts-reexport/src/core.ts
vendored
Normal file
6
gitnexus/test/fixtures/cross-file-binding/ts-reexport/src/core.ts
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export class Config {
|
||||
validate(): boolean { return true; }
|
||||
}
|
||||
export function getConfig(): Config {
|
||||
return new Config();
|
||||
}
|
||||
1
gitnexus/test/fixtures/cross-file-binding/ts-reexport/src/index.ts
vendored
Normal file
1
gitnexus/test/fixtures/cross-file-binding/ts-reexport/src/index.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { getConfig } from './core';
|
||||
6
gitnexus/test/fixtures/cross-file-binding/ts-return-type/src/api.ts
vendored
Normal file
6
gitnexus/test/fixtures/cross-file-binding/ts-return-type/src/api.ts
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export class Config {
|
||||
validate(): boolean { return true; }
|
||||
}
|
||||
export function getConfig(): Config {
|
||||
return new Config();
|
||||
}
|
||||
5
gitnexus/test/fixtures/cross-file-binding/ts-return-type/src/consumer.ts
vendored
Normal file
5
gitnexus/test/fixtures/cross-file-binding/ts-return-type/src/consumer.ts
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { getConfig } from './api';
|
||||
export function run() {
|
||||
const c = getConfig();
|
||||
c.validate();
|
||||
}
|
||||
5
gitnexus/test/fixtures/cross-file-binding/ts-simple/src/app.ts
vendored
Normal file
5
gitnexus/test/fixtures/cross-file-binding/ts-simple/src/app.ts
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { user } from './service';
|
||||
export function main() {
|
||||
user.save();
|
||||
user.getName();
|
||||
}
|
||||
7
gitnexus/test/fixtures/cross-file-binding/ts-simple/src/models.ts
vendored
Normal file
7
gitnexus/test/fixtures/cross-file-binding/ts-simple/src/models.ts
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export class User {
|
||||
save(): void {}
|
||||
getName(): string { return ''; }
|
||||
}
|
||||
export function getUser(): User {
|
||||
return new User();
|
||||
}
|
||||
2
gitnexus/test/fixtures/cross-file-binding/ts-simple/src/service.ts
vendored
Normal file
2
gitnexus/test/fixtures/cross-file-binding/ts-simple/src/service.ts
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
import { getUser } from './models';
|
||||
export const user = getUser();
|
||||
232
gitnexus/test/integration/cross-file-binding.test.ts
Normal file
232
gitnexus/test/integration/cross-file-binding.test.ts
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
/**
|
||||
* Phase 14: Cross-file type binding propagation
|
||||
*
|
||||
* When file A exports `const user = getUser()` (resolved to type User), and
|
||||
* file B imports `user`, Phase 14 seeds `user → User` into file B's type
|
||||
* environment, enabling `user.save()` in file B to produce a CALLS edge to
|
||||
* User#save.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import path from 'path';
|
||||
import {
|
||||
getRelationships,
|
||||
getNodesByLabel,
|
||||
runPipelineFromRepo,
|
||||
type PipelineResult,
|
||||
} from './resolvers/helpers.js';
|
||||
|
||||
const CROSS_FILE_FIXTURES = path.resolve(__dirname, '..', 'fixtures', 'cross-file-binding');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Simple cross-file: models → service → app
|
||||
// models.ts exports getUser(): User
|
||||
// service.ts exports const user = getUser() (user → User via call-result)
|
||||
// app.ts imports user from service → seeds user → User → resolves user.save()
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Cross-File Binding Propagation: TypeScript simple cross-file', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(
|
||||
path.join(CROSS_FILE_FIXTURES, 'ts-simple'),
|
||||
() => {},
|
||||
);
|
||||
}, 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 function', () => {
|
||||
expect(getNodesByLabel(result, 'Function')).toContain('getUser');
|
||||
expect(getNodesByLabel(result, 'Function')).toContain('main');
|
||||
});
|
||||
|
||||
it('resolves user.save() in main() to User#save via cross-file binding', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const saveCall = calls.find(c =>
|
||||
c.target === 'save' &&
|
||||
c.source === 'main' &&
|
||||
c.targetFilePath.includes('models'),
|
||||
);
|
||||
expect(saveCall).toBeDefined();
|
||||
});
|
||||
|
||||
it('resolves user.getName() in main() to User#getName via cross-file binding', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const getNameCall = calls.find(c =>
|
||||
c.target === 'getName' &&
|
||||
c.source === 'main' &&
|
||||
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();
|
||||
});
|
||||
|
||||
it('emits IMPORTS edges across all three files', () => {
|
||||
const imports = getRelationships(result, 'IMPORTS');
|
||||
// service.ts → models.ts and app.ts → service.ts
|
||||
expect(imports.length).toBeGreaterThanOrEqual(2);
|
||||
const paths = imports.map(e => `${e.sourceFilePath} → ${e.targetFilePath}`);
|
||||
expect(paths.some(p => p.includes('service') && p.includes('models'))).toBe(true);
|
||||
expect(paths.some(p => p.includes('app') && p.includes('service'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Re-export chain: core → index (barrel) → app
|
||||
// core.ts exports getConfig(): Config
|
||||
// index.ts re-exports getConfig from core (no new bindings)
|
||||
// app.ts imports getConfig from index, creates local const config = getConfig()
|
||||
// → config.validate() resolves to Config#validate via local call-result binding
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Cross-File Binding Propagation: TypeScript re-export chain', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(
|
||||
path.join(CROSS_FILE_FIXTURES, 'ts-reexport'),
|
||||
() => {},
|
||||
);
|
||||
}, 60000);
|
||||
|
||||
it('detects Config class with validate method', () => {
|
||||
expect(getNodesByLabel(result, 'Class')).toContain('Config');
|
||||
expect(getNodesByLabel(result, 'Method')).toContain('validate');
|
||||
});
|
||||
|
||||
it('detects getConfig function and init function', () => {
|
||||
expect(getNodesByLabel(result, 'Function')).toContain('getConfig');
|
||||
expect(getNodesByLabel(result, 'Function')).toContain('init');
|
||||
});
|
||||
|
||||
it('resolves config.validate() in init() to Config#validate', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const validateCall = calls.find(c =>
|
||||
c.target === 'validate' &&
|
||||
c.source === 'init' &&
|
||||
c.targetFilePath.includes('core'),
|
||||
);
|
||||
expect(validateCall).toBeDefined();
|
||||
});
|
||||
|
||||
it('emits HAS_METHOD edge from Config to validate', () => {
|
||||
const hasMethod = getRelationships(result, 'HAS_METHOD');
|
||||
const edge = hasMethod.find(e => e.source === 'Config' && e.target === 'validate');
|
||||
expect(edge).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// E3: Cross-file return type propagation
|
||||
// api.ts exports getConfig(): Config
|
||||
// consumer.ts imports getConfig, calls const c = getConfig(); c.validate()
|
||||
// → c is typed Config via importedReturnTypes (E3), enabling Config#validate edge
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Cross-File Binding Propagation: TypeScript E3 return type propagation', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(
|
||||
path.join(CROSS_FILE_FIXTURES, 'ts-return-type'),
|
||||
() => {},
|
||||
);
|
||||
}, 60000);
|
||||
|
||||
it('detects Config class with validate method', () => {
|
||||
expect(getNodesByLabel(result, 'Class')).toContain('Config');
|
||||
expect(getNodesByLabel(result, 'Method')).toContain('validate');
|
||||
});
|
||||
|
||||
it('detects getConfig function and run function', () => {
|
||||
expect(getNodesByLabel(result, 'Function')).toContain('getConfig');
|
||||
expect(getNodesByLabel(result, 'Function')).toContain('run');
|
||||
});
|
||||
|
||||
it('resolves c.validate() in run() to Config#validate via cross-file return type propagation', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const validateCall = calls.find(c =>
|
||||
c.target === 'validate' &&
|
||||
c.source === 'run' &&
|
||||
c.targetFilePath.includes('api'),
|
||||
);
|
||||
expect(validateCall).toBeDefined();
|
||||
});
|
||||
|
||||
it('emits HAS_METHOD edge from Config to validate', () => {
|
||||
const hasMethod = getRelationships(result, 'HAS_METHOD');
|
||||
const edge = hasMethod.find(e => e.source === 'Config' && e.target === 'validate');
|
||||
expect(edge).toBeDefined();
|
||||
});
|
||||
|
||||
it('emits IMPORTS edge from consumer to api', () => {
|
||||
const imports = getRelationships(result, 'IMPORTS');
|
||||
const edge = imports.find(e =>
|
||||
e.sourceFilePath.includes('consumer') && e.targetFilePath.includes('api'),
|
||||
);
|
||||
expect(edge).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Circular imports: a.ts ↔ b.ts
|
||||
// a.ts imports getB from b.ts; b.ts imports A from a.ts
|
||||
// Conservative expectation: pipeline completes without error.
|
||||
// Cross-file binding propagation across cycles is not guaranteed.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Cross-File Binding Propagation: TypeScript circular imports', () => {
|
||||
let result: PipelineResult;
|
||||
let pipelineError: unknown;
|
||||
|
||||
beforeAll(async () => {
|
||||
try {
|
||||
result = await runPipelineFromRepo(
|
||||
path.join(CROSS_FILE_FIXTURES, 'ts-circular'),
|
||||
() => {},
|
||||
);
|
||||
} catch (err) {
|
||||
pipelineError = err;
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
it('pipeline completes without throwing on circular imports', () => {
|
||||
expect(pipelineError).toBeUndefined();
|
||||
});
|
||||
|
||||
it('detects both class A and class B', () => {
|
||||
expect(getNodesByLabel(result, 'Class')).toContain('A');
|
||||
expect(getNodesByLabel(result, 'Class')).toContain('B');
|
||||
});
|
||||
|
||||
it('detects doA and doB methods', () => {
|
||||
expect(getNodesByLabel(result, 'Method')).toContain('doA');
|
||||
expect(getNodesByLabel(result, 'Method')).toContain('doB');
|
||||
});
|
||||
|
||||
it('detects processA and getB functions', () => {
|
||||
expect(getNodesByLabel(result, 'Function')).toContain('processA');
|
||||
expect(getNodesByLabel(result, 'Function')).toContain('getB');
|
||||
});
|
||||
|
||||
it('emits IMPORTS edges reflecting the circular dependency', () => {
|
||||
const imports = getRelationships(result, 'IMPORTS');
|
||||
const paths = imports.map(e => `${e.sourceFilePath} → ${e.targetFilePath}`);
|
||||
// a.ts imports from b.ts
|
||||
expect(paths.some(p => p.includes('a.ts') && p.includes('b.ts'))).toBe(true);
|
||||
// b.ts imports from a.ts
|
||||
expect(paths.some(p => p.includes('b.ts') && p.includes('a.ts'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -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';
|
||||
|
||||
|
|
@ -49,9 +49,12 @@ describe('C++ diamond inheritance', () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it('captures 1 Method node from duck.cpp (speak)', () => {
|
||||
it('captures speak as Method nodes (declaration in headers + definition in .cpp)', () => {
|
||||
const methods = getNodesByLabel(result, 'Method');
|
||||
expect(methods).toEqual(['speak']);
|
||||
expect(methods).toContain('speak');
|
||||
// speak appears in animal.h (virtual declaration), duck.h (override declaration),
|
||||
// and duck.cpp (out-of-line definition) — all captured as Method nodes
|
||||
expect(methods.filter(m => m === 'speak').length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('no OVERRIDES edges target Property nodes', () => {
|
||||
|
|
@ -1135,3 +1138,69 @@ describe('C++ default parameter arity resolution', () => {
|
|||
expect(greetCalls.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 14: Cross-file binding propagation (via synthesized wildcard imports)
|
||||
// models/user.h declares User class with save() and get_name() methods
|
||||
// models/user_factory.h declares User get_user() free function
|
||||
// app/main.cpp includes user_factory.h, calls get_user().save()
|
||||
// → user 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, 'cpp-cross-file'),
|
||||
() => {},
|
||||
);
|
||||
}, 60000);
|
||||
|
||||
it('detects User class with save and get_name methods', () => {
|
||||
expect(getNodesByLabel(result, 'Class')).toContain('User');
|
||||
expect(getNodesByLabel(result, 'Method')).toContain('save');
|
||||
expect(getNodesByLabel(result, 'Method')).toContain('get_name');
|
||||
});
|
||||
|
||||
it('detects get_user factory function and process consumer', () => {
|
||||
expect(getNodesByLabel(result, 'Function')).toContain('get_user');
|
||||
expect(getNodesByLabel(result, 'Function')).toContain('process');
|
||||
});
|
||||
|
||||
it('emits IMPORTS edge from main.cpp to headers', () => {
|
||||
const imports = getRelationships(result, 'IMPORTS');
|
||||
const edge = imports.find(e =>
|
||||
e.sourceFilePath.includes('main') && e.targetFilePath.includes('models'),
|
||||
);
|
||||
expect(edge).toBeDefined();
|
||||
});
|
||||
|
||||
it('resolves user.save() in process() to User#save via cross-file propagation', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const saveCall = calls.find(c =>
|
||||
c.target === 'save' &&
|
||||
c.source === 'process' &&
|
||||
c.targetFilePath.includes('models'),
|
||||
);
|
||||
expect(saveCall).toBeDefined();
|
||||
});
|
||||
|
||||
it('resolves user.get_name() in process() to User#get_name via cross-file propagation', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const getNameCall = calls.find(c =>
|
||||
c.target === 'get_name' &&
|
||||
c.source === 'process' &&
|
||||
c.targetFilePath.includes('models'),
|
||||
);
|
||||
expect(getNameCall).toBeDefined();
|
||||
});
|
||||
|
||||
it('emits HAS_METHOD edges linking save and get_name to User (via header declarations)', () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
@ -1182,3 +1182,68 @@ describe('Go inc/dec write access tracking (Phase B)', () => {
|
|||
expect(countDec).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 14: Cross-file binding propagation (via synthesized wildcard imports)
|
||||
// models/user.go exports User struct with Save() and GetName() methods
|
||||
// models/factory.go exports GetUser() -> User
|
||||
// app/main.go imports models package, calls models.GetUser().Save()
|
||||
// → user is typed User via cross-file return type propagation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Go cross-file binding propagation', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(
|
||||
path.join(CROSS_FILE_FIXTURES, 'go-cross-file'),
|
||||
() => {},
|
||||
);
|
||||
}, 60000);
|
||||
|
||||
it('detects User struct with Save and GetName methods', () => {
|
||||
expect(getNodesByLabel(result, 'Struct')).toContain('User');
|
||||
expect(getNodesByLabel(result, 'Method')).toContain('Save');
|
||||
expect(getNodesByLabel(result, 'Method')).toContain('GetName');
|
||||
});
|
||||
|
||||
it('detects GetUser factory function', () => {
|
||||
expect(getNodesByLabel(result, 'Function')).toContain('GetUser');
|
||||
});
|
||||
|
||||
it('emits IMPORTS edge from main.go to models package files', () => {
|
||||
const imports = getRelationships(result, 'IMPORTS');
|
||||
const edge = imports.find(e =>
|
||||
e.sourceFilePath.includes('main') && e.targetFilePath.includes('models'),
|
||||
);
|
||||
expect(edge).toBeDefined();
|
||||
});
|
||||
|
||||
it('resolves user.Save() in main() to User#Save via cross-file propagation', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const saveCall = calls.find(c =>
|
||||
c.target === 'Save' &&
|
||||
c.source === 'main' &&
|
||||
c.targetFilePath.includes('models'),
|
||||
);
|
||||
expect(saveCall).toBeDefined();
|
||||
});
|
||||
|
||||
it('resolves user.GetName() in main() to User#GetName via cross-file propagation', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const getNameCall = calls.find(c =>
|
||||
c.target === 'GetName' &&
|
||||
c.source === 'main' &&
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,7 +6,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';
|
||||
|
||||
|
|
@ -1079,3 +1079,69 @@ describe('Ruby default parameter arity resolution', () => {
|
|||
expect(greetCalls.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 14: Cross-file binding propagation (via synthesized wildcard imports)
|
||||
// models/user.rb exports User class with save and get_name methods
|
||||
// models/user_factory.rb exports UserFactory with self.get_user -> User.new
|
||||
// app.rb requires both, calls UserFactory.get_user then .save / .get_name
|
||||
// → user is typed User via cross-file return type propagation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Ruby cross-file binding propagation', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(
|
||||
path.join(CROSS_FILE_FIXTURES, 'rb-cross-file'),
|
||||
() => {},
|
||||
);
|
||||
}, 60000);
|
||||
|
||||
it('detects User class with save and get_name methods', () => {
|
||||
expect(getNodesByLabel(result, 'Class')).toContain('User');
|
||||
expect(getNodesByLabel(result, 'Method')).toContain('save');
|
||||
expect(getNodesByLabel(result, 'Method')).toContain('get_name');
|
||||
});
|
||||
|
||||
it('detects UserFactory class and get_user method', () => {
|
||||
expect(getNodesByLabel(result, 'Class')).toContain('UserFactory');
|
||||
expect(getNodesByLabel(result, 'Method')).toContain('get_user');
|
||||
});
|
||||
|
||||
it('emits IMPORTS edge from app.rb to models', () => {
|
||||
const imports = getRelationships(result, 'IMPORTS');
|
||||
const edge = imports.find(e =>
|
||||
e.sourceFilePath.includes('app') && e.targetFilePath.includes('models'),
|
||||
);
|
||||
expect(edge).toBeDefined();
|
||||
});
|
||||
|
||||
it('resolves user.save in process to User#save via cross-file propagation', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const saveCall = calls.find(c =>
|
||||
c.target === 'save' &&
|
||||
c.source === 'process' &&
|
||||
c.targetFilePath.includes('models'),
|
||||
);
|
||||
expect(saveCall).toBeDefined();
|
||||
});
|
||||
|
||||
it('resolves user.get_name in process to User#get_name via cross-file propagation', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const getNameCall = calls.find(c =>
|
||||
c.target === 'get_name' &&
|
||||
c.source === 'process' &&
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
@ -1512,3 +1512,69 @@ describe('Rust struct_pattern destructuring resolution (Phase A)', () => {
|
|||
expect(saveCalls.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 14: Cross-file binding propagation
|
||||
// src/models.rs exports User struct with save() and get_name() methods
|
||||
// src/factory.rs exports get_user() -> User (uses crate::models::User)
|
||||
// src/main.rs uses crate::factory::get_user, calls u.save() / u.get_name()
|
||||
// → u is typed User via cross-file return type propagation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Rust cross-file binding propagation', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(
|
||||
path.join(CROSS_FILE_FIXTURES, 'rs-cross-file'),
|
||||
() => {},
|
||||
);
|
||||
}, 60000);
|
||||
|
||||
it('detects User struct with save and get_name methods', () => {
|
||||
expect(getNodesByLabel(result, 'Struct')).toContain('User');
|
||||
expect(getNodesByLabel(result, 'Function')).toContain('save');
|
||||
expect(getNodesByLabel(result, 'Function')).toContain('get_name');
|
||||
});
|
||||
|
||||
it('detects get_user and process functions', () => {
|
||||
expect(getNodesByLabel(result, 'Function')).toContain('get_user');
|
||||
expect(getNodesByLabel(result, 'Function')).toContain('process');
|
||||
});
|
||||
|
||||
it('emits IMPORTS edge from main.rs to factory.rs', () => {
|
||||
const imports = getRelationships(result, 'IMPORTS');
|
||||
const edge = imports.find(e =>
|
||||
e.sourceFilePath.includes('main') && e.targetFilePath.includes('factory'),
|
||||
);
|
||||
expect(edge).toBeDefined();
|
||||
});
|
||||
|
||||
it('resolves u.save() in process() to User#save via cross-file return type propagation', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const saveCall = calls.find(c =>
|
||||
c.target === 'save' &&
|
||||
c.source === 'process' &&
|
||||
c.targetFilePath.includes('models'),
|
||||
);
|
||||
expect(saveCall).toBeDefined();
|
||||
});
|
||||
|
||||
it('resolves u.get_name() in process() 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 === 'process' &&
|
||||
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, vi, beforeEach } from 'vitest';
|
||||
import { processCallsFromExtracted } from '../../src/core/ingestion/call-processor.js';
|
||||
import { processCallsFromExtracted, seedCrossFileReceiverTypes } from '../../src/core/ingestion/call-processor.js';
|
||||
import { extractReturnTypeName } from '../../src/core/ingestion/type-extractors/shared.js';
|
||||
import { createResolutionContext, type ResolutionContext } from '../../src/core/ingestion/resolution-context.js';
|
||||
import { createKnowledgeGraph } from '../../src/core/graph/graph.js';
|
||||
|
|
@ -868,3 +868,167 @@ describe('extractReturnTypeName', () => {
|
|||
expect(extractReturnTypeName('UserService')).toBe('UserService');
|
||||
});
|
||||
});
|
||||
|
||||
describe('seedCrossFileReceiverTypes', () => {
|
||||
it('single-hop: imported receiver gets type from ExportedTypeMap', () => {
|
||||
const calls: ExtractedCall[] = [{
|
||||
filePath: 'src/service.ts',
|
||||
calledName: 'save',
|
||||
sourceId: 'Function:src/service.ts:run',
|
||||
receiverName: 'repo',
|
||||
callForm: 'member',
|
||||
}];
|
||||
|
||||
const namedImportMap = new Map([
|
||||
['src/service.ts', new Map([
|
||||
['repo', { sourcePath: 'src/models/repo.ts', exportedName: 'repo' }],
|
||||
])],
|
||||
]);
|
||||
|
||||
const exportedTypeMap = new Map([
|
||||
['src/models/repo.ts', new Map([
|
||||
['repo', 'Repo'],
|
||||
])],
|
||||
]);
|
||||
|
||||
const { enrichedCount } = seedCrossFileReceiverTypes(calls, namedImportMap, exportedTypeMap);
|
||||
|
||||
expect(enrichedCount).toBe(1);
|
||||
expect(calls[0].receiverTypeName).toBe('Repo');
|
||||
});
|
||||
|
||||
it('no-op when receiverTypeName already exists', () => {
|
||||
const calls: ExtractedCall[] = [{
|
||||
filePath: 'src/service.ts',
|
||||
calledName: 'save',
|
||||
sourceId: 'Function:src/service.ts:run',
|
||||
receiverName: 'repo',
|
||||
receiverTypeName: 'AlreadyKnown',
|
||||
callForm: 'member',
|
||||
}];
|
||||
|
||||
const namedImportMap = new Map([
|
||||
['src/service.ts', new Map([
|
||||
['repo', { sourcePath: 'src/models/repo.ts', exportedName: 'repo' }],
|
||||
])],
|
||||
]);
|
||||
|
||||
const exportedTypeMap = new Map([
|
||||
['src/models/repo.ts', new Map([
|
||||
['repo', 'Repo'],
|
||||
])],
|
||||
]);
|
||||
|
||||
const { enrichedCount } = seedCrossFileReceiverTypes(calls, namedImportMap, exportedTypeMap);
|
||||
|
||||
expect(enrichedCount).toBe(0);
|
||||
expect(calls[0].receiverTypeName).toBe('AlreadyKnown');
|
||||
});
|
||||
|
||||
it('no-op for free function calls (callForm !== member)', () => {
|
||||
const calls: ExtractedCall[] = [{
|
||||
filePath: 'src/service.ts',
|
||||
calledName: 'doSomething',
|
||||
sourceId: 'Function:src/service.ts:run',
|
||||
receiverName: 'repo',
|
||||
callForm: 'free',
|
||||
}];
|
||||
|
||||
const namedImportMap = new Map([
|
||||
['src/service.ts', new Map([
|
||||
['repo', { sourcePath: 'src/models/repo.ts', exportedName: 'repo' }],
|
||||
])],
|
||||
]);
|
||||
|
||||
const exportedTypeMap = new Map([
|
||||
['src/models/repo.ts', new Map([
|
||||
['repo', 'Repo'],
|
||||
])],
|
||||
]);
|
||||
|
||||
const { enrichedCount } = seedCrossFileReceiverTypes(calls, namedImportMap, exportedTypeMap);
|
||||
|
||||
expect(enrichedCount).toBe(0);
|
||||
expect(calls[0].receiverTypeName).toBeUndefined();
|
||||
});
|
||||
|
||||
it('aliased imports: local name maps to exported name via binding', () => {
|
||||
const calls: ExtractedCall[] = [{
|
||||
filePath: 'src/controller.ts',
|
||||
calledName: 'find',
|
||||
sourceId: 'Function:src/controller.ts:handle',
|
||||
receiverName: 'myRepo',
|
||||
callForm: 'member',
|
||||
}];
|
||||
|
||||
// import { repoInstance as myRepo } from 'src/models/repo.ts'
|
||||
const namedImportMap = new Map([
|
||||
['src/controller.ts', new Map([
|
||||
['myRepo', { sourcePath: 'src/models/repo.ts', exportedName: 'repoInstance' }],
|
||||
])],
|
||||
]);
|
||||
|
||||
const exportedTypeMap = new Map([
|
||||
['src/models/repo.ts', new Map([
|
||||
['repoInstance', 'Repo'],
|
||||
])],
|
||||
]);
|
||||
|
||||
const { enrichedCount } = seedCrossFileReceiverTypes(calls, namedImportMap, exportedTypeMap);
|
||||
|
||||
expect(enrichedCount).toBe(1);
|
||||
expect(calls[0].receiverTypeName).toBe('Repo');
|
||||
});
|
||||
|
||||
it('early exit when maps are empty', () => {
|
||||
const calls: ExtractedCall[] = [{
|
||||
filePath: 'src/service.ts',
|
||||
calledName: 'save',
|
||||
sourceId: 'Function:src/service.ts:run',
|
||||
receiverName: 'repo',
|
||||
callForm: 'member',
|
||||
}];
|
||||
|
||||
const { enrichedCount: countA } = seedCrossFileReceiverTypes(
|
||||
calls,
|
||||
new Map(),
|
||||
new Map([['src/models/repo.ts', new Map([['repo', 'Repo']])]]),
|
||||
);
|
||||
expect(countA).toBe(0);
|
||||
|
||||
const { enrichedCount: countB } = seedCrossFileReceiverTypes(
|
||||
calls,
|
||||
new Map([['src/service.ts', new Map([['repo', { sourcePath: 'src/models/repo.ts', exportedName: 'repo' }]])]]),
|
||||
new Map(),
|
||||
);
|
||||
expect(countB).toBe(0);
|
||||
|
||||
expect(calls[0].receiverTypeName).toBeUndefined();
|
||||
});
|
||||
|
||||
it('no mutation when no matching exports found', () => {
|
||||
const calls: ExtractedCall[] = [{
|
||||
filePath: 'src/service.ts',
|
||||
calledName: 'save',
|
||||
sourceId: 'Function:src/service.ts:run',
|
||||
receiverName: 'repo',
|
||||
callForm: 'member',
|
||||
}];
|
||||
|
||||
// namedImportMap has the file, but exportedTypeMap has no entry for the source path
|
||||
const namedImportMap = new Map([
|
||||
['src/service.ts', new Map([
|
||||
['repo', { sourcePath: 'src/models/repo.ts', exportedName: 'repo' }],
|
||||
])],
|
||||
]);
|
||||
|
||||
const exportedTypeMap = new Map([
|
||||
['src/other-file.ts', new Map([['something', 'OtherType']])],
|
||||
]);
|
||||
|
||||
const { enrichedCount } = seedCrossFileReceiverTypes(calls, namedImportMap, exportedTypeMap);
|
||||
|
||||
expect(enrichedCount).toBe(0);
|
||||
expect(calls[0].receiverTypeName).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
152
gitnexus/test/unit/topological-sort.test.ts
Normal file
152
gitnexus/test/unit/topological-sort.test.ts
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { topologicalLevelSort } from '../../src/core/ingestion/pipeline.js';
|
||||
|
||||
describe('topologicalLevelSort', () => {
|
||||
it('returns empty levels for empty graph', () => {
|
||||
const importMap = new Map<string, Set<string>>();
|
||||
const { levels, cycleCount } = topologicalLevelSort(importMap);
|
||||
expect(levels).toEqual([]);
|
||||
expect(cycleCount).toBe(0);
|
||||
});
|
||||
|
||||
it('returns single level for files with no imports', () => {
|
||||
const importMap = new Map<string, Set<string>>([
|
||||
['a.ts', new Set()],
|
||||
['b.ts', new Set()],
|
||||
]);
|
||||
const { levels } = topologicalLevelSort(importMap);
|
||||
expect(levels).toHaveLength(1);
|
||||
expect(levels[0]).toContain('a.ts');
|
||||
expect(levels[0]).toContain('b.ts');
|
||||
});
|
||||
|
||||
it('orders files by dependency depth', () => {
|
||||
// b imports a, c imports b → a first, then b, then c
|
||||
const importMap = new Map<string, Set<string>>([
|
||||
['a.ts', new Set()],
|
||||
['b.ts', new Set(['a.ts'])],
|
||||
['c.ts', new Set(['b.ts'])],
|
||||
]);
|
||||
const { levels } = topologicalLevelSort(importMap);
|
||||
expect(levels).toHaveLength(3);
|
||||
expect(levels[0]).toContain('a.ts');
|
||||
expect(levels[1]).toContain('b.ts');
|
||||
expect(levels[2]).toContain('c.ts');
|
||||
});
|
||||
|
||||
it('groups independent files at the same level', () => {
|
||||
// b and c both import a → a at level 0, b and c at level 1
|
||||
const importMap = new Map<string, Set<string>>([
|
||||
['a.ts', new Set()],
|
||||
['b.ts', new Set(['a.ts'])],
|
||||
['c.ts', new Set(['a.ts'])],
|
||||
]);
|
||||
const { levels } = topologicalLevelSort(importMap);
|
||||
expect(levels).toHaveLength(2);
|
||||
expect(levels[0]).toContain('a.ts');
|
||||
expect(levels[1]).toContain('b.ts');
|
||||
expect(levels[1]).toContain('c.ts');
|
||||
});
|
||||
|
||||
it('handles cycles by grouping them in a final level', () => {
|
||||
// a imports b, b imports a — circular
|
||||
const importMap = new Map<string, Set<string>>([
|
||||
['a.ts', new Set(['b.ts'])],
|
||||
['b.ts', new Set(['a.ts'])],
|
||||
]);
|
||||
const { levels, cycleCount } = topologicalLevelSort(importMap);
|
||||
// Both should appear (in a cycle group)
|
||||
const allFiles = levels.flat();
|
||||
expect(allFiles).toContain('a.ts');
|
||||
expect(allFiles).toContain('b.ts');
|
||||
expect(cycleCount).toBe(2);
|
||||
});
|
||||
|
||||
it('handles disconnected components', () => {
|
||||
// Two independent groups with no cross-links
|
||||
const importMap = new Map<string, Set<string>>([
|
||||
['a.ts', new Set()],
|
||||
['b.ts', new Set(['a.ts'])],
|
||||
['x.ts', new Set()],
|
||||
['y.ts', new Set(['x.ts'])],
|
||||
]);
|
||||
const { levels } = topologicalLevelSort(importMap);
|
||||
// Level 0 has both roots, level 1 has both dependents
|
||||
expect(levels[0]).toContain('a.ts');
|
||||
expect(levels[0]).toContain('x.ts');
|
||||
expect(levels[1]).toContain('b.ts');
|
||||
expect(levels[1]).toContain('y.ts');
|
||||
});
|
||||
|
||||
it('handles diamond dependencies', () => {
|
||||
// d imports b and c; b and c both import a
|
||||
const importMap = new Map<string, Set<string>>([
|
||||
['a.ts', new Set()],
|
||||
['b.ts', new Set(['a.ts'])],
|
||||
['c.ts', new Set(['a.ts'])],
|
||||
['d.ts', new Set(['b.ts', 'c.ts'])],
|
||||
]);
|
||||
const { levels } = topologicalLevelSort(importMap);
|
||||
expect(levels).toHaveLength(3);
|
||||
expect(levels[0]).toContain('a.ts');
|
||||
expect(levels[1]).toContain('b.ts');
|
||||
expect(levels[1]).toContain('c.ts');
|
||||
expect(levels[2]).toContain('d.ts');
|
||||
});
|
||||
|
||||
it('handles a single file with no imports', () => {
|
||||
const importMap = new Map<string, Set<string>>([
|
||||
['only.ts', new Set()],
|
||||
]);
|
||||
const { levels } = topologicalLevelSort(importMap);
|
||||
expect(levels).toHaveLength(1);
|
||||
expect(levels[0]).toContain('only.ts');
|
||||
});
|
||||
|
||||
it('handles a dependency on a file not explicitly in the map', () => {
|
||||
// b imports external.ts which is not itself a key in importMap
|
||||
const importMap = new Map<string, Set<string>>([
|
||||
['b.ts', new Set(['external.ts'])],
|
||||
]);
|
||||
const { levels } = topologicalLevelSort(importMap);
|
||||
// external.ts has in-degree 0 (no one depends on it as a key), appears first
|
||||
// b.ts depends on external.ts so appears after
|
||||
const allFiles = levels.flat();
|
||||
expect(allFiles).toContain('external.ts');
|
||||
expect(allFiles).toContain('b.ts');
|
||||
const externalLevel = levels.findIndex(l => l.includes('external.ts'));
|
||||
const bLevel = levels.findIndex(l => l.includes('b.ts'));
|
||||
expect(externalLevel).toBeLessThan(bLevel);
|
||||
});
|
||||
|
||||
it('handles a cycle mixed with an acyclic dependent', () => {
|
||||
// a and b are cyclic; c depends on b (and ends up in cycle group too
|
||||
// because b never reaches in-degree 0)
|
||||
const importMap = new Map<string, Set<string>>([
|
||||
['a.ts', new Set(['b.ts'])],
|
||||
['b.ts', new Set(['a.ts'])],
|
||||
['c.ts', new Set(['b.ts'])],
|
||||
]);
|
||||
const { levels } = topologicalLevelSort(importMap);
|
||||
// No file has in-degree 0 to start: a needs b, b needs a, c needs b
|
||||
// All end up in the cycle group
|
||||
const allFiles = levels.flat();
|
||||
expect(allFiles).toContain('a.ts');
|
||||
expect(allFiles).toContain('b.ts');
|
||||
expect(allFiles).toContain('c.ts');
|
||||
});
|
||||
|
||||
it('all files appear exactly once across all levels', () => {
|
||||
const importMap = new Map<string, Set<string>>([
|
||||
['a.ts', new Set()],
|
||||
['b.ts', new Set(['a.ts'])],
|
||||
['c.ts', new Set(['a.ts'])],
|
||||
['d.ts', new Set(['b.ts', 'c.ts'])],
|
||||
]);
|
||||
const { levels } = topologicalLevelSort(importMap);
|
||||
const allFiles = levels.flat();
|
||||
const uniqueFiles = new Set(allFiles);
|
||||
expect(uniqueFiles.size).toBe(allFiles.length);
|
||||
expect(uniqueFiles.size).toBe(4);
|
||||
});
|
||||
});
|
||||
|
|
@ -4044,4 +4044,201 @@ class App {
|
|||
});
|
||||
});
|
||||
|
||||
describe('Phase 14: importedBindings seeding', () => {
|
||||
it('seeds imported bindings into file scope for unbound names', () => {
|
||||
// Source has no local declaration of 'config', so the imported binding wins
|
||||
const tree = parse('', TypeScript.typescript);
|
||||
const { env } = buildTypeEnv(tree, 'typescript', {
|
||||
importedBindings: new Map([['config', 'Config']]),
|
||||
});
|
||||
const fileScope = env.get('');
|
||||
expect(fileScope?.get('config')).toBe('Config');
|
||||
});
|
||||
|
||||
it('local declarations take precedence over imported bindings', () => {
|
||||
// Source declares config: AppConfig — imported binding for 'config' must not overwrite it
|
||||
const tree = parse('const config: AppConfig = getConfig();', TypeScript.typescript);
|
||||
const { env } = buildTypeEnv(tree, 'typescript', {
|
||||
importedBindings: new Map([['config', 'Config']]),
|
||||
});
|
||||
const fileScope = env.get('');
|
||||
// AppConfig from the local annotation wins over the imported 'Config'
|
||||
expect(fileScope?.get('config')).toBe('AppConfig');
|
||||
});
|
||||
|
||||
it('does nothing when importedBindings is empty', () => {
|
||||
const tree = parse('const user: User = getUser();', TypeScript.typescript);
|
||||
const { env: envWithout } = buildTypeEnv(tree, 'typescript');
|
||||
const { env: envWith } = buildTypeEnv(tree, 'typescript', {
|
||||
importedBindings: new Map(),
|
||||
});
|
||||
// Both envs should produce the same file-scope content
|
||||
const scopeWithout = envWithout.get('');
|
||||
const scopeWith = envWith.get('');
|
||||
expect(scopeWith?.get('user')).toBe(scopeWithout?.get('user'));
|
||||
expect(scopeWith?.size).toBe(scopeWithout?.size);
|
||||
});
|
||||
|
||||
it('seeds multiple bindings', () => {
|
||||
const tree = parse('', TypeScript.typescript);
|
||||
const { env } = buildTypeEnv(tree, 'typescript', {
|
||||
importedBindings: new Map([
|
||||
['user', 'User'],
|
||||
['config', 'Config'],
|
||||
]),
|
||||
});
|
||||
const fileScope = env.get('');
|
||||
expect(fileScope?.get('user')).toBe('User');
|
||||
expect(fileScope?.get('config')).toBe('Config');
|
||||
});
|
||||
|
||||
it('seeded bindings are reachable via lookup from a nested call node', () => {
|
||||
// A call inside a function should still be able to look up a file-scope seeded binding
|
||||
const code = `
|
||||
function process() {
|
||||
config.validate();
|
||||
}`;
|
||||
const tree = parse(code, TypeScript.typescript);
|
||||
const typeEnv = buildTypeEnv(tree, 'typescript', {
|
||||
importedBindings: new Map([['config', 'Config']]),
|
||||
});
|
||||
|
||||
const calls: any[] = [];
|
||||
function findCalls(node: any) {
|
||||
if (node.type === 'call_expression') calls.push(node);
|
||||
for (let i = 0; i < node.childCount; i++) findCalls(node.child(i));
|
||||
}
|
||||
findCalls(tree.rootNode);
|
||||
|
||||
// config.validate() — lookup 'config' from inside the process function scope
|
||||
expect(typeEnv.lookup('config', calls[0])).toBe('Config');
|
||||
});
|
||||
|
||||
it('seeds bindings with no conflict when local file has unrelated declarations', () => {
|
||||
// File has 'user' declared locally; 'config' comes from importedBindings
|
||||
const tree = parse('const user: User = getUser();', TypeScript.typescript);
|
||||
const { env } = buildTypeEnv(tree, 'typescript', {
|
||||
importedBindings: new Map([['config', 'Config']]),
|
||||
});
|
||||
const fileScope = env.get('');
|
||||
// Local binding preserved
|
||||
expect(fileScope?.get('user')).toBe('User');
|
||||
// Imported binding added for the name that had no local declaration
|
||||
expect(fileScope?.get('config')).toBe('Config');
|
||||
});
|
||||
});
|
||||
|
||||
describe('importedReturnTypes (Phase 14 E3)', () => {
|
||||
// Minimal mock SymbolTable that returns a known callable
|
||||
const makeSymbolTable = (
|
||||
callables: Array<{ name: string; returnType?: string }>,
|
||||
) => ({
|
||||
lookupFuzzyCallable: (name: string) =>
|
||||
callables
|
||||
.filter(c => c.name === name)
|
||||
.map(c => ({ nodeId: 'n1', filePath: 'src.ts', type: 'Function' as const, returnType: c.returnType })),
|
||||
lookupFuzzy: () => [],
|
||||
lookupExact: () => undefined,
|
||||
lookupExactFull: () => undefined,
|
||||
add: () => {},
|
||||
getStats: () => ({ fileCount: 0, globalSymbolCount: 0 }),
|
||||
clear: () => {},
|
||||
});
|
||||
|
||||
it('SymbolTable has unambiguous match → uses it, ignores cross-file', () => {
|
||||
// SymbolTable knows getConfig() returns Config (SymbolType)
|
||||
// importedReturnTypes says getConfig → WrongType — SymbolTable must win
|
||||
const symbolTable = makeSymbolTable([{ name: 'getConfig', returnType: 'Config' }]);
|
||||
const tree = parse('const c = getConfig();', TypeScript.typescript);
|
||||
const typeEnv = buildTypeEnv(tree, 'typescript', {
|
||||
symbolTable: symbolTable as any,
|
||||
importedReturnTypes: new Map([['getConfig', 'WrongType']]),
|
||||
});
|
||||
// SymbolTable result (Config) wins over cross-file fallback (WrongType)
|
||||
expect(flatGet(typeEnv.env, 'c')).toBe('Config');
|
||||
});
|
||||
|
||||
it('SymbolTable has no match (0 results) → falls back to cross-file', () => {
|
||||
// SymbolTable knows nothing about getConfig
|
||||
const symbolTable = makeSymbolTable([]);
|
||||
const tree = parse('const c = getConfig();', TypeScript.typescript);
|
||||
const typeEnv = buildTypeEnv(tree, 'typescript', {
|
||||
symbolTable: symbolTable as any,
|
||||
importedReturnTypes: new Map([['getConfig', 'Config']]),
|
||||
});
|
||||
// Cross-file fallback provides Config
|
||||
expect(flatGet(typeEnv.env, 'c')).toBe('Config');
|
||||
});
|
||||
|
||||
it('SymbolTable has 2+ matches (ambiguous) → returns undefined, NO cross-file fallback', () => {
|
||||
// Two overloads of process() — ambiguous → must NOT fall back to cross-file
|
||||
const symbolTable = makeSymbolTable([
|
||||
{ name: 'process', returnType: 'User' },
|
||||
{ name: 'process', returnType: 'Admin' },
|
||||
]);
|
||||
const tree = parse('const r = process();', TypeScript.typescript);
|
||||
const typeEnv = buildTypeEnv(tree, 'typescript', {
|
||||
symbolTable: symbolTable as any,
|
||||
importedReturnTypes: new Map([['process', 'User']]),
|
||||
});
|
||||
// Ambiguous → conservative → no binding produced
|
||||
expect(flatGet(typeEnv.env, 'r')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('no SymbolTable → uses cross-file return types directly', () => {
|
||||
const tree = parse('const c = getConfig();', TypeScript.typescript);
|
||||
const typeEnv = buildTypeEnv(tree, 'typescript', {
|
||||
importedReturnTypes: new Map([['getConfig', 'Config']]),
|
||||
});
|
||||
expect(flatGet(typeEnv.env, 'c')).toBe('Config');
|
||||
});
|
||||
|
||||
it('cross-file has entry but SymbolTable covers it → SymbolTable wins', () => {
|
||||
// SymbolTable provides the authoritative return type; cross-file entry is ignored
|
||||
const symbolTable = makeSymbolTable([{ name: 'getUser', returnType: 'User' }]);
|
||||
const tree = parse('const u = getUser();', TypeScript.typescript);
|
||||
const typeEnv = buildTypeEnv(tree, 'typescript', {
|
||||
symbolTable: symbolTable as any,
|
||||
importedReturnTypes: new Map([['getUser', 'CrossFileUser']]),
|
||||
});
|
||||
// SymbolTable result (User) wins
|
||||
expect(flatGet(typeEnv.env, 'u')).toBe('User');
|
||||
});
|
||||
|
||||
it('does nothing when importedReturnTypes is absent', () => {
|
||||
const tree = parse('const c = getConfig();', TypeScript.typescript);
|
||||
const typeEnv = buildTypeEnv(tree, 'typescript');
|
||||
// No annotation, no SymbolTable, no cross-file → no binding
|
||||
expect(flatGet(typeEnv.env, 'c')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('resolved cross-file callee enables downstream call edges via lookup', () => {
|
||||
// File has: const c = getConfig(); c.validate();
|
||||
// importedReturnTypes maps getConfig → Config
|
||||
// After fixpoint, c should be typed as Config, making lookup('c', ...) return 'Config'
|
||||
const code = `
|
||||
function process() {
|
||||
const c = getConfig();
|
||||
c.validate();
|
||||
}`;
|
||||
const tree = parse(code, TypeScript.typescript);
|
||||
const typeEnv = buildTypeEnv(tree, 'typescript', {
|
||||
importedReturnTypes: new Map([['getConfig', 'Config']]),
|
||||
});
|
||||
|
||||
const calls: any[] = [];
|
||||
function findCalls(node: any) {
|
||||
if (node.type === 'call_expression') calls.push(node);
|
||||
for (let i = 0; i < node.childCount; i++) findCalls(node.child(i));
|
||||
}
|
||||
findCalls(tree.rootNode);
|
||||
|
||||
// calls[0] = getConfig(), calls[1] = c.validate()
|
||||
// From inside process(), c should resolve to Config
|
||||
const validateCall = calls.find((n: any) => n.text.includes('validate'));
|
||||
expect(validateCall).toBeDefined();
|
||||
expect(typeEnv.lookup('c', validateCall)).toBe('Config');
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
|
|
|||
|
|
@ -125,35 +125,51 @@ Languages benefiting: Java, Kotlin, C#, C++, TypeScript (overloading). All OOP l
|
|||
|
||||
---
|
||||
|
||||
### Phase 14: Cross-File Binding Propagation
|
||||
### Phase 14: Cross-File Binding Propagation ✅
|
||||
|
||||
**Problem:** `buildTypeEnv` is per-file. Inferred types don't cross file boundaries.
|
||||
**Shipped in** `feat/phase14-cross-file-binding-propagation`.
|
||||
|
||||
```typescript
|
||||
// file-a.ts — fixpoint resolves: config → Config
|
||||
export const config = getConfig();
|
||||
Three enrichment mechanisms:
|
||||
- **E1:** `seedCrossFileReceiverTypes` — pre-seeds `receiverTypeName` for single-hop imported receivers (zero re-parse)
|
||||
- **E2:** `ExportedTypeMap` seeded into `importedBindings` for re-resolution pass
|
||||
- **E3:** `buildImportedReturnTypes` — cross-file return types for imported callables (local-first, SymbolTable takes precedence)
|
||||
|
||||
// file-b.ts — config has no type
|
||||
import { config } from './file-a';
|
||||
config.validate(); // missed
|
||||
```
|
||||
Architecture:
|
||||
- Topological import ordering via Kahn's BFS (`topologicalLevelSort`, returns `{ levels, cycleCount }`)
|
||||
- Cycle-safe: files in cycles grouped in final level, no cross-cycle propagation
|
||||
- `runCrossFileBindingPropagation()` extracted as standalone pipeline phase
|
||||
- `synthesizeWildcardImportBindings()` expands whole-module imports (Go/Ruby/C/C++/Swift) into per-symbol namedImportMap entries from graph-exported symbols — runs before Phase 14
|
||||
- Worker path: `buildExportedTypeMapFromGraph` collects Tier 0 (annotated) exports only
|
||||
- Sequential path: `collectExportedBindings` captures full fixpoint-inferred exports
|
||||
|
||||
**Approach: Export-type index.** After each file's fixpoint, export resolved bindings for exported symbols into `ExportedBindings: Map<filePath, Map<symbolName, typeName>>`. Subsequent files seed scopeEnv from this index for imported symbols.
|
||||
**Per-language Phase 14 coverage:**
|
||||
| Language | namedImportMap | ExportedTypeMap (E1/E2) | E3 (importedReturnTypes) | Benefit |
|
||||
|----------|:-:|:-:|:-:|---|
|
||||
| TypeScript | Full (named imports) | File-scope vars | Full | **High** |
|
||||
| JavaScript | Full (named imports) | File-scope vars | Full | **High** |
|
||||
| Python | from-imports | File-scope vars | Full | **High** |
|
||||
| Kotlin | Top-level fns | Top-level props | Full | **High** |
|
||||
| Rust | use clauses | Limited | Full | **High** |
|
||||
| Go | Synthesized¹ | Exported symbols | Full | **Medium** |
|
||||
| Ruby | Synthesized¹ | Exported symbols | Full | **Medium** |
|
||||
| C/C++ | Synthesized¹ | Exported symbols | Full | **Medium** |
|
||||
| Swift | Synthesized¹ | Exported symbols | Full | **Low** (Phase S blocked) |
|
||||
| PHP | use classes | Inert (class-scope) | Inert (no fn imports) | **Marginal** |
|
||||
| Java | Classes + static methods | Inert (no file-scope) | Via SymbolTable | **Medium** |
|
||||
| C# | Alias + `using static` | Inert (no file-scope) | Via SymbolTable | **Medium** |
|
||||
|
||||
**Details:**
|
||||
- Process files in topological import order (import-processor already builds the dependency graph)
|
||||
- Re-exports: follow import chain transitively in `ExportedBindings`
|
||||
- Barrel files (`index.ts`): chain of re-exports — same mechanism
|
||||
- Default exports: keyed as `"default"` in the map, mapped to local name at import site
|
||||
- Dynamic imports (`import()`, conditional `require()`): excluded — runtime-only edges
|
||||
- Circular imports: files in a cycle processed in arbitrary order within the cycle; cross-cycle bindings don't propagate (conservative)
|
||||
- Parallelism preserved within topological levels
|
||||
¹ Whole-module import languages: namedImportMap entries synthesized from graph-exported symbols via `synthesizeWildcardImportBindings()` (capped at 1000 per file)
|
||||
|
||||
**Why this is last:** Every earlier phase makes the per-file fixpoint stronger, reducing cases where cross-file propagation is needed. This is also the highest-risk architectural change.
|
||||
**Named binding extraction details:**
|
||||
- Java: `import static X.Y.method` now captured (static modifier detection). Ambiguous static imports (same method from multiple classes) fall through to Tier 2a for arity narrowing.
|
||||
- C#: `using static NS.Type;` now captured (last segment as class binding). Non-alias `using NS;` remains unsupported (namespace import requires type inference).
|
||||
|
||||
**Risks:** Topological ordering correctness (mitigated by reusing import-processor's existing graph). Re-export chain depth (bounded by import depth, typically 2-3). Memory for `ExportedBindings` (~100K entries for 10K-file monorepo — negligible).
|
||||
**Resolved limitations (this PR):**
|
||||
- ~~Worker path vs sequential path quality split~~ — workers now return file-scope TypeEnv bindings; main thread merges fixpoint-inferred exports into ExportedTypeMap (filtered by graph `isExported`)
|
||||
- ~~`lookupRawReturnType` no cross-file fallback~~ — separate `importedRawReturnTypes` map stores raw declared types (e.g., `User[]`) for for-loop element extraction via `extractElementTypeFromString`
|
||||
- ~~C++ header method declarations~~ — tree-sitter query fix: `field_identifier` added to declaration pattern alongside `identifier`, plus pointer/reference return type variants
|
||||
|
||||
**Impact: High | Effort: High**
|
||||
**Impact: High | Effort: High** — delivered
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -161,14 +177,14 @@ config.validate(); // missed
|
|||
|
||||
```
|
||||
Milestone D (Phases A, B, C) ✅ ──┐
|
||||
├──→ Phase 14 (cross-file)
|
||||
├──→ Phase 14 (cross-file) ✅
|
||||
Phase P (polymorphism) ───────────┤
|
||||
│
|
||||
Phase S (Swift parity) ───────────┘
|
||||
|
||||
Phase P.1–P.4 are delivered. P.5 (covariant return types) remains open.
|
||||
Phase P and Phase S are independent of each other and Phase 14.
|
||||
Phase 14 benefits from Phase P (better per-file resolution = fewer cross-file gaps).
|
||||
Phase 14 is delivered. Remaining open: Phase P.5, Phase S.
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -184,7 +200,7 @@ Phase 14 benefits from Phase P (better per-file resolution = fewer cross-file ga
|
|||
- ~~Virtual dispatch: `Dog()` uses `call_expression` (no `new` keyword)~~ — **RESOLVED** via `detectConstructorType` hook
|
||||
|
||||
### All languages
|
||||
- Cross-file binding propagation → Phase 14
|
||||
- ~~Cross-file binding propagation → Phase 14~~ — **DELIVERED** for all 13 languages via two mechanisms: (1) named import extraction (TS/JS/Python/Kotlin/Rust/PHP/Java/C#), (2) wildcard import synthesis from graph-exported symbols (Go/Ruby/C/C++/Swift). Remaining gap: C# non-alias `using NS;` (namespace import, requires type inference).
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -206,9 +222,9 @@ Unified fixpoint loop, call-result binding, field access binding, method-call-re
|
|||
|
||||
Consolidated Phases 10–13 into 3 balanced phases. Loop-fixpoint bridge, MRO-aware inheritance walking, `this`/`self` resolution, object/struct destructuring, null-check narrowing. Kotlin null-check bug fix. Full 11-language integration test coverage.
|
||||
|
||||
### Milestone E — Cross-Boundary ← **next** (Phase 14)
|
||||
### Milestone E — Cross-Boundary ✅ (Phase 14)
|
||||
|
||||
Export-type index, cross-file binding propagation.
|
||||
Export-type index, cross-file binding propagation. Full coverage for TS/JS/Python/Kotlin. Marginal for PHP. Inert for Java/C#/Go/Ruby/C/C++ (relies on Phase 9 SymbolTable).
|
||||
|
||||
### Milestone P — Polymorphism & Overloading (Phase P)
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ This system is designed to be:
|
|||
- **Conservative** — it prefers missing a binding over introducing a misleading one
|
||||
- **Walk + fixpoint** — bindings are collected during a single AST walk, then a unified fixpoint loop iterates over pending assignments (copy, callResult, fieldAccess, methodCallResult) until no new bindings are produced
|
||||
- **Scope-aware** — function-local bindings are isolated from file-level bindings
|
||||
- **Per-file** — the environment is built for one file at a time, though it may consult the global `SymbolTable` for validation in specific cases
|
||||
- **Per-file with cross-file seeding** — the environment is built for one file at a time, but Phase 14 seeds upstream bindings (imported types, return types) into the file scope before the fixpoint for all 13 languages
|
||||
|
||||
It is **not** a full compiler type checker. Its job is to recover enough type information to improve call-edge accuracy during ingestion.
|
||||
|
||||
|
|
@ -120,10 +120,16 @@ It does:
|
|||
It does not:
|
||||
|
||||
- perform full semantic type checking
|
||||
- run fixpoint inference
|
||||
- propagate inferred bindings across files as ordinary environment entries
|
||||
- guarantee resolution for every ambiguous construct
|
||||
|
||||
It now does (Phase 14):
|
||||
|
||||
- run a unified fixpoint loop per file for copy/callResult/fieldAccess/methodCallResult chains
|
||||
- propagate inferred bindings across files for all 13 supported languages:
|
||||
- **Named import extraction** (TS/JS/Python/Kotlin/Rust/PHP/Java/C#): per-symbol bindings extracted from import AST nodes
|
||||
- **Wildcard import synthesis** (Go/Ruby/C/C++/Swift): namedImportMap entries synthesized from graph-exported symbols via `synthesizeWildcardImportBindings()`, enabling cross-file propagation for whole-module-import languages
|
||||
- seed imported bindings into file scope after walk, before fixpoint (local declarations always win)
|
||||
|
||||
---
|
||||
|
||||
## TypeEnvironment Model
|
||||
|
|
@ -389,6 +395,7 @@ So return-type-aware receiver inference already exists in a constrained downstre
|
|||
| Method overload disambiguation | Yes** | No | Yes | Yes | Yes | No | No | No | No | No | No | Yes | No |
|
||||
| Constructor-visible virtual dispatch | Yes | No | Yes | Yes‡‡ | Yes | No | No | No | No | No | No | Yes§§ | No |
|
||||
| Optional parameter arity resolution | Yes | No | No | Yes | Yes | No | No | Yes | Yes | Yes | No | Yes | No |
|
||||
| Cross-file binding propagation | Yes | Yes | Yes‖‖ | Yes | Yes¶¶ | Yes*** | Yes | Yes | Partial | Yes*** | Yes*** | Yes*** | Yes*** |
|
||||
|
||||
\* Python class-level annotated attributes (`address: Address`) now resolve `declaredType` correctly. The `self.x` instance attribute pattern is not yet supported.
|
||||
|
||||
|
|
@ -412,6 +419,12 @@ So return-type-aware receiver inference already exists in a constrained downstre
|
|||
|
||||
§§ C++ smart pointer virtual dispatch supported for `make_shared<T>()`/`make_unique<T>()` factory patterns. Raw pointer `new` also supported.
|
||||
|
||||
‖‖ Java: `import static X.Y.method` now captured. Ambiguous static imports (same name from multiple classes) fall through to Tier 2a for arity narrowing. Non-static lowercase imports still skipped (package imports).
|
||||
|
||||
¶¶ C#: `using static NS.Type;` now captured (last segment as class binding). Non-alias `using NS;` still unsupported — namespace imports can't be reduced to per-symbol bindings without type inference.
|
||||
|
||||
\*\*\* Whole-module-import languages (Go, Ruby, C/C++, Swift): namedImportMap entries synthesized from graph-exported symbols via `synthesizeWildcardImportBindings()`. Not from import AST node extraction.
|
||||
|
||||
---
|
||||
|
||||
## Current Strengths
|
||||
|
|
@ -432,6 +445,7 @@ The current system provides strong value for call resolution because it combines
|
|||
- method overload disambiguation via argument literal types (Java, Kotlin, C#, C++)
|
||||
- constructor-visible virtual dispatch for same-file subclasses (Java, C#, TypeScript, C++, Kotlin)
|
||||
- optional/default parameter arity resolution — calls with omitted optional args still resolve (TS, Python, Kotlin, C#, C++, PHP, Ruby)
|
||||
- cross-file binding propagation across all 13 languages — named import extraction for languages with per-symbol imports, wildcard import synthesis for whole-module-import languages (Go, Ruby, C/C++, Swift)
|
||||
|
||||
This is enough to materially improve call-edge precision even without implementing a full static type system.
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue