mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
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:
parent
c4ee911463
commit
bd59fa95ce
59 changed files with 807 additions and 4217 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>();
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 }];
|
||||
}
|
||||
|
|
@ -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 }];
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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: [],
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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). */
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
|
|
|
|||
11
gitnexus/test/fixtures/lang-resolution/laravel-route-resolution/app/Admin/OrderController.php
vendored
Normal file
11
gitnexus/test/fixtures/lang-resolution/laravel-route-resolution/app/Admin/OrderController.php
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<?php
|
||||
|
||||
namespace App\Admin;
|
||||
|
||||
class OrderController
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
return 'admin orders';
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
class OrderController
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
return 'public orders';
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
class PhotoController
|
||||
{
|
||||
public function list()
|
||||
{
|
||||
return 'photos';
|
||||
}
|
||||
}
|
||||
16
gitnexus/test/fixtures/lang-resolution/laravel-route-resolution/routes/web.php
vendored
Normal file
16
gitnexus/test/fixtures/lang-resolution/laravel-route-resolution/routes/web.php
vendored
Normal 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']);
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
297
gitnexus/test/unit/call-processor-routes.test.ts
Normal file
297
gitnexus/test/unit/call-processor-routes.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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.
|
||||
|
|
@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue