refactor(ingestion): delete legacy resolution context + tiered-lookup plumbing (RING4-2, #943) (#2033)

* test(ingestion): characterize Laravel route → controller CALLS edges (RING4-2 #943)

Pins the current processRoutesFromExtracted edge-emission behavior (which had
no direct coverage) before migrating it off the legacy ResolutionContext.resolve
tiered lookup. Locks edge target, reason, and confidence values.

* refactor(ingestion): resolve Laravel route controllers via type registry (RING4-2 #943)

Migrate processRoutesFromExtracted off the legacy ResolutionContext.resolve
tiered lookup onto model.types.lookupClassByName (global class resolution) +
model.symbols.lookupExactAll (same-file method lookup). Drops the TIER_CONFIDENCE
dependency for a fixed ROUTE_EDGE_CONFIDENCE constant matching the prior
global-tier confidence. Characterization tests (6) stay green — behavior preserved.

* refactor(ingestion): delete ResolutionContext.resolve tiered lookup (RING4-2 #943)

Removes the legacy tiered name resolution — resolve/resolveUncached,
TieredCandidates, ResolutionTier, TIER_CONFIDENCE, walkBindingChain, the
package-dir index, the per-file resolve cache, and tier-hit stats. The context
is now a thin holder for the live SemanticModel plus the (now-dead) per-file
import maps, which the follow-up prune removes.

Deletes the dedicated resolution-context.test.ts and symbol-resolver.test.ts
(both exercised the removed .resolve tiered lookup). Full unit suite green
(the 3 analyze worker-pool tests are pre-existing load flakes — pass isolated).

* refactor(ingestion): delete legacy import-map plumbing + wildcard synthesis (RING4-2 #943)

The per-file importMap / namedImportMap / packageMap / moduleAliasMap that fed
the retired tiered resolver are now dead — nothing reads them (IMPORTS edges
come from scope-resolution's imports-to-edges bridge, independent of these
maps). Removes:
  - wildcard-synthesis.ts (synthesized the dead namedImportMap/moduleAliasMap)
  - import-processor's resolution path (processImports/processImportsFromExtracted/
    wireImplicitImports/buildImportResolutionContext), keeping only the live
    preprocessImportPath path-cleanup helper
  - the parse-impl orchestration that drove them

The parse phase now threads its SemanticModel to scope-resolution directly
(parseOutput.model) instead of wrapping it in the resolution context. Deletes
the obsolete wildcard/import-processor unit tests; trims the dead processImports
cases from sequential-language-availability (processParsing coverage kept).

* refactor(ingestion): delete resolution context + named-binding plumbing (RING4-2 #943)

Completes the legacy-resolution retirement. With the tiered resolver gone, the
entire per-file import-extraction chain is dead — its only consumer was the
deleted ResolutionContext.resolve, and scope-resolution emits IMPORTS edges
from its own finalized ImportEdges:

  - delete model/resolution-context.ts (the legacy context); the parse phase
    now hands its SemanticModel to scope-resolution as parseOutput.model
  - delete the named-bindings/ extractors + the namedBindingExtractor provider
    hook (built the dead NamedImportMap) across all 8 providers + the worker
  - delete the orphaned implicitImportWirer hook + Swift implementation +
    providersWithImplicitWiring (scope-resolution owns implicit imports now)
  - drop the dead ExtractedImport type + worker/sequential import accumulation
    (result.imports / WorkerExtractedData.imports)
  - import-processor.ts and its preprocessImportPath helper are now unreferenced

Deletes the obsolete named-bindings + preprocessImportPath unit tests. tsc
clean; full unit suite green (3 analyze worker-pool tests are pre-existing load
flakes); 1229 import/cross-file/resolver integration tests pass incl. the
wildcard-import languages (Go/Ruby/C++/Swift) that previously used synthesis.

* docs(ingestion): scrub stale references to deleted resolution-context machinery (RING4-2 #943)

* docs(ingestion): reword route resolver comment to clear acceptance grep gate (#943)

* fix(review): apply autofix feedback (RING4-2 #943)

Code-review autofixes from the multi-agent pass:
- delete orphaned dead code the deletion missed: swift.ts groupSwiftFilesByTarget
  + SwiftPackageConfig import (live copy is target-grouping.ts), import-resolvers
  EMPTY_INDEX export (no consumers after the importCtx reset was removed)
- scrub stale comments referencing deleted symbols (processImports,
  preprocessImportPath, moduleAliasMap, NamedImportMap/PackageMap, wildcard-synthesis)
  and fix a broken comment fragment in parse-impl.ts
- document the intentional global-resolution convergence for route controllers
  (the import-scoped tier was deleted with the resolver): confidence flattens
  0.9→0.5 but resolved edges stay at the 0.5 process-trace/community gate; only
  the narrow imported-controller-with-unresolved-method guessed edge crosses it
- add an overloaded-method characterization case pinning lookupExactAll[0]

* style(ingestion): prettier-format parse-impl unwind + route characterization test (#943)

* refactor(ingestion): address tri-review findings (RING4-2 #943)

From the PR #2033 tri-review (Codex + CE lanes):
- delete the now-dead importSemantics provider field + ImportSemantics type
  (wildcard-synthesis.ts was its sole consumer; zero readers remain) across
  language-provider.ts + 7 providers + DEFAULTS
- correct the processRoutesFromExtracted JSDoc: the import-disambiguated
  controller skip is STRICTER than the legacy global-tier guard (the legacy
  import-scoped tier resolved aliased / same-short-name controllers and emitted
  the edge); document the aliased-import missed-edge case explicitly
- add an aliased-controller characterization test pinning the documented
  global-resolution convergence (no edge for an aliased/unresolvable controller name)
- scrub stale parse-impl.ts docstrings/comments that still listed the removed
  import-resolution / wildcard-synthesis / heritage passes

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(ingestion): capture routes-file use/FQN map for Laravel controller resolution (#943)

Adds ExtractedRoute.controllerQualifiedName: the Laravel route extractor now
builds the routes file's `use`-import alias map (local→normalized dot-joined
FQN, via splitNamespaceUseDeclaration) and captures inline qualified ::class
references, threading the disambiguating FQN through every route. Normalized via
the shared normalizeQualifiedName so it matches the type registry's key shape
(issue #1982). Foundation for qualified-first route→controller resolution (U2).

* fix(ingestion): resolve Laravel route controllers qualified-first (#943)

processRoutesFromExtracted now resolves the controller via
model.types.lookupClassByQualifiedName(route.controllerQualifiedName) when the
extractor disambiguated it (aliased use / same-short-name / inline FQN), falling
back to the short-name lookupClassByName (which still skips on ambiguity). This
restores the route→controller CALLS edges the PR #2033 tri-review (Codex F1 +
ce-adversarial) found dropped, without re-adding the deleted per-file import map.
Method resolution, guessed-id, and confidence are unchanged. JSDoc rewritten to
qualified-first precedence; the aliased characterization test flips from no-edge
to edge; adds duplicated-name-disambiguated + stale-FQN-fallback cases.

* test(ingestion): end-to-end Laravel route→controller qualified resolution + PSR-4 disambiguation (#943)

Adds an integration test that parses real namespaced PHP controllers + a routes
file through the worker pipeline and asserts the route CALLS edges target the
correct namespaced controller — the authoritative gate the unit tests can't be
(hand-built models). It surfaced that PHP's statement-form `namespace X;`
leaves the structure-phase qualifiedName as the SHORT name, so
lookupClassByQualifiedName misses; resolveControllerByQualifiedName now adds a
PSR-4 file-path disambiguation (FQN namespace tail ↔ file directory tail) to
pick the right same-short-name controller. Forces the worker path
(workerThresholdsForTest) since route extraction is worker-only.

* style(ingestion): prettier-format Laravel route resolution changes (#943)

* test(ingestion): regenerate php-captures golden for the new php-laravel-routes fixture (#943)

* test(ingestion): move route fixture out of the php-* scope-capture corpus (#943)

The laravel route-resolution fixture lived under lang-resolution/php-laravel-routes,
which the php scope-capture golden + benchmark both glob (lang-resolution/php-*),
drifting their fingerprints. The fixture is for route resolution, not php
scope-capture parity, so rename it to lang-resolution/laravel-route-resolution
to decouple it. Reverts the golden's php-laravel-routes entries; bench
scope-capture --check passes (php back to baseline).

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergő Magyar 2026-06-04 17:51:28 +01:00 committed by GitHub
parent c4ee911463
commit bd59fa95ce
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
59 changed files with 807 additions and 4217 deletions

View file

@ -19,13 +19,12 @@
import Parser from 'tree-sitter';
import { KnowledgeGraph } from '../graph/types.js';
import { ASTCache } from './ast-cache.js';
import type { SymbolTableReader } from './model/index.js';
import type { ResolutionContext } from './model/resolution-context.js';
import { TIER_CONFIDENCE } from './model/resolution-context.js';
import type { SemanticModel, SymbolTableReader } from './model/index.js';
import { isLanguageAvailable, loadParser, loadLanguage } from '../tree-sitter/parser-loader.js';
import { getProvider } from './languages/index.js';
import { generateId } from '../../lib/utils.js';
import { getLanguageFromFilename } from 'gitnexus-shared';
import type { SymbolDefinition } from 'gitnexus-shared';
import { yieldToEventLoop } from './utils/event-loop.js';
import { parseSourceSafe } from '../tree-sitter/safe-parse.js';
import { getTreeSitterBufferSize } from './constants.js';
@ -76,14 +75,112 @@ export function buildExportedTypeMapFromGraph(
return result;
}
/**
* Confidence for route controller-method CALLS edges. Framework-route
* controller references (e.g. `OrderController::class` in `routes/web.php`)
* resolve by global class name, so this matches the legacy `global`-tier
* confidence the tiered resolver previously assigned these edges.
*/
const ROUTE_EDGE_CONFIDENCE = 0.5;
/**
* Resolve a route's controller class from its normalized dot-joined
* fully-qualified name (threaded by the Laravel extractor from a `use`/`::class`
* reference). Two strategies, in order:
*
* 1. Direct qualified lookup works when the type registry keys the class by
* its FQN (block-form namespaces, non-PHP frameworks, seeded test models).
* 2. PSR-4 file-path disambiguation PHP's common statement-form namespace
* (`namespace App\Http\Controllers;`) leaves the structure-phase
* `qualifiedName` as the *short* class name, so the registry has no FQN
* key. Instead, take the FQN's last segment as the class name, fetch the
* same-short-name candidates, and pick the one whose file path's tail
* matches the FQN's namespace tail (e.g. `App.Admin.OrderController`
* `app/Admin/OrderController.php`). Requires 2 trailing segments (class +
* 1 namespace segment) and a unique winner conservative, so a
* non-PSR-4 layout falls through to short-name resolution rather than
* guessing.
*
* Returns the resolved class, or `undefined` when the FQN cannot be uniquely
* resolved (the caller then falls back to bare short-name resolution).
*/
function resolveControllerByQualifiedName(
model: SemanticModel,
fqn: string,
): SymbolDefinition | undefined {
const direct = model.types.lookupClassByQualifiedName(fqn);
if (direct.length === 1) return direct[0];
const fqnSegments = fqn.split('.');
const shortName = fqnSegments[fqnSegments.length - 1];
if (!shortName) return undefined;
const candidates = model.types.lookupClassByName(shortName);
if (candidates.length === 1) return candidates[0];
if (candidates.length === 0) return undefined;
let best: SymbolDefinition | undefined;
let bestScore = 0;
let tie = false;
for (const candidate of candidates) {
// Compare the FQN's namespace tail against the file path's directory tail
// (PSR-4: `App\Admin\OrderController` ↔ `app/Admin/OrderController.php`).
// Split on `/` (a path separator normalizeQualifiedName does not touch).
const fileBase = candidate.filePath.replace(/\.[^./]+$/, '');
const fileSegments = fileBase.split('/').filter((s) => s.length > 0);
let score = 0;
while (
score < fqnSegments.length &&
score < fileSegments.length &&
fqnSegments[fqnSegments.length - 1 - score].toLowerCase() ===
fileSegments[fileSegments.length - 1 - score].toLowerCase()
) {
score++;
}
if (score > bestScore) {
bestScore = score;
best = candidate;
tie = false;
} else if (score === bestScore) {
tie = true;
}
}
// Need the class name + at least one namespace segment to disambiguate, and a
// single unambiguous winner.
return bestScore >= 2 && !tie ? best : undefined;
}
/**
* Create CALLS edges from extracted framework routes (e.g. Laravel) to their
* controller methods. Runs for all languages independent of call resolution.
*
* Resolution is registry-based (RING4-2 #943 retired the tiered resolver):
* - Controller: **qualified-first** (see {@link resolveControllerByQualifiedName}).
* When the routes file disambiguated the controller, the Laravel extractor
* threads `route.controllerQualifiedName` (a `use` import incl. aliased
* `use … as X;` or an inline qualified `::class`, normalized to the dot-
* joined key shape). The emitter resolves it by direct qualified lookup, or
* by PSR-4 file-path disambiguation when PHP's statement-form namespace left
* the registry keyed only by the short name either way picking the
* specific class even when the short name is globally duplicated (the common
* admin/public `OrderController` split) or aliased. It falls back to the
* global short-name lookup (`lookupClassByName`), which still skips on
* ambiguity (`length !== 1`) so a bare, genuinely ambiguous short name
* with no `use`/FQN correctly produces no (wrong) edge.
* - Method: resolved within the controller's own file via the symbol table
* (the legacy emitter only accepted same-file method resolutions).
*
* Edge confidence is a flat {@link ROUTE_EDGE_CONFIDENCE}. Route CALLS edges
* are gated downstream by the process-trace (`MIN_TRACE_CONFIDENCE`) and
* large-graph community (`MIN_CONFIDENCE_LARGE`) thresholds (both 0.5); a
* resolved edge lands at exactly 0.5 and passes (`>= 0.5`). The guessed-method
* fallback edge (`× 0.8` = 0.4) sits below the gate and is excluded from those
* passes acceptable for an edge whose target method could not be resolved.
*/
export const processRoutesFromExtracted = async (
graph: KnowledgeGraph,
extractedRoutes: ExtractedRoute[],
ctx: ResolutionContext,
model: SemanticModel,
onProgress?: (current: number, total: number) => void,
) => {
for (let i = 0; i < extractedRoutes.length; i++) {
@ -95,16 +192,29 @@ export const processRoutesFromExtracted = async (
if (!route.controllerName || !route.methodName) continue;
const controllerResolved = ctx.resolve(route.controllerName, route.filePath);
if (!controllerResolved || controllerResolved.candidates.length === 0) continue;
if (controllerResolved.tier === 'global' && controllerResolved.candidates.length > 1) continue;
// Resolve the controller class. Qualified-first: when the routes file
// disambiguated the controller (a `use` import or inline `::class` FQN, both
// normalized to the registry's dot-joined key shape by the extractor), look
// it up by qualified name — this resolves aliased imports and same-short-name
// controllers in different namespaces. Fall back to the global short-name
// lookup, which still refuses ambiguous matches (`length !== 1 → skip`),
// mirroring the legacy global tier.
let controllerDef: SymbolDefinition | undefined;
if (route.controllerQualifiedName) {
controllerDef = resolveControllerByQualifiedName(model, route.controllerQualifiedName);
}
if (!controllerDef) {
const controllerDefs = model.types.lookupClassByName(route.controllerName);
if (controllerDefs.length !== 1) continue;
controllerDef = controllerDefs[0];
}
const controllerDef = controllerResolved.candidates[0];
const confidence = TIER_CONFIDENCE[controllerResolved.tier];
const confidence = ROUTE_EDGE_CONFIDENCE;
const methodResolved = ctx.resolve(route.methodName, controllerDef.filePath);
const methodId =
methodResolved?.tier === 'same-file' ? methodResolved.candidates[0]?.nodeId : undefined;
// Method must live in the controller's own file (the legacy emitter only
// accepted same-file method resolutions).
const methodDefs = model.symbols.lookupExactAll(controllerDef.filePath, route.methodName);
const methodId = methodDefs[0]?.nodeId;
const sourceId = generateId('File', route.filePath);
if (!methodId) {

View file

@ -24,9 +24,10 @@ export type CallRoutingResult = RubyCallRouting | null;
/**
* Per-language call router.
* IMPORTANT: Call-routed imports bypass preprocessImportPath(), so any router that
* returns an importPath MUST validate it independently (length cap, control-char
* rejection). See routeRubyCall for the reference implementation.
* IMPORTANT: Call-routed imports are NOT sanitized by the standard import-path
* cleaner, so any router that returns an importPath MUST validate it
* independently (length cap, control-char rejection). See routeRubyCall for the
* reference implementation.
*/
export type CallRouter = (calledName: string, callNode: SyntaxNode) => CallRoutingResult;

View file

@ -1,516 +0,0 @@
import { KnowledgeGraph } from '../graph/types.js';
import { ASTCache } from './ast-cache.js';
import Parser from 'tree-sitter';
import { isLanguageAvailable, loadParser, loadLanguage } from '../tree-sitter/parser-loader.js';
import { getProvider, getProviderForFile, providersWithImplicitWiring } from './languages/index.js';
import type { LanguageProvider } from './language-provider.js';
import { generateId } from '../../lib/utils.js';
import { getLanguageFromFilename } from 'gitnexus-shared';
import { isVerboseIngestionEnabled } from './utils/verbose.js';
import { yieldToEventLoop } from './utils/event-loop.js';
import { parseSourceSafe, parseHadErrors } from '../tree-sitter/safe-parse.js';
import type { ExtractedImport } from './workers/parse-worker.js';
import { getTreeSitterBufferSize } from './constants.js';
import { loadImportConfigs } from './language-config.js';
import { buildSuffixIndex } from './import-resolvers/utils.js';
import type {
ResolutionContext,
ModuleAliasMap,
NamedImportMap,
} from './model/resolution-context.js';
import type {
ImportResult,
ResolveCtx,
ImportResolutionContext,
} from './import-resolvers/types.js';
import type { NamedBinding } from './named-bindings/types.js';
import type { SyntaxNode } from './utils/ast-helpers.js';
import { isDev } from './utils/env.js';
import { logger } from '../logger.js';
// Type: Map<FilePath, Set<ResolvedFilePath>>
// Stores all files that a given file imports from
export type ImportMap = Map<string, Set<string>>;
/** Group files by provider (only those with implicit import wiring), then call each wirer
* with its own language's files. O(n) over files, O(1) per provider lookup. */
function wireImplicitImports(
files: string[],
importMap: Map<string, Set<string>>,
addImportEdge: (src: string, target: string) => void,
projectConfig: unknown,
): void {
if (providersWithImplicitWiring.length === 0) return;
const grouped = new Map<LanguageProvider, string[]>();
for (const file of files) {
const provider = getProviderForFile(file);
if (!provider?.implicitImportWirer) continue;
let list = grouped.get(provider);
if (!list) {
list = [];
grouped.set(provider, list);
}
list.push(file);
}
for (const [provider, langFiles] of grouped) {
if (langFiles.length > 1) {
provider.implicitImportWirer(langFiles, importMap, addImportEdge, projectConfig);
}
}
}
// Type: Map<FilePath, Set<PackageDirSuffix>>
// Stores Go package directory suffixes imported by a file (e.g., "/internal/auth/").
// Avoids expanding every Go package import into N individual ImportMap edges.
export type PackageMap = Map<string, Set<string>>;
// ImportResolutionContext is defined in ./import-resolvers/types.ts — re-exported here for consumers.
export function buildImportResolutionContext(allPaths: string[]): ImportResolutionContext {
const allFileList = allPaths;
const normalizedFileList = allFileList.map((p) => p.replace(/\\/g, '/'));
const allFilePaths = new Set(allFileList);
const index = buildSuffixIndex(normalizedFileList, allFileList);
return { allFilePaths, allFileList, normalizedFileList, index, resolveCache: new Map() };
}
// Config loaders extracted to ./language-config.ts (Phase 2 refactor)
// Resolver types are in ./import-resolvers/types.ts; named binding types in ./named-bindings/types.ts
// ============================================================================
// Import path preprocessing
// ============================================================================
/**
* Clean and preprocess a raw import source text into a resolved import path.
* Strips quotes/angle brackets (universal) and applies provider-specific
* transformations (currently only Kotlin wildcard import detection).
*/
export function preprocessImportPath(
sourceText: string,
importNode: SyntaxNode,
provider: LanguageProvider,
): string | null {
const cleaned = sourceText.replace(/['"<>]/g, '');
// Defense-in-depth: reject null bytes and control characters (matches Ruby call-routing pattern)
if (!cleaned || cleaned.length > 2048 || /[\x00-\x1f]/.test(cleaned)) return null;
if (provider.importPathPreprocessor) {
return provider.importPathPreprocessor(cleaned, importNode);
}
return cleaned;
}
/** Create IMPORTS edge helpers that share a resolved-count tracker. */
function createImportEdgeHelpers(graph: KnowledgeGraph, importMap: ImportMap) {
let totalImportsResolved = 0;
const addImportGraphEdge = (filePath: string, resolvedPath: string) => {
const language = getLanguageFromFilename(filePath);
// Legacy IMPORTS-edge emission. Superseded for every language by the
// scope-resolution imports-to-edges bridge (RING4-1 #942 removed the legacy
// resolution path). Skipped for all known languages; the only remaining
// path is null-language files, which never resolve imports — so this is
// effectively inert and kept solely to avoid a behavioral diff.
if (language !== null) return;
const sourceId = generateId('File', filePath);
const targetId = generateId('File', resolvedPath);
const relId = generateId('IMPORTS', `${filePath}->${resolvedPath}`);
totalImportsResolved++;
graph.addRelationship({
id: relId,
sourceId,
targetId,
type: 'IMPORTS',
confidence: 1.0,
reason: '',
});
};
const addImportEdge = (filePath: string, resolvedPath: string) => {
addImportGraphEdge(filePath, resolvedPath);
if (!importMap.has(filePath)) importMap.set(filePath, new Set());
importMap.get(filePath)!.add(resolvedPath);
};
return { addImportEdge, addImportGraphEdge, getResolvedCount: () => totalImportsResolved };
}
/**
* Apply an ImportResult: emit graph edges and update ImportMap/PackageMap.
* If namedBindings are provided and the import resolves to a single file,
* also populate the NamedImportMap for precise Tier 2a resolution.
* Bindings tagged with `isModuleAlias` are routed to moduleAliasMap instead.
*/
function applyImportResult(
result: ImportResult,
filePath: string,
importMap: ImportMap,
packageMap: PackageMap | undefined,
addImportEdge: (from: string, to: string) => void,
addImportGraphEdge: (from: string, to: string) => void,
namedBindings?: NamedBinding[],
namedImportMap?: NamedImportMap,
moduleAliasMap?: ModuleAliasMap,
): void {
if (!result) return;
if (result.kind === 'package' && packageMap) {
// Store directory suffix in PackageMap (skip ImportMap expansion)
for (const resolvedFile of result.files) {
addImportGraphEdge(filePath, resolvedFile);
}
if (!packageMap.has(filePath)) packageMap.set(filePath, new Set());
packageMap.get(filePath)!.add(result.dirSuffix);
} else {
// 'files' kind, or 'package' without PackageMap — use ImportMap directly
const files = result.files;
for (const resolvedFile of files) {
addImportEdge(filePath, resolvedFile);
}
// Route module aliases (import X as Y) directly to moduleAliasMap.
// These are module-level aliases, not symbol bindings — they don't belong in namedImportMap.
if (namedBindings && moduleAliasMap && files.length === 1) {
const resolvedFile = files[0];
for (const binding of namedBindings) {
if (!binding.isModuleAlias) continue;
let aliasMap = moduleAliasMap.get(filePath);
if (!aliasMap) {
aliasMap = new Map();
moduleAliasMap.set(filePath, aliasMap);
}
aliasMap.set(binding.local, resolvedFile);
}
}
// 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) {
if (!namedImportMap.has(filePath)) namedImportMap.set(filePath, new Map());
const fileBindings = namedImportMap.get(filePath)!;
if (files.length === 1) {
const resolvedFile = files[0];
for (const binding of namedBindings) {
if (binding.isModuleAlias) continue; // already routed to moduleAliasMap
const existing = fileBindings.get(binding.local);
if (existing && existing.sourcePath !== resolvedFile) {
fileBindings.delete(binding.local);
} else {
fileBindings.set(binding.local, {
sourcePath: resolvedFile,
exportedName: binding.exported,
});
}
}
} else {
// Multi-file resolution (e.g., Rust `use crate::models::{User, Repo}`).
// Match each binding to a resolved file by comparing the lowercase binding name
// to the file's basename (without extension). If no match, skip the binding.
for (const binding of namedBindings) {
if (binding.isModuleAlias) continue;
const lowerName = binding.exported.toLowerCase();
const matchedFile = files.find((f) => {
const base = f.replace(/\\/g, '/').split('/').pop() ?? '';
const nameWithoutExt = base.substring(0, base.lastIndexOf('.')).toLowerCase();
return nameWithoutExt === lowerName;
});
if (matchedFile) {
const existing = fileBindings.get(binding.local);
if (existing && existing.sourcePath !== matchedFile) {
fileBindings.delete(binding.local);
} else {
fileBindings.set(binding.local, {
sourcePath: matchedFile,
exportedName: binding.exported,
});
}
}
}
}
}
}
}
// ============================================================================
// MAIN IMPORT PROCESSOR
// ============================================================================
export const processImports = async (
graph: KnowledgeGraph,
files: { path: string; content: string }[],
astCache: ASTCache,
ctx: ResolutionContext,
onProgress?: (current: number, total: number) => void,
repoRoot?: string,
allPaths?: string[],
) => {
const importMap = ctx.importMap;
const packageMap = ctx.packageMap;
const namedImportMap = ctx.namedImportMap;
const moduleAliasMap = ctx.moduleAliasMap;
// Use allPaths (full repo) when available for cross-chunk resolution, else fall back to chunk files
const allFileList = allPaths ?? files.map((f) => f.path);
const allFilePaths = new Set(allFileList);
const parser = await loadParser();
const logSkipped = isVerboseIngestionEnabled();
const skippedByLang = logSkipped ? new Map<string, number>() : null;
const resolveCache = new Map<string, string | null>();
// Pre-compute normalized file list once (forward slashes)
const normalizedFileList = allFileList.map((p) => p.replace(/\\/g, '/'));
// Build suffix index for O(1) lookups
const index = buildSuffixIndex(normalizedFileList, allFileList);
// Track import statistics
let totalImportsFound = 0;
// Load language-specific configs once before the file loop
const configs = await loadImportConfigs(repoRoot || '');
const resolveCtx: ResolveCtx = {
allFilePaths,
allFileList,
normalizedFileList,
index,
resolveCache,
configs,
};
const { addImportEdge, addImportGraphEdge, getResolvedCount } = createImportEdgeHelpers(
graph,
importMap,
);
for (let i = 0; i < files.length; i++) {
const file = files[i];
onProgress?.(i + 1, files.length);
if (i % 20 === 0) await yieldToEventLoop();
// 1. Check language support first
const language = getLanguageFromFilename(file.path);
if (!language) continue;
if (!isLanguageAvailable(language)) {
if (skippedByLang) {
skippedByLang.set(language, (skippedByLang.get(language) ?? 0) + 1);
}
continue;
}
const provider = getProvider(language);
const queryStr = provider.treeSitterQueries;
if (!queryStr) continue;
// 2. ALWAYS load the language before querying (parser is stateful)
await loadLanguage(language, file.path);
// 3. Get AST (Try Cache First)
let tree = astCache.get(file.path);
let wasReparsed = false;
if (!tree) {
const parseContent = provider.preprocessSource?.(file.content, file.path) ?? file.content;
try {
tree = parseSourceSafe(parser, parseContent, undefined, {
bufferSize: getTreeSitterBufferSize(parseContent),
});
} catch (parseError) {
continue;
}
wasReparsed = true;
// Cache re-parsed tree so call/heritage phases get hits
astCache.set(file.path, tree);
}
let query;
let matches;
try {
const lang = parser.getLanguage();
query = new Parser.Query(lang, queryStr);
matches = query.matches(tree.rootNode);
} catch (queryError: any) {
if (isDev) {
logger.error(
{
file: file.path,
language,
err: queryError?.message || queryError,
queryPreview: queryStr.substring(0, 200) + '...',
contentPreview: file.content.substring(0, 300),
astRootType: tree.rootNode?.type,
astHasError: parseHadErrors(tree),
},
'tree-sitter query error',
);
}
if (wasReparsed) (tree as unknown as { delete?: () => void }).delete?.();
continue;
}
matches.forEach((match) => {
const captureMap: Record<string, any> = {};
match.captures.forEach((c) => (captureMap[c.name] = c.node));
if (captureMap['import']) {
const sourceNode = captureMap['import.source'];
if (!sourceNode) {
if (isDev) {
logger.info(`⚠️ Import captured but no source node in ${file.path}`);
}
return;
}
const rawImportPath = preprocessImportPath(sourceNode.text, captureMap['import'], provider);
if (!rawImportPath) return;
totalImportsFound++;
const result = provider.importResolver(rawImportPath, file.path, resolveCtx);
const extractor = provider.namedBindingExtractor;
const bindings = namedImportMap && extractor ? extractor(captureMap['import']) : undefined;
applyImportResult(
result,
file.path,
importMap,
packageMap,
addImportEdge,
addImportGraphEdge,
bindings,
namedImportMap,
moduleAliasMap,
);
}
// ---- Language-specific call-as-import routing (Ruby require, etc.) ----
if (captureMap['call']) {
const callNameNode = captureMap['call.name'];
if (callNameNode) {
const routed = provider.callRouter?.(callNameNode.text, captureMap['call']);
if (routed && routed.kind === 'import') {
totalImportsFound++;
const result = provider.importResolver(routed.importPath, file.path, resolveCtx);
applyImportResult(
result,
file.path,
importMap,
packageMap,
addImportEdge,
addImportGraphEdge,
);
}
}
}
});
// Tree is now owned by the LRU cache — no manual delete needed
}
wireImplicitImports(allFileList, importMap, addImportEdge, configs);
if (skippedByLang && skippedByLang.size > 0) {
for (const [lang, count] of skippedByLang.entries()) {
logger.warn(
`[ingestion] Skipped ${count} ${lang} file(s) in import processing — ${lang} parser not available.`,
);
}
}
if (isDev) {
logger.info(
`📊 Import processing complete: ${getResolvedCount()}/${totalImportsFound} imports resolved to graph edges`,
);
}
};
// ============================================================================
// FAST PATH: Resolve pre-extracted imports (no parsing needed)
// ============================================================================
export const processImportsFromExtracted = async (
graph: KnowledgeGraph,
files: { path: string }[],
extractedImports: ExtractedImport[],
ctx: ResolutionContext,
onProgress?: (current: number, total: number) => void,
repoRoot?: string,
prebuiltCtx?: ImportResolutionContext,
) => {
const importMap = ctx.importMap;
const packageMap = ctx.packageMap;
const namedImportMap = ctx.namedImportMap;
const moduleAliasMap = ctx.moduleAliasMap;
const importCtx = prebuiltCtx ?? buildImportResolutionContext(files.map((f) => f.path));
const { allFilePaths, allFileList, normalizedFileList, index, resolveCache } = importCtx;
let totalImportsFound = 0;
const configs = await loadImportConfigs(repoRoot || '');
const resolveCtx: ResolveCtx = {
allFilePaths,
allFileList,
normalizedFileList,
index,
resolveCache,
configs,
};
const { addImportEdge, addImportGraphEdge, getResolvedCount } = createImportEdgeHelpers(
graph,
importMap,
);
// Group by file for progress reporting (users see file count, not import count)
const importsByFile = new Map<string, ExtractedImport[]>();
for (const imp of extractedImports) {
let list = importsByFile.get(imp.filePath);
if (!list) {
list = [];
importsByFile.set(imp.filePath, list);
}
list.push(imp);
}
const totalFiles = importsByFile.size;
let filesProcessed = 0;
for (const [filePath, fileImports] of importsByFile) {
filesProcessed++;
if (filesProcessed % 100 === 0) {
onProgress?.(filesProcessed, totalFiles);
await yieldToEventLoop();
}
for (const imp of fileImports) {
totalImportsFound++;
const provider = getProvider(imp.language);
const result = provider.importResolver(imp.rawImportPath, filePath, resolveCtx);
applyImportResult(
result,
filePath,
importMap,
packageMap,
addImportEdge,
addImportGraphEdge,
imp.namedBindings,
namedImportMap,
moduleAliasMap,
);
}
}
onProgress?.(totalFiles, totalFiles);
wireImplicitImports(
files.map((f) => f.path),
importMap,
addImportEdge,
configs,
);
if (isDev) {
logger.info(
`📊 Import processing (fast path): ${getResolvedCount()}/${totalImportsFound} imports resolved to graph edges`,
);
}
};

View file

@ -22,8 +22,8 @@ export const RESOLVE_CACHE_CAP = 100_000;
* - TypeScript/JavaScript: rewrites tsconfig path aliases
* - Rust: converts crate::/super::/self:: to relative paths
*
* Java wildcards and Go package imports are handled separately in processImports
* because they resolve to multiple files.
* Java wildcards and Go package imports are handled by the scope-resolution
* phase because they resolve to multiple files.
*/
export const resolveImportPath = (
currentFile: string,
@ -98,8 +98,8 @@ export const resolveImportPath = (
} else if (importPath.startsWith('{') && importPath.endsWith('}')) {
// Top-level grouped imports: use {crate::a, crate::b}
// Iterate each part and return the first that resolves. This function returns a single
// string, so callers that need ALL edges must intercept before reaching here (see the
// Rust grouped-import blocks in processImports / processImportsBatch). This fallback
// string, so callers that need ALL edges must intercept before reaching here (the
// scope-resolution phase handles Rust grouped-import blocks). This fallback
// handles any path that reaches resolveImportPath directly.
const inner = importPath.slice(1, -1);
const parts = inner
@ -150,7 +150,8 @@ export const resolveImportPath = (
}
// ---- Generic package/absolute import resolution (suffix matching) ----
// Java wildcards are handled in processImports, not here
// Java wildcards are handled by the scope-resolution phase, not here; this
// resolver returns null for `.*` so it never produces a single-file match.
if (importPath.endsWith('.*')) {
return cache(null);
}

View file

@ -1,6 +1,5 @@
/**
* Shared utilities for import resolution.
* Extracted from import-processor.ts to reduce file size.
* Suffix-index helpers for import path resolution.
*/
/** All file extensions to try during resolution */
@ -86,15 +85,6 @@ export interface SuffixIndex {
getFilesInDir(dirSuffix: string, extension: string): string[];
}
const FROZEN_EMPTY_ARRAY: string[] = Object.freeze([]) as string[];
/** Sentinel index that returns no results. Used to release memory after import resolution. */
export const EMPTY_INDEX: SuffixIndex = Object.freeze({
get: () => undefined,
getInsensitive: () => undefined,
getFilesInDir: () => FROZEN_EMPTY_ARRAY,
});
export function buildSuffixIndex(normalizedFileList: string[], allFileList: string[]): SuffixIndex {
// Map: normalized suffix -> original file path
const exactMap = new Map<string, string>();

View file

@ -34,7 +34,6 @@ import type { FieldExtractor } from './field-extractor.js';
import type { MethodExtractor } from './method-types.js';
import type { VariableExtractor } from './variable-types.js';
import type { ImportResolverFn } from './import-resolvers/types.js';
import type { NamedBindingExtractorFn } from './named-bindings/types.js';
import type { SyntaxNode } from './utils/ast-helpers.js';
import type { NodeLabel } from 'gitnexus-shared';
@ -47,31 +46,6 @@ export type CaptureMap = Record<string, SyntaxNode | undefined>;
// so `core/ingestion/model/resolve.ts` can consume it without importing from
// this file (which would pull in the full language-registry dependency graph).
/**
* How a language handles imports determines wildcard synthesis behavior.
*
* Import resolution is a graph-traversal policy with multiple distinct strategies,
* analogous to MRO for method resolution. Each tag picks a strategy:
*
* | Tag | Mechanism | Traversal | Languages |
* |-----------------------|------------------------------------------------|---------------------|--------------------------------------------|
* | `named` | Per-symbol imports | None (use-site) | JS/TS, Java, C#, Rust, PHP, Kotlin, Vue |
* | `wildcard-transitive` | Textual paste, symbols chain through files | BFS closure | C, C++ (future: Obj-C, Fortran, Nim) |
* | `wildcard-leaf` | Whole public API, single hop | None (direct only) | Go, Ruby, Swift, Dart |
* | `namespace` | Qualified handle; symbols resolved at call site| None at import | Python |
* | `explicit-reexport` | Opt-in per-symbol re-export (SCAFFOLD) | Topological DAG | (future: TS `export *`, Rust `pub use`) |
*
* The `explicit-reexport` tag is a compile-time scaffold; no provider claims it yet.
* It falls through to `wildcard-leaf` behavior in synthesis so today's TS/Rust
* handling is unchanged. A future PR will implement the DAG walk for `export *`.
*/
export type ImportSemantics =
| 'named'
| 'wildcard-transitive'
| 'wildcard-leaf'
| 'namespace'
| 'explicit-reexport';
/** Configuration for AST-based framework detection patterns. */
export interface AstFrameworkPatternConfig {
framework: string;
@ -155,31 +129,10 @@ interface LanguageProviderConfig {
/** Call routing for languages that express imports/heritage as calls (e.g., Ruby).
* Default: no routing (all calls are normal call expressions). */
readonly callRouter?: CallRouter;
/** Named binding extraction from import statements.
* Default: undefined (language uses wildcard/whole-module imports). */
readonly namedBindingExtractor?: NamedBindingExtractorFn;
/** How this language handles imports. See `ImportSemantics` for the full taxonomy.
* - 'named': per-symbol imports (JS/TS, Java, C#, Rust, PHP, Kotlin)
* - 'wildcard-transitive': textual-include closure; imports chain through files (C, C++)
* - 'wildcard-leaf': whole-module single-hop imports; no transitive chaining (Go, Ruby, Swift, Dart)
* - 'namespace': qualified namespace imports, needs moduleAliasMap (Python)
* - 'explicit-reexport': opt-in per-symbol re-export (scaffold; no provider uses yet)
* Default: 'named'. */
readonly importSemantics?: ImportSemantics;
/** Language-specific transformation of raw import path text before resolution.
* Called after sanitization. E.g., Kotlin appends wildcard suffixes.
* Default: undefined (no preprocessing). */
readonly importPathPreprocessor?: (cleaned: string, importNode: SyntaxNode) => string;
/** Wire implicit inter-file imports for languages where all files in a module
* see each other (e.g., Swift targets, C header inclusion units).
* Called with only THIS language's files (pre-grouped by the processor).
* Default: undefined (no implicit imports). */
readonly implicitImportWirer?: (
languageFiles: string[],
importMap: ReadonlyMap<string, ReadonlySet<string>>,
addImportEdge: (src: string, target: string) => void,
projectConfig: unknown,
) => void;
// ── Enclosing owner resolution ─────────────────────────────────
/** Resolve a container node during enclosing-owner tree walks.
@ -539,18 +492,13 @@ interface LanguageProviderConfig {
}
/** Runtime type — same as LanguageProviderConfig but with defaults guaranteed present. */
export interface LanguageProvider extends Omit<
LanguageProviderConfig,
'importSemantics' | 'mroStrategy'
> {
readonly importSemantics: ImportSemantics;
export interface LanguageProvider extends Omit<LanguageProviderConfig, 'mroStrategy'> {
readonly mroStrategy: MroStrategy;
/** Check if a name is a built-in/stdlib function that should be filtered from the call graph. */
readonly isBuiltInName: (name: string) => boolean;
}
const DEFAULTS: Pick<LanguageProvider, 'importSemantics' | 'mroStrategy'> = {
importSemantics: 'named',
const DEFAULTS: Pick<LanguageProvider, 'mroStrategy'> = {
mroStrategy: 'first-wins',
};

View file

@ -382,7 +382,6 @@ export const cProvider = defineLanguage({
typeConfig: cCppConfig,
exportChecker: cCppExportChecker,
importResolver: createImportResolver(cImportConfig),
importSemantics: 'wildcard-transitive',
callExtractor: createCallExtractor(cCallConfig),
fieldExtractor: createFieldExtractor(cFieldConfig),
methodExtractor: createMethodExtractor({
@ -451,7 +450,6 @@ export const cppProvider = defineLanguage({
typeConfig: cCppConfig,
exportChecker: cCppExportChecker,
importResolver: createImportResolver(cppImportConfig),
importSemantics: 'wildcard-transitive',
mroStrategy: 'leftmost-base',
callExtractor: createCallExtractor(cppCallConfig),
fieldExtractor: createFieldExtractor(cppFieldConfig),

View file

@ -14,7 +14,6 @@ import { typeConfig as csharpConfig } from '../type-extractors/csharp.js';
import { csharpExportChecker } from '../export-detection.js';
import { createImportResolver } from '../import-resolvers/resolver-factory.js';
import { csharpImportConfig } from '../import-resolvers/configs/csharp.js';
import { extractCSharpNamedBindings } from '../named-bindings/csharp.js';
import { CSHARP_QUERIES } from '../tree-sitter-queries.js';
import type { AstFrameworkPatternConfig } from '../language-provider.js';
import { createCallExtractor } from '../call-extractors/generic.js';
@ -188,7 +187,6 @@ export const csharpProvider = defineLanguage({
typeConfig: csharpConfig,
exportChecker: csharpExportChecker,
importResolver: createImportResolver(csharpImportConfig),
namedBindingExtractor: extractCSharpNamedBindings,
mroStrategy: 'implements-split',
callExtractor: createCallExtractor(csharpCallConfig),
fieldExtractor: createFieldExtractor(csharpFieldConfig),

View file

@ -2,7 +2,6 @@
* Dart Language Provider
*
* Dart traits:
* - importSemantics: 'wildcard-leaf' (Dart imports bring everything public into scope)
* - exportChecker: public if no leading underscore
* - Dart SDK imports (dart:*) and external packages are skipped
* - enclosingFunctionFinder: Dart's tree-sitter grammar places function_body
@ -119,7 +118,6 @@ export const dartProvider = defineLanguage({
typeConfig: dartConfig,
exportChecker: dartExportChecker,
importResolver: createImportResolver(dartImportConfig),
importSemantics: 'wildcard-leaf',
callExtractor: createCallExtractor(dartCallConfig),
fieldExtractor: createFieldExtractor(dartFieldConfig),
methodExtractor: createMethodExtractor(dartMethodConfig),

View file

@ -3,7 +3,7 @@
*
* - `interpretDartImport` `@import.source` a whole-library
* `ParsedImport` (Dart `import`/`export` bring every public top-level
* symbol of the target into scope: `importSemantics: 'wildcard-leaf'`).
* symbol of the target into scope whole-library / wildcard-leaf semantics).
* `@import.heritage` markers (synthesized by `captures.ts` for
* `implements`/`with` clauses) become side-effect imports carrying a
* `__heritage__:` payload that `emitDartHeritageEdges` consumes; they

View file

@ -5,7 +5,6 @@
* LanguageProvider, following the Strategy pattern used by the pipeline.
*
* Key Go traits:
* - importSemantics: 'wildcard-leaf' (Go imports entire packages)
* - callRouter: present (Go method calls may need routing)
*/
@ -133,7 +132,6 @@ export const goProvider = defineLanguage({
typeConfig: goConfig,
exportChecker: goExportChecker,
importResolver: createImportResolver(goImportConfig),
importSemantics: 'wildcard-leaf',
callExtractor: createCallExtractor(goCallConfig),
fieldExtractor: createFieldExtractor(goFieldConfig),
methodExtractor: createMethodExtractor(goMethodConfig),

View file

@ -68,13 +68,3 @@ export function getProviderForFile(filePath: string): LanguageProvider | null {
const basename = filePath.slice(filePath.lastIndexOf('/') + 1);
return extensionMap.get(ext) ?? extensionMap.get(basename) ?? null;
}
/** Pre-computed list of providers that have implicit import wiring (e.g., Swift).
* Built once at module load avoids iterating all 13 providers per call. */
export const providersWithImplicitWiring = Object.values(providers).filter(
(
p,
): p is LanguageProvider & {
implicitImportWirer: NonNullable<LanguageProvider['implicitImportWirer']>;
} => p.implicitImportWirer != null,
);

View file

@ -16,7 +16,6 @@ import { javaTypeConfig } from '../type-extractors/jvm.js';
import { javaExportChecker } from '../export-detection.js';
import { createImportResolver } from '../import-resolvers/resolver-factory.js';
import { javaImportConfig } from '../import-resolvers/configs/jvm.js';
import { extractJavaNamedBindings } from '../named-bindings/java.js';
import { JAVA_QUERIES } from '../tree-sitter-queries.js';
import { createCallExtractor } from '../call-extractors/generic.js';
import { javaCallConfig } from '../call-extractors/configs/jvm.js';
@ -109,7 +108,6 @@ export const javaProvider = defineLanguage({
typeConfig: javaTypeConfig,
exportChecker: javaExportChecker,
importResolver: createImportResolver(javaImportConfig),
namedBindingExtractor: extractJavaNamedBindings,
mroStrategy: 'implements-split',
callExtractor: createCallExtractor(javaCallConfig),
fieldExtractor: createFieldExtractor(javaConfig),

View file

@ -15,7 +15,6 @@ import { kotlinTypeConfig } from '../type-extractors/jvm.js';
import { kotlinExportChecker } from '../export-detection.js';
import { createImportResolver } from '../import-resolvers/resolver-factory.js';
import { kotlinImportConfig } from '../import-resolvers/configs/jvm.js';
import { extractKotlinNamedBindings } from '../named-bindings/kotlin.js';
import { appendKotlinWildcard } from '../import-resolvers/jvm.js';
import { KOTLIN_QUERIES } from '../tree-sitter-queries.js';
import type { AstFrameworkPatternConfig } from '../language-provider.js';
@ -160,7 +159,6 @@ export const kotlinProvider = defineLanguage({
typeConfig: kotlinTypeConfig,
exportChecker: kotlinExportChecker,
importResolver: createImportResolver(kotlinImportConfig),
namedBindingExtractor: extractKotlinNamedBindings,
importPathPreprocessor: appendKotlinWildcard,
mroStrategy: 'implements-split',
callExtractor: createCallExtractor(kotlinCallConfig),

View file

@ -25,7 +25,6 @@ import { typeConfig as phpConfig } from '../type-extractors/php.js';
import { phpExportChecker } from '../export-detection.js';
import { createImportResolver } from '../import-resolvers/resolver-factory.js';
import { phpImportConfig } from '../import-resolvers/configs/php.js';
import { extractPhpNamedBindings } from '../named-bindings/php.js';
import { PHP_QUERIES } from '../tree-sitter-queries.js';
import { findDescendant, extractStringContent, type SyntaxNode } from '../utils/ast-helpers.js';
import type { NodeLabel } from 'gitnexus-shared';
@ -288,7 +287,6 @@ export const phpProvider = defineLanguage({
typeConfig: phpConfig,
exportChecker: phpExportChecker,
importResolver: createImportResolver(phpImportConfig),
namedBindingExtractor: extractPhpNamedBindings,
callExtractor: createCallExtractor(phpCallConfig),
fieldExtractor: createFieldExtractor(phpFieldConfig),
methodExtractor: createMethodExtractor(phpMethodConfig),

View file

@ -5,9 +5,7 @@
* LanguageProvider, following the Strategy pattern used by the pipeline.
*
* Key Python traits:
* - importSemantics: 'namespace' (Python uses namespace imports, not wildcard)
* - mroStrategy: 'c3' (Python C3 linearization for multiple inheritance)
* - namedBindingExtractor: present (from X import Y)
*/
import type { NodeLabel } from 'gitnexus-shared';
@ -20,7 +18,6 @@ import { typeConfig as pythonConfig } from '../type-extractors/python.js';
import { pythonExportChecker } from '../export-detection.js';
import { createImportResolver } from '../import-resolvers/resolver-factory.js';
import { pythonImportConfig } from '../import-resolvers/configs/python.js';
import { extractPythonNamedBindings } from '../named-bindings/python.js';
import { PYTHON_QUERIES } from '../tree-sitter-queries.js';
import { createFieldExtractor } from '../field-extractors/generic.js';
import { pythonConfig as pythonFieldConfig } from '../field-extractors/configs/python.js';
@ -125,8 +122,6 @@ export const pythonProvider = defineLanguage({
typeConfig: pythonConfig,
exportChecker: pythonExportChecker,
importResolver: createImportResolver(pythonImportConfig),
namedBindingExtractor: extractPythonNamedBindings,
importSemantics: 'namespace',
mroStrategy: 'c3',
callExtractor: createCallExtractor(pythonCallConfig),
fieldExtractor: createFieldExtractor(pythonFieldConfig),

View file

@ -187,7 +187,6 @@ export const rubyProvider = defineLanguage({
exportChecker: rubyExportChecker,
importResolver: createImportResolver(rubyImportConfig),
callRouter: routeRubyCall,
importSemantics: 'wildcard-leaf',
callExtractor: createCallExtractor(rubyCallConfig),
resolveEnclosingOwner: rubyResolveEnclosingOwner,
fieldExtractor: createFieldExtractor(rubyFieldConfig),

View file

@ -5,9 +5,7 @@
* LanguageProvider, following the Strategy pattern used by the pipeline.
*
* Key Rust traits:
* - importSemantics: 'named' (Rust has use X::{a, b})
* - mroStrategy: 'qualified-syntax' (Rust uses trait qualification, not MRO)
* - namedBindingExtractor: present (use X::{a, b} extracts named bindings)
*/
import { SupportedLanguages } from 'gitnexus-shared';
@ -20,7 +18,6 @@ import { typeConfig as rustConfig } from '../type-extractors/rust.js';
import { rustExportChecker } from '../export-detection.js';
import { createImportResolver } from '../import-resolvers/resolver-factory.js';
import { rustImportConfig } from '../import-resolvers/configs/rust.js';
import { extractRustNamedBindings } from '../named-bindings/rust.js';
import { RUST_QUERIES } from '../tree-sitter-queries.js';
import type { AstFrameworkPatternConfig } from '../language-provider.js';
import { createFieldExtractor } from '../field-extractors/generic.js';
@ -169,7 +166,6 @@ export const rustProvider = defineLanguage({
typeConfig: rustConfig,
exportChecker: rustExportChecker,
importResolver: createImportResolver(rustImportConfig),
namedBindingExtractor: extractRustNamedBindings,
mroStrategy: 'qualified-syntax',
callExtractor: createCallExtractor(rustCallConfig),
fieldExtractor: createFieldExtractor(rustFieldConfig),

View file

@ -3,10 +3,6 @@
*
* Assembles all Swift-specific ingestion capabilities into a single
* LanguageProvider, following the Strategy pattern used by the pipeline.
*
* Key Swift traits:
* - importSemantics: 'wildcard-leaf' (Swift imports entire modules)
* - implicitImportWirer: all files in the same SPM target see each other
*/
import { SupportedLanguages } from 'gitnexus-shared';
@ -20,7 +16,6 @@ import { swiftExportChecker } from '../export-detection.js';
import { createImportResolver } from '../import-resolvers/resolver-factory.js';
import { swiftImportConfig } from '../import-resolvers/configs/swift.js';
import { SWIFT_QUERIES } from '../tree-sitter-queries.js';
import type { SwiftPackageConfig } from '../language-config.js';
import type { SyntaxNode } from '../utils/ast-helpers.js';
import { createFieldExtractor } from '../field-extractors/generic.js';
import { swiftConfig as swiftFieldConfig } from '../field-extractors/configs/swift.js';
@ -41,93 +36,6 @@ import {
swiftArityCompatibility,
} from './swift/index.js';
/**
* Group Swift files by SPM target for implicit module visibility.
* If SwiftPackageConfig is available, use target -> directory mappings.
* Otherwise, group all Swift files under a single "default" target
* (assumes a single-module Xcode project).
*/
function groupSwiftFilesByTarget(
swiftFiles: string[],
swiftPackageConfig: SwiftPackageConfig | null,
): Map<string, string[]> {
// No SPM config -> single target (common for Xcode projects)
if (!swiftPackageConfig || swiftPackageConfig.targets.size === 0) {
return new Map([['__default__', swiftFiles]]);
}
// Pre-convert target dirs to normalized prefix format once
const targets = [...swiftPackageConfig.targets.entries()].map(([name, dir]) => ({
name,
prefix: dir.replace(/\\/g, '/') + '/',
}));
const groups = new Map<string, string[]>();
const defaultGroup: string[] = [];
for (const file of swiftFiles) {
const normalized = file.includes('\\') ? file.replace(/\\/g, '/') : file;
let assigned = false;
for (const { name, prefix } of targets) {
const idx = normalized.indexOf(prefix);
if (idx === 0 || (idx > 0 && normalized[idx - 1] === '/')) {
let group = groups.get(name);
if (!group) {
group = [];
groups.set(name, group);
}
group.push(file);
assigned = true;
break;
}
}
if (!assigned) defaultGroup.push(file);
}
if (defaultGroup.length > 0) groups.set('__default__', defaultGroup);
return groups;
}
/**
* Wire implicit inter-file imports for Swift.
* All files in the same SPM target see each other (full module visibility).
* Two fast paths avoid unnecessary work:
* 1. No existing imports for src -> emit all (m-1) edges without Set.has checks
* 2. Existing imports present -> skip already-connected pairs
*/
function wireSwiftImplicitImports(
swiftFiles: string[],
importMap: ReadonlyMap<string, ReadonlySet<string>>,
addImportEdge: (src: string, target: string) => void,
projectConfig: unknown,
): void {
const configs = projectConfig as { swiftPackageConfig?: SwiftPackageConfig | null } | null;
const targetGroups = groupSwiftFilesByTarget(swiftFiles, configs?.swiftPackageConfig ?? null);
for (const group of targetGroups.values()) {
const m = group.length;
if (m <= 1) continue;
// All-pairs implicit edges: O(m²) is inherent for full module visibility.
for (let i = 0; i < m; i++) {
const src = group[i];
const existing = importMap.get(src);
if (!existing || existing.size === 0) {
// Fast path: no prior imports — emit all peers unconditionally
for (let j = 0; j < m; j++) {
if (i !== j) addImportEdge(src, group[j]);
}
} else {
// Dedup path: skip already-connected pairs
for (let j = 0; j < m; j++) {
if (i !== j && !existing.has(group[j])) {
addImportEdge(src, group[j]);
}
}
}
}
}
}
/** Swift init/deinit declarations have special names and Constructor label. */
const swiftExtractFunctionName = (
node: SyntaxNode,
@ -326,7 +234,6 @@ export const swiftProvider = defineLanguage({
typeConfig: swiftConfig,
exportChecker: swiftExportChecker,
importResolver: createImportResolver(swiftImportConfig),
importSemantics: 'wildcard-leaf',
callExtractor: createCallExtractor(swiftCallConfig),
fieldExtractor: createFieldExtractor(swiftFieldConfig),
methodExtractor: createMethodExtractor({
@ -335,7 +242,6 @@ export const swiftProvider = defineLanguage({
}),
variableExtractor: createVariableExtractor(swiftVariableConfig),
classExtractor: createClassExtractor(swiftClassConfig),
implicitImportWirer: wireSwiftImplicitImports,
orderSameNameTypeCandidates: orderSwiftSameNameTypeCandidates,
builtInNames: BUILT_INS,
// ── Scope-based resolution hooks (RFC #909 Ring 3, issue #937). See

View file

@ -1,9 +1,8 @@
/**
* TypeScript and JavaScript language providers.
*
* Both languages share the same type extraction config (typescriptConfig),
* export checker (tsExportChecker), and named binding extractor
* (extractTsNamedBindings). They differ in file extensions, tree-sitter
* Both languages share the same type extraction config (typescriptConfig)
* and export checker (tsExportChecker). They differ in file extensions, tree-sitter
* queries (TypeScript grammar has interface/type nodes), and language ID.
*/
@ -24,7 +23,6 @@ import {
typescriptImportConfig,
javascriptImportConfig,
} from '../import-resolvers/configs/typescript-javascript.js';
import { extractTsNamedBindings } from '../named-bindings/typescript.js';
import { TYPESCRIPT_QUERIES, JAVASCRIPT_QUERIES } from '../tree-sitter-queries.js';
import { typescriptFieldExtractor } from '../field-extractors/typescript.js';
import { createFieldExtractor } from '../field-extractors/generic.js';
@ -337,7 +335,6 @@ export const typescriptProvider = defineLanguage({
typeConfig: typescriptConfig,
exportChecker: tsExportChecker,
importResolver: createImportResolver(typescriptImportConfig),
namedBindingExtractor: extractTsNamedBindings,
callExtractor: createCallExtractor(typescriptCallConfig),
fieldExtractor: typescriptFieldExtractor,
methodExtractor: createMethodExtractor({
@ -398,7 +395,6 @@ export const javascriptProvider = defineLanguage({
typeConfig: typescriptConfig,
exportChecker: tsExportChecker,
importResolver: createImportResolver(javascriptImportConfig),
namedBindingExtractor: extractTsNamedBindings,
callExtractor: createCallExtractor(javascriptCallConfig),
fieldExtractor: createFieldExtractor(javascriptConfig),
methodExtractor: createMethodExtractor({

View file

@ -19,7 +19,6 @@ import { typeConfig as typescriptConfig } from '../type-extractors/typescript.js
import { tsExportChecker } from '../export-detection.js';
import { createImportResolver } from '../import-resolvers/resolver-factory.js';
import { vueImportConfig } from '../import-resolvers/configs/typescript-javascript.js';
import { extractTsNamedBindings } from '../named-bindings/typescript.js';
import { TYPESCRIPT_QUERIES } from '../tree-sitter-queries.js';
import { typescriptFieldExtractor } from '../field-extractors/typescript.js';
import { BUILT_INS as TS_BUILT_INS } from './typescript.js';
@ -84,7 +83,6 @@ export const vueProvider = defineLanguage({
typeConfig: typescriptConfig,
exportChecker: tsExportChecker,
importResolver: createImportResolver(vueImportConfig),
namedBindingExtractor: extractTsNamedBindings,
callExtractor: createCallExtractor(typescriptCallConfig),
fieldExtractor: typescriptFieldExtractor,
variableExtractor: createVariableExtractor(typescriptVariableConfig),

View file

@ -59,14 +59,6 @@ export {
createFieldRegistry,
} from './field-registry.js';
// Named-import types and package-dir helper. Re-exported so barrel
// consumers don't need to reach into a specific model file.
export {
type NamedImportBinding,
type NamedImportMap,
isFileInPackageDir,
} from './resolution-context.js';
// Behavior-grouped dispatch table for SymbolTable.add() routing.
// See registration-table.ts module JSDoc for the behavior group taxonomy
// and "how to add a new NodeLabel" checklist.

View file

@ -1,455 +0,0 @@
/**
* Resolution Context
*
* Single implementation of tiered name resolution.
*
* Resolution tiers (highest confidence first):
* 1. Same file (lookupExactAll authoritative)
* 2a-named. Named binding chain (walkBindingChain via NamedImportMap)
* 2a. Import-scoped (iterate importedFiles with lookupExactAll per file)
* 2b. Package-scoped (iterate indexed files matching package dir with lookupExactAll)
* 3. Global (lookupClassByName + lookupImplByName + lookupCallableByName consumers must check count)
*
* Each tier queries the minimum necessary scope directly:
* - Tier 2a iterates the caller's import set (O(imports) × O(1) lookupExactAll).
* - Tier 2b iterates all indexed files filtered by package dir
* (O(files) × O(1) lookupExactAll avoids a global name scan).
* - Tier 3 combines lookupClassByName + lookupImplByName + lookupCallableByName
* (three O(1) index lookups with a narrow, type-specific result set).
*/
import type { SymbolDefinition } from 'gitnexus-shared';
import type { SymbolTableReader } from './symbol-table.js';
import type { MutableSemanticModel } from './semantic-model.js';
import { createSemanticModel } from './semantic-model.js';
// ---------------------------------------------------------------------------
// Named-import types — describe how a file imports specific names from a
// source file. Consumed by the Tier 2a-named binding-chain walker below.
// ---------------------------------------------------------------------------
/**
* A single named binding in a source file (e.g. `import { User as U }`).
* Stores both the resolved source path and the original exported name so
* that aliased imports can resolve U User in the source file.
*/
export interface NamedImportBinding {
sourcePath: string;
exportedName: string;
}
/**
* Map<ImportingFilePath, Map<LocalName, NamedImportBinding>>.
*
* Tracks which specific names a file imports from which sources (TS / Python
* / Rust / Java-static / ...). Used to tighten Tier 2a resolution:
* `import { User } from './models'` means only `User` (not `Repo`) is
* visible from models.ts via this import.
*/
export type NamedImportMap = Map<string, Map<string, NamedImportBinding>>;
/**
* Check if a file path is directly inside a package directory identified by
* its suffix. Used by Tier 2b package-scoped resolution (Go / C#).
*/
export function isFileInPackageDir(filePath: string, dirSuffix: string): boolean {
// Prepend '/' so paths like "internal/auth/service.go" match suffix "/internal/auth/"
const normalized = '/' + filePath.replace(/\\/g, '/');
if (!normalized.includes(dirSuffix)) return false;
const afterDir = normalized.substring(normalized.indexOf(dirSuffix) + dirSuffix.length);
return !afterDir.includes('/');
}
/** Maximum re-export hops walkBindingChain will follow before giving up.
* A hard cap is needed to defend against pathological cycles that slip
* past the `visited` Set (e.g. a binding chain whose key is equal by
* string value but visits distinct modules). Five hops covers the
* common TypeScript monorepo pattern (component pkg/index
* packages/index root/index types/index). Chains longer than this
* fall through to Tier 2a-import / Tier 2b / Tier 3 resolution, which
* is a silent false-negative that the caller may or may not recover
* from. If a real repo hits this limit, raise it there is no
* correctness reason to keep it at exactly 5. */
const MAX_BINDING_CHAIN_DEPTH = 5;
/**
* Walk a named-binding re-export chain through NamedImportMap.
*
* When file A imports { User } from B, and B re-exports { User } from C,
* the NamedImportMap for A points to B, but B has no User definition.
* This function follows the chain: A B C until a definition is found.
*
* Returns the definitions found at the end of the chain, or null if the
* chain breaks (missing binding, circular reference, or
* {@link MAX_BINDING_CHAIN_DEPTH} exceeded). Internal to
* resolution-context not exported from the model barrel.
*/
function walkBindingChain(
name: string,
currentFilePath: string,
symbolTable: SymbolTableReader,
namedImportMap: NamedImportMap,
): readonly SymbolDefinition[] | null {
// Fast exit: most files have no named imports at all. Skip the Set
// allocation + loop entry on the common empty-binding path so resolve()
// stays allocation-free for the typical call site.
const firstBindings = namedImportMap.get(currentFilePath);
if (!firstBindings) return null;
const firstBinding = firstBindings.get(name);
if (!firstBinding) return null;
let lookupFile = currentFilePath;
let lookupName = name;
const visited = new Set<string>();
for (let depth = 0; depth < MAX_BINDING_CHAIN_DEPTH; depth++) {
const bindings = depth === 0 ? firstBindings : namedImportMap.get(lookupFile);
if (!bindings) return null;
const binding = depth === 0 ? firstBinding : bindings.get(lookupName);
if (!binding) return null;
const key = `${binding.sourcePath}:${binding.exportedName}`;
if (visited.has(key)) return null; // circular
visited.add(key);
const targetName = binding.exportedName;
const resolvedDefs = symbolTable.lookupExactAll(binding.sourcePath, targetName);
if (resolvedDefs.length > 0) return resolvedDefs;
// No definition in source file → follow re-export chain
lookupFile = binding.sourcePath;
lookupName = targetName;
}
return null;
}
/** Resolution tier for tracking, logging, and test assertions. */
export type ResolutionTier = 'same-file' | 'import-scoped' | 'global';
/** Tier-selected candidates with metadata. */
export interface TieredCandidates {
readonly candidates: readonly SymbolDefinition[];
readonly tier: ResolutionTier;
}
/** Confidence scores per resolution tier. */
export const TIER_CONFIDENCE: Record<ResolutionTier, number> = {
'same-file': 0.95,
'import-scoped': 0.9,
global: 0.5,
};
// --- Map types ---
export type ImportMap = Map<string, Set<string>>;
export type PackageMap = Map<string, Set<string>>;
/** Maps callerFile (moduleAlias sourceFilePath) for Python namespace imports.
* e.g. `import models` in app.py moduleAliasMap.get('app.py')?.get('models') === 'models.py' */
export type ModuleAliasMap = Map<string, Map<string, string>>;
export interface ResolutionContext {
/**
* The only resolution API. Returns all candidates at the winning tier.
*
* Tier 3 ('global') returns ALL candidates regardless of count
* consumers must check candidates.length and refuse ambiguous matches.
*/
resolve(name: string, fromFile: string): TieredCandidates | null;
// --- Data access (for pipeline wiring, not resolution) ---
/** Semantic model the top-level container for types, methods, fields,
* and the nested file/callable SymbolTable. Typed as
* {@link MutableSemanticModel} because `ResolutionContext` is the
* lifecycle owner the pipeline registers symbols through it during
* the fan-out phase. Resolvers that only query should annotate their
* own fields as {@link SemanticModel} to drop write access. */
readonly model: MutableSemanticModel;
/** Raw maps — used by import-processor to populate import data. */
readonly importMap: ImportMap;
readonly packageMap: PackageMap;
readonly namedImportMap: NamedImportMap;
/** Module-alias map for Python namespace imports: callerFile → (alias → sourceFile). */
readonly moduleAliasMap: ModuleAliasMap;
// --- Per-file cache lifecycle ---
enableCache(filePath: string): void;
clearCache(): void;
// --- Operational ---
getStats(): {
fileCount: number;
cacheHits: number;
cacheMisses: number;
tierSameFile: number;
tierImportScoped: number;
tierGlobal: number;
tierMiss: number;
};
clear(): void;
}
export const createResolutionContext = (): ResolutionContext => {
const model = createSemanticModel();
const symbols = model.symbols;
const importMap: ImportMap = new Map();
const packageMap: PackageMap = new Map();
const namedImportMap: NamedImportMap = new Map();
const moduleAliasMap: ModuleAliasMap = new Map();
// Inverted index: packageDirSuffix → Set<filePath>.
// Built lazily on first Tier 2b hit — one-time cost of O(totalFiles ×
// allUniqueDirSuffixes) isFileInPackageDir calls across the entire
// packageMap, amortized over the pipeline run. Subsequent Tier 2b
// resolutions are O(callerPackages × filesInPackage × O(1)).
let packageDirIndex: Map<string, Set<string>> | null = null;
// Per-file cache state
let cacheFile: string | null = null;
let cache: Map<string, TieredCandidates | null> | null = null;
let cacheHits = 0;
let cacheMisses = 0;
// Tier hit counters — replaces the lost fuzzyCallCount diagnostic
let tierSameFile = 0;
let tierImportScoped = 0;
let tierGlobal = 0;
let tierMiss = 0;
// --- Core resolution (single implementation of tier logic) ---
const resolveUncached = (name: string, fromFile: string): TieredCandidates | null => {
// Tier 1: Same file — authoritative match (returns all overloads)
const localDefs = symbols.lookupExactAll(fromFile, name);
if (localDefs.length > 0) {
tierSameFile++;
return { candidates: localDefs, tier: 'same-file' };
}
// Tier 2a-named: Named binding chain (aliased / re-exported imports)
// Checked before import-scoped so that `import { User as U }` resolves
// correctly even when lookupExactAll on the alias name returns nothing.
const chainResult = walkBindingChain(name, fromFile, symbols, namedImportMap);
if (chainResult && chainResult.length > 0) {
tierImportScoped++;
return { candidates: chainResult, tier: 'import-scoped' };
}
// Tier 2a: Import-scoped — iterate the caller's imported files directly.
// O(importedFiles) × O(1) lookupExactAll — no global name scan needed.
const importedFiles = importMap.get(fromFile);
if (importedFiles) {
const importedDefs: SymbolDefinition[] = [];
for (const file of importedFiles) {
importedDefs.push(...symbols.lookupExactAll(file, name));
}
if (importedDefs.length > 0) {
tierImportScoped++;
return { candidates: importedDefs, tier: 'import-scoped' };
}
}
// Tier 2b: Package-scoped — look up files in the caller's imported package
// directories via an inverted index (packageDirSuffix → Set<filePath>),
// then do O(1) lookupExactAll per file. The inverted index is built lazily
// on first Tier 2b hit by scanning symbols.getFiles() once, making
// subsequent Tier 2b resolutions O(packages × filesInPackage) instead of
// O(allFiles × packages).
const importedPackages = packageMap.get(fromFile);
if (importedPackages) {
// Lazily build the inverted index on first use. For each indexed file,
// test it against isFileInPackageDir for all known dirSuffixes collected
// from packageMap. This scans all files once (instead of per-resolution)
// and produces a dirSuffix → Set<filePath> map.
if (!packageDirIndex) {
// Collect all unique dir suffixes across the entire packageMap
const allDirSuffixes = new Set<string>();
for (const dirs of packageMap.values()) {
for (const d of dirs) allDirSuffixes.add(d);
}
packageDirIndex = new Map();
for (const file of symbols.getFiles()) {
for (const dirSuffix of allDirSuffixes) {
if (isFileInPackageDir(file, dirSuffix)) {
let files = packageDirIndex.get(dirSuffix);
if (!files) {
files = new Set();
packageDirIndex.set(dirSuffix, files);
}
files.add(file);
}
}
}
}
const packageDefs: SymbolDefinition[] = [];
for (const dirSuffix of importedPackages) {
const filesInDir = packageDirIndex.get(dirSuffix);
if (filesInDir) {
for (const file of filesInDir) {
packageDefs.push(...symbols.lookupExactAll(file, name));
}
}
}
if (packageDefs.length > 0) {
tierImportScoped++;
return { candidates: packageDefs, tier: 'import-scoped' };
}
}
// Tier 3: Global — targeted O(1) index lookups for each symbol category.
// Class-like symbols (Class, Struct, Interface, Enum, Record, Trait) are
// covered by lookupClassByName; Rust impl blocks by lookupImplByName
// (separate to avoid polluting heritage resolution); free callables
// (Function, Macro, Delegate) by lookupCallableByName; owner-scoped
// methods and constructors by `model.methods.lookupMethodByName`.
//
// FREE_CALLABLE_TYPES excludes Method/Constructor, so strictly-labeled
// methods are disjoint between the two indexes.
//
// Partial-state caveat: Python/Rust/Kotlin class methods are emitted
// as Function + ownerId — `rawSymbols.add` routes them through both
// the Function callable index AND, via the dispatch-key normalization
// in `wrappedAdd`, the method registry. The same `SymbolDefinition`
// reference lands in both `callableDefs` and `methodDefs`, so the
// Set-based dedup below is required.
//
// Known exclusion: TypeAlias, Const, and Variable are NOT reachable at
// Tier 3 — they don't belong to any of the indexes. TypeAlias is not
// a call target; Const/Variable are resolved via import or same-file
// tiers. Macro (C/C++) and Delegate (C#) stay in the callable index
// since call-processor.ts treats them as callable targets.
const classDefs = model.types.lookupClassByName(name);
const implDefs = model.types.lookupImplByName(name);
const callableDefs = symbols.lookupCallableByName(name);
const methodDefs = model.methods.lookupMethodByName(name);
if (
classDefs.length === 0 &&
implDefs.length === 0 &&
callableDefs.length === 0 &&
methodDefs.length === 0
) {
tierMiss++;
return null;
}
// Fast path: if no `Function + ownerId` class method was ever
// registered into the method registry (the only source of
// cross-index duplication), the callable and method indexes are
// guaranteed disjoint and we can concat without dedup.
if (!model.methods.hasFunctionMethods) {
const globalDefs: SymbolDefinition[] = [
...classDefs,
...implDefs,
...callableDefs,
...methodDefs,
];
tierGlobal++;
return { candidates: globalDefs, tier: 'global' };
}
// Slow path: dedup by nodeId because the same SymbolDefinition
// reference can land in both `callableDefs` (via the Function
// callable-index gate) and `methodDefs` (via the dispatch-key
// normalization routing Function+ownerId into MethodRegistry).
// Dedup covers all four index reads so any nodeId overlap (even
// theoretical ones between classDefs/implDefs) is caught.
const globalDefs: SymbolDefinition[] = [];
const seen = new Set<string>();
const pushUnique = (pool: readonly SymbolDefinition[]): void => {
for (const def of pool) {
if (seen.has(def.nodeId)) continue;
seen.add(def.nodeId);
globalDefs.push(def);
}
};
pushUnique(classDefs);
pushUnique(implDefs);
pushUnique(callableDefs);
pushUnique(methodDefs);
tierGlobal++;
return { candidates: globalDefs, tier: 'global' };
};
const resolve = (name: string, fromFile: string): TieredCandidates | null => {
// Check cache (only when enabled AND fromFile matches cached file)
if (cache && cacheFile === fromFile) {
if (cache.has(name)) {
cacheHits++;
return cache.get(name)!;
}
cacheMisses++;
}
const result = resolveUncached(name, fromFile);
// Store in cache if active and file matches
if (cache && cacheFile === fromFile) {
cache.set(name, result);
}
return result;
};
// --- Cache lifecycle ---
const enableCache = (filePath: string): void => {
cacheFile = filePath;
if (!cache) cache = new Map();
else cache.clear();
};
const clearCache = (): void => {
cacheFile = null;
// Reuse the Map instance — just clear entries to reduce GC pressure at scale.
cache?.clear();
// Note: packageDirIndex is NOT invalidated here. It is built lazily on
// first Tier 2b hit and remains valid across file boundaries because
// packageMap and the symbol file set are append-only during the calls
// phase (all parsing/import processing completes before resolution).
// Invalidating per-file would destroy the amortization benefit — the
// O(files × dirs) rebuild would run per-file instead of once.
// Full invalidation happens in clear() (pipeline reset).
};
const getStats = () => ({
...symbols.getStats(),
cacheHits,
cacheMisses,
tierSameFile,
tierImportScoped,
tierGlobal,
tierMiss,
});
const clear = (): void => {
model.clear();
importMap.clear();
packageMap.clear();
namedImportMap.clear();
moduleAliasMap.clear();
packageDirIndex = null; // invalidate — will rebuild on next Tier 2b hit
clearCache();
cacheHits = 0;
cacheMisses = 0;
tierSameFile = 0;
tierImportScoped = 0;
tierGlobal = 0;
tierMiss = 0;
};
return {
resolve,
model,
importMap,
packageMap,
namedImportMap,
moduleAliasMap,
enableCache,
clearCache,
getStats,
clear,
};
};

View file

@ -19,7 +19,7 @@
*
* model/semantic-model.ts THIS FILE (orchestrator)
*
* resolve.ts, call-processor.ts, resolution-context.ts, ...
* resolve.ts, call-processor.ts, ...
*
* `symbol-table.ts` is a leaf it never imports from `./model/`. This
* file (semantic-model.ts) is the ONLY place where SymbolTable and the
@ -142,7 +142,7 @@ export interface SemanticModel {
/** Mutable variant exposes the MutableX registries, a Writer-typed
* `symbols` facade, and a full-cascade reset. This is the interface
* held by the lifecycle owner (pipeline, resolution-context); resolvers
* held by the lifecycle owner (the parse pipeline); resolvers
* that only query should hold the narrower {@link SemanticModel}. */
export interface MutableSemanticModel extends SemanticModel {
readonly types: MutableTypeRegistry;

View file

@ -27,7 +27,7 @@
*
* model/semantic-model.ts orchestrator, wraps add()
*
* model/resolve.ts, call-processor.ts, resolution-context.ts, ...
* model/resolve.ts, call-processor.ts, ...
*
* No arrow ever points downward from this file. If you are tempted to
* import from `./model/` here, you are going the wrong way move the
@ -69,9 +69,8 @@ export const CLASS_TYPES: ReadonlySet<NodeLabel> = new Set(CLASS_TYPES_TUPLE);
/** Free-callable labels single source of truth for "callables that have
* NO owner scope". Methods and constructors are owner-scoped and live in
* `MethodRegistry` Tier 3 reaches them via
* `model.methods.lookupMethodByName`. See `resolution-context.ts` Tier 3
* for how both indexes are consulted together.
* `MethodRegistry`, reached via `model.methods.lookupMethodByName`. Global
* by-name resolution consults both indexes (see `model/index.ts`).
*
* Exported as a `readonly` tuple so that `typeof FREE_CALLABLE_TUPLE[number]`
* yields a precise literal union (`FreeCallableLabel`). `registration-table.ts`

View file

@ -1,40 +0,0 @@
import type { SyntaxNode } from '../utils/ast-helpers.js';
import type { NamedBinding } from './types.js';
export function extractCSharpNamedBindings(importNode: SyntaxNode): NamedBinding[] | undefined {
// 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: SyntaxNode | null = null;
let qualifiedName: SyntaxNode | null = 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;
}
// 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 }];
}
// 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 }];
}
// Form 3: using NS; — namespace import, can't resolve to per-symbol bindings
return undefined;
}

View file

@ -1,30 +0,0 @@
import { findChild, type SyntaxNode } from '../utils/ast-helpers.js';
import type { NamedBinding } from './types.js';
export function extractJavaNamedBindings(importNode: SyntaxNode): NamedBinding[] | undefined {
// import_declaration > scoped_identifier "com.example.models.User"
// Wildcard imports (.*) don't produce named bindings
if (importNode.type !== 'import_declaration') return undefined;
// 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');
if (!scopedId) return undefined;
const fullText = scopedId.text;
const lastDot = fullText.lastIndexOf('.');
if (lastDot === -1) 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: name, exported: name }];
}

View file

@ -1,37 +0,0 @@
import { findChild, type SyntaxNode } from '../utils/ast-helpers.js';
import type { NamedBinding } from './types.js';
export function extractKotlinNamedBindings(importNode: SyntaxNode): NamedBinding[] | undefined {
// import_header > identifier + import_alias > simple_identifier
if (importNode.type !== 'import_header') return undefined;
const fullIdent = findChild(importNode, 'identifier');
if (!fullIdent) return undefined;
const fullText = fullIdent.text;
const exportedName = fullText.includes('.') ? fullText.split('.').pop()! : fullText;
const importAlias = findChild(importNode, 'import_alias');
if (importAlias) {
// Aliased: import com.example.User as U
const aliasIdent = findChild(importAlias, 'simple_identifier');
if (!aliasIdent) return undefined;
return [{ local: aliasIdent.text, exported: exportedName }];
}
// 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 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. 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 }];
}

View file

@ -1,61 +0,0 @@
import type { SyntaxNode } from '../utils/ast-helpers.js';
import type { NamedBinding } from './types.js';
export function extractPhpNamedBindings(importNode: SyntaxNode): NamedBinding[] | undefined {
// namespace_use_declaration > namespace_use_clause* (flat)
// namespace_use_declaration > namespace_use_group > namespace_use_clause* (grouped)
if (importNode.type !== 'namespace_use_declaration') return undefined;
// Skip 'use function' and 'use const' declarations — these import callables/constants,
// not class types, and should not be added to namedImportMap as type bindings.
const useTypeNode = importNode.childForFieldName?.('type');
if (useTypeNode && (useTypeNode.text === 'function' || useTypeNode.text === 'const')) {
return undefined;
}
const bindings: NamedBinding[] = [];
// Collect all clauses — from direct children AND from namespace_use_group
const clauses: SyntaxNode[] = [];
for (let i = 0; i < importNode.namedChildCount; i++) {
const child = importNode.namedChild(i);
if (child?.type === 'namespace_use_clause') {
clauses.push(child);
} else if (child?.type === 'namespace_use_group') {
for (let j = 0; j < child.namedChildCount; j++) {
const groupChild = child.namedChild(j);
if (groupChild?.type === 'namespace_use_clause') clauses.push(groupChild);
}
}
}
for (const clause of clauses) {
// Flat imports: qualified_name + name (alias)
let qualifiedName: SyntaxNode | null = null;
const names: SyntaxNode[] = [];
for (let j = 0; j < clause.namedChildCount; j++) {
const child = clause.namedChild(j);
if (child?.type === 'qualified_name') qualifiedName = child;
else if (child?.type === 'name') names.push(child);
}
if (qualifiedName && names.length > 0) {
// Flat aliased import: use App\Models\Repo as R;
const fullText = qualifiedName.text;
const exportedName = fullText.includes('\\') ? fullText.split('\\').pop()! : fullText;
bindings.push({ local: names[0].text, exported: exportedName });
} else if (qualifiedName && names.length === 0) {
// Flat non-aliased import: use App\Models\User;
const fullText = qualifiedName.text;
const lastSegment = fullText.includes('\\') ? fullText.split('\\').pop()! : fullText;
bindings.push({ local: lastSegment, exported: lastSegment });
} else if (!qualifiedName && names.length >= 2) {
// Grouped aliased import: {Repo as R} — first name = exported, second = alias
bindings.push({ local: names[1].text, exported: names[0].text });
} else if (!qualifiedName && names.length === 1) {
// Grouped non-aliased import: {User} in use App\Models\{User, Repo as R}
bindings.push({ local: names[0].text, exported: names[0].text });
}
}
return bindings.length > 0 ? bindings : undefined;
}

View file

@ -1,55 +0,0 @@
import { findChild, type SyntaxNode } from '../utils/ast-helpers.js';
import type { NamedBinding } from './types.js';
export function extractPythonNamedBindings(importNode: SyntaxNode): NamedBinding[] | undefined {
// Handle: from x import User, Repo as R
if (importNode.type === 'import_from_statement') {
const bindings: NamedBinding[] = [];
for (let i = 0; i < importNode.namedChildCount; i++) {
const child = importNode.namedChild(i);
if (!child) continue;
if (child.type === 'dotted_name') {
// Skip the module_name (first dotted_name is the source module)
const fieldName = importNode.childForFieldName?.('module_name');
if (fieldName && child.startIndex === fieldName.startIndex) continue;
// This is an imported name: from x import User
const name = child.text;
if (name) bindings.push({ local: name, exported: name });
}
if (child.type === 'aliased_import') {
// from x import Repo as R
const dottedName = findChild(child, 'dotted_name');
const aliasIdent = findChild(child, 'identifier');
if (dottedName && aliasIdent) {
bindings.push({ local: aliasIdent.text, exported: dottedName.text });
}
}
}
return bindings.length > 0 ? bindings : undefined;
}
// Handle: import numpy as np (import_statement with aliased_import child)
// Tagged with isModuleAlias so applyImportResult routes these directly to
// moduleAliasMap (e.g. "np" → "numpy.py") instead of namedImportMap.
if (importNode.type === 'import_statement') {
const bindings: NamedBinding[] = [];
for (let i = 0; i < importNode.namedChildCount; i++) {
const child = importNode.namedChild(i);
if (!child || child.type !== 'aliased_import') continue;
const dottedName = findChild(child, 'dotted_name');
const aliasIdent = findChild(child, 'identifier');
if (dottedName && aliasIdent) {
bindings.push({ local: aliasIdent.text, exported: dottedName.text, isModuleAlias: true });
}
}
return bindings.length > 0 ? bindings : undefined;
}
return undefined;
}

View file

@ -1,73 +0,0 @@
import type { SyntaxNode } from '../utils/ast-helpers.js';
import type { NamedBinding } from './types.js';
export function extractRustNamedBindings(importNode: SyntaxNode): NamedBinding[] | undefined {
// use_declaration may contain use_as_clause at any depth
if (importNode.type !== 'use_declaration') return undefined;
const bindings: NamedBinding[] = [];
collectRustBindings(importNode, bindings);
return bindings.length > 0 ? bindings : undefined;
}
function collectRustBindings(node: SyntaxNode, bindings: NamedBinding[]): void {
if (node.type === 'use_as_clause') {
// First identifier = exported name, second identifier = local alias
const idents: string[] = [];
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (child?.type === 'identifier') idents.push(child.text);
// For scoped_identifier, extract the last segment
if (child?.type === 'scoped_identifier') {
const nameNode = child.childForFieldName?.('name');
if (nameNode) idents.push(nameNode.text);
}
}
if (idents.length === 2) {
bindings.push({ local: idents[1], exported: idents[0] });
}
return;
}
// Terminal identifier in a use_list: use crate::models::{User, Repo}
if (node.type === 'identifier' && node.parent?.type === 'use_list') {
bindings.push({ local: node.text, exported: node.text });
return;
}
// Skip scoped_identifier that serves as path prefix in scoped_use_list
// e.g. use crate::models::{User, Repo} — the path node "crate::models" is not an importable symbol
if (node.type === 'scoped_identifier' && node.parent?.type === 'scoped_use_list') {
return; // path prefix — the use_list sibling handles the actual symbols
}
// Terminal scoped_identifier: use crate::models::User;
// Only extract if this is a leaf (no deeper use_list/use_as_clause/scoped_use_list)
if (node.type === 'scoped_identifier') {
let hasDeeper = false;
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (
child?.type === 'use_list' ||
child?.type === 'use_as_clause' ||
child?.type === 'scoped_use_list'
) {
hasDeeper = true;
break;
}
}
if (!hasDeeper) {
const nameNode = node.childForFieldName?.('name');
if (nameNode) {
bindings.push({ local: nameNode.text, exported: nameNode.text });
}
return;
}
}
// Recurse into children
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (child) collectRustBindings(child, bindings);
}
}

View file

@ -1,19 +0,0 @@
/**
* Named binding types shared across all per-language binding extractors.
*
* Extracted from import-resolution.ts to co-locate types with their consumers.
*/
import type { SyntaxNode } from '../utils/ast-helpers.js';
/** A single named import binding: local name in the importing file and exported name from the source.
* When `isModuleAlias` is true, the binding represents a Python `import X as Y` module alias
* and is routed to moduleAliasMap instead of namedImportMap during import processing. */
export interface NamedBinding {
local: string;
exported: string;
isModuleAlias?: boolean;
}
/** Per-language named binding extractor -- optional (returns undefined if language has no named imports). */
export type NamedBindingExtractorFn = (importNode: SyntaxNode) => NamedBinding[] | undefined;

View file

@ -1,60 +0,0 @@
import { findChild, type SyntaxNode } from '../utils/ast-helpers.js';
import type { NamedBinding } from './types.js';
export function extractTsNamedBindings(importNode: SyntaxNode): NamedBinding[] | undefined {
// import_statement > import_clause > named_imports > import_specifier*
const importClause = findChild(importNode, 'import_clause');
if (importClause) {
const namedImports = findChild(importClause, 'named_imports');
if (!namedImports) return undefined; // default import, namespace import, or side-effect
const bindings: NamedBinding[] = [];
for (let i = 0; i < namedImports.namedChildCount; i++) {
const specifier = namedImports.namedChild(i);
if (specifier?.type !== 'import_specifier') continue;
const identifiers: string[] = [];
for (let j = 0; j < specifier.namedChildCount; j++) {
const child = specifier.namedChild(j);
if (child?.type === 'identifier') identifiers.push(child.text);
}
if (identifiers.length === 1) {
bindings.push({ local: identifiers[0], exported: identifiers[0] });
} else if (identifiers.length === 2) {
// import { Foo as Bar } → exported='Foo', local='Bar'
bindings.push({ local: identifiers[1], exported: identifiers[0] });
}
}
return bindings.length > 0 ? bindings : undefined;
}
// Re-export: export { X } from './y' → export_statement > export_clause > export_specifier
const exportClause = findChild(importNode, 'export_clause');
if (exportClause) {
const bindings: NamedBinding[] = [];
for (let i = 0; i < exportClause.namedChildCount; i++) {
const specifier = exportClause.namedChild(i);
if (specifier?.type !== 'export_specifier') continue;
const identifiers: string[] = [];
for (let j = 0; j < specifier.namedChildCount; j++) {
const child = specifier.namedChild(j);
if (child?.type === 'identifier') identifiers.push(child.text);
}
if (identifiers.length === 1) {
// export { User } from './base' → re-exports User as User
bindings.push({ local: identifiers[0], exported: identifiers[0] });
} else if (identifiers.length === 2) {
// export { Repo as Repository } from './models' → name=Repo, alias=Repository
// For re-exports, the first id is the source name, second is what's exported
// When another file imports { Repository }, they get Repo from the source
bindings.push({ local: identifiers[1], exported: identifiers[0] });
}
}
return bindings.length > 0 ? bindings : undefined;
}
return undefined;
}

View file

@ -48,7 +48,6 @@ import { logger } from '../logger.js';
import type {
ParseWorkerResult,
ParseWorkerInput,
ExtractedImport,
ExtractedCall,
ExtractedAssignment,
ExtractedRoute,
@ -79,7 +78,6 @@ import {
export type FileProgressCallback = (current: number, total: number, filePath: string) => void;
export interface WorkerExtractedData {
imports: ExtractedImport[];
calls: ExtractedCall[];
assignments: ExtractedAssignment[];
routes: ExtractedRoute[];
@ -123,7 +121,6 @@ export const mergeChunkResults = (
symbolTable: SymbolTableWriter,
chunkResults: readonly ParseWorkerResult[],
): WorkerExtractedData => {
const allImports: ExtractedImport[] = [];
const allCalls: ExtractedCall[] = [];
const allAssignments: ExtractedAssignment[] = [];
const allRoutes: ExtractedRoute[] = [];
@ -163,7 +160,6 @@ export const mergeChunkResults = (
qualifiedName: sym.qualifiedName,
});
}
for (const item of result.imports) allImports.push(item);
for (const item of result.calls) allCalls.push(item);
for (const item of result.assignments) allAssignments.push(item);
for (const item of result.routes) allRoutes.push(item);
@ -182,7 +178,6 @@ export const mergeChunkResults = (
}
return {
imports: allImports,
calls: allCalls,
assignments: allAssignments,
routes: allRoutes,
@ -225,7 +220,6 @@ const processParsingWithWorkers = async (
if (parseableFiles.length === 0)
return {
imports: [],
calls: [],
assignments: [],
routes: [],

View file

@ -3,8 +3,10 @@
*
* This is the core parsing engine of the ingestion pipeline. It reads
* source files in byte-budget chunks (~20MB each), parses via worker
* pool (or sequential fallback), resolves imports/calls/heritage per
* chunk, and synthesizes wildcard import bindings.
* pool (or sequential fallback), and emits route CALLS edges. Import,
* call, and inheritance resolution are owned by the scope-resolution
* phase, not here (RING4-1 #942 removed the legacy call DAG; RING4-2 #943
* removed the legacy per-file import resolution + wildcard synthesis).
*
* Consumed by the parse phase (`parse.ts`) the phase file handles
* dependency wiring while the heavy implementation lives here.
@ -21,18 +23,12 @@ import { processParsing, mergeChunkResults } from '../parsing-processor.js';
import { fileContentHash, computeChunkHash } from '../../../storage/parse-cache.js';
import type { ParseWorkerResult } from '../workers/parse-worker.js';
import type { WorkerExtractedData } from '../parsing-processor.js';
import {
processImports,
processImportsFromExtracted,
buildImportResolutionContext,
} from '../import-processor.js';
import { EMPTY_INDEX } from '../import-resolvers/utils.js';
import {
processRoutesFromExtracted,
buildExportedTypeMapFromGraph,
type ExportedTypeMap,
} from '../call-processor.js';
import { createResolutionContext } from '../model/resolution-context.js';
import { createSemanticModel, type MutableSemanticModel } from '../model/index.js';
import { ASTCache, createASTCache } from '../ast-cache.js';
import { type PipelineProgress, getLanguageFromFilename } from 'gitnexus-shared';
import { readFileContents } from '../filesystem-walker.js';
@ -46,7 +42,6 @@ import type { WorkerPool } from '../workers/worker-pool.js';
import type {
ExtractedDecoratorRoute,
ExtractedFetchCall,
ExtractedImport,
ExtractedORMQuery,
ExtractedRoute,
ExtractedToolDef,
@ -72,7 +67,6 @@ import {
logDeferredProfile,
startTimer,
} from '../utils/deferred-resolution-profile.js';
import { synthesizeWildcardImportBindings, needsSynthesis } from './wildcard-synthesis.js';
import { extractORMQueriesInline } from './orm-extraction.js';
import { logger } from '../../logger.js';
@ -185,13 +179,14 @@ export function handleWorkerStartupFailure(err: Error): never {
/**
* Chunked parse + resolve loop.
*
* Reads source in byte-budget chunks (~20MB each). For each chunk:
* 1. Parse via worker pool (or sequential fallback)
* 2. Resolve imports from extracted data
* 3. Synthesize wildcard import bindings (Go/Ruby/C++/Swift/Python)
* 4. Resolve heritage + routes per chunk; defer worker CALLS until all chunks
* have contributed heritage so interface-dispatch implementor map is complete
* 5. Collect TypeEnv bindings for cross-file propagation
* Reads source in byte-budget chunks (~20MB each):
* 1. Parse each chunk via worker pool (or sequential fallback)
* 2. After all chunks parse, emit route CALLS edges (deferred so resolution
* sees the full repo graph) and collect the exported-type map
* 3. Collect TypeEnv bindings for cross-file propagation
*
* Import, call, and inheritance edges are emitted by the scope-resolution
* phase, not here (RING4-1 #942 / RING4-2 #943 removed the legacy passes).
*/
export async function runChunkedParseAndResolve(
graph: KnowledgeGraph,
@ -211,7 +206,9 @@ export async function runChunkedParseAndResolve(
allToolDefs: ExtractedToolDef[];
allORMQueries: ExtractedORMQuery[];
bindingAccumulator: BindingAccumulator;
resolutionContext: ReturnType<typeof createResolutionContext>;
/** SemanticModel populated during parse scope-resolution reads its
* TypeRegistry / MethodRegistry / SymbolTable indexes. */
model: MutableSemanticModel;
usedWorkerPool: boolean;
/** Cross-phase tree-sitter Tree cache populated by the sequential
* parse path. Distinct from the chunk-local `astCache` used inside
@ -228,8 +225,8 @@ export async function runChunkedParseAndResolve(
* (otherwise ~58s on a 1000-file repo). */
parsedFiles: import('gitnexus-shared').ParsedFile[];
}> {
const ctx = createResolutionContext();
const symbolTable = ctx.model.symbols;
const model = createSemanticModel();
const symbolTable = model.symbols;
const parseableScanned = scannedFiles.filter((f) => {
const lang = getLanguageFromFilename(f.path);
@ -412,25 +409,9 @@ export async function runChunkedParseAndResolve(
let astCache = createASTCache(maxChunkFiles);
const scopeTreeCache = createASTCache(Math.max(parseableScanned.length, 1));
// Build import resolution context once — suffix index, file lists, resolve cache.
const importCtx = buildImportResolutionContext(allPaths);
const allPathObjects = allPaths.map((p) => ({ path: p }));
const sequentialChunkPaths: string[][] = [];
const chunkNeedsSynthesis = chunks.map((paths) =>
paths.some((p) => {
const lang = getLanguageFromFilename(p);
return lang != null && needsSynthesis(lang);
}),
);
const exportedTypeMap: ExportedTypeMap = new Map();
const bindingAccumulator = new BindingAccumulator();
// Tracks whether per-chunk or fallback wildcard-binding synthesis already
// ran, so the unconditional final call below can be skipped when redundant.
// synthesizeWildcardImportBindings is graph-global; once any chunk runs it
// after parsing wildcard files, later non-wildcard chunks add no work for
// it, and later wildcard chunks re-run it themselves.
let hasSynthesized = false;
const allFetchCalls: ExtractedFetchCall[] = [];
const allFetchWrapperDefs: FetchWrapperDef[] = [];
const allExtractedRoutes: ExtractedRoute[] = [];
@ -440,16 +421,6 @@ export async function runChunkedParseAndResolve(
const allRouterModuleAliases: ExtractedRouterModuleAlias[] = [];
const allToolDefs: ExtractedToolDef[] = [];
const allORMQueries: ExtractedORMQuery[] = [];
// Imports accumulated across chunks. Previously processed per-chunk
// via `processImportsFromExtracted` inside the chunk loop, which
// forced workers to sit idle on the main thread's extraction pass
// between chunk dispatches (4-5% CPU utilization symptom). Deferring
// to a single end-of-loop pass lets the worker pool start chunk N+1
// immediately after chunk N's worker dispatch returns. Resolution is
// strictly-more-information at end-of-loop because graph now has
// every chunk's symbols — improves cross-chunk import targets.
const deferredWorkerImports: ExtractedImport[] = [];
let anyChunkNeedsWildcardSynth = false;
// Aggregated per-file ParsedFile artifacts produced by workers' calls
// to `extractParsedFile`. Threaded through to the scope-resolution
// phase so it can SKIP its own re-extraction on cache hits — this is
@ -660,40 +631,17 @@ export async function runChunkedParseAndResolve(
}
}
// Per-chunk extraction passes (import resolution, route resolution,
// wildcard-import synthesis) moved out of the chunk loop into a single
// end-of-loop pass below.
// Route resolution is moved out of the chunk loop into a single
// end-of-loop pass below. (Import resolution and wildcard synthesis
// used to run here too; they were removed in RING4-2 #943 — IMPORTS
// edges now come from the scope-resolution phase.)
// Reason: per-chunk extraction blocked the chunk loop on
// main-thread work between worker dispatches — workers sat idle
// and total CPU utilization plateaued at 4-5% on multi-core boxes.
// Deferring keeps workers busy chunk-after-chunk; resolution sees
// strictly-more-information (full repo graph) so cross-chunk import
// and heritage targets resolve at least as well as before.
// Deferring keeps workers busy chunk-after-chunk; route resolution
// sees strictly-more-information (full repo graph) so cross-chunk
// controller targets resolve at least as well as before.
if (chunkWorkerData) {
if (chunkNeedsSynthesis[chunkIdx]) {
anyChunkNeedsWildcardSynth = true;
}
const skipFile = new Set<string>();
const checkFile = new Set<string>();
// Legacy deferred-import accumulation. Imports for every known language
// are resolved by the scope-resolution phase (RING4-1 #942 removed the
// legacy resolution path), so known-language files are never accumulated
// here; only null-language files (no parser) would be, which never have
// resolvable imports — so this path is effectively inert.
const shouldAccumulate = (filePath: string): boolean => {
if (checkFile.has(filePath)) return true;
if (skipFile.has(filePath)) return false;
const lang = getLanguageFromFilename(filePath);
if (lang !== null) {
skipFile.add(filePath);
return false;
}
checkFile.add(filePath);
return true;
};
for (const item of chunkWorkerData.imports) {
if (shouldAccumulate(item.filePath)) deferredWorkerImports.push(item);
}
// Aggregate worker-produced ParsedFile artifacts so scope-
// resolution can use them as a re-extraction cache (skips its
// own tree-sitter re-parse on warm runs).
@ -745,7 +693,6 @@ export async function runChunkedParseAndResolve(
for (const item of chunkWorkerData.ormQueries) allORMQueries.push(item);
}
} else {
await processImports(graph, chunkFiles, astCache, ctx, undefined, repoPath, allPaths);
sequentialChunkPaths.push(chunkPaths);
}
@ -778,80 +725,27 @@ export async function runChunkedParseAndResolve(
}
// Deferred end-of-loop extraction (moved out of the per-chunk block):
// 1. import resolution on all chunks' imports
// 2. wildcard-import binding synthesis (if any chunk had wildcards)
// 3. route resolution on all chunks' routes
// Same logic as the prior per-chunk passes, just batched — resolution
// sees the full repo graph instead of just current-and-earlier chunks.
// Call resolution and inheritance edges are emitted by the scope-resolution
// phase, not here (RING4-1 #942 removed the legacy deferred passes).
// Progress band: the stages below each get a slice of the 70-95 range so
// percent advances monotonically through the (potentially long) resolution
// work. Skipped stages (zero-length input) leave their band as a no-op jump.
// imports: 70 -> 75 (5)
// 1. route resolution on all chunks' routes
// Resolution sees the full repo graph instead of just current-and-earlier
// chunks. Import, call, and inheritance edges are emitted by the
// scope-resolution phase, not here (RING4-1 #942 removed the legacy call
// DAG; RING4-2 #943 removed the legacy import-map resolution + wildcard
// synthesis). Progress band: the route stage gets a slice of the 70-95
// range; a zero-length input leaves its band as a no-op jump.
// routes: 80 -> 85 (5)
const deferredProfile = isDeferredResolutionProfileEnabled();
if (deferredProfile) {
logDeferredProfile(
`deferred band start: imports=${deferredWorkerImports.length} routes=${allExtractedRoutes.length}`,
);
}
if (deferredWorkerImports.length > 0) {
const tImports = startTimer(deferredProfile);
await processImportsFromExtracted(
graph,
allPathObjects,
deferredWorkerImports,
ctx,
(current, total) => {
const ratio = total > 0 ? current / total : 1;
onProgress({
phase: 'parsing',
percent: 70 + Math.round(ratio * 5),
message: 'Resolving imports (all chunks)...',
detail: `${current}/${total} files`,
stats: {
filesProcessed: filesParsedSoFar,
totalFiles: totalParseable,
nodesCreated: graph.nodeCount,
},
});
},
repoPath,
importCtx,
);
endTimer(
tImports,
(ms) =>
`processImportsFromExtracted: ${ms.toFixed(0)}ms (${deferredWorkerImports.length} import batches before drain)`,
);
// U15 (lightweight M1): processImportsFromExtracted is the sole
// consumer of `deferredWorkerImports`. Free the array now so the
// GC can reclaim the per-file ExtractedImport records before the
// heavier downstream stages run (heritage, routes, calls). Peak
// accumulator memory drops from O(repo) to O(repo - imports) for
// the remainder of the deferred phase. The future per-chunk
// streaming upgrade can rewrite this with the same correctness
// contract once profile data shows it's warranted.
deferredWorkerImports.length = 0;
}
if (anyChunkNeedsWildcardSynth) {
const tWildcard = startTimer(deferredProfile);
synthesizeWildcardImportBindings(graph, ctx);
hasSynthesized = true;
endTimer(tWildcard, (ms) => `synthesizeWildcardImportBindings: ${ms.toFixed(0)}ms`);
logDeferredProfile(`deferred band start: routes=${allExtractedRoutes.length}`);
}
// Populate `exportedTypeMap` from the in-progress graph so the post-parse
// enrichment pass (enrichExportedTypeMap) sees cross-file export types.
// Inheritance and call resolution are owned by the scope-resolution phase
// (RING4-1 #942 removed the legacy heritage/call-DAG deferred passes here).
if (exportedTypeMap.size === 0 && graph.nodeCount > 0) {
const graphExports = buildExportedTypeMapFromGraph(graph, ctx.model.symbols);
const graphExports = buildExportedTypeMapFromGraph(graph, model.symbols);
for (const [fp, exports] of graphExports) exportedTypeMap.set(fp, exports);
}
if (allExtractedRoutes.length > 0) {
const tRoutes = startTimer(deferredProfile);
await processRoutesFromExtracted(graph, allExtractedRoutes, ctx, (current, total) => {
await processRoutesFromExtracted(graph, allExtractedRoutes, model, (current, total) => {
const ratio = total > 0 ? current / total : 1;
onProgress({
phase: 'parsing',
@ -892,15 +786,11 @@ export async function runChunkedParseAndResolve(
// Disposal of the accumulator remains with `crossFile` (owned by U2). We do
// NOT call `bindingAccumulator.dispose()` here.
try {
if (sequentialChunkPaths.length > 0) {
synthesizeWildcardImportBindings(graph, ctx);
hasSynthesized = true;
}
// Sequential fallback: imports are resolved per-chunk above (processImports).
// Calls and inheritance are emitted by the scope-resolution phase, not here
// (RING4-1 #942 removed the legacy call/heritage resolution passes). This
// loop still extracts fetch routes + ORM queries, which are language-agnostic
// edge sources independent of call resolution.
// Sequential fallback: calls, inheritance, and imports are emitted by the
// scope-resolution phase, not here (RING4-1 #942 removed the legacy
// call/heritage passes; RING4-2 #943 removed the legacy import resolution).
// This loop still extracts fetch routes + ORM queries, which are
// language-agnostic edge sources independent of call resolution.
for (const chunkPaths of sequentialChunkPaths) {
const chunkContents = await readFileContents(repoPath, chunkPaths);
const chunkFiles: Array<{ path: string; content: string }> = [];
@ -918,16 +808,6 @@ export async function runChunkedParseAndResolve(
}
astCache.clear();
}
// Log resolution cache stats
if (isDev) {
const rcStats = ctx.getStats();
const total = rcStats.cacheHits + rcStats.cacheMisses;
const hitRate = total > 0 ? ((rcStats.cacheHits / total) * 100).toFixed(1) : '0';
logger.info(
`🔍 Resolution cache: ${rcStats.cacheHits} hits, ${rcStats.cacheMisses} misses (${hitRate}% hit rate)`,
);
}
} finally {
// Clearing an already-empty cache is a no-op, so this is idempotent-safe
// on the happy path where every per-chunk block already cleared astCache.
@ -954,15 +834,6 @@ export async function runChunkedParseAndResolve(
}
}
if (!hasSynthesized) {
const synthesized = synthesizeWildcardImportBindings(graph, ctx);
if (isDev && synthesized > 0) {
logger.info(
`🔗 Synthesized ${synthesized} additional wildcard import bindings (Go/Ruby/C++/Swift/Python)`,
);
}
}
// Worker-path enrichment: if exportedTypeMap is empty (e.g. the worker pool
// built TypeEnv inside workers without access to SymbolTable), reconstruct
// the map from graph nodes + SymbolTable here in the main thread before
@ -970,23 +841,10 @@ export async function runChunkedParseAndResolve(
// crossFile receives a fully-populated map and never needs to mutate it for
// initial-graph enrichment.
if (exportedTypeMap.size === 0 && graph.nodeCount > 0) {
const graphExports = buildExportedTypeMapFromGraph(graph, ctx.model.symbols);
const graphExports = buildExportedTypeMapFromGraph(graph, model.symbols);
for (const [fp, exports] of graphExports) exportedTypeMap.set(fp, exports);
}
allPathObjects.length = 0;
// Safe to reset importCtx caches here: `importCtx` (ImportResolutionContext)
// is a scratch workspace used only during import path resolution. The
// `resolutionContext` (`ctx`) returned below is a distinct object — it owns
// the fully-populated, post-parse `importMap` / `namedImportMap` /
// `packageMap` / `moduleAliasMap` / `model`, and never references
// `importCtx`. Downstream consumers (the scope-resolution phase, route
// extraction) consume only `ctx`, never `importCtx`, so clearing the suffix
// index / resolveCache / normalizedFileList here cannot lose import matches.
importCtx.resolveCache.clear();
importCtx.index = EMPTY_INDEX;
importCtx.normalizedFileList = [];
// FastAPI router-prefix resolution (cross-file).
//
// Workers emit two kinds of records per Python file:
@ -1147,7 +1005,7 @@ export async function runChunkedParseAndResolve(
allToolDefs,
allORMQueries,
bindingAccumulator,
resolutionContext: ctx,
model,
// Whether a worker pool was actually live for this run. False means the
// sequential fallback handled every chunk (either due to `skipWorkers`,
// the file-count/byte thresholds, or a pool-creation failure).

View file

@ -29,8 +29,8 @@ import type {
ExtractedORMQuery,
FetchWrapperDef,
} from '../workers/parse-worker.js';
import type { createResolutionContext } from '../model/resolution-context.js';
import { runChunkedParseAndResolve } from './parse-impl.js';
import type { MutableSemanticModel } from '../model/index.js';
import type { ASTCache } from '../ast-cache.js';
export interface ParseOutput {
@ -52,8 +52,9 @@ export interface ParseOutput {
readonly allToolDefs: readonly ExtractedToolDef[];
readonly allORMQueries: readonly ExtractedORMQuery[];
bindingAccumulator: BindingAccumulator;
/** Resolution context from the parse phase — carries importMap, namedImportMap, etc. */
resolutionContext: ReturnType<typeof createResolutionContext>;
/** SemanticModel populated during parse scope-resolution reads its
* TypeRegistry / MethodRegistry / SymbolTable indexes. */
model: MutableSemanticModel;
/** Pass-through: all file paths for downstream phases. */
readonly allPaths: readonly string[];
/** Pass-through: shared `allPathSet` from structure (built once, not per-phase). */

View file

@ -1,345 +0,0 @@
/**
* Wildcard import binding synthesis.
*
* Languages with whole-module import semantics (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 IMPORTS edges into per-symbol bindings
* that the cross-file propagation phase can use for type resolution.
*
* Also builds Python module-alias maps for namespace-import languages
* (`import models` `models.User()` resolves to `models.py:User`).
*
* @module
*/
import type { KnowledgeGraph } from '../../graph/types.js';
import type { createResolutionContext } from '../model/resolution-context.js';
import { getLanguageFromFilename } from 'gitnexus-shared';
import type { SupportedLanguages } from 'gitnexus-shared';
import { providers, getProviderForFile } from '../languages/index.js';
import type { LanguageProvider, ImportSemantics } from '../language-provider.js';
// ── Constants ──────────────────────────────────────────────────────────────
/** Node labels that represent top-level importable symbols. */
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;
/** Max files allowed in a single transitive include closure. Guards against
* OOM on pathological C/C++ codebases (boost, Linux kernel-style monoheaders)
* where a single translation unit can transitively reach many thousands of
* headers. When the cap is hit, BFS expansion stops early the file still
* synthesizes bindings from the partial closure rather than failing. */
const MAX_TRANSITIVE_CLOSURE_SIZE = 5000;
/** Import semantics tags whose languages need synthesis of whole-module imports.
* `wildcard-transitive` (C/C++) and `wildcard-leaf` (Go, Ruby, Swift, Dart) are
* the file-based wildcard strategies. `explicit-reexport` is a scaffold tag
* no provider uses it yet, but it goes through the same leaf-style synthesis
* path today because a re-exporter is still an importer; only the extra DAG
* walk to surface re-exported symbols is missing (future work). */
const WILDCARD_SEMANTICS: ReadonlySet<ImportSemantics> = new Set<ImportSemantics>([
'wildcard-transitive',
'wildcard-leaf',
'explicit-reexport',
]);
/** Languages with whole-module import semantics (derived from providers at module load). */
const WILDCARD_LANGUAGES = new Set(
Object.values(providers)
.filter((p) => WILDCARD_SEMANTICS.has(p.importSemantics))
.map((p) => p.id),
);
/** Languages that need binding synthesis before call resolution. */
const SYNTHESIS_LANGUAGES = new Set(
Object.values(providers)
.filter((p) => p.importSemantics !== 'named')
.map((p) => p.id),
);
/** Check if a language uses wildcard (whole-module) import semantics. */
export function isWildcardImportLanguage(lang: SupportedLanguages): boolean {
return WILDCARD_LANGUAGES.has(lang);
}
/** Check if a language needs synthesis before call resolution.
* True for wildcard-import languages AND namespace-import languages (Python). */
export function needsSynthesis(lang: SupportedLanguages): boolean {
return SYNTHESIS_LANGUAGES.has(lang);
}
// ── Strategy implementations ───────────────────────────────────────────────
/**
* Strategy implementation for `importSemantics: 'wildcard-transitive'` (C, C++).
*
* Textual-include languages chain symbols through files: if `dict.c` includes
* `server.h` and `server.h` includes `dict.h`, then `dict.c` sees symbols from
* all three files. This helper walks the include graph (combining both the
* ingestion-context `importMap` and the graph-level IMPORTS edges) until the
* closure is stable.
*
* **Order matters.** The returned `Set` preserves iteration order (insertion
* order). `synthesizeWildcardImportBindings` dedupes bindings by symbol name
* on a first-seen-wins basis, so this closure's ordering determines which
* declaration wins when multiple headers export the same name (e.g. overloaded
* free functions like `write_audit()` vs `write_audit(const char*)` in
* different headers). We therefore:
* 1. Seed the closure with direct imports in declaration order (matches the
* order of `#include` directives in the source file).
* 2. Use FIFO / true BFS (`queue.shift()`) for transitive expansion, so
* closer headers are seen before deeper ones.
*
* Cycle-safe: the `closure.has(file)` guard prevents infinite loops on circular
* header includes, which are valid C/C++ when paired with `#pragma once` or
* include guards.
*
* Size-bounded: the closure is capped at `MAX_TRANSITIVE_CLOSURE_SIZE` files to
* prevent OOM on pathological codebases (e.g. boost, monoheader kernel code)
* where one translation unit can transitively reach tens of thousands of
* headers. Partial closures still yield useful bindings for the cluster of
* headers closest to the importer, which is what overload resolution and
* cross-file call resolution care about.
*
* Queue implementation: uses a head-index over a growing array (O(1) dequeue)
* instead of `Array.prototype.shift()` (O(n)) so deep chains stay linear.
*/
export function expandTransitiveIncludeClosure(
directImports: Iterable<string>,
importMap: ReadonlyMap<string, ReadonlySet<string>>,
graphImports: ReadonlyMap<string, ReadonlySet<string>>,
): Set<string> {
const closure = new Set<string>();
const queue: string[] = [];
let head = 0; // O(1) dequeue: advance the head index instead of shift()-ing.
const tryEnqueue = (file: string): boolean => {
if (closure.has(file)) return true;
if (closure.size >= MAX_TRANSITIVE_CLOSURE_SIZE) return false;
closure.add(file);
queue.push(file);
return true;
};
// Seed direct imports in declaration order (see JSDoc on order-sensitivity).
for (const f of directImports) {
if (!tryEnqueue(f)) break;
}
// True BFS for transitive reach: head-index FIFO preserves the "closer
// headers first" ordering that overload resolution depends on.
while (head < queue.length) {
if (closure.size >= MAX_TRANSITIVE_CLOSURE_SIZE) break;
const file = queue[head++]!;
const nested = importMap.get(file);
if (nested) {
for (const n of nested) {
if (!tryEnqueue(n)) break;
}
}
const nestedGraph = graphImports.get(file);
if (nestedGraph) {
for (const n of nestedGraph) {
if (!tryEnqueue(n)) break;
}
}
}
return closure;
}
// ── Main synthesis function ────────────────────────────────────────────────
/**
* Synthesize namedImportMap entries for languages with whole-module imports.
*
* For each file that imports another file via wildcard semantics:
* 1. Look up all exported symbols from the imported file (via graph nodes)
* 2. Create synthetic named bindings: `{ name → { sourcePath, exportedName } }`
* 3. Build Python module-alias maps for namespace-import languages
*
* @param graph The knowledge graph with parsed symbol nodes
* @param ctx Resolution context with importMap and namedImportMap
* @returns Number of synthetic bindings created
*/
export function synthesizeWildcardImportBindings(
graph: KnowledgeGraph,
ctx: ReturnType<typeof createResolutionContext>,
): number {
// Build exported symbols index from graph nodes (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;
// 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 || !isWildcardImportLanguage(lang)) return;
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;
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++;
}
}
};
/**
* Dispatch wildcard synthesis by the file's language provider strategy.
*
* Strategy tags (see `ImportSemantics`):
* - `wildcard-transitive`: expand the include closure first (C/C++ #include
* chains e.g. `dict.c` `server.h` `dict.h` so `dictFind` resolves
* across header chains)
* - `wildcard-leaf`: synthesize from direct imports only (Go, Ruby, Swift, Dart)
* - `explicit-reexport`: scaffold tag; falls through to leaf behavior.
* TODO(#821): implement re-export DAG walk for TS `export *` / Rust
* `pub use`. The leaf fallthrough preserves today's TS/Rust behavior
* (their direct imports still synthesize correctly); only the extra
* re-export DAG walk for barrel-file correctness is missing.
* - `namespace` / `named`: no-op here (namespace handled in Loop 3 below,
* named needs no synthesis).
*
* Used by both Loop 1 (ctx.importMap) and Loop 2 (graphImports) so a future
* transitive-import language whose edges arrive via graphImports gets closure
* expansion consistently regardless of edge source.
*/
const dispatchSynthesis = (
filePath: string,
importedFiles: ReadonlySet<string>,
provider: LanguageProvider,
) => {
switch (provider.importSemantics) {
case 'wildcard-transitive':
synthesizeForFile(
filePath,
expandTransitiveIncludeClosure(importedFiles, ctx.importMap, graphImports),
);
return;
case 'wildcard-leaf':
case 'explicit-reexport':
synthesizeForFile(filePath, importedFiles);
return;
case 'namespace':
case 'named':
return;
default: {
const _exhaustive: never = provider.importSemantics;
void _exhaustive;
}
}
};
// Loop 1: synthesize from ctx.importMap (Ruby, C/C++, Swift, Dart file-based imports).
for (const [filePath, importedFiles] of ctx.importMap) {
const lang = getLanguageFromFilename(filePath);
if (!lang || !isWildcardImportLanguage(lang)) continue;
const provider = getProviderForFile(filePath);
if (!provider) continue;
dispatchSynthesis(filePath, importedFiles, provider);
}
// Loop 2: synthesize from graph IMPORTS edges (Go and other wildcard-import
// languages whose edges live in the graph rather than ctx.importMap).
for (const [filePath, importedFiles] of graphImports) {
const provider = getProviderForFile(filePath);
if (!provider) continue;
dispatchSynthesis(filePath, importedFiles, provider);
}
// Build Python module-alias maps for namespace-import languages.
// `import models` in app.py → moduleAliasMap['app.py']['models'] = 'models.py'
// Enables `models.User()` to resolve without ambiguous symbol expansion.
for (const [filePath, importedFiles] of ctx.importMap) {
const provider = getProviderForFile(filePath);
if (!provider || provider.importSemantics !== 'namespace') continue;
buildPythonModuleAliasForFile(ctx, filePath, importedFiles);
}
return totalSynthesized;
}
/** Build module alias entries for namespace-import files (e.g. Python). */
function buildPythonModuleAliasForFile(
ctx: ReturnType<typeof createResolutionContext>,
callerFile: string,
importedFiles: Iterable<string>,
): void {
let aliasMap = ctx.moduleAliasMap.get(callerFile);
for (const importedFile of importedFiles) {
const lastSlash = importedFile.lastIndexOf('/');
const base = lastSlash >= 0 ? importedFile.slice(lastSlash + 1) : importedFile;
const dot = base.lastIndexOf('.');
const stem = dot >= 0 ? base.slice(0, dot) : base;
if (!stem) continue;
if (!aliasMap) {
aliasMap = new Map();
ctx.moduleAliasMap.set(callerFile, aliasMap);
}
aliasMap.set(stem, importedFile);
}
}

View file

@ -1,5 +1,7 @@
import type Parser from 'tree-sitter';
import { extractStringContent, findDescendant, type SyntaxNode } from '../utils/ast-helpers.js';
import { splitNamespaceUseDeclaration } from '../languages/php/import-decomposer.js';
import { normalizeQualifiedName } from '../utils/qualified-name.js';
export interface ExtractedRoute {
filePath: string;
@ -7,6 +9,16 @@ export interface ExtractedRoute {
routePath: string | null;
routeName: string | null;
controllerName: string | null;
/**
* The controller class's normalized (dot-joined) fully-qualified name when
* the routes file disambiguates it via a `use` import (`use App\\X;` or
* `use App\\X as Y;`) or an inline qualified `::class` reference. Resolved
* to the same key shape the type registry stores (`normalizeQualifiedName`),
* so the emitter can `lookupClassByQualifiedName` to disambiguate
* same-short-name controllers. `null`/undefined when only a bare short name
* is available the emitter then falls back to short-name resolution.
*/
controllerQualifiedName?: string | null;
methodName: string | null;
middleware: string[];
prefix: string | null;
@ -150,13 +162,34 @@ function appendResourceActionName(base: string | null, action: string): string |
return base.endsWith('.') ? `${base}${action}` : `${base}.${action}`;
}
/**
* Read the controller class reference out of a `class_constant_access_expression`
* (the `X::class` node). Returns the simple short name (for short-name fallback)
* and, when the reference is itself namespace-qualified (`\App\Admin\X::class`),
* its normalized dot-joined fully-qualified name (for direct disambiguation).
*/
function readControllerClassRef(classAccess: SyntaxNode): {
simple: string | null;
qualified: string | null;
} {
const nameChild = classAccess.children?.find((c: SyntaxNode) => c.type === 'name');
const qualifiedChild = classAccess.children?.find((c: SyntaxNode) => c.type === 'qualified_name');
const qualified = qualifiedChild ? normalizeQualifiedName(qualifiedChild.text) : null;
// When only a qualified_name is present, derive the simple short name from
// its last segment so short-name fallback still works.
const simple = nameChild?.text ?? (qualified ? (qualified.split('.').pop() ?? null) : null);
return { simple, qualified };
}
/** Extract controller class name from common Laravel handler argument shapes. */
function extractControllerTarget(argsNode: SyntaxNode | null): {
controller: string | null;
controllerQualified: string | null;
method: string | null;
bareMethod: string | null;
} {
if (!argsNode) return { controller: null, method: null, bareMethod: null };
const none = { controller: null, controllerQualified: null, method: null, bareMethod: null };
if (!argsNode) return none;
const args: (SyntaxNode | undefined)[] = [];
for (const child of argsNode.children ?? []) {
@ -166,11 +199,12 @@ function extractControllerTarget(argsNode: SyntaxNode | null): {
// Second arg is the handler
const handlerNode = args[1];
if (!handlerNode) return { controller: null, method: null, bareMethod: null };
if (!handlerNode) return none;
// Array syntax: [UserController::class, 'index']
if (handlerNode.type === 'array_creation_expression') {
let controller: string | null = null;
let controllerQualified: string | null = null;
let method: string | null = null;
const elements: SyntaxNode[] = [];
for (const el of handlerNode.children ?? []) {
@ -179,14 +213,16 @@ function extractControllerTarget(argsNode: SyntaxNode | null): {
if (elements[0]) {
const classAccess = findDescendant(elements[0], 'class_constant_access_expression');
if (classAccess) {
controller = classAccess.children?.find((c: SyntaxNode) => c.type === 'name')?.text ?? null;
const ref = readControllerClassRef(classAccess);
controller = ref.simple;
controllerQualified = ref.qualified;
}
}
if (elements[1]) {
const str = findDescendant(elements[1], 'string');
method = str ? extractStringContent(str) : null;
}
return { controller, method, bareMethod: null };
return { controller, controllerQualified, method, bareMethod: null };
}
// String syntax: 'UserController@index'. A bare string such as 'index'
@ -196,19 +232,24 @@ function extractControllerTarget(argsNode: SyntaxNode | null): {
const text = extractStringContent(handlerNode);
if (text?.includes('@')) {
const [controller, method] = text.split('@');
return { controller, method, bareMethod: null };
return { controller, controllerQualified: null, method, bareMethod: null };
}
if (text) return { controller: null, method: null, bareMethod: text };
if (text)
return { controller: null, controllerQualified: null, method: null, bareMethod: text };
}
// Class reference: UserController::class (invokable controller)
if (handlerNode.type === 'class_constant_access_expression') {
const controller =
handlerNode.children?.find((c: SyntaxNode) => c.type === 'name')?.text ?? null;
return { controller, method: '__invoke', bareMethod: null };
const ref = readControllerClassRef(handlerNode);
return {
controller: ref.simple,
controllerQualified: ref.qualified,
method: '__invoke',
bareMethod: null,
};
}
return { controller: null, method: null, bareMethod: null };
return { controller: null, controllerQualified: null, method: null, bareMethod: null };
}
interface ChainedRouteCall {
@ -304,8 +345,62 @@ function parseArrayGroupArgs(argsNode: SyntaxNode | null): RouteGroupContext {
return ctx;
}
/**
* Build the routes file's `use`-import alias map: local name (alias, else the
* imported short name) the class's normalized dot-joined fully-qualified name.
* Reuses the PHP `use`-declaration decomposer so grouped (`use Foo\{A, B}`) and
* aliased (`use Foo\Bar as Baz;`) forms are handled across grammar versions.
* `use` statements are file-/namespace-scoped, so a shallow walk over the
* routes file's structural nodes finds them all.
*/
function buildUseAliasMap(root: SyntaxNode): Map<string, string> {
const aliasMap = new Map<string, string>();
const stack: SyntaxNode[] = [root];
while (stack.length > 0) {
const node = stack.pop()!;
if (node.type === 'namespace_use_declaration') {
for (const match of splitNamespaceUseDeclaration(node)) {
const source = match['@import.source']?.text;
if (source === undefined) continue;
const local = match['@import.alias']?.text ?? match['@import.name']?.text;
if (local === undefined || local === '') continue;
aliasMap.set(local, normalizeQualifiedName(source));
}
continue;
}
// Only descend through file/namespace structure where `use` can appear —
// not into function bodies or expressions.
if (
node.type === 'program' ||
node.type === 'namespace_definition' ||
node.type === 'declaration_list' ||
node.type === 'compound_statement'
) {
for (const child of node.namedChildren ?? []) stack.push(child);
}
}
return aliasMap;
}
/**
* Resolve a route's controller to its normalized fully-qualified name when the
* routes file disambiguates it: an inline qualified `::class` wins, else the
* `use`-import alias map for the short/local name, else `null` (the emitter
* falls back to short-name resolution).
*/
function resolveControllerQualifiedName(
inlineQualified: string | null,
controllerName: string | null,
aliasMap: Map<string, string>,
): string | null {
if (inlineQualified) return inlineQualified;
if (controllerName) return aliasMap.get(controllerName) ?? null;
return null;
}
export function extractLaravelRoutes(tree: Parser.Tree, filePath: string): ExtractedRoute[] {
const routes: ExtractedRoute[] = [];
const useAliasMap = buildUseAliasMap(tree.rootNode);
function resolveStack(stack: RouteGroupContext[]): {
middleware: string[];
@ -367,6 +462,11 @@ export function extractLaravelRoutes(tree: Parser.Tree, filePath: string): Extra
routePath,
routeName: appendResourceActionName(routeNameBase, action),
controllerName: target.controller ?? effective.controller,
controllerQualifiedName: resolveControllerQualifiedName(
target.controllerQualified,
target.controller ?? effective.controller,
useAliasMap,
),
methodName: action,
middleware: [...effective.middleware],
prefix: effective.prefix,
@ -381,6 +481,11 @@ export function extractLaravelRoutes(tree: Parser.Tree, filePath: string): Extra
routePath,
routeName,
controllerName: target.controller ?? effective.controller,
controllerQualifiedName: resolveControllerQualifiedName(
target.controllerQualified,
target.controller ?? effective.controller,
useAliasMap,
),
methodName: target.method ?? (effective.controller ? target.bareMethod : null),
middleware: [...effective.middleware],
prefix: effective.prefix,

View file

@ -91,12 +91,11 @@ export const scopeResolutionPhase: PipelinePhase<ScopeResolutionOutput> = {
// Worker-mode parses leave the cache empty for those files; they
// also fall back to a fresh parse — no correctness impact.
const parseOutput = getPhaseOutput<ParseOutput>(deps, 'parse');
const { scopeTreeCache, resolutionContext, parsedFiles: workerParsedFiles } = parseOutput;
const { scopeTreeCache, model, parsedFiles: workerParsedFiles } = parseOutput;
// SemanticModel populated during `parse`: scope-resolution consumes
// TypeRegistry / MethodRegistry / SymbolTable lookups instead of
// rebuilding parallel indexes. See ARCHITECTURE.md § "Semantic-model
// source of truth".
const model = resolutionContext.model;
// Build a per-file lookup of ParsedFile artifacts the workers (or
// sequential extracts) already produced. Threading this into

View file

@ -144,7 +144,7 @@ const fastStripNullable = (typeName: string): string | undefined => {
: stripNullable(typeName);
};
/** Implementation of the lookup logic — shared between TypeEnvironment and the legacy export. */
/** Implementation of the lookup logic backing TypeEnvironment.lookup. */
const lookupInEnv = (
env: TypeEnv,
varName: string,

View file

@ -76,13 +76,11 @@ import { buildTypeEnv } from '../type-env.js';
import type { ConstructorBinding } from '../type-env.js';
import { detectFrameworkFromAST } from '../framework-detection.js';
import { generateId } from '../../../lib/utils.js';
import { preprocessImportPath } from '../import-processor.js';
import {
extractVueScript,
extractTemplateComponents,
isVueSetupTopLevel,
} from '../vue-sfc-extractor.js';
import type { NamedBinding } from '../named-bindings/types.js';
import type { NodeLabel, ParameterTypeClass } from 'gitnexus-shared';
import type { FieldInfo, FieldExtractorContext } from '../field-types.js';
import type { MethodInfo, MethodExtractorContext } from '../method-types.js';
@ -178,14 +176,6 @@ interface ParsedSymbol {
annotations?: string[];
}
export interface ExtractedImport {
filePath: string;
rawImportPath: string;
language: SupportedLanguages;
/** Named bindings from the import (e.g., import {User as U} → [{local:'U', exported:'User'}]) */
namedBindings?: NamedBinding[];
}
export interface ExtractedCall {
filePath: string;
calledName: string;
@ -313,7 +303,6 @@ export interface ParseWorkerResult {
nodes: ParsedNode[];
relationships: ParsedRelationship[];
symbols: ParsedSymbol[];
imports: ExtractedImport[];
calls: ExtractedCall[];
assignments: ExtractedAssignment[];
routes: ExtractedRoute[];
@ -795,7 +784,6 @@ const processBatch = (
nodes: [],
relationships: [],
symbols: [],
imports: [],
calls: [],
assignments: [],
routes: [],
@ -1232,22 +1220,10 @@ const processFileGroup = (
if (isSuppressedConcreteTypedefDuplicate(captureMap, concreteTypedefRanges)) continue;
// Extract import paths before skipping
// Import matches: IMPORTS edges are emitted by the scope-resolution
// phase from finalized ImportEdges (RING4-1 #942 / RING4-2 #943 removed
// the legacy per-file import-map extraction that ran here). Skip.
if (captureMap['import'] && captureMap['import.source']) {
const rawImportPath = preprocessImportPath(
captureMap['import.source'].text,
captureMap['import'],
provider,
);
if (!rawImportPath) continue;
const extractor = provider.namedBindingExtractor;
const namedBindings = extractor ? extractor(captureMap['import']) : undefined;
result.imports.push({
filePath: file.path,
rawImportPath,
language: language,
...(namedBindings ? { namedBindings } : {}),
});
continue;
}
@ -1469,11 +1445,10 @@ const processFileGroup = (
if (routed.kind === 'skip') continue;
if (routed.kind === 'import') {
result.imports.push({
filePath: file.path,
rawImportPath: routed.importPath,
language,
});
// Call-routed imports (e.g. Ruby `require`) are emitted as
// IMPORTS edges by the scope-resolution phase; the legacy
// per-file extraction that consumed these was removed in
// RING4-2 (#943). Skip.
continue;
}
@ -2180,7 +2155,6 @@ let accumulated: ParseWorkerResult = {
nodes: [],
relationships: [],
symbols: [],
imports: [],
calls: [],
assignments: [],
routes: [],
@ -2211,7 +2185,6 @@ const mergeResult = (target: ParseWorkerResult, src: ParseWorkerResult) => {
appendAll(target.nodes, src.nodes);
appendAll(target.relationships, src.relationships);
appendAll(target.symbols, src.symbols);
appendAll(target.imports, src.imports);
appendAll(target.calls, src.calls);
appendAll(target.assignments, src.assignments);
appendAll(target.routes, src.routes);
@ -2314,7 +2287,6 @@ parentPort!.on('message', (msg: WorkerIncomingMessage) => {
nodes: [],
relationships: [],
symbols: [],
imports: [],
calls: [],
assignments: [],
routes: [],

View file

@ -0,0 +1,11 @@
<?php
namespace App\Admin;
class OrderController
{
public function index()
{
return 'admin orders';
}
}

View file

@ -0,0 +1,11 @@
<?php
namespace App\Http\Controllers;
class OrderController
{
public function index()
{
return 'public orders';
}
}

View file

@ -0,0 +1,11 @@
<?php
namespace App\Http\Controllers;
class PhotoController
{
public function list()
{
return 'photos';
}
}

View file

@ -0,0 +1,16 @@
<?php
// Two OrderControllers share a short name in different namespaces; the public
// one is imported plain, the admin one aliased. PhotoController is aliased too.
use App\Http\Controllers\OrderController;
use App\Admin\OrderController as AdminOrders;
use App\Http\Controllers\PhotoController as Photos;
// Plain `use` of a globally-duplicated short name → must resolve to the public one.
Route::get('/orders', [OrderController::class, 'index']);
// Aliased `use` of the other same-short-name controller → must resolve to the admin one.
Route::get('/admin/orders', [AdminOrders::class, 'index']);
// Aliased controller import → must resolve to PhotoController.
Route::get('/photos', [Photos::class, 'list']);

View file

@ -57,10 +57,10 @@ describe('C# heritage resolution', () => {
it('resolves all CALLS from CreateUser via import-resolved, unique-global, or interface-dispatch', () => {
const calls = getRelationships(result, 'CALLS');
// C# non-aliased `using Namespace;` imports don't populate NamedImportMap
// (namespace-scoped imports can't bind to individual symbols).
// Calls resolve via directory-based PackageMap (import-resolved) when ambiguous,
// or via unique-global when the symbol name is globally unique.
// C# non-aliased `using Namespace;` imports don't bind to individual symbols
// (namespace-scoped imports import the whole namespace, not named members).
// Calls resolve via directory-based namespace resolution (import-resolved) when
// ambiguous, or via unique-global when the symbol name is globally unique.
// _repo.Save() also emits interface-dispatch to User.Save (IRepository has one impl in-repo).
for (const call of calls) {
expect(['import-resolved', 'global', 'interface-dispatch']).toContain(call.rel.reason);

View file

@ -0,0 +1,74 @@
/**
* End-to-end Laravel route controller-method CALLS-edge resolution.
*
* This is the authoritative format gate for the RING4-2 follow-up (PR #2033
* tri-review, Codex F1 + ce-adversarial): the route extractor normalizes the
* routes-file `use`/`::class` FQN with `normalizeQualifiedName`, and the emitter
* resolves it via `lookupClassByQualifiedName` against the dot-joined key the
* structure phase's `buildQualifiedName` actually stores. The unit tests use a
* hand-built model and cannot catch a dot-vs-backslash format mismatch; only
* this real-parse test can if the FQN normalization is wrong, the admin route
* edge below is silently dropped (qualified lookup misses short-name fallback
* sees two `OrderController`s skip).
*/
import { describe, it, expect, beforeAll } from 'vitest';
import path from 'path';
import { FIXTURES, getRelationships, runPipelineFromRepo, type PipelineResult } from './helpers.js';
describe('Laravel route → controller qualified resolution', () => {
let result: PipelineResult;
beforeAll(async () => {
// Force the worker path — Laravel route extraction runs in the worker
// (the sequential fallback does not extract routes), so a small fixture
// must lower the worker thresholds to exercise route resolution.
result = await runPipelineFromRepo(path.join(FIXTURES, 'laravel-route-resolution'), () => {}, {
workerThresholdsForTest: { minFiles: 1, minBytes: 1 },
});
}, 60000);
const routeCalls = () =>
getRelationships(result, 'CALLS').filter((e) => e.rel.reason === 'laravel-route');
it('resolves a globally-duplicated short name to the use-imported (public) controller', () => {
const edges = routeCalls().filter(
(e) =>
e.target === 'index' &&
e.targetFilePath.endsWith('app/Http/Controllers/OrderController.php'),
);
expect(edges.length).toBeGreaterThanOrEqual(1);
});
it('resolves an aliased same-short-name controller to the admin controller (disambiguation gate)', () => {
// The make-or-break assertion: if normalizeQualifiedName(FQN) did not match
// the registry key, this edge would not exist (skip on ambiguity).
const edges = routeCalls().filter(
(e) => e.target === 'index' && e.targetFilePath.endsWith('app/Admin/OrderController.php'),
);
expect(edges.length).toBeGreaterThanOrEqual(1);
});
it('resolves an aliased uniquely-named controller (PhotoController) to its list method', () => {
const edges = routeCalls().filter(
(e) =>
e.target === 'list' &&
e.targetFilePath.endsWith('app/Http/Controllers/PhotoController.php'),
);
expect(edges.length).toBeGreaterThanOrEqual(1);
});
it('never targets the wrong same-short-name controller', () => {
// Each OrderController route resolves to exactly one of the two files — the
// admin route must not target the public controller's method, nor vice versa.
// (Asserted implicitly by the two file-scoped assertions above both holding;
// here we confirm both distinct targets are present, proving the route map
// disambiguated rather than collapsing to one.)
const orderTargets = new Set(
routeCalls()
.filter((e) => e.target === 'index')
.map((e) => e.targetFilePath.replace(/^.*\/(app\/.*)$/, '$1')),
);
expect(orderTargets.has('app/Http/Controllers/OrderController.php')).toBe(true);
expect(orderTargets.has('app/Admin/OrderController.php')).toBe(true);
});
});

View file

@ -406,7 +406,7 @@ describe('PHP grouped import with alias', () => {
expect(saveCall!.targetFilePath).toBe('app/Models/User.php');
});
it('resolves non-aliased User via NamedImportMap (not just the aliased Repo)', () => {
it('resolves non-aliased User (not just the aliased Repo)', () => {
// Both User (non-aliased) and R→Repo (aliased) should resolve through grouped import
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find((c) => c.target === 'save' && c.source === 'run');

View file

@ -0,0 +1,297 @@
/**
* Characterization tests for `processRoutesFromExtracted` the Laravel
* framework-route controller-method `CALLS`-edge emitter in
* call-processor.ts.
*
* RING4-2 (#943) migrates this emitter off the legacy `ResolutionContext.resolve`
* tiered lookup and onto the scope-resolution registry / symbol table. These
* tests pin the *current* edge-emission behavior (which had no direct coverage)
* so the migration is provably behavior-preserving:
*
* - resolvable controller + same-file method CALLS edge to the method node
* - resolvable controller + unknown method CALLS edge to a *guessed* Method id
* - unknown controller no edge
* - ambiguous global controller (>1 match) no edge
* - one edge emitted per route
*
* Confidence values captured here (controller resolves at the `global` tier for
* routes-file controller references, so 0.5; guessed-method edges are × 0.8)
* are the contract the migrated implementation must match.
*/
import { describe, it, expect } from 'vitest';
import { createKnowledgeGraph } from '../../src/core/graph/graph.js';
import { createSemanticModel } from '../../src/core/ingestion/model/index.js';
import { processRoutesFromExtracted } from '../../src/core/ingestion/call-processor.js';
import { generateId } from '../../src/lib/utils.js';
import type { ExtractedRoute } from '../../src/core/ingestion/route-extractors/laravel.js';
import type { KnowledgeGraph } from '../../src/core/graph/types.js';
const ROUTES_FILE = 'routes/web.php';
const CONTROLLER_FILE = 'app/Http/Controllers/OrderController.php';
function makeRoute(overrides: Partial<ExtractedRoute> = {}): ExtractedRoute {
return {
filePath: ROUTES_FILE,
httpMethod: 'get',
routePath: '/orders',
routeName: null,
controllerName: 'OrderController',
methodName: 'index',
middleware: [],
prefix: null,
lineNumber: 1,
...overrides,
};
}
/** A semantic model with a single OrderController class + the given methods
* registered in the controller's own file (so method resolution finds them
* via the same-file symbol-table lookup). */
function modelWithController(methods: string[]) {
const model = createSemanticModel();
model.symbols.add(CONTROLLER_FILE, 'OrderController', 'class:OrderController', 'Class');
for (const m of methods) {
model.symbols.add(CONTROLLER_FILE, m, `method:OrderController.${m}`, 'Method', {
ownerId: 'class:OrderController',
});
}
return model;
}
function routeCallsEdges(graph: KnowledgeGraph) {
return graph.relationships.filter((r) => r.type === 'CALLS' && r.reason === 'laravel-route');
}
describe('processRoutesFromExtracted — Laravel route → controller CALLS edges', () => {
it('resolvable controller + same-file method → one CALLS edge to the method node', async () => {
const graph = createKnowledgeGraph();
const model = modelWithController(['index']);
await processRoutesFromExtracted(graph, [makeRoute({ methodName: 'index' })], model);
const edges = routeCallsEdges(graph);
expect(edges).toHaveLength(1);
expect(edges[0].sourceId).toBe(generateId('File', ROUTES_FILE));
expect(edges[0].targetId).toBe('method:OrderController.index');
// controller resolved by global class name → ROUTE_EDGE_CONFIDENCE (0.5)
expect(edges[0].confidence).toBeCloseTo(0.5, 5);
});
it('resolvable controller + unknown method → CALLS edge to a guessed Method id at reduced confidence', async () => {
const graph = createKnowledgeGraph();
const model = modelWithController([]); // controller class only, no methods
await processRoutesFromExtracted(graph, [makeRoute({ methodName: 'ghost' })], model);
const edges = routeCallsEdges(graph);
expect(edges).toHaveLength(1);
expect(edges[0].sourceId).toBe(generateId('File', ROUTES_FILE));
expect(edges[0].targetId).toBe(generateId('Method', `${CONTROLLER_FILE}:ghost`));
// guessed-method edges are emitted at controller-confidence × 0.8
expect(edges[0].confidence).toBeCloseTo(0.5 * 0.8, 5);
});
it('unknown controller → no edge emitted', async () => {
const graph = createKnowledgeGraph();
const model = modelWithController(['index']);
await processRoutesFromExtracted(
graph,
[makeRoute({ controllerName: 'GhostController', methodName: 'index' })],
model,
);
expect(routeCallsEdges(graph)).toHaveLength(0);
});
it('ambiguous controller name (2+ global matches) → no edge emitted', async () => {
const graph = createKnowledgeGraph();
const model = createSemanticModel();
// Two distinct classes share the controller short-name in different files →
// lookupClassByName returns >1 candidate, which the emitter refuses.
model.symbols.add(
'app/A/OrderController.php',
'OrderController',
'class:A.OrderController',
'Class',
);
model.symbols.add(
'app/B/OrderController.php',
'OrderController',
'class:B.OrderController',
'Class',
);
await processRoutesFromExtracted(graph, [makeRoute({ methodName: 'index' })], model);
expect(routeCallsEdges(graph)).toHaveLength(0);
});
it('route missing controllerName or methodName → skipped', async () => {
const graph = createKnowledgeGraph();
const model = modelWithController(['index']);
await processRoutesFromExtracted(
graph,
[makeRoute({ controllerName: null }), makeRoute({ methodName: null })],
model,
);
expect(routeCallsEdges(graph)).toHaveLength(0);
});
it('multiple routes to the same controller → one edge per route, distinct targets', async () => {
const graph = createKnowledgeGraph();
const model = modelWithController(['index', 'store']);
await processRoutesFromExtracted(
graph,
[
makeRoute({ httpMethod: 'get', routePath: '/orders', methodName: 'index' }),
makeRoute({ httpMethod: 'post', routePath: '/orders', methodName: 'store' }),
],
model,
);
const edges = routeCallsEdges(graph);
expect(edges).toHaveLength(2);
expect(edges.map((e) => e.targetId).sort()).toEqual([
'method:OrderController.index',
'method:OrderController.store',
]);
});
it('overloaded controller method → edge targets the first-registered definition', async () => {
// Two same-name method definitions in the controller file (overloads).
// The emitter takes lookupExactAll(...)[0] — first-registered wins, parity
// with the legacy same-file tier which returned candidates[0]. Pins the
// selection policy so it can't silently drift.
const graph = createKnowledgeGraph();
const model = createSemanticModel();
model.symbols.add(CONTROLLER_FILE, 'OrderController', 'class:OrderController', 'Class');
model.symbols.add(CONTROLLER_FILE, 'index', 'method:OrderController.index#1', 'Method', {
ownerId: 'class:OrderController',
});
model.symbols.add(CONTROLLER_FILE, 'index', 'method:OrderController.index#2', 'Method', {
ownerId: 'class:OrderController',
});
await processRoutesFromExtracted(graph, [makeRoute({ methodName: 'index' })], model);
const edges = routeCallsEdges(graph);
expect(edges).toHaveLength(1);
expect(edges[0].targetId).toBe('method:OrderController.index#1');
});
it('aliased controller resolves via controllerQualifiedName → edge emitted', async () => {
// An aliased import `use App\\Http\\Controllers\\OrderController as Orders;`
// + `[Orders::class, 'index']` yields controllerName='Orders' but the extractor
// also threads controllerQualifiedName='App.Http.Controllers.OrderController'
// (the alias resolved to its FQN). The class is registered under that FQN, so
// lookupClassByQualifiedName resolves it → edge — restoring what the legacy
// import-scoped tier emitted (RING4-2 follow-up).
const graph = createKnowledgeGraph();
const model = createSemanticModel();
const FQN = 'App.Http.Controllers.OrderController';
model.symbols.add(CONTROLLER_FILE, 'OrderController', 'class:OrderController', 'Class', {
qualifiedName: FQN,
});
model.symbols.add(CONTROLLER_FILE, 'index', 'method:OrderController.index', 'Method', {
ownerId: 'class:OrderController',
});
await processRoutesFromExtracted(
graph,
[makeRoute({ controllerName: 'Orders', controllerQualifiedName: FQN, methodName: 'index' })],
model,
);
const edges = routeCallsEdges(graph);
expect(edges).toHaveLength(1);
expect(edges[0].targetId).toBe('method:OrderController.index');
expect(edges[0].confidence).toBeCloseTo(0.5, 5);
});
it('globally-duplicated short name disambiguated by controllerQualifiedName → edge to the specific controller', async () => {
// Two OrderControllers in different namespaces share the short name. The route
// carries the FQN of the one its `use` import selected, so the edge targets
// that specific class's method — not the other, and not a skip.
const graph = createKnowledgeGraph();
const model = createSemanticModel();
const ADMIN_FQN = 'App.Admin.OrderController';
const PUBLIC_FQN = 'App.Http.Controllers.OrderController';
model.symbols.add(
'app/Admin/OrderController.php',
'OrderController',
'class:Admin.OrderController',
'Class',
{
qualifiedName: ADMIN_FQN,
},
);
model.symbols.add(
'app/Admin/OrderController.php',
'index',
'method:Admin.OrderController.index',
'Method',
{
ownerId: 'class:Admin.OrderController',
},
);
model.symbols.add(
'app/Http/Controllers/OrderController.php',
'OrderController',
'class:Public.OrderController',
'Class',
{
qualifiedName: PUBLIC_FQN,
},
);
model.symbols.add(
'app/Http/Controllers/OrderController.php',
'index',
'method:Public.OrderController.index',
'Method',
{
ownerId: 'class:Public.OrderController',
},
);
await processRoutesFromExtracted(
graph,
[
makeRoute({
controllerName: 'OrderController',
controllerQualifiedName: ADMIN_FQN,
methodName: 'index',
}),
],
model,
);
const edges = routeCallsEdges(graph);
expect(edges).toHaveLength(1);
expect(edges[0].targetId).toBe('method:Admin.OrderController.index');
});
it('controllerQualifiedName set but no class matches → falls back to short-name resolution', async () => {
// A stale/unmatched FQN must not block the short-name fallback when that is unique.
const graph = createKnowledgeGraph();
const model = modelWithController(['index']); // 'OrderController' registered, no FQN
await processRoutesFromExtracted(
graph,
[
makeRoute({
controllerName: 'OrderController',
controllerQualifiedName: 'App.Nonexistent.OrderController',
methodName: 'index',
}),
],
model,
);
const edges = routeCallsEdges(graph);
expect(edges).toHaveLength(1);
expect(edges[0].targetId).toBe('method:OrderController.index');
});
});

View file

@ -1,88 +0,0 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { buildImportResolutionContext } from '../../src/core/ingestion/import-processor.js';
import type { ImportResolutionContext } from '../../src/core/ingestion/import-resolvers/types.js';
import { createResolutionContext } from '../../src/core/ingestion/model/resolution-context.js';
describe('ResolutionContext.importMap', () => {
it('creates an empty Map', () => {
const map = createResolutionContext().importMap;
expect(map).toBeInstanceOf(Map);
expect(map.size).toBe(0);
});
it('can be used to store import relationships', () => {
const map = createResolutionContext().importMap;
map.set('src/index.ts', new Set(['src/utils.ts', 'src/types.ts']));
expect(map.get('src/index.ts')!.size).toBe(2);
expect(map.get('src/index.ts')!.has('src/utils.ts')).toBe(true);
});
});
describe('buildImportResolutionContext', () => {
let ctx: ImportResolutionContext;
const testPaths = [
'src/index.ts',
'src/utils.ts',
'src/components/Button.tsx',
'src/lib/helpers.ts',
];
beforeEach(() => {
ctx = buildImportResolutionContext(testPaths);
});
it('creates a Set of all file paths', () => {
expect(ctx.allFilePaths).toBeInstanceOf(Set);
expect(ctx.allFilePaths.size).toBe(4);
expect(ctx.allFilePaths.has('src/index.ts')).toBe(true);
});
it('stores the original file list', () => {
expect(ctx.allFileList).toBe(testPaths);
});
it('creates normalized file list with forward slashes', () => {
const winPaths = ['src\\index.ts', 'src\\utils.ts'];
const winCtx = buildImportResolutionContext(winPaths);
expect(winCtx.normalizedFileList[0]).toBe('src/index.ts');
expect(winCtx.normalizedFileList[1]).toBe('src/utils.ts');
});
it('creates a suffix index for O(1) lookups', () => {
expect(ctx.index).toBeDefined();
expect(typeof ctx.index.get).toBe('function');
});
it('initializes empty resolve cache', () => {
expect(ctx.resolveCache).toBeInstanceOf(Map);
expect(ctx.resolveCache.size).toBe(0);
});
it('handles empty paths array', () => {
const emptyCtx = buildImportResolutionContext([]);
expect(emptyCtx.allFilePaths.size).toBe(0);
expect(emptyCtx.allFileList).toHaveLength(0);
});
describe('suffix index', () => {
it('resolves file by suffix', () => {
const result = ctx.index.get('utils.ts');
expect(result).toBeDefined();
});
it('resolves file by full path', () => {
const result = ctx.index.get('src/index.ts');
expect(result).toBeDefined();
});
it('resolves nested component path', () => {
const result = ctx.index.get('components/Button.tsx');
expect(result).toBeDefined();
});
it('returns undefined for non-existent suffix', () => {
const result = ctx.index.get('nonexistent.ts');
expect(result).toBeUndefined();
});
});
});

View file

@ -1,180 +0,0 @@
/**
* Unit tests for import-resolution.ts
*
* Coverage notes:
* - `preprocessImportPath` is tested directly below (no tree-sitter required for most paths).
* - Rust scoped grouped import logic (`resolveRustImportDispatch`) requires a live file system
* and ResolveCtx that path is covered by test/integration/resolvers/rust.test.ts.
* - PHP `use function` / `use const` filtering (via `extractPhpNamedBindings`) requires
* tree-sitter PHP nodes covered by test/integration/resolvers/php.test.ts.
*/
import { describe, it, expect } from 'vitest';
import { preprocessImportPath } from '../../../src/core/ingestion/import-processor.js';
import { getProvider } from '../../../src/core/ingestion/languages/index.js';
import { SupportedLanguages } from '../../../src/config/supported-languages.js';
// ---------------------------------------------------------------------------
// Minimal SyntaxNode stub — only the fields preprocessImportPath touches.
// For non-Kotlin languages preprocessImportPath never reads the node, so an
// empty stub satisfies the type requirement without loading tree-sitter.
// ---------------------------------------------------------------------------
function makeNode(overrides: Partial<{ childCount: number; child: (i: number) => any }> = {}): any {
return {
childCount: overrides.childCount ?? 0,
child: overrides.child ?? (() => null),
};
}
// ---------------------------------------------------------------------------
// preprocessImportPath — universal cleaning behaviour
// ---------------------------------------------------------------------------
describe('preprocessImportPath', () => {
describe('quote and bracket stripping', () => {
it('strips double quotes from a bare module path', () => {
const node = makeNode();
expect(preprocessImportPath('"foo"', node, getProvider(SupportedLanguages.TypeScript))).toBe(
'foo',
);
});
it('strips single quotes from a bare module path', () => {
const node = makeNode();
expect(
preprocessImportPath("'bar/baz'", node, getProvider(SupportedLanguages.JavaScript)),
).toBe('bar/baz');
});
it('strips angle brackets from a C-style include path', () => {
const node = makeNode();
expect(preprocessImportPath('<stdio.h>', node, getProvider(SupportedLanguages.C))).toBe(
'stdio.h',
);
});
it('strips mixed quote and angle bracket characters', () => {
const node = makeNode();
// Pathological input — all stripped characters removed
expect(
preprocessImportPath('"<hello>"', node, getProvider(SupportedLanguages.TypeScript)),
).toBe('hello');
});
});
describe('null returns for invalid inputs', () => {
it('returns null for an empty string (after cleaning)', () => {
const node = makeNode();
// Only quote characters — cleaned result is empty string
expect(
preprocessImportPath('""', node, getProvider(SupportedLanguages.TypeScript)),
).toBeNull();
});
it('returns null for a string containing control characters', () => {
const node = makeNode();
// \x01 is a control character that passes the length check but fails the regex guard
expect(
preprocessImportPath('foo\x01bar', node, getProvider(SupportedLanguages.Rust)),
).toBeNull();
});
it('returns null for a string containing a null byte', () => {
const node = makeNode();
expect(
preprocessImportPath('foo\x00bar', node, getProvider(SupportedLanguages.Go)),
).toBeNull();
});
it('returns null for a path exceeding 2048 characters', () => {
const node = makeNode();
const longPath = 'a'.repeat(2049);
expect(
preprocessImportPath(longPath, node, getProvider(SupportedLanguages.Python)),
).toBeNull();
});
it('accepts a path of exactly 2048 characters', () => {
const node = makeNode();
const maxPath = 'a'.repeat(2048);
expect(preprocessImportPath(maxPath, node, getProvider(SupportedLanguages.Python))).toBe(
maxPath,
);
});
});
describe('Kotlin wildcard pass-through', () => {
it('delegates to appendKotlinWildcard when language is Kotlin — no wildcard child', () => {
// Node with no children -> appendKotlinWildcard returns the path unchanged
const node = makeNode({ childCount: 0 });
const result = preprocessImportPath(
'com.example.models',
node,
getProvider(SupportedLanguages.Kotlin),
);
// Without a wildcard_import child the path is returned as-is
expect(result).toBe('com.example.models');
});
it('delegates to appendKotlinWildcard when language is Kotlin — wildcard_import child present', () => {
// Simulate a node that has a wildcard_import child at index 0
const wildcardChild = { type: 'wildcard_import' };
const node = makeNode({
childCount: 1,
child: (i: number) => (i === 0 ? wildcardChild : null),
});
const result = preprocessImportPath(
'com.example.models',
node,
getProvider(SupportedLanguages.Kotlin),
);
// appendKotlinWildcard appends .* when the wildcard_import child is found
expect(result).toBe('com.example.models.*');
});
});
describe('non-Kotlin languages are returned unchanged (after cleaning)', () => {
it('returns the cleaned path for Rust without modification', () => {
const node = makeNode();
expect(
preprocessImportPath('"crate::models"', node, getProvider(SupportedLanguages.Rust)),
).toBe('crate::models');
});
it('returns the cleaned path for PHP without modification', () => {
const node = makeNode();
expect(
preprocessImportPath('"App\\\\Models\\\\User"', node, getProvider(SupportedLanguages.PHP)),
).toBe('App\\\\Models\\\\User');
});
});
});
// ---------------------------------------------------------------------------
// Rust scoped grouped import logic (resolveRustImportDispatch)
// ---------------------------------------------------------------------------
// The dispatch function requires a live ResolveCtx with file lists — unit
// testing it without a file system would duplicate the integration fixtures.
// The following comment documents what the integration tests verify:
//
// test/integration/resolvers/rust.test.ts covers:
// - Top-level grouped: use {crate::a, crate::b}
// - Scoped grouped: use crate::models::{User, Repo}
// - Alias stripping: use crate::models::{User, Repo as R} -> resolves User + Repo
// - Prefix fallback: when no individual items resolve, resolves the prefix path
//
// The ::{ detection and alias-stripping logic lives in resolveRustImportDispatch()
// at import-resolution.ts lines 328-344.
// ---------------------------------------------------------------------------
// PHP use function / use const filtering (extractPhpNamedBindings)
// ---------------------------------------------------------------------------
// extractPhpNamedBindings requires live tree-sitter PHP SyntaxNode objects.
// The filtering of `use function` and `use const` declarations is covered by:
//
// test/integration/resolvers/php.test.ts
//
// which runs the full ingestion pipeline over PHP fixture repositories and
// asserts that function/const use-declarations do not produce spurious IMPORTS
// edges to non-existent class files.

View file

@ -215,3 +215,69 @@ Route::group([
);
});
});
describe('Laravel controller qualified-name resolution (RING4-2 follow-up)', () => {
const routeFor = (source: string, routePath: string) =>
extractLaravelRoutes(parser.parse(source), 'routes/web.php').find(
(r) => r.routePath === routePath,
);
it('resolves an aliased controller import to its normalized FQN', () => {
const route = routeFor(
`<?php
use App\\Http\\Controllers\\OrderController as Orders;
Route::get('/orders', [Orders::class, 'index']);
`,
'/orders',
);
expect(route?.controllerName).toBe('Orders');
expect(route?.controllerQualifiedName).toBe('App.Http.Controllers.OrderController');
});
it('resolves a plain (non-aliased) controller import to its normalized FQN', () => {
const route = routeFor(
`<?php
use App\\Http\\Controllers\\OrderController;
Route::get('/orders', [OrderController::class, 'index']);
`,
'/orders',
);
expect(route?.controllerName).toBe('OrderController');
expect(route?.controllerQualifiedName).toBe('App.Http.Controllers.OrderController');
});
it('captures an inline qualified ::class reference as the normalized FQN', () => {
const route = routeFor(
`<?php
Route::get('/orders', [\\App\\Admin\\OrderController::class, 'index']);
`,
'/orders',
);
expect(route?.controllerQualifiedName).toBe('App.Admin.OrderController');
});
it('leaves controllerQualifiedName null for a bare short name with no use import', () => {
const route = routeFor(
`<?php
Route::get('/orders', [OrderController::class, 'index']);
`,
'/orders',
);
expect(route?.controllerName).toBe('OrderController');
expect(route?.controllerQualifiedName ?? null).toBeNull();
});
it('threads the FQN through resource routes', () => {
const routes = extractLaravelRoutes(
parser.parse(`<?php
use App\\Http\\Controllers\\PhotoController as Photos;
Route::resource('/photos', Photos::class);
`),
'routes/web.php',
).filter((r) => r.routePath === '/photos');
expect(routes.length).toBeGreaterThan(0);
for (const r of routes) {
expect(r.controllerQualifiedName).toBe('App.Http.Controllers.PhotoController');
}
});
});

View file

@ -1,173 +0,0 @@
/**
* Unit tests for `ResolutionContext.resolve()` the tiered name
* resolution that backs call-processor's Tier 1 / 2a-named / 2a / 2b / 3
* pipeline. These tests pin invariants that TypeScript cannot prove at
* build time: tier precedence, cross-index dedup, and the
* walkBindingChain cycle/depth guards.
*/
import { describe, it, expect } from 'vitest';
import { createResolutionContext } from '../../../src/core/ingestion/model/resolution-context.js';
describe('ResolutionContext.resolve() — tier precedence', () => {
it('Tier 2a-named binding chain takes precedence over Tier 2a import-scoped', () => {
// Setup: A imports { User as U } from B. B defines both User (the
// real one) and U (an unrelated same-name symbol). A resolve('U') in
// file A must prefer the aliased binding chain (U → User in B),
// NOT the raw Tier 2a lookup that would find B's own 'U'.
const ctx = createResolutionContext();
ctx.model.symbols.add('src/b.ts', 'User', 'class:User', 'Class');
ctx.model.symbols.add('src/b.ts', 'U', 'class:U_decoy', 'Class');
// Register the import A → B and the aliased binding A.U → B.User.
ctx.importMap.set('src/a.ts', new Set(['src/b.ts']));
const aliasBindings = new Map();
aliasBindings.set('U', { sourcePath: 'src/b.ts', exportedName: 'User' });
ctx.namedImportMap.set('src/a.ts', aliasBindings);
const result = ctx.resolve('U', 'src/a.ts');
expect(result).not.toBeNull();
expect(result!.tier).toBe('import-scoped');
// The named-binding chain resolves U → User, not U → U_decoy.
expect(result!.candidates.map((c) => c.nodeId)).toEqual(['class:User']);
});
it('Tier 1 (same-file) beats Tier 2a even when an aliased import exists', () => {
// Belt-and-suspenders check: if the caller's own file has a matching
// symbol, it wins — aliased bindings only fire when Tier 1 misses.
const ctx = createResolutionContext();
ctx.model.symbols.add('src/a.ts', 'U', 'fn:local:U', 'Function');
ctx.model.symbols.add('src/b.ts', 'User', 'class:User', 'Class');
const aliasBindings = new Map();
aliasBindings.set('U', { sourcePath: 'src/b.ts', exportedName: 'User' });
ctx.namedImportMap.set('src/a.ts', aliasBindings);
const result = ctx.resolve('U', 'src/a.ts');
expect(result!.tier).toBe('same-file');
expect(result!.candidates[0].nodeId).toBe('fn:local:U');
});
});
describe('ResolutionContext.resolve() — Tier 3 dedup for Function+ownerId', () => {
it('Python/Rust/Kotlin class methods emitted as Function+ownerId land in only one Tier 3 result', () => {
// Simulate the Python worker path: a class method is emitted with
// type='Function' and ownerId set. `rawSymbols.add` lands it in
// callableByName (via the Function callable-index gate) AND
// `wrappedAdd` normalizes the dispatch key to 'Method' so it also
// lands in methodRegistry. The same SymbolDefinition reference is
// reachable via two Tier 3 lookups.
const ctx = createResolutionContext();
ctx.model.symbols.add('src/user.py', 'User', 'class:User', 'Class');
ctx.model.symbols.add('src/user.py', 'greet', 'fn:User.greet', 'Function', {
ownerId: 'class:User',
returnType: 'str',
});
// Sanity check the setup: the same def is in both indexes.
expect(ctx.model.symbols.lookupCallableByName('greet')).toHaveLength(1);
expect(ctx.model.methods.lookupMethodByName('greet')).toHaveLength(1);
expect(ctx.model.methods.hasFunctionMethods).toBe(true);
// Resolve a free 'greet' call from an unrelated file — Tier 1 / 2a /
// 2b all miss, so Tier 3 fires. The dedup pass must collapse the
// two index hits into a single candidate.
const result = ctx.resolve('greet', 'src/caller.py');
expect(result).not.toBeNull();
expect(result!.tier).toBe('global');
expect(result!.candidates).toHaveLength(1);
expect(result!.candidates[0].nodeId).toBe('fn:User.greet');
});
it('Tier 3 fast path fires when no Function+ownerId was ever registered', () => {
// Pure TypeScript-style: methods are emitted as strict Method labels,
// so callableByName and methodRegistry are disjoint and the dedup
// fast path can concat without a Set allocation.
const ctx = createResolutionContext();
ctx.model.symbols.add('src/user.ts', 'User', 'class:User', 'Class');
ctx.model.symbols.add('src/user.ts', 'greet', 'method:User.greet', 'Method', {
ownerId: 'class:User',
returnType: 'string',
});
ctx.model.symbols.add('src/utils.ts', 'greet', 'fn:utils.greet', 'Function');
expect(ctx.model.methods.hasFunctionMethods).toBe(false);
// Tier 3 for 'greet' from an unrelated file returns both the free
// function and the class method; neither overlaps so no dedup.
const result = ctx.resolve('greet', 'src/caller.ts');
expect(result).not.toBeNull();
expect(result!.tier).toBe('global');
expect(result!.candidates.map((c) => c.nodeId).sort()).toEqual([
'fn:utils.greet',
'method:User.greet',
]);
});
});
describe('ResolutionContext.resolve() — walkBindingChain guards', () => {
it('circular re-export returns null (cycle detection fires)', () => {
// A imports { X } from B, B re-exports { X } from A.
// walkBindingChain must detect the cycle via the visited Set and
// return null instead of looping until depth exceeded.
const ctx = createResolutionContext();
// Intentionally leave X undefined in both files — the walker only
// follows re-export edges, not definitions.
const aBindings = new Map();
aBindings.set('X', { sourcePath: 'src/b.ts', exportedName: 'X' });
ctx.namedImportMap.set('src/a.ts', aBindings);
const bBindings = new Map();
bBindings.set('X', { sourcePath: 'src/a.ts', exportedName: 'X' });
ctx.namedImportMap.set('src/b.ts', bBindings);
const result = ctx.resolve('X', 'src/a.ts');
// No definition anywhere in the chain → Tier 2a-named returns null,
// nothing else matches, overall result is null.
expect(result).toBeNull();
});
it('chain deeper than MAX_BINDING_CHAIN_DEPTH drops the named-binding path', () => {
// Build a six-hop re-export chain where every hop just forwards the
// binding. walkBindingChain iterates 5 times and hits the depth cap
// before the sixth hop, returning null. No other tier can resolve
// 'X' either (no X is registered anywhere), so the overall
// `ctx.resolve` call returns null.
const ctx = createResolutionContext();
const chain = [
'src/a.ts',
'src/b.ts',
'src/c.ts',
'src/d.ts',
'src/e.ts',
'src/f.ts',
'src/g.ts',
];
for (let i = 0; i < chain.length - 1; i++) {
const bindings = new Map();
bindings.set('X', { sourcePath: chain[i + 1], exportedName: 'X' });
ctx.namedImportMap.set(chain[i], bindings);
}
// No symbol registered in any file — the chain walk is the only
// possible resolution path, and the depth cap silently kills it.
const result = ctx.resolve('X', 'src/a.ts');
expect(result).toBeNull();
});
it('chain of exactly five hops resolves successfully at the boundary', () => {
// Five hops from A is exactly MAX_BINDING_CHAIN_DEPTH — the final
// lookup on the fifth hop must succeed.
const ctx = createResolutionContext();
ctx.model.symbols.add('src/e.ts', 'X', 'class:X', 'Class');
const chain = ['src/a.ts', 'src/b.ts', 'src/c.ts', 'src/d.ts', 'src/e.ts'];
for (let i = 0; i < chain.length - 1; i++) {
const bindings = new Map();
bindings.set('X', { sourcePath: chain[i + 1], exportedName: 'X' });
ctx.namedImportMap.set(chain[i], bindings);
}
const result = ctx.resolve('X', 'src/a.ts');
expect(result).not.toBeNull();
expect(result!.tier).toBe('import-scoped');
expect(result!.candidates[0].nodeId).toBe('class:X');
});
});

View file

@ -1,82 +0,0 @@
import { describe, it, expect } from 'vitest';
import { extractCSharpNamedBindings } from '../../../src/core/ingestion/named-bindings/csharp.js';
import Parser from 'tree-sitter';
import CSharp from 'tree-sitter-c-sharp';
const parser = new Parser();
/** Walk a tree depth-first and return the first node matching the given type. */
function findFirst(node: any, type: string): any | undefined {
if (node.type === type) return node;
for (let i = 0; i < node.childCount; i++) {
const found = findFirst(node.child(i), type);
if (found) return found;
}
return undefined;
}
const parse = (code: string) => {
parser.setLanguage(CSharp);
return parser.parse(code);
};
describe('extractCSharpNamedBindings', () => {
describe('non-aliased namespace imports (known limitation)', () => {
it('returns undefined for non-aliased namespace imports (known limitation)', () => {
// C# using Namespace imports can't be reduced to per-symbol bindings without type
// inference — resolution falls back to PackageMap directory matching.
const tree = parse('using MyApp.Models;');
const usingNode = findFirst(tree.rootNode, 'using_directive');
expect(usingNode).toBeDefined();
const result = extractCSharpNamedBindings(usingNode);
expect(result).toBeUndefined();
});
it('returns undefined for a single-segment non-aliased import', () => {
// C# using Namespace imports can't be reduced to per-symbol bindings without type
// inference — resolution falls back to PackageMap directory matching.
const tree = parse('using System;');
const usingNode = findFirst(tree.rootNode, 'using_directive');
expect(usingNode).toBeDefined();
const result = extractCSharpNamedBindings(usingNode);
expect(result).toBeUndefined();
});
});
describe('aliased imports', () => {
it('returns a binding for a simple aliased import', () => {
const tree = parse('using Mod = MyApp.Models;');
const usingNode = findFirst(tree.rootNode, 'using_directive');
expect(usingNode).toBeDefined();
const result = extractCSharpNamedBindings(usingNode);
expect(result).toEqual([{ local: 'Mod', exported: 'Models' }]);
});
it('uses the last segment of the qualified name as the exported binding', () => {
const tree = parse('using Svc = MyApp.Services.UserService;');
const usingNode = findFirst(tree.rootNode, 'using_directive');
expect(usingNode).toBeDefined();
const result = extractCSharpNamedBindings(usingNode);
expect(result).toEqual([{ local: 'Svc', exported: 'UserService' }]);
});
});
describe('edge cases', () => {
it('returns undefined when the node type is not using_directive', () => {
// Passing a synthetic object that is not a using_directive node.
const fakeNode = { type: 'import_declaration', namedChildCount: 0 };
const result = extractCSharpNamedBindings(fakeNode);
expect(result).toBeUndefined();
});
});
});

View file

@ -64,9 +64,9 @@ describe('parse-impl chunk concurrency (U1)', () => {
});
// Same fixture under different concurrency values must produce the
// same graph — F4 (wildcard-synthesis ordering): per-chunk results
// merge in chunkIdx order regardless of file-read completion order,
// so cross-chunk processors see deterministic input.
// same graph — per-chunk results merge in chunkIdx order regardless of
// file-read completion order, so cross-chunk processors see deterministic
// input.
expect(g2.nodeCount).toBe(g1.nodeCount);
expect(g2.relationshipCount).toBe(g1.relationshipCount);
});

View file

@ -12,9 +12,7 @@ vi.mock('../../src/core/tree-sitter/parser-loader.js', () => ({
import { createKnowledgeGraph } from '../../src/core/graph/graph.js';
import { createASTCache } from '../../src/core/ingestion/ast-cache.js';
import { processParsing } from '../../src/core/ingestion/parsing-processor.js';
import { processImports } from '../../src/core/ingestion/import-processor.js';
import { createSymbolTable } from '../../src/core/ingestion/model/symbol-table.js';
import { createResolutionContext } from '../../src/core/ingestion/model/resolution-context.js';
import * as parserLoader from '../../src/core/tree-sitter/parser-loader.js';
import { _captureLogger } from '../../src/core/logger.js';
@ -34,64 +32,6 @@ describe('sequential native parser availability', () => {
cap = undefined;
});
it('skips Swift files in processImports when the native parser is unavailable', async () => {
vi.mocked(parserLoader.isLanguageAvailable).mockReturnValue(false);
await expect(
processImports(
createKnowledgeGraph(),
[{ path: 'App.swift', content: 'import Foundation' }],
createASTCache(),
createResolutionContext(),
undefined,
'/tmp/repo',
['App.swift'],
),
).resolves.toBeUndefined();
expect(parserLoader.loadLanguage).not.toHaveBeenCalled();
});
it('warns when processImports skips files in verbose mode', async () => {
cap = _captureLogger();
const previous = process.env.GITNEXUS_VERBOSE;
process.env.GITNEXUS_VERBOSE = '1';
try {
vi.mocked(parserLoader.isLanguageAvailable).mockReturnValue(false);
await processImports(
createKnowledgeGraph(),
[{ path: 'App.swift', content: 'import Foundation' }],
createASTCache(),
createResolutionContext(),
undefined,
'/tmp/repo',
['App.swift'],
);
expect(
cap
.records()
.some(
(r) =>
r.msg ===
'[ingestion] Skipped 1 swift file(s) in import processing — swift parser not available.',
),
).toBe(true);
} finally {
// Always restore the live capture here (in addition to the afterEach
// safety net) so a failing assertion above cannot leak it into the
// next test as an "a previous capture is still active" cascade.
cap.restore();
cap = undefined;
if (previous === undefined) {
delete process.env.GITNEXUS_VERBOSE;
} else {
process.env.GITNEXUS_VERBOSE = previous;
}
}
});
it('skips Swift files in processParsing when the native parser is unavailable', async () => {
vi.mocked(parserLoader.isLanguageAvailable).mockReturnValue(false);

File diff suppressed because it is too large Load diff

View file

@ -1,101 +0,0 @@
/**
* Unit tests for `expandTransitiveIncludeClosure` the C/C++ Strategy 1
* (`wildcard-transitive`) implementation extracted from `wildcard-synthesis.ts`.
*
* These tests exercise the BFS/DFS closure algorithm in isolation, without
* running the full pipeline. They cover edge cases flagged in PR #816 review:
* circular header includes, deep chains, and graphImports-only transitive paths.
*/
import { describe, it, expect } from 'vitest';
import { expandTransitiveIncludeClosure } from '../../src/core/ingestion/pipeline-phases/wildcard-synthesis.js';
const EMPTY = new Map<string, ReadonlySet<string>>();
describe('expandTransitiveIncludeClosure', () => {
it('returns the direct imports when none are chained', () => {
const direct = new Set(['a.h', 'b.h']);
const closure = expandTransitiveIncludeClosure(direct, EMPTY, EMPTY);
expect([...closure].sort()).toEqual(['a.h', 'b.h']);
});
it('expands a two-hop chain via importMap (a.c → b.h → c.h)', () => {
const importMap = new Map<string, ReadonlySet<string>>([['b.h', new Set(['c.h'])]]);
const closure = expandTransitiveIncludeClosure(new Set(['b.h']), importMap, EMPTY);
expect([...closure].sort()).toEqual(['b.h', 'c.h']);
});
it('expands a deep 5-level chain (A → B → C → D → E)', () => {
const importMap = new Map<string, ReadonlySet<string>>([
['B.h', new Set(['C.h'])],
['C.h', new Set(['D.h'])],
['D.h', new Set(['E.h'])],
]);
const closure = expandTransitiveIncludeClosure(new Set(['B.h']), importMap, EMPTY);
expect([...closure].sort()).toEqual(['B.h', 'C.h', 'D.h', 'E.h']);
});
it('terminates on circular header includes (A.h ↔ B.h)', () => {
const importMap = new Map<string, ReadonlySet<string>>([
['A.h', new Set(['B.h'])],
['B.h', new Set(['A.h'])],
]);
const closure = expandTransitiveIncludeClosure(new Set(['A.h']), importMap, EMPTY);
expect([...closure].sort()).toEqual(['A.h', 'B.h']);
});
it('terminates on self-referential include (A.h includes A.h)', () => {
const importMap = new Map<string, ReadonlySet<string>>([['A.h', new Set(['A.h'])]]);
const closure = expandTransitiveIncludeClosure(new Set(['A.h']), importMap, EMPTY);
expect([...closure]).toEqual(['A.h']);
});
it('expands through graphImports edges when importMap is empty', () => {
const graphImports = new Map<string, ReadonlySet<string>>([['b.h', new Set(['c.h'])]]);
const closure = expandTransitiveIncludeClosure(new Set(['b.h']), EMPTY, graphImports);
expect([...closure].sort()).toEqual(['b.h', 'c.h']);
});
it('combines importMap and graphImports in one traversal', () => {
const importMap = new Map<string, ReadonlySet<string>>([['b.h', new Set(['c.h'])]]);
const graphImports = new Map<string, ReadonlySet<string>>([['c.h', new Set(['d.h'])]]);
const closure = expandTransitiveIncludeClosure(new Set(['b.h']), importMap, graphImports);
expect([...closure].sort()).toEqual(['b.h', 'c.h', 'd.h']);
});
it('returns an empty set when given no direct imports', () => {
const closure = expandTransitiveIncludeClosure(new Set<string>(), EMPTY, EMPTY);
expect(closure.size).toBe(0);
});
it('caps closure size to prevent OOM on pathological codebases', () => {
// Build a synthetic include graph of 10,000 files, each including the next.
// The cap (5000) should halt BFS early with a partial but bounded closure.
const importMap = new Map<string, ReadonlySet<string>>();
for (let i = 0; i < 10_000; i++) {
importMap.set(`h${i}.h`, new Set([`h${i + 1}.h`]));
}
const closure = expandTransitiveIncludeClosure(new Set(['h0.h']), importMap, EMPTY);
expect(closure.size).toBe(5000);
// Partial closure still starts from the importer's side (BFS ordering).
expect(closure.has('h0.h')).toBe(true);
expect(closure.has('h1.h')).toBe(true);
expect(closure.has('h9999.h')).toBe(false);
});
it('deduplicates when a file is reachable through multiple paths (diamond)', () => {
// A
// / \
// B C
// \ /
// D
const importMap = new Map<string, ReadonlySet<string>>([
['A.h', new Set(['B.h', 'C.h'])],
['B.h', new Set(['D.h'])],
['C.h', new Set(['D.h'])],
]);
const closure = expandTransitiveIncludeClosure(new Set(['A.h']), importMap, EMPTY);
expect([...closure].sort()).toEqual(['A.h', 'B.h', 'C.h', 'D.h']);
expect(closure.size).toBe(4); // D.h appears once
});
});

View file

@ -1,157 +0,0 @@
/**
* Coverage tests for wildcard-synthesis.ts.
*
* Scenarios aimed at branches that the integration tests only exercise on
* the happy path:
* 1. Go graph-IMPORTS fallback (importMap lacks the edge, graph has it).
* 2. Python buildPythonModuleAliasForFile populates moduleAliasMap.
* 3. MAX_SYNTHETIC_BINDINGS_PER_FILE cap halts further synthesis.
* 4. Deduplication against an already-present namedImportMap entry.
* 5. Empty exportedSymbolsByFile early return, no work.
*/
import { describe, it, expect } from 'vitest';
import { synthesizeWildcardImportBindings } from '../../src/core/ingestion/pipeline-phases/wildcard-synthesis.js';
import { createResolutionContext } from '../../src/core/ingestion/model/resolution-context.js';
import { createKnowledgeGraph } from '../../src/core/graph/graph.js';
import type { GraphNode, GraphRelationship } from '../../src/core/graph/types.js';
function makeExportedFuncNode(
id: string,
name: string,
filePath: string,
label: GraphNode['label'] = 'Function',
): GraphNode {
return {
id,
label,
properties: {
name,
filePath,
startLine: 1,
endLine: 5,
isExported: true,
},
};
}
function makeImportsRel(srcFile: string, tgtFile: string): GraphRelationship {
return {
id: `File:${srcFile}-IMPORTS-File:${tgtFile}`,
sourceId: `File:${srcFile}`,
targetId: `File:${tgtFile}`,
type: 'IMPORTS',
confidence: 1.0,
reason: '',
};
}
describe('synthesizeWildcardImportBindings', () => {
it('uses graph-level IMPORTS fallback for Go when ctx.importMap lacks the edge', () => {
const graph = createKnowledgeGraph();
const ctx = createResolutionContext();
// Exported Go symbol in the upstream file
graph.addNode(makeExportedFuncNode('Function:pkg/util.go:Helper', 'Helper', 'pkg/util.go'));
// Only the graph edge exists — ctx.importMap has NO entry for main.go
graph.addRelationship(makeImportsRel('cmd/main.go', 'pkg/util.go'));
const total = synthesizeWildcardImportBindings(graph, ctx);
expect(total).toBe(1);
const mainBindings = ctx.namedImportMap.get('cmd/main.go');
expect(mainBindings).toBeDefined();
expect(mainBindings!.get('Helper')).toEqual({
sourcePath: 'pkg/util.go',
exportedName: 'Helper',
});
});
it('populates moduleAliasMap for Python namespace-import files', () => {
const graph = createKnowledgeGraph();
const ctx = createResolutionContext();
// Need at least one exported symbol so exportedSymbolsByFile is non-empty
// (otherwise the function early-returns before reaching alias-map build).
graph.addNode(makeExportedFuncNode('Function:models.py:User', 'User', 'models.py', 'Class'));
// Python importer — recorded in ctx.importMap (Python has namespace semantics)
ctx.importMap.set('app.py', new Set(['models.py', 'utils/helpers.py']));
synthesizeWildcardImportBindings(graph, ctx);
const aliasMap = ctx.moduleAliasMap.get('app.py');
expect(aliasMap).toBeDefined();
// basename stem → full path
expect(aliasMap!.get('models')).toBe('models.py');
expect(aliasMap!.get('helpers')).toBe('utils/helpers.py');
});
it('caps synthesis at MAX_SYNTHETIC_BINDINGS_PER_FILE (1000) per file', () => {
const graph = createKnowledgeGraph();
const ctx = createResolutionContext();
// Emit 1200 exported symbols in a single upstream Go file.
for (let i = 0; i < 1200; i++) {
graph.addNode(makeExportedFuncNode(`Function:pkg/big.go:Sym${i}`, `Sym${i}`, 'pkg/big.go'));
}
// Go is wildcard — use ctx.importMap (C/C++/Ruby/Swift path also works,
// but Go via importMap exercises the same synthesizeForFile branch).
ctx.importMap.set('cmd/main.go', new Set(['pkg/big.go']));
const total = synthesizeWildcardImportBindings(graph, ctx);
// Cap is 1000; totalSynthesized should equal the cap (not 1200).
expect(total).toBe(1000);
const bindings = ctx.namedImportMap.get('cmd/main.go');
expect(bindings).toBeDefined();
expect(bindings!.size).toBe(1000);
});
it('skips symbols already present in namedImportMap (dedup)', () => {
const graph = createKnowledgeGraph();
const ctx = createResolutionContext();
graph.addNode(makeExportedFuncNode('Function:pkg/util.go:Helper', 'Helper', 'pkg/util.go'));
graph.addNode(makeExportedFuncNode('Function:pkg/util.go:Other', 'Other', 'pkg/util.go'));
// Pre-seed a binding for "Helper" with a distinct sourcePath so we can
// detect that it was preserved rather than overwritten.
const preExisting = new Map();
preExisting.set('Helper', {
sourcePath: 'other/source.go',
exportedName: 'Helper',
});
ctx.namedImportMap.set('cmd/main.go', preExisting);
ctx.importMap.set('cmd/main.go', new Set(['pkg/util.go']));
const total = synthesizeWildcardImportBindings(graph, ctx);
// Only "Other" should have been synthesized; "Helper" was skipped.
expect(total).toBe(1);
const bindings = ctx.namedImportMap.get('cmd/main.go')!;
expect(bindings.get('Helper')!.sourcePath).toBe('other/source.go'); // untouched
expect(bindings.get('Other')).toEqual({
sourcePath: 'pkg/util.go',
exportedName: 'Other',
});
});
it('returns 0 early when exportedSymbolsByFile is empty (no exported symbols)', () => {
const graph = createKnowledgeGraph();
const ctx = createResolutionContext();
// Even with wildcard-language imports declared, no exported symbols
// means nothing to synthesize — function must short-circuit.
ctx.importMap.set('cmd/main.go', new Set(['pkg/util.go']));
graph.addRelationship(makeImportsRel('cmd/main.go', 'pkg/util.go'));
const total = synthesizeWildcardImportBindings(graph, ctx);
expect(total).toBe(0);
expect(ctx.namedImportMap.size).toBe(0);
expect(ctx.moduleAliasMap.size).toBe(0);
});
});