Skip to content
Closed
8 changes: 7 additions & 1 deletion gitnexus-shared/src/scope-resolution/finalize-algorithm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ export interface FinalizeHooks {
targetRaw: string,
fromFile: string,
workspaceIndex: WorkspaceIndex,
parsedImport?: ParsedImport,
): string | readonly string[] | null;

/**
Expand Down Expand Up @@ -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) {
Expand Down
5 changes: 5 additions & 0 deletions gitnexus-shared/src/scope-resolution/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
}
/**
Expand Down
4 changes: 2 additions & 2 deletions gitnexus/bench/scope-capture/baselines.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
5 changes: 5 additions & 0 deletions gitnexus/src/core/ingestion/filesystem-walker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
19 changes: 15 additions & 4 deletions gitnexus/src/core/ingestion/import-resolvers/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,15 +95,26 @@ export function buildSuffixIndex(normalizedFileList: string[], allFileList: stri
// Map: directory suffix -> list of file paths in that directory
const dirMap = new Map<string, string[]>();

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"]
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);
}
Expand Down
13 changes: 13 additions & 0 deletions gitnexus/src/core/ingestion/languages/php/import-decomposer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -119,6 +121,7 @@ function parseUseClause(clause: SyntaxNode, qualifier: PhpImportKind): PhpImport
if (alias !== '') {
return {
kind: 'alias',
symbolKind: symbolKindFor(qualifier),
source,
name: alias,
alias,
Expand All @@ -130,6 +133,7 @@ function parseUseClause(clause: SyntaxNode, qualifier: PhpImportKind): PhpImport

return {
kind: qualifier,
symbolKind: symbolKindFor(qualifier),
source,
name: lastSegment(source),
atNode: clause,
Expand Down Expand Up @@ -214,6 +218,7 @@ function parseInnerClause(
if (alias !== '') {
return {
kind: 'alias',
symbolKind: symbolKindFor(qualifier),
source,
name: alias,
alias,
Expand All @@ -225,6 +230,7 @@ function parseInnerClause(

return {
kind: qualifier,
symbolKind: symbolKindFor(qualifier),
source,
name: lastSegment(innerPath),
atNode: clause,
Expand All @@ -237,6 +243,7 @@ function buildImportMatch(stmtNode: SyntaxNode, spec: PhpImportSpec): CaptureMat
const m: Record<string, Capture> = {
'@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),
};
Expand All @@ -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++) {
Expand Down
96 changes: 95 additions & 1 deletion gitnexus/src/core/ingestion/languages/php/import-target.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -26,6 +27,60 @@ export interface PhpResolveContext {
readonly allFilePaths: ReadonlySet<string>;
}

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<string>();
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 ──────────────────────────────────────────────────

/**
Expand Down Expand Up @@ -117,6 +172,7 @@ export function resolvePhpImportTargetInternal(
_fromFile: string,
allFilePaths: ReadonlySet<string>,
resolutionConfig?: unknown,
context?: ImportResolutionContext,
): string | null {
if (targetRaw === '') return null;

Expand All @@ -129,12 +185,50 @@ export function resolvePhpImportTargetInternal(
const normalizedFileList = [...allFiles].map((f) => f.replace(/\\/g, '/'));
const allFileList = [...allFiles];

return resolvePhpImportInternal(
const resolved = resolvePhpImportInternal(
targetRaw,
composerConfig,
allFiles,
normalizedFileList,
allFileList,
undefined,
);

const parsedImport = context?.parsedImport;
const symbolKind =
parsedImport?.kind === 'named' || parsedImport?.kind === 'alias'
? parsedImport.importedSymbolKind
: undefined;
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 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 = 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;
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;
}
11 changes: 11 additions & 0 deletions gitnexus/src/core/ingestion/languages/php/interpret.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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': {
Expand All @@ -39,6 +46,7 @@ export function interpretPhpImport(captures: CaptureMatch): ParsedImport | null
localName,
importedName: localName,
targetRaw: source,
importedSymbolKind,
};
}
case 'alias': {
Expand All @@ -53,6 +61,7 @@ export function interpretPhpImport(captures: CaptureMatch): ParsedImport | null
importedName,
alias,
targetRaw: source,
importedSymbolKind,
};
}
case 'function': {
Expand All @@ -64,6 +73,7 @@ export function interpretPhpImport(captures: CaptureMatch): ParsedImport | null
localName,
importedName: localName,
targetRaw: source,
importedSymbolKind,
};
}
case 'const': {
Expand All @@ -74,6 +84,7 @@ export function interpretPhpImport(captures: CaptureMatch): ParsedImport | null
localName,
importedName: localName,
targetRaw: source,
importedSymbolKind,
};
}
default:
Expand Down
4 changes: 2 additions & 2 deletions gitnexus/src/core/ingestion/languages/php/scope-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),

Expand Down
9 changes: 7 additions & 2 deletions gitnexus/src/core/ingestion/languages/rust/range-binding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading