diff --git a/gitnexus/src/core/ingestion/languages/php/import-target.ts b/gitnexus/src/core/ingestion/languages/php/import-target.ts index 25d3e5309..543d493e1 100644 --- a/gitnexus/src/core/ingestion/languages/php/import-target.ts +++ b/gitnexus/src/core/ingestion/languages/php/import-target.ts @@ -15,7 +15,7 @@ * `linkStatus: 'unresolved'`. */ -import type { ParsedImport, WorkspaceIndex } from 'gitnexus-shared'; +import type { ParsedFile, ParsedImport, WorkspaceIndex } from 'gitnexus-shared'; import type { ImportResolutionContext } from '../../scope-resolution/contract/scope-resolver.js'; import { resolvePhpImportInternal } from '../../import-resolvers/php.js'; import type { ComposerConfig } from '../../language-config.js'; @@ -72,13 +72,42 @@ function namespaceDirectories( return [...directories]; } -function isDirectChild(filePath: string, directory: string): boolean { +// A scope-resolution pass shares one stable parsedFiles array across imports. +const phpDirectoryIndexCache = new WeakMap< + readonly ParsedFile[], + ReadonlyMap +>(); + +function directoryAliases(filePath: string): string[] { const normalizedPath = normalizePhpPath(filePath); const separator = normalizedPath.lastIndexOf('/'); - if (separator < 0) return directory === ''; + if (separator < 0) return ['']; + const parent = normalizedPath.slice(0, separator); - const normalizedDirectory = normalizePhpPath(directory); - return parent === normalizedDirectory || parent.endsWith(`/${normalizedDirectory}`); + const aliases = new Set([parent]); + const segments = parent.split('/').filter(Boolean); + for (let index = 0; index < segments.length; index++) { + aliases.add(segments.slice(index).join('/')); + } + return [...aliases]; +} + +function filesByDirectory( + parsedFiles: readonly ParsedFile[], +): ReadonlyMap { + const cached = phpDirectoryIndexCache.get(parsedFiles); + if (cached) return cached; + + const mutable = new Map(); + for (const parsed of parsedFiles) { + for (const directory of directoryAliases(parsed.filePath)) { + const files = mutable.get(directory) ?? []; + files.push(parsed); + mutable.set(directory, files); + } + } + phpDirectoryIndexCache.set(parsedFiles, mutable); + return mutable; } // ─── loadResolutionConfig ────────────────────────────────────────────────── @@ -211,9 +240,12 @@ export function resolvePhpImportTargetInternal( if (importedName === undefined) return resolved; const directories = namespaceDirectories(targetRaw, composerConfig, resolved); - const candidateFiles = context.parsedFiles.filter((parsed) => - directories.some((directory) => isDirectChild(parsed.filePath, directory)), - ); + const directoryIndex = filesByDirectory(context.parsedFiles); + const candidateFiles = [ + ...new Set( + directories.flatMap((directory) => directoryIndex.get(normalizePhpPath(directory)) ?? []), + ), + ]; const expectedType = symbolKind === 'function' ? 'Function' : 'Variable'; const declaringFiles = candidateFiles.filter((parsed) => parsed.localDefs.some((def) => { diff --git a/gitnexus/test/unit/scope-resolution/php/php-import-target.test.ts b/gitnexus/test/unit/scope-resolution/php/php-import-target.test.ts index b3a739364..194b393af 100644 --- a/gitnexus/test/unit/scope-resolution/php/php-import-target.test.ts +++ b/gitnexus/test/unit/scope-resolution/php/php-import-target.test.ts @@ -51,6 +51,33 @@ describe('resolvePhpImportTargetInternal declaration selection', () => { ).toBe(factory); }); + it('reuses directory selection without leaking candidates across namespaces', () => { + const models = '/repo/app/Models/functions.php'; + const services = '/repo/app/Services/functions.php'; + const parsedFiles = [ + parsedFile(models, [definition(models, 'Function', 'getUser')]), + parsedFile(services, [definition(services, 'Function', 'getUser')]), + ]; + + const first = resolvePhpImportTargetInternal( + functionImport.targetRaw, + '/repo/app/Main.php', + new Set(parsedFiles.map((parsed) => parsed.filePath)), + composerConfig, + { parsedFiles, parsedImport: functionImport }, + ); + const second = resolvePhpImportTargetInternal( + functionImport.targetRaw, + '/repo/app/Main.php', + new Set(parsedFiles.map((parsed) => parsed.filePath)), + composerConfig, + { parsedFiles, parsedImport: functionImport }, + ); + + expect(first).toBe(models); + expect(second).toBe(models); + }); + it('fails closed when the namespace has duplicate function declarations', () => { const first = '/repo/app/Models/First.php'; const second = '/repo/app/Models/Second.php';