From c588fc6ace3ab118ef740eaaac16e86e4294591c Mon Sep 17 00:00:00 2001 From: Eva Date: Tue, 14 Jul 2026 03:44:53 +0700 Subject: [PATCH 1/7] fix(scan): canonicalize repository path order --- .../src/core/ingestion/filesystem-walker.ts | 5 +++ .../test/unit/filesystem-walker-order.test.ts | 39 +++++++++++++++++++ 2 files changed, 44 insertions(+) create mode 100644 gitnexus/test/unit/filesystem-walker-order.test.ts diff --git a/gitnexus/src/core/ingestion/filesystem-walker.ts b/gitnexus/src/core/ingestion/filesystem-walker.ts index 0af28a9586..407d1aad77 100644 --- a/gitnexus/src/core/ingestion/filesystem-walker.ts +++ b/gitnexus/src/core/ingestion/filesystem-walker.ts @@ -83,6 +83,11 @@ export const walkRepositoryPaths = async ( } } + // Filesystem/glob traversal order is not stable across filesystems or repeated + // scans. Canonicalize once at the scan boundary so structure, scope resolution, + // suffix indexes, and parse chunking all observe the same repository order. + entries.sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0)); + if (skippedLarge > 0) { const isDefault = maxFileSizeBytes === DEFAULT_MAX_FILE_SIZE_BYTES; const isOverrideUnset = !process.env.GITNEXUS_MAX_FILE_SIZE; diff --git a/gitnexus/test/unit/filesystem-walker-order.test.ts b/gitnexus/test/unit/filesystem-walker-order.test.ts new file mode 100644 index 0000000000..7b75128a47 --- /dev/null +++ b/gitnexus/test/unit/filesystem-walker-order.test.ts @@ -0,0 +1,39 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { glob } from 'glob'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('glob', () => ({ glob: vi.fn() })); +vi.mock('../../src/config/ignore-service.js', () => ({ + createIgnoreFilter: vi.fn(async () => []), +})); + +import { walkRepositoryPaths } from '../../src/core/ingestion/filesystem-walker.js'; + +const temporaryRoots: string[] = []; + +afterEach(async () => { + vi.mocked(glob).mockReset(); + await Promise.all( + temporaryRoots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true })), + ); +}); + +describe('walkRepositoryPaths ordering', () => { + it('returns accepted files in canonical path order when glob order is unstable', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-scan-order-')); + temporaryRoots.push(root); + await Promise.all( + ['zeta.ts', 'alpha.ts', 'middle.ts'].map((file) => + fs.writeFile(path.join(root, file), `export const ${file[0]} = true;\n`), + ), + ); + vi.mocked(glob).mockResolvedValue(['zeta.ts', 'alpha.ts', 'middle.ts']); + + const result = await walkRepositoryPaths(root); + + expect(result.map((entry) => entry.path)).toEqual(['alpha.ts', 'middle.ts', 'zeta.ts']); + }); +}); From 58c8358cc7dd2495c246f03cc705f83f20c257a2 Mon Sep 17 00:00:00 2001 From: Eva Date: Tue, 14 Jul 2026 04:09:28 +0700 Subject: [PATCH 2/7] fix(imports): stabilize suffix lookup without reordering phases --- .../src/core/ingestion/filesystem-walker.ts | 5 --- .../core/ingestion/import-resolvers/utils.ts | 17 ++++++-- .../test/unit/filesystem-walker-order.test.ts | 39 ------------------- .../test/unit/suffix-index-ambiguity.test.ts | 14 +++++++ 4 files changed, 28 insertions(+), 47 deletions(-) delete mode 100644 gitnexus/test/unit/filesystem-walker-order.test.ts diff --git a/gitnexus/src/core/ingestion/filesystem-walker.ts b/gitnexus/src/core/ingestion/filesystem-walker.ts index 407d1aad77..0af28a9586 100644 --- a/gitnexus/src/core/ingestion/filesystem-walker.ts +++ b/gitnexus/src/core/ingestion/filesystem-walker.ts @@ -83,11 +83,6 @@ export const walkRepositoryPaths = async ( } } - // Filesystem/glob traversal order is not stable across filesystems or repeated - // scans. Canonicalize once at the scan boundary so structure, scope resolution, - // suffix indexes, and parse chunking all observe the same repository order. - entries.sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0)); - if (skippedLarge > 0) { const isDefault = maxFileSizeBytes === DEFAULT_MAX_FILE_SIZE_BYTES; const isOverrideUnset = !process.env.GITNEXUS_MAX_FILE_SIZE; diff --git a/gitnexus/src/core/ingestion/import-resolvers/utils.ts b/gitnexus/src/core/ingestion/import-resolvers/utils.ts index 6a033c1ee7..6737335d5e 100644 --- a/gitnexus/src/core/ingestion/import-resolvers/utils.ts +++ b/gitnexus/src/core/ingestion/import-resolvers/utils.ts @@ -95,9 +95,20 @@ export function buildSuffixIndex(normalizedFileList: string[], allFileList: stri // Map: directory suffix -> list of file paths in that directory const dirMap = new Map(); - for (let i = 0; i < normalizedFileList.length; i++) { - const normalized = normalizedFileList[i]; - const original = allFileList[i]; + // First-wins suffixes must not inherit filesystem or parser insertion order. + // Sort a paired copy so callers keep their phase-specific file order. + const orderedFiles = normalizedFileList.map((normalized, index) => ({ + normalized, + original: allFileList[index], + })); + orderedFiles.sort((left, right) => { + if (left.normalized !== right.normalized) { + return left.normalized < right.normalized ? -1 : 1; + } + return left.original < right.original ? -1 : left.original > right.original ? 1 : 0; + }); + + for (const { normalized, original } of orderedFiles) { const parts = normalized.split('/'); // Index all suffixes: "a/b/c.java" -> ["c.java", "b/c.java", "a/b/c.java"] diff --git a/gitnexus/test/unit/filesystem-walker-order.test.ts b/gitnexus/test/unit/filesystem-walker-order.test.ts deleted file mode 100644 index 7b75128a47..0000000000 --- a/gitnexus/test/unit/filesystem-walker-order.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import fs from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; - -import { glob } from 'glob'; -import { afterEach, describe, expect, it, vi } from 'vitest'; - -vi.mock('glob', () => ({ glob: vi.fn() })); -vi.mock('../../src/config/ignore-service.js', () => ({ - createIgnoreFilter: vi.fn(async () => []), -})); - -import { walkRepositoryPaths } from '../../src/core/ingestion/filesystem-walker.js'; - -const temporaryRoots: string[] = []; - -afterEach(async () => { - vi.mocked(glob).mockReset(); - await Promise.all( - temporaryRoots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true })), - ); -}); - -describe('walkRepositoryPaths ordering', () => { - it('returns accepted files in canonical path order when glob order is unstable', async () => { - const root = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-scan-order-')); - temporaryRoots.push(root); - await Promise.all( - ['zeta.ts', 'alpha.ts', 'middle.ts'].map((file) => - fs.writeFile(path.join(root, file), `export const ${file[0]} = true;\n`), - ), - ); - vi.mocked(glob).mockResolvedValue(['zeta.ts', 'alpha.ts', 'middle.ts']); - - const result = await walkRepositoryPaths(root); - - expect(result.map((entry) => entry.path)).toEqual(['alpha.ts', 'middle.ts', 'zeta.ts']); - }); -}); diff --git a/gitnexus/test/unit/suffix-index-ambiguity.test.ts b/gitnexus/test/unit/suffix-index-ambiguity.test.ts index fd786c045a..28cfe280aa 100644 --- a/gitnexus/test/unit/suffix-index-ambiguity.test.ts +++ b/gitnexus/test/unit/suffix-index-ambiguity.test.ts @@ -172,6 +172,20 @@ describe('resolvePythonImport — namespace packages (no __init__.py)', () => { }); }); +// --------------------------------------------------------------------------- +// Suffix index determinism +// --------------------------------------------------------------------------- + +describe('buildSuffixIndex determinism', () => { + it('selects the same ambiguous suffix target regardless of input order', () => { + const first = makeCtx(['packages/zeta/user.ts', 'packages/alpha/user.ts']); + const reversed = makeCtx(['packages/alpha/user.ts', 'packages/zeta/user.ts']); + + expect(first.index.get('user.ts')).toBe('packages/alpha/user.ts'); + expect(reversed.index.get('user.ts')).toBe('packages/alpha/user.ts'); + }); +}); + // --------------------------------------------------------------------------- // Ruby: bare require does NOT use proximity // --------------------------------------------------------------------------- From 9e52054be1f6181e09409929020a5df75d09a033 Mon Sep 17 00:00:00 2001 From: Eva Date: Tue, 14 Jul 2026 04:14:31 +0700 Subject: [PATCH 3/7] test(imports): preserve caller file order --- gitnexus/src/core/ingestion/import-resolvers/utils.ts | 2 +- gitnexus/test/unit/suffix-index-ambiguity.test.ts | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/gitnexus/src/core/ingestion/import-resolvers/utils.ts b/gitnexus/src/core/ingestion/import-resolvers/utils.ts index 6737335d5e..f556769737 100644 --- a/gitnexus/src/core/ingestion/import-resolvers/utils.ts +++ b/gitnexus/src/core/ingestion/import-resolvers/utils.ts @@ -114,7 +114,7 @@ export function buildSuffixIndex(normalizedFileList: string[], allFileList: stri // Index all suffixes: "a/b/c.java" -> ["c.java", "b/c.java", "a/b/c.java"] for (let j = parts.length - 1; j >= 0; j--) { const suffix = parts.slice(j).join('/'); - // Only store first match (longest path wins for ambiguous suffixes) + // Only store the canonical first match for ambiguous suffixes. if (!exactMap.has(suffix)) { exactMap.set(suffix, original); } diff --git a/gitnexus/test/unit/suffix-index-ambiguity.test.ts b/gitnexus/test/unit/suffix-index-ambiguity.test.ts index 28cfe280aa..eb54c062cb 100644 --- a/gitnexus/test/unit/suffix-index-ambiguity.test.ts +++ b/gitnexus/test/unit/suffix-index-ambiguity.test.ts @@ -178,11 +178,15 @@ describe('resolvePythonImport — namespace packages (no __init__.py)', () => { describe('buildSuffixIndex determinism', () => { it('selects the same ambiguous suffix target regardless of input order', () => { - const first = makeCtx(['packages/zeta/user.ts', 'packages/alpha/user.ts']); - const reversed = makeCtx(['packages/alpha/user.ts', 'packages/zeta/user.ts']); + const firstInput = ['packages/zeta/user.ts', 'packages/alpha/user.ts']; + const reversedInput = [...firstInput].reverse(); + const first = makeCtx(firstInput); + const reversed = makeCtx(reversedInput); expect(first.index.get('user.ts')).toBe('packages/alpha/user.ts'); expect(reversed.index.get('user.ts')).toBe('packages/alpha/user.ts'); + expect(first.files).toEqual(firstInput); + expect(reversed.files).toEqual(reversedInput); }); }); From e6cb54b3ac34fdcb260dcbdb688d260ad4105a1c Mon Sep 17 00:00:00 2001 From: Eva Date: Tue, 14 Jul 2026 12:15:54 +0700 Subject: [PATCH 4/7] fix(scan): canonicalize traversal without order-sensitive Rust binding --- .../src/core/ingestion/filesystem-walker.ts | 5 +++ .../ingestion/languages/rust/range-binding.ts | 9 ++++- .../test/unit/filesystem-walker-order.test.ts | 39 +++++++++++++++++++ 3 files changed, 51 insertions(+), 2 deletions(-) create mode 100644 gitnexus/test/unit/filesystem-walker-order.test.ts diff --git a/gitnexus/src/core/ingestion/filesystem-walker.ts b/gitnexus/src/core/ingestion/filesystem-walker.ts index 0af28a9586..823b3670fd 100644 --- a/gitnexus/src/core/ingestion/filesystem-walker.ts +++ b/gitnexus/src/core/ingestion/filesystem-walker.ts @@ -83,6 +83,11 @@ export const walkRepositoryPaths = async ( } } + // Filesystem/glob traversal order is not stable across filesystems or repeated + // scans. Canonicalize once at the scan boundary so every downstream phase sees + // the same repository order. + entries.sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0)); + if (skippedLarge > 0) { const isDefault = maxFileSizeBytes === DEFAULT_MAX_FILE_SIZE_BYTES; const isOverrideUnset = !process.env.GITNEXUS_MAX_FILE_SIZE; diff --git a/gitnexus/src/core/ingestion/languages/rust/range-binding.ts b/gitnexus/src/core/ingestion/languages/rust/range-binding.ts index 285b84d524..4c7224333d 100644 --- a/gitnexus/src/core/ingestion/languages/rust/range-binding.ts +++ b/gitnexus/src/core/ingestion/languages/rust/range-binding.ts @@ -89,6 +89,13 @@ export function populateRustRangeBindings( } } } + + // Publish per-type member bindings for the whole workspace before resolving + // assignments. Otherwise an importer processed before its defining file can + // miss a field or identity-method type solely because of file order. + const scopeMap = new Map(parsed.scopes.map((scope) => [scope.id, scope])); + processFieldTypeBindings(tree.rootNode, parsed, scopeMap); + processIdentityMethodBindings(parsed); } for (const parsed of parsedFiles) { @@ -122,8 +129,6 @@ export function populateRustRangeBindings( const moduleScope = parsed.scopes.find((s) => s.kind === 'Module'); if (moduleScope === undefined) continue; - processFieldTypeBindings(tree.rootNode, parsed, scopeMap); - processIdentityMethodBindings(parsed); processForLoops(tree.rootNode, parsed, scopeMap, moduleScope, allReturnTypes); processPatternBindings(tree.rootNode, parsed, scopeMap, moduleScope); processStructDestructuring(tree.rootNode, parsed, scopeMap, moduleScope, allFieldTypes); diff --git a/gitnexus/test/unit/filesystem-walker-order.test.ts b/gitnexus/test/unit/filesystem-walker-order.test.ts new file mode 100644 index 0000000000..7b75128a47 --- /dev/null +++ b/gitnexus/test/unit/filesystem-walker-order.test.ts @@ -0,0 +1,39 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { glob } from 'glob'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('glob', () => ({ glob: vi.fn() })); +vi.mock('../../src/config/ignore-service.js', () => ({ + createIgnoreFilter: vi.fn(async () => []), +})); + +import { walkRepositoryPaths } from '../../src/core/ingestion/filesystem-walker.js'; + +const temporaryRoots: string[] = []; + +afterEach(async () => { + vi.mocked(glob).mockReset(); + await Promise.all( + temporaryRoots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true })), + ); +}); + +describe('walkRepositoryPaths ordering', () => { + it('returns accepted files in canonical path order when glob order is unstable', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-scan-order-')); + temporaryRoots.push(root); + await Promise.all( + ['zeta.ts', 'alpha.ts', 'middle.ts'].map((file) => + fs.writeFile(path.join(root, file), `export const ${file[0]} = true;\n`), + ), + ); + vi.mocked(glob).mockResolvedValue(['zeta.ts', 'alpha.ts', 'middle.ts']); + + const result = await walkRepositoryPaths(root); + + expect(result.map((entry) => entry.path)).toEqual(['alpha.ts', 'middle.ts', 'zeta.ts']); + }); +}); From c45c251ef57d79141d69fa072fcc3eb653dff678 Mon Sep 17 00:00:00 2001 From: Eva Date: Tue, 14 Jul 2026 12:43:54 +0700 Subject: [PATCH 5/7] fix(php): resolve function imports by declaring file --- .../scope-resolution/finalize-algorithm.ts | 8 ++++- gitnexus-shared/src/scope-resolution/types.ts | 5 +++ .../languages/php/import-decomposer.ts | 13 ++++++++ .../ingestion/languages/php/import-target.ts | 32 ++++++++++++++++++- .../core/ingestion/languages/php/interpret.ts | 11 +++++++ .../ingestion/languages/php/scope-resolver.ts | 4 +-- .../contract/scope-resolver.ts | 12 +++++++ .../scope-resolution/pipeline/run.ts | 7 ++-- .../test/integration/resolvers/php.test.ts | 5 +++ 9 files changed, 91 insertions(+), 6 deletions(-) diff --git a/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts b/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts index f5d3dd0bf4..f381bb12e3 100644 --- a/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts +++ b/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts @@ -93,6 +93,7 @@ export interface FinalizeHooks { targetRaw: string, fromFile: string, workspaceIndex: WorkspaceIndex, + parsedImport?: ParsedImport, ): string | readonly string[] | null; /** @@ -348,7 +349,12 @@ function makeEdgeDrafts( ]; } - const targetFile = hooks.resolveImportTarget(parsed.targetRaw ?? '', file.filePath, workspace); + const targetFile = hooks.resolveImportTarget( + parsed.targetRaw ?? '', + file.filePath, + workspace, + parsed, + ); // Edge is unresolvable at the file level — mark unresolved now. if (targetFile === null) { diff --git a/gitnexus-shared/src/scope-resolution/types.ts b/gitnexus-shared/src/scope-resolution/types.ts index bf837639e6..6012694cfa 100644 --- a/gitnexus-shared/src/scope-resolution/types.ts +++ b/gitnexus-shared/src/scope-resolution/types.ts @@ -105,6 +105,9 @@ export type ParsedImport = readonly localName: string; readonly importedName: string; readonly targetRaw: string; + /** Provider-specific imported symbol category when module and symbol + * namespaces have distinct resolution rules (for example PHP). */ + readonly importedSymbolKind?: 'type' | 'function' | 'const'; /** * Set by providers when `targetRaw` already names the imported symbol * rather than only its containing module. Consumers that compose @@ -127,6 +130,8 @@ export type ParsedImport = readonly alias: string; readonly targetRaw: string; /** See the same field on the `named` variant. */ + readonly importedSymbolKind?: 'type' | 'function' | 'const'; + /** See the same field on the `named` variant. */ readonly targetIncludesImportedName?: boolean; } /** diff --git a/gitnexus/src/core/ingestion/languages/php/import-decomposer.ts b/gitnexus/src/core/ingestion/languages/php/import-decomposer.ts index bcf57d99d5..f3d6cfc736 100644 --- a/gitnexus/src/core/ingestion/languages/php/import-decomposer.ts +++ b/gitnexus/src/core/ingestion/languages/php/import-decomposer.ts @@ -22,9 +22,11 @@ import type { Capture, CaptureMatch } from 'gitnexus-shared'; import { nodeToCapture, syntheticCapture, type SyntaxNode } from '../../utils/ast-helpers.js'; export type PhpImportKind = 'namespace' | 'alias' | 'function' | 'const'; +type PhpImportedSymbolKind = 'type' | 'function' | 'const'; interface PhpImportSpec { readonly kind: PhpImportKind; + readonly symbolKind: PhpImportedSymbolKind; /** Full backslash-separated path (backslashes intact): `Foo\Bar\Baz`. */ readonly source: string; /** Local binding name — last source segment for plain imports, the @@ -119,6 +121,7 @@ function parseUseClause(clause: SyntaxNode, qualifier: PhpImportKind): PhpImport if (alias !== '') { return { kind: 'alias', + symbolKind: symbolKindFor(qualifier), source, name: alias, alias, @@ -130,6 +133,7 @@ function parseUseClause(clause: SyntaxNode, qualifier: PhpImportKind): PhpImport return { kind: qualifier, + symbolKind: symbolKindFor(qualifier), source, name: lastSegment(source), atNode: clause, @@ -214,6 +218,7 @@ function parseInnerClause( if (alias !== '') { return { kind: 'alias', + symbolKind: symbolKindFor(qualifier), source, name: alias, alias, @@ -225,6 +230,7 @@ function parseInnerClause( return { kind: qualifier, + symbolKind: symbolKindFor(qualifier), source, name: lastSegment(innerPath), atNode: clause, @@ -237,6 +243,7 @@ function buildImportMatch(stmtNode: SyntaxNode, spec: PhpImportSpec): CaptureMat const m: Record = { '@import.statement': nodeToCapture('@import.statement', stmtNode), '@import.kind': syntheticCapture('@import.kind', spec.atNode, spec.kind), + '@import.symbol-kind': syntheticCapture('@import.symbol-kind', spec.atNode, spec.symbolKind), '@import.source': syntheticCapture('@import.source', spec.atNode, spec.source), '@import.name': syntheticCapture('@import.name', spec.atNode, spec.name), }; @@ -254,6 +261,12 @@ function lastSegment(path: string): string { return parts[parts.length - 1] ?? path; } +function symbolKindFor(kind: PhpImportKind): PhpImportedSymbolKind { + if (kind === 'function') return 'function'; + if (kind === 'const') return 'const'; + return 'type'; +} + /** Find the first named child with a given node type. */ function findNamedChild(node: SyntaxNode, type: string): SyntaxNode | null { for (let i = 0; i < node.namedChildCount; i++) { diff --git a/gitnexus/src/core/ingestion/languages/php/import-target.ts b/gitnexus/src/core/ingestion/languages/php/import-target.ts index ebf7938b32..1449bacb39 100644 --- a/gitnexus/src/core/ingestion/languages/php/import-target.ts +++ b/gitnexus/src/core/ingestion/languages/php/import-target.ts @@ -16,6 +16,7 @@ */ import type { 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'; import { readFileSync } from 'node:fs'; @@ -117,6 +118,7 @@ export function resolvePhpImportTargetInternal( _fromFile: string, allFilePaths: ReadonlySet, resolutionConfig?: unknown, + context?: ImportResolutionContext, ): string | null { if (targetRaw === '') return null; @@ -129,7 +131,7 @@ export function resolvePhpImportTargetInternal( const normalizedFileList = [...allFiles].map((f) => f.replace(/\\/g, '/')); const allFileList = [...allFiles]; - return resolvePhpImportInternal( + const resolved = resolvePhpImportInternal( targetRaw, composerConfig, allFiles, @@ -137,4 +139,32 @@ export function resolvePhpImportTargetInternal( allFileList, undefined, ); + + const parsedImport = context?.parsedImport; + const symbolKind = + parsedImport?.kind === 'named' || parsedImport?.kind === 'alias' + ? parsedImport.importedSymbolKind + : undefined; + if (resolved === null || (symbolKind !== 'function' && symbolKind !== 'const')) return resolved; + + const importedName = targetRaw.replace(/\\/g, '/').split('/').filter(Boolean).at(-1); + if (importedName === undefined) return resolved; + + const normalizedResolved = resolved.replace(/\\/g, '/'); + const resolvedDirectory = normalizedResolved.slice(0, normalizedResolved.lastIndexOf('/') + 1); + const expectedType = symbolKind === 'function' ? 'Function' : 'Variable'; + const declaringFiles = context.parsedFiles.filter((parsed) => { + const normalizedPath = parsed.filePath.replace(/\\/g, '/'); + if (!normalizedPath.startsWith(resolvedDirectory)) return false; + if (normalizedPath.slice(resolvedDirectory.length).includes('/')) return false; + + return parsed.localDefs.some((def) => { + if (def.type !== expectedType) return false; + const simpleName = (def.qualifiedName ?? '').split(/[\\.]/).at(-1); + return simpleName === importedName; + }); + }); + + if (declaringFiles.length > 1) return null; + return declaringFiles.length === 1 ? declaringFiles[0].filePath : resolved; } diff --git a/gitnexus/src/core/ingestion/languages/php/interpret.ts b/gitnexus/src/core/ingestion/languages/php/interpret.ts index 8aa07a7367..7c920f9ef9 100644 --- a/gitnexus/src/core/ingestion/languages/php/interpret.ts +++ b/gitnexus/src/core/ingestion/languages/php/interpret.ts @@ -20,12 +20,19 @@ export function interpretPhpImport(captures: CaptureMatch): ParsedImport | null const sourceCap = captures['@import.source']; const nameCap = captures['@import.name']; const aliasCap = captures['@import.alias']; + const symbolKindCap = captures['@import.symbol-kind']; const kind = kindCap?.text; if (kind === undefined || sourceCap === undefined) return null; const source = sourceCap.text.trim(); if (source === '') return null; + const importedSymbolKind = + symbolKindCap?.text === 'function' || symbolKindCap?.text === 'const' + ? symbolKindCap.text + : kind === 'function' || kind === 'const' + ? kind + : 'type'; switch (kind) { case 'namespace': { @@ -39,6 +46,7 @@ export function interpretPhpImport(captures: CaptureMatch): ParsedImport | null localName, importedName: localName, targetRaw: source, + importedSymbolKind, }; } case 'alias': { @@ -53,6 +61,7 @@ export function interpretPhpImport(captures: CaptureMatch): ParsedImport | null importedName, alias, targetRaw: source, + importedSymbolKind, }; } case 'function': { @@ -64,6 +73,7 @@ export function interpretPhpImport(captures: CaptureMatch): ParsedImport | null localName, importedName: localName, targetRaw: source, + importedSymbolKind, }; } case 'const': { @@ -74,6 +84,7 @@ export function interpretPhpImport(captures: CaptureMatch): ParsedImport | null localName, importedName: localName, targetRaw: source, + importedSymbolKind, }; } default: diff --git a/gitnexus/src/core/ingestion/languages/php/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/php/scope-resolver.ts index 8b1fcf41b0..2b775e0e3a 100644 --- a/gitnexus/src/core/ingestion/languages/php/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/php/scope-resolver.ts @@ -354,8 +354,8 @@ const phpScopeResolver: ScopeResolver = { languageProvider: phpProvider, importEdgeReason: 'php-scope: use', - resolveImportTarget: (targetRaw, fromFile, allFilePaths, resolutionConfig) => - resolvePhpImportTargetInternal(targetRaw, fromFile, allFilePaths, resolutionConfig), + resolveImportTarget: (targetRaw, fromFile, allFilePaths, resolutionConfig, context) => + resolvePhpImportTargetInternal(targetRaw, fromFile, allFilePaths, resolutionConfig, context), loadResolutionConfig: (repoPath) => loadPhpComposerConfig(repoPath), diff --git a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts index d10e67da91..e75b793d79 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts @@ -273,6 +273,7 @@ import type { Callsite, ConstraintContext, ParsedFile, + ParsedImport, ReferenceSite, ScopeId, SupportedLanguages, @@ -302,6 +303,11 @@ export type ReceiverMemberResolution = | { readonly kind: 'resolved'; readonly definition: SymbolDefinition } | { readonly kind: 'ambiguous'; readonly candidateIds: readonly string[] }; +export interface ImportResolutionContext { + readonly parsedFiles: readonly ParsedFile[]; + readonly parsedImport?: ParsedImport; +} + /** Re-exported for ScopeResolver consumers — same shape as * `RegistryProviders.constraintCompatibility`'s third parameter. */ export type { ConstraintContext } from 'gitnexus-shared'; @@ -340,12 +346,18 @@ export interface ScopeResolver { * orchestrator). TypeScript uses this to thread `tsconfig.json` path * aliases through to the standard resolver. Languages that don't * need any extra config ignore the parameter. + * + * `context.parsedFiles` is the complete, read-only language workspace. It is + * optional so resolvers that only need paths retain their existing shape. + * `context.parsedImport` is the exact import being finalized. PHP uses both + * when a PSR-4 import names a function instead of a file. */ resolveImportTarget( targetRaw: string, fromFile: string, allFilePaths: ReadonlySet, resolutionConfig?: unknown, + context?: ImportResolutionContext, ): string | readonly string[] | null; /** diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts index 62cb0083eb..5f11120b2b 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts @@ -561,8 +561,11 @@ export function runScopeResolution( const resolutionConfig = input.resolutionConfig; const finalized = finalizeScopeModel(parsedFiles, { hooks: { - resolveImportTarget: (targetRaw, fromFile) => - provider.resolveImportTarget(targetRaw, fromFile, allFilePaths, resolutionConfig), + resolveImportTarget: (targetRaw, fromFile, _workspaceIndex, parsedImport) => + provider.resolveImportTarget(targetRaw, fromFile, allFilePaths, resolutionConfig, { + parsedFiles, + parsedImport, + }), expandsWildcardTo: (targetModuleScope) => provider.expandsWildcardTo?.(targetModuleScope, parsedFiles) ?? [], mergeBindings: (existing, incoming, scopeId) => diff --git a/gitnexus/test/integration/resolvers/php.test.ts b/gitnexus/test/integration/resolvers/php.test.ts index 73b8f5babf..d25d7da67d 100644 --- a/gitnexus/test/integration/resolvers/php.test.ts +++ b/gitnexus/test/integration/resolvers/php.test.ts @@ -1621,6 +1621,11 @@ describe('PHP cross-file binding propagation', () => { (e) => e.sourceFilePath.includes('Main') && e.targetFilePath.includes('UserFactory'), ); expect(edge).toBeDefined(); + + const unrelatedEdge = imports.find( + (e) => e.sourceFilePath.includes('Main') && e.targetFilePath.endsWith('/Models/User.php'), + ); + expect(unrelatedEdge).toBeUndefined(); }); it('resolves $u->save() in run() to User#save via cross-file return type propagation', () => { From b2881861bef424d94b846373c9efb417226a146a Mon Sep 17 00:00:00 2001 From: Eva Date: Tue, 14 Jul 2026 12:51:20 +0700 Subject: [PATCH 6/7] test(bench): rebaseline PHP import capture shape --- gitnexus/bench/scope-capture/baselines.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gitnexus/bench/scope-capture/baselines.json b/gitnexus/bench/scope-capture/baselines.json index d3383ae846..6ec633ac32 100644 --- a/gitnexus/bench/scope-capture/baselines.json +++ b/gitnexus/bench/scope-capture/baselines.json @@ -39,9 +39,9 @@ "_note": "PR #1934: F66/F68 let-binding pattern narrowing; F71 union (Struct-labeled, now materialized via legacy @definition.struct + resolvable); F72 macro FULLY WIRED \u2014 @declaration.macro/@reference.macro + MacroRegistry \u2192 USES edges to Macro nodes (never a same-named fn). + rust-macro / rust-union fixtures and merged with origin/main #1975 rust-scoped-impl; fingerprint re-baselined (scaling ~0.99, fixture_count 126). #1992: + rust-nested-tail-collision-generic and rust-generic-impl-same-method-name (F3) fixtures \u2014 pure fixture-corpus drift, no scope-extractor change; fixture_count 127->129, fingerprint 56ffc1c0->b00aea0f." }, "php": { - "fingerprint": "bc2c27c5ba26d5aea61142a2a99fb772222f5b969205260eb7a71b4c0bd73cdb", + "fingerprint": "31c9e3f3cb7094a2bf9021cf9db859036e002f8b44605cd993b470fc600e97cb", "scaling_budget": 1.5, - "_rebaselined": "#1956: heritage-bearing scale source (class extends Base + use trait); both forms gated at scale; linear (~1.04).", + "_rebaselined": "#1956: heritage-bearing scale source (class extends Base + use trait); both forms gated at scale; linear (~1.04). | #95: PHP imports carry a symbol-kind capture so function/constant imports resolve by declaring file; capture shape changes, scaling remains linear (~1.04).", "_note": "PR #1931: F53 import multi-clause, F54 enum_case, F55 anonymous_class \u2014 fixture count 138\u2192140, fingerprint drift expected." }, "ruby": { From 8097d86f858384d540e54d5cb48e6d4cd08fea01 Mon Sep 17 00:00:00 2001 From: Eva Date: Tue, 14 Jul 2026 13:12:11 +0700 Subject: [PATCH 7/7] fix(php): resolve symbol-named PSR-4 imports --- .../ingestion/languages/php/import-target.ts | 88 ++++++++++++++--- .../expected-captures.json | 62 ++++++------ .../php/php-import-target.test.ts | 94 +++++++++++++++++++ 3 files changed, 201 insertions(+), 43 deletions(-) create mode 100644 gitnexus/test/unit/scope-resolution/php/php-import-target.test.ts diff --git a/gitnexus/src/core/ingestion/languages/php/import-target.ts b/gitnexus/src/core/ingestion/languages/php/import-target.ts index 1449bacb39..25d3e53091 100644 --- a/gitnexus/src/core/ingestion/languages/php/import-target.ts +++ b/gitnexus/src/core/ingestion/languages/php/import-target.ts @@ -27,6 +27,60 @@ export interface PhpResolveContext { readonly allFilePaths: ReadonlySet; } +function normalizePhpPath(value: string): string { + return value.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, ''); +} + +function namespaceDirectories( + targetRaw: string, + composerConfig: ComposerConfig | null, + resolved: string | null, +): string[] { + const directories = new Set(); + if (resolved !== null) { + const normalizedResolved = normalizePhpPath(resolved); + const separator = normalizedResolved.lastIndexOf('/'); + if (separator >= 0) directories.add(normalizedResolved.slice(0, separator)); + } + + if (composerConfig === null) return [...directories]; + + const normalizedTarget = normalizePhpPath(targetRaw); + const mappings = [...composerConfig.psr4.entries()].sort((left, right) => { + const lengthDifference = right[0].length - left[0].length; + return lengthDifference !== 0 ? lengthDifference : left[0].localeCompare(right[0]); + }); + for (const [namespacePrefix, directoryPrefix] of mappings) { + const normalizedPrefix = normalizePhpPath(namespacePrefix); + if ( + normalizedTarget !== normalizedPrefix && + !normalizedTarget.startsWith(`${normalizedPrefix}/`) + ) { + continue; + } + + const remainder = normalizedTarget.slice(normalizedPrefix.length).replace(/^\//, ''); + const separator = remainder.lastIndexOf('/'); + const relativeNamespace = separator >= 0 ? remainder.slice(0, separator) : ''; + directories.add( + normalizePhpPath( + relativeNamespace === '' ? directoryPrefix : `${directoryPrefix}/${relativeNamespace}`, + ), + ); + break; + } + return [...directories]; +} + +function isDirectChild(filePath: string, directory: string): boolean { + const normalizedPath = normalizePhpPath(filePath); + const separator = normalizedPath.lastIndexOf('/'); + if (separator < 0) return directory === ''; + const parent = normalizedPath.slice(0, separator); + const normalizedDirectory = normalizePhpPath(directory); + return parent === normalizedDirectory || parent.endsWith(`/${normalizedDirectory}`); +} + // ─── loadResolutionConfig ────────────────────────────────────────────────── /** @@ -145,26 +199,36 @@ export function resolvePhpImportTargetInternal( parsedImport?.kind === 'named' || parsedImport?.kind === 'alias' ? parsedImport.importedSymbolKind : undefined; - if (resolved === null || (symbolKind !== 'function' && symbolKind !== 'const')) return resolved; + if ( + context === undefined || + parsedImport === undefined || + (symbolKind !== 'function' && symbolKind !== 'const') + ) { + return resolved; + } const importedName = targetRaw.replace(/\\/g, '/').split('/').filter(Boolean).at(-1); if (importedName === undefined) return resolved; - const normalizedResolved = resolved.replace(/\\/g, '/'); - const resolvedDirectory = normalizedResolved.slice(0, normalizedResolved.lastIndexOf('/') + 1); + const directories = namespaceDirectories(targetRaw, composerConfig, resolved); + const candidateFiles = context.parsedFiles.filter((parsed) => + directories.some((directory) => isDirectChild(parsed.filePath, directory)), + ); const expectedType = symbolKind === 'function' ? 'Function' : 'Variable'; - const declaringFiles = context.parsedFiles.filter((parsed) => { - const normalizedPath = parsed.filePath.replace(/\\/g, '/'); - if (!normalizedPath.startsWith(resolvedDirectory)) return false; - if (normalizedPath.slice(resolvedDirectory.length).includes('/')) return false; - - return parsed.localDefs.some((def) => { + const declaringFiles = candidateFiles.filter((parsed) => + parsed.localDefs.some((def) => { if (def.type !== expectedType) return false; const simpleName = (def.qualifiedName ?? '').split(/[\\.]/).at(-1); return simpleName === importedName; - }); - }); + }), + ); if (declaringFiles.length > 1) return null; - return declaringFiles.length === 1 ? declaringFiles[0].filePath : resolved; + if (declaringFiles.length === 1) return declaringFiles[0].filePath; + + // PHP constants are not currently emitted as local definitions. A single + // file in the namespace directory is still unambiguous; multiple files must + // fail closed rather than inheriting Set iteration order. + if (symbolKind === 'const' && candidateFiles.length === 1) return candidateFiles[0].filePath; + return resolved; } diff --git a/gitnexus/test/fixtures/php-captures-golden/expected-captures.json b/gitnexus/test/fixtures/php-captures-golden/expected-captures.json index ecbe428c8a..06ba547053 100644 --- a/gitnexus/test/fixtures/php-captures-golden/expected-captures.json +++ b/gitnexus/test/fixtures/php-captures-golden/expected-captures.json @@ -5,11 +5,11 @@ }, "php-abstract-dispatch/src/Repositories/SqlRepository.php": { "captureGroups": 14, - "digest": "5905bb450b29d4e186d74b50f70f07a54c8e0d4b8c3748c998c1579e72283215" + "digest": "584da0b8d38ba3b2e45513e24ee17e0366bcfe48e353c274daed367250a0ccd9" }, "php-abstract-dispatch/src/app.php": { "captureGroups": 10, - "digest": "52ce761f1d53a56034124fa5e674863bce9092c1b523f6d71139c4f335e8b9f0" + "digest": "af85999f2ddf2bf718cc419553efd09425919835460bd6739277cc570cb2d3c4" }, "php-alias-imports/app/Models/Repo.php": { "captureGroups": 14, @@ -21,7 +21,7 @@ }, "php-alias-imports/app/Services/Main.php": { "captureGroups": 15, - "digest": "bf8e1b8079956e7ef9ebfcb3ca38f547e9760da5c83856e93163efe6961067b9" + "digest": "685798f8164a494d7dc794cf6076ff5ccade0e79fe570947a824be0099011739" }, "php-ambiguous/app/Models/Dispatchable.php": { "captureGroups": 6, @@ -41,7 +41,7 @@ }, "php-ambiguous/app/Services/UserHandler.php": { "captureGroups": 12, - "digest": "1c47e7020939a6cd6bb18f3732b51a16f4c75ded0fe9883a87d6706a2c624d1f" + "digest": "87bbd866d8748b7cd4932aea209abc1e17f9eca518c0a5bb0dc9572cc5cd95be" }, "php-anonymous-class/anon.php": { "captureGroups": 5, @@ -61,15 +61,15 @@ }, "php-app/app/Models/BaseModel.php": { "captureGroups": 18, - "digest": "be7ca5be7e28417afdef2e45c8cfed9e04594b341b0d676e7a00fdd84c94e42a" + "digest": "7d5a013f34f909846e3a91cef4b3daa8638034e5c6939d90ccd7dae34c8f9272" }, "php-app/app/Models/User.php": { "captureGroups": 27, - "digest": "b6b08be1af66cbd757cfd715389725952c0e2a8e5e910ed715594c0b07570a37" + "digest": "718b9ba0238e2139ff34027d68cb135c4698a2fc401a4f85200864b1e27a9a78" }, "php-app/app/Services/UserService.php": { "captureGroups": 38, - "digest": "80b8557e183052f25222cfda8879e08b1613752001f58670ce7b3dbf4b8bee28" + "digest": "a911c5af5042738b1ca9aeb71dad06f4918d8c41e84b6a27ffaa0eadfe56ca22" }, "php-app/app/Traits/HasTimestamps.php": { "captureGroups": 11, @@ -89,7 +89,7 @@ }, "php-assignment-chain/app/Services/AppService.php": { "captureGroups": 15, - "digest": "36c5358d7f724cc0906e087828c86d9a29fe0629e2bad6105967fe8d671aa6fe" + "digest": "0e9a9ef52a987dbd12c9478a75d5019c2a0059ab4b862924597d2ecd32f03aa7" }, "php-call-result-binding/App.php": { "captureGroups": 23, @@ -97,7 +97,7 @@ }, "php-calls/app/Services/UserService.php": { "captureGroups": 7, - "digest": "94f1cdfb0c444dc9e8116d1bbf824dc88584046539b2cbc48297729eb49e41a1" + "digest": "84e2e65cbb4026ef9e67ac2710d1304c8dffe335f2b1b1cb70123157a04acf50" }, "php-calls/app/Utils/OneArg/log.php": { "captureGroups": 5, @@ -109,7 +109,7 @@ }, "php-child-extends-parent/src/App.php": { "captureGroups": 11, - "digest": "0c8a7c7edaa20009b71f22fef98230119a4738d2a21c8b88d64047c3c932fd36" + "digest": "6e8b1d8f2e9c02eebcf8e2b6cacc15dfbd48c9fc03011c03d70afd265c38f3dc" }, "php-child-extends-parent/src/Child.php": { "captureGroups": 5, @@ -125,7 +125,7 @@ }, "php-constructor-calls/app.php": { "captureGroups": 8, - "digest": "23ef0f05446126892e598872a0b3c3d2f96e3b0dc50e301f1fa784a56ebc81d4" + "digest": "0f3e6cf60248ae02ca0a744d3fa4f67b2d8421858c29243c4fcaf60763c7ac3d" }, "php-constructor-promotion-fields/Models.php": { "captureGroups": 22, @@ -145,7 +145,7 @@ }, "php-constructor-type-inference/app/Services/AppService.php": { "captureGroups": 15, - "digest": "26181127fe9e9bf04431a7cc801624bde9dd284049627bb7ca0a4c9234141eec" + "digest": "5aee8297c1f3e032523fe871571ee4410422b7ee932b259e9f36b0cab4d79c8f" }, "php-coverage/enum-and-anon.php": { "captureGroups": 9, @@ -153,7 +153,7 @@ }, "php-coverage/multi-import.php": { "captureGroups": 7, - "digest": "4d72fbfba40f2e083aa55d1c48bb500aeb7dd615de18b48b00eaca5b26a2001e" + "digest": "e3746c5f7a68dc4dd9d658159077eb272573e27dfc29fedeadfbd6ab7ef35a10" }, "php-deep-field-chain/Models.php": { "captureGroups": 26, @@ -245,7 +245,7 @@ }, "php-fqn-cross-namespace/app/Services/Service.php": { "captureGroups": 15, - "digest": "94a2bec57ccde7aa663ce2027c7f36151d18ee257830663365bc14f9d4d5f703" + "digest": "e711548813d38a0db268d89a7edff1d31bbaa9ff0399898e09f8e93bdbc1c785" }, "php-grandparent-resolution/app/Models/A.php": { "captureGroups": 9, @@ -265,7 +265,7 @@ }, "php-grandparent-resolution/app/Services/App.php": { "captureGroups": 12, - "digest": "5d0c3524e1ca41ee57bd05fd0a2831804598fca8079ea4ece38045d9c4bb1113" + "digest": "7273ba524f587c01ca3ba9ededfdfef635c20fd9c6ade95225e81aff07ace6a6" }, "php-grouped-imports/app/Models/Repo.php": { "captureGroups": 7, @@ -277,11 +277,11 @@ }, "php-grouped-imports/app/Services/Main.php": { "captureGroups": 15, - "digest": "b315d87f85f0899ed3883ec529b86a57f2d1a88d41b2b615e707307e2889c049" + "digest": "aef9ddf30722a053664637b7fefdfcdd541baf09b24e65a3a0d80fe920189828" }, "php-local-shadow/app/Services/Main.php": { "captureGroups": 9, - "digest": "b4b3e35399501d98521dc9d9281a4e5c94449b580cc6cb9b3ed4187e79ee2c07" + "digest": "52549cd82f7e2e69fb8bb81bca4e5e3487e216f4305023b77579de92374b9ee3" }, "php-local-shadow/app/Utils/Logger.php": { "captureGroups": 5, @@ -293,7 +293,7 @@ }, "php-member-calls/app/Services/UserService.php": { "captureGroups": 11, - "digest": "6f6d5d34edd1cd4e32ea77db7e4b07b73de9ca09bb658123455820af8228d0cc" + "digest": "dfe3ef69a8d6dbd95cf5a6bdc754914973b7208ad0c9575bf2595a0f84ac22cf" }, "php-method-chain-binding/App.php": { "captureGroups": 48, @@ -309,7 +309,7 @@ }, "php-method-enrichment/src/app.php": { "captureGroups": 11, - "digest": "52791f6945c8c4ae083b816bd3af239bce44f0e97cbf80cdc119f4f366015138" + "digest": "ebfa35aa3eb065977f922ae72f353d5bf31dd49956c3e9aaef349718e555b7a7" }, "php-mro-arity-mismatch/app/Models/ChildModel.php": { "captureGroups": 16, @@ -325,11 +325,11 @@ }, "php-mro-arity-mismatch/app/Services/Caller.php": { "captureGroups": 22, - "digest": "d51fdbcf226f31f9220a506cceb9f538642ff99d238a1828b6b43fce6193f664" + "digest": "20f76e10fc598152597ba8df71deccb698761938bfde8cf7c3437044c8fcdcf1" }, "php-namespace-fallback-isolation/src/App/Caller.php": { "captureGroups": 13, - "digest": "d5040d068fd8227388da7f25cc471f154adb37cd6bbfb78b5cac00f44bf45734" + "digest": "48f132404e2d00efbf49302a69e5ab31540eb0eac71cd2fca43a8a0eb4533d98" }, "php-namespace-fallback-isolation/src/App/Utils/Caller.php": { "captureGroups": 8, @@ -353,7 +353,7 @@ }, "php-nullable-receiver/app/Services/AppService.php": { "captureGroups": 13, - "digest": "cea6ae3f9e32a3e0448a279034bcf039b1d5af6334ab1b1ae43d9c55b6ca094d" + "digest": "52e6db35e8fa4174ce698fec802b9b034dcda8f49fd7ab98cd9e884a6bd9777f" }, "php-overload-dispatch/src/Services/Formatter.php": { "captureGroups": 7, @@ -365,7 +365,7 @@ }, "php-overload-dispatch/src/app.php": { "captureGroups": 10, - "digest": "d11621fbaec9f5015e61f35b5c2747b9f2da090a346adc5fcc10f191af61f048" + "digest": "d29646b0c2d1e072ee0265acf641f99f3c443b57859c2582b782d5a485bfb089" }, "php-parent-resolution/app/Models/BaseModel.php": { "captureGroups": 7, @@ -421,7 +421,7 @@ }, "php-receiver-resolution/app/Services/AppService.php": { "captureGroups": 13, - "digest": "59783c7af75e9075f00f425984ca1ba7a4c556bb0301e2eb2c3fdcaa94cd547f" + "digest": "5e92226af09405c7fc4a02d3aeafcedc8462f3e525b8f67b8509f38091488505" }, "php-response-shapes/api/items.php": { "captureGroups": 11, @@ -445,7 +445,7 @@ }, "php-return-type/app/Services/UserService.php": { "captureGroups": 17, - "digest": "fcc2d78ac4bdde1ac5179e6623a35299465bcbf9bb016375100bcb636624d043" + "digest": "ac4851815d7a450ab92f7f68ccadabf219d2534b99ef1b65ced0a344188f2a69" }, "php-self-this-resolution/app/Models/Repo.php": { "captureGroups": 7, @@ -481,7 +481,7 @@ }, "php-transitive-traits/app/Models/Consumer.php": { "captureGroups": 18, - "digest": "c4524f4fa18f6e7f0fd76a11920a6a65ab547ab4cf0fa5799b42b7678e14db08" + "digest": "19b4d42452d84a4da0d1f05a03880561cee4d77ae0efd382351933875787ad96" }, "php-transitive-traits/app/Traits/TraitA.php": { "captureGroups": 8, @@ -501,7 +501,7 @@ }, "php-typed-properties/app/Services/UserService.php": { "captureGroups": 12, - "digest": "2ec84482a3e2332f3f15ebb3f2d8d01d1d56e62da256127ece571a8c2e0c070c" + "digest": "9f0540a4f7f322ee96dad4437c4e41e087f29a6993d2659fc48bd26e3277e963" }, "php-typed-property-dedup/app/Models/UserRepo.php": { "captureGroups": 7, @@ -509,7 +509,7 @@ }, "php-typed-property-dedup/app/Services/Mixed.php": { "captureGroups": 14, - "digest": "9f4ba6bd183c20acaad547b6a713e80498eb70eaabaa7b416650bb64098bde0d" + "digest": "97d09b46f6e66bef700f4686e5988c98372853203885b9a2d99dafce6fdc8343" }, "php-unresolved-receiver-arity/app/Models/Handler.php": { "captureGroups": 21, @@ -529,7 +529,7 @@ }, "php-use-function-const/app/Services/Calculator.php": { "captureGroups": 15, - "digest": "d6996da68f917c3ae6b52b530d166e8266f5053ce6c9b76e1643cb61edcafa38" + "digest": "b2206861c8550b6d2c7610ff167c42f4236c7249c87584b418e05f687233ad22" }, "php-use-function-const/app/Utils/helpers.php": { "captureGroups": 6, @@ -537,7 +537,7 @@ }, "php-variadic-arity-minimum/app/Services/Caller.php": { "captureGroups": 29, - "digest": "d5669fe609abae6b7db19c15c0cb795859e0b4b0570ae83fbe80a392f3775ca8" + "digest": "4ac7be9ff21c52b76630cdcfde0eb41af43e126c2de5db327e9f82c46c3aabbc" }, "php-variadic-arity-minimum/app/Utils/Logger.php": { "captureGroups": 18, @@ -545,7 +545,7 @@ }, "php-variadic-resolution/app/Services/AppService.php": { "captureGroups": 9, - "digest": "8b5298358fba8f578b2470f9d93ffbc1089d1ef3208c1142185880b4dc174751" + "digest": "6591007d2f4ba85b25a59c11c3ae200452fbad23cacb1ebc04291a074b534dd8" }, "php-variadic-resolution/app/Utils/Logger.php": { "captureGroups": 7, 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 new file mode 100644 index 0000000000..b3a7393648 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/php/php-import-target.test.ts @@ -0,0 +1,94 @@ +import type { ParsedFile, ParsedImport, SymbolDefinition } from 'gitnexus-shared'; +import { describe, expect, it } from 'vitest'; + +import type { ComposerConfig } from '../../../../src/core/ingestion/language-config.js'; +import { resolvePhpImportTargetInternal } from '../../../../src/core/ingestion/languages/php/import-target.js'; + +const composerConfig: ComposerConfig = { psr4: new Map([['App', 'app']]) }; + +function parsedFile(filePath: string, definitions: readonly SymbolDefinition[]): ParsedFile { + return { filePath, localDefs: definitions } as ParsedFile; +} + +function definition( + filePath: string, + type: SymbolDefinition['type'], + name: string, +): SymbolDefinition { + return { + nodeId: `def:${filePath}:${type}:${name}`, + filePath, + type, + qualifiedName: name, + }; +} + +const functionImport: ParsedImport = { + kind: 'named', + localName: 'getUser', + importedName: 'getUser', + targetRaw: 'App\\Models\\getUser', + importedSymbolKind: 'function', +}; + +describe('resolvePhpImportTargetInternal declaration selection', () => { + it('finds a unique function declaration when the symbol name is not a filename', () => { + const user = '/repo/app/Models/User.php'; + const factory = '/repo/app/Models/UserFactory.php'; + const parsedFiles = [ + parsedFile(user, [definition(user, 'Class', 'User')]), + parsedFile(factory, [definition(factory, 'Function', 'getUser')]), + ]; + + expect( + resolvePhpImportTargetInternal( + functionImport.targetRaw, + '/repo/app/Main.php', + new Set(parsedFiles.map((parsed) => parsed.filePath)), + composerConfig, + { parsedFiles, parsedImport: functionImport }, + ), + ).toBe(factory); + }); + + it('fails closed when the namespace has duplicate function declarations', () => { + const first = '/repo/app/Models/First.php'; + const second = '/repo/app/Models/Second.php'; + const parsedFiles = [ + parsedFile(first, [definition(first, 'Function', 'getUser')]), + parsedFile(second, [definition(second, 'Function', 'getUser')]), + ]; + + expect( + resolvePhpImportTargetInternal( + functionImport.targetRaw, + '/repo/app/Main.php', + new Set(parsedFiles.map((parsed) => parsed.filePath)), + composerConfig, + { parsedFiles, parsedImport: functionImport }, + ), + ).toBeNull(); + }); + + it('resolves a constant only when its namespace directory has one candidate file', () => { + const constants = '/repo/app/Config/constants.php'; + const parsedFiles = [parsedFile(constants, [])]; + const parsedImport: ParsedImport = { + kind: 'named', + localName: 'MAX_RETRIES', + importedName: 'MAX_RETRIES', + targetRaw: 'App\\Config\\MAX_RETRIES', + importedSymbolKind: 'const', + }; + + expect( + resolvePhpImportTargetInternal( + parsedImport.targetRaw, + '/repo/app/Main.php', + new Set([constants]), + composerConfig, + { parsedFiles, parsedImport }, + ), + ).toBe(constants); + }); +});