Merge branch 'main' into feat/java-const-route-resolver

This commit is contained in:
Gergő Magyar 2026-08-24 23:57:42 -07:00 committed by GitHub
commit a1ea58ef61
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 732 additions and 235 deletions

File diff suppressed because one or more lines are too long

View file

@ -399,12 +399,9 @@
* per parsed file, O(files) with no depth term, and the count gate in
* import-target-index-reuse.contract.test.ts is what holds it to one build
* per pass;
* - PHP's leg is measured with NO composer.json `resolutionConfig` is
* undefined here, as it always has been so `namespaceDirectories` only
* ever returns the directory of an already-resolved file and the PSR-4
* mapping branch stays unreached, exactly as `csharp` cannot reach the
* csproj leg. Closing that is a second PHP arm on the `csharp_csproj`
* precedent, not a parameter;
* - PHP runs with the Composer PSR-4 configuration every production project
* supplies. Configured hits and unmatched dependency misses share one
* workload, so the Composer gate cannot become an unmeasured fast path;
* - the `const` tail of PHP's leg (`candidateFiles.length === 1`) is a
* different ANSWER, not a different cost: `function` runs the identical
* candidate gather and `localDefs` filter and diverges only in the last two
@ -550,13 +547,10 @@ const HEAP_LARGE = 32000;
const HEAP_PAD = 8;
/** The languages whose retained per-pass index carries a BUDGET a ceiling, a
* floor derived from `heap_reading_bytes`, and the linear-growth ratio arm.
* All eight are measured the same way as the other nine (`retainedPassBytes`,
* one real import through the real resolver); what this list decides is which
* GATE a reading gets, not whether it is taken. The first five reach the shared
* `WorkspaceFileIndex` and retained NOTHING at BASE; `csharp_csproj` is the
* same corpus through the same index under the csproj context, and it is here
* rather than excluded as a duplicate because after #2903 its READ PATTERN,
* not its corpus, decides the number.
* All arms are measured through `retainedPassBytes`, one real import through
* the real resolver; this list decides which GATE a reading gets, not whether
* it is taken. The configured C# arm stays here because its read pattern
* reaches retained structures that the unconfigured arm cannot observe.
*
* The remaining three are `HEAP_BOUNDED`, DERIVED from this list rather than
* written beside it, and they carry an upper bound and NO floor. That asymmetry
@ -565,7 +559,7 @@ const HEAP_PAD = 8;
* would gate the noise. rust reads 16 B at both scales; swift's ratio is 0.888
* and cobol's 1.082, both outside the linearity every budgeted arm shows, so a
* floor and a ratio arm would be measuring the measurement. See the MEMORY
* section of the header for what re-measuring all seventeen found. */
* section of the header for what re-measuring the full inventory found. */
const HEAP_BUDGETED = [
'csharp',
'csharp_csproj',
@ -601,7 +595,7 @@ const HEAP_BUDGETED = [
/**
* The arms handed the fifth `context` argument `{ parsedFiles, parsedImport }`
* because their registered hook DECLARES it. Four of seventeen, and the
* because their registered hook DECLARES it. Four of seventeen arms, and the
* inventory arm at the foot of this file reconciles that claim against
* `SCOPE_RESOLVERS` in both directions rather than trusting this line.
*
@ -714,6 +708,15 @@ const joinBase = (baseUrl, rest) => (baseUrl === '' ? rest : `${baseUrl}/${rest}
*/
const tsBaseUrlFor = (pad) =>
pad === 0 ? '' : Array.from({ length: pad }, (_, n) => `d${n}`).join('/');
const phpComposerConfigFor = (pad) => ({
psr4: new Map([['App', joinBase(tsBaseUrlFor(pad), 'src/App')]]),
authoritativePsr4: new Set(['App']),
});
const renderPhpComposerConfig = (config) =>
[...config.psr4]
.map(([namespace, directory]) => `${namespace || '<root>'}=${directory || '<root>'}`)
.sort()
.join(';');
/** Keyed by LAYOUT name, so there is no `csharp_csproj` row: `buildFiles`
* aliases that arm to `csharp` before this table is read. */
const EXTENSION = {
@ -919,7 +922,7 @@ function collideDir(lang, d, i) {
`mod${d}/src/main/kotlin/com/example/models/inner/com/example/models`
: `mod${d}/src/main/kotlin/com/example/models`;
}
if (lang === 'php') return `svc${d}/src/Models`;
if (lang === 'php') return `src/App/Svc${d}/Models`;
if (lang === 'java') {
return d % 7 === 0
? `svc${d}/src/main/java/com/example/model/inner/model`
@ -1035,6 +1038,12 @@ function buildFiles(lang, fileCount, pad, shape) {
: ext;
files.push(`${prefix}${dir}/${stem}${suffix}`);
}
// One real suffix decoy makes the PHP external gate observable: with the
// gate, Vendor0 stays unresolved; without it, suffix fallback resolves this
// path and the exact fingerprint/external-probe result changes.
if (layout === 'php' && files.length > 0) {
files[files.length - 1] = `${prefix}legacy/Vendor0/Ghost/Missing.php`;
}
return files;
}
@ -1145,9 +1154,8 @@ function kotlinBenchmarkPackage(filePath) {
* The owner segment is the file's own directory name (`Ns7`, `Models`, `pkg7`),
* which is stable across the `small`, `deep` and `collide` arms so the `deep`
* arm differs from `small` in path DEPTH alone, exactly as it does for the path
* set. That matters here: `directoryAliases` emits one entry per path segment,
* so `filesByDirectory` is O(files × depth) and the depth arm is the only one
* that can see it.
* set. `filesByDirectory` is exact and linear in the file count; the shared
* suffix index remains the path-depth-sensitive structure this arm measures.
*/
function buildParsedFiles(lang, files) {
const parsedFiles = [];
@ -1247,21 +1255,18 @@ function uniqueTarget(lang, { local, r, d, j, dirs }) {
: `com.ghost${(r >>> 4) % 97}.deep.Missing`;
}
if (lang === 'php') {
// Backslash-separated, the way a `use` statement is actually written; the
// resolver normalizes them. No composer.json is threaded (the adapter's
// `resolutionConfig` is left undefined), so every one of these lands on
// `suffixResolve` — the leg that ran one `findIndex` over every file per
// path part per extension, ~50 of them, and measured 96.40 ms per import at
// 20k files before #2901.
return local
? `App\\Ns${d}\\File${j}`
: (r >>> 3) % 2 === 0
? [
'Psr\\Log\\LoggerInterface',
'Symfony\\Component\\Console\\Command',
'Doctrine\\ORM\\EntityManager',
][(r >>> 4) % 3]
: `Vendor${(r >>> 4) % 97}\\Ghost\\Missing`;
if (local) {
const namespace = d % 7 === 0 ? `Ns${d}\\Sub\\Ns${d}` : `Ns${d}`;
const leadingSeparator = (r >>> 3) % 4 === 0 ? '\\' : '';
return `${leadingSeparator}App\\${namespace}\\File${j}`;
}
return (r >>> 3) % 2 === 0
? [
'Psr\\Log\\LoggerInterface',
'Symfony\\Component\\Console\\Command',
'Doctrine\\ORM\\EntityManager',
][(r >>> 4) % 3]
: `Vendor${(r >>> 4) % 97}\\Ghost\\Missing`;
}
if (lang === 'java') {
// Java has NO in-repo-namespace gate (#2910 is filed for it), so a JDK
@ -1451,21 +1456,17 @@ function collideTarget(lang, { local, r, d, j, dirs }) {
: `com.ghost${(r >>> 4) % 97}.deep.Missing`;
}
if (lang === 'php') {
// `Models\Mod{n}` is carried by every service, so the segment-suffix key it
// resolves through holds one entry no matter how many files exist: PHP
// answers from keyed maps and is collision-IMMUNE, which is what this arm
// asserts. The local spelling still always resolves, as it does on the
// unique layout — PHP's cascade strips leading segments, so even the
// nested-same-name slice is reachable by a shorter suffix.
return local
? `App\\Models\\Mod${Math.floor(j / dirs)}`
: (r >>> 3) % 2 === 0
? [
'Psr\\Log\\LoggerInterface',
'Symfony\\Component\\Console\\Command',
'Doctrine\\ORM\\EntityManager',
][(r >>> 4) % 3]
: `Vendor${(r >>> 4) % 97}\\Ghost\\Missing`;
if (local) {
const leadingSeparator = (r >>> 3) % 4 === 0 ? '\\' : '';
return `${leadingSeparator}App\\Svc${j % dirs}\\Models\\Mod${Math.floor(j / dirs)}`;
}
return (r >>> 3) % 2 === 0
? [
'Psr\\Log\\LoggerInterface',
'Symfony\\Component\\Console\\Command',
'Doctrine\\ORM\\EntityManager',
][(r >>> 4) % 3]
: `Vendor${(r >>> 4) % 97}\\Ghost\\Missing`;
}
if (lang === 'java') {
// Every file declares the same package despite living under different
@ -1607,6 +1608,9 @@ function buildRepo(lang, fileCount, pad = 0, shape = 'unique') {
imports.push([from, mintTarget(lang, { local, r, d, j, dirs })]);
}
}
if (lang === 'php' && imports.length > 0) {
imports[0] = [files[0], 'Vendor0\\Ghost\\Missing'];
}
return { files, imports };
}
@ -1667,7 +1671,7 @@ function newPass(lang, files, pad = 0) {
restoreBenchmarkSideChannels(lang, parsedFiles);
return {
allFilePaths: new Set(parsedFiles.map((f) => f.filePath)),
config: undefined,
config: lang === 'php' ? phpComposerConfigFor(pad) : undefined,
parsedFiles,
};
}
@ -2039,7 +2043,9 @@ const HEAP_PROBE_TARGET = {
// (`getFilesInDir`) before answering null — the three-map read pattern.
csharp_csproj: 'App.Missing0',
ruby: 'gem0/missing/thing',
php: 'Vendor0\\Ghost\\Missing',
// A mapped-but-missing class forces the Composer mapping and suffix-index
// read paths. The separate external probe below keeps the fast gate visible.
php: 'App\\HeapGhost0\\AbsentHeapProbe',
java: 'com.google.common.vendor0.Missing',
javascript: 'vendor0/lib/missing',
python: 'vendor0.deep.missing',
@ -2107,11 +2113,24 @@ function measureHeap(lang) {
GC();
GC();
const probe = HEAP_PROBE_TARGET[lang];
const read = (files) => retainedPassBytes(lang, files, probe);
const read = (files) => retainedPassBytes(lang, files, probe, lang === 'php' ? HEAP_PAD : 0);
const small = flatten(buildFiles(lang, HEAP_SMALL, HEAP_PAD, 'unique'));
const bytesSmall = read(small);
const large = flatten(buildFiles(lang, HEAP_LARGE, HEAP_PAD, 'unique'));
const bytesLarge = read(large);
const phpGateShape =
lang === 'php'
? (() => {
const externalProbe = 'Vendor0\\Ghost\\Missing';
const config = phpComposerConfigFor(HEAP_PAD);
const pass = newPass(lang, large, HEAP_PAD);
return {
resolution_config: renderPhpComposerConfig(config),
external_probe: externalProbe,
external_result: renderResolved(resolveOne(lang, large[0], externalProbe, pass)),
};
})()
: {};
return {
files_small: HEAP_SMALL,
files_large: HEAP_LARGE,
@ -2121,6 +2140,7 @@ function measureHeap(lang) {
bytes_large: bytesLarge,
mib_large: Number((bytesLarge / 1024 / 1024).toFixed(2)),
ratio: Number((bytesLarge / bytesSmall / (HEAP_LARGE / HEAP_SMALL)).toFixed(3)),
...phpGateShape,
};
}
@ -2213,10 +2233,11 @@ const CONTEXT_PROBE = {
function measureContext(lang) {
const { from, target, parsedFiles } = CONTEXT_PROBE[lang];
const allFilePaths = new Set(parsedFiles.map((f) => f.filePath));
const config = lang === 'php' ? phpComposerConfigFor(0) : undefined;
const answer = (files) => {
restoreBenchmarkSideChannels(lang, files ?? []);
return renderResolved(
resolveOne(lang, from, target, { allFilePaths, config: undefined, parsedFiles: files }),
resolveOne(lang, from, target, { allFilePaths, config, parsedFiles: files }),
);
};
return {
@ -2249,11 +2270,11 @@ if (CHECK && GC === null) {
/**
* Every arm, and the registered language each one exercises.
*
* This used to be a hand-written list of seventeen strings under a comment
* This used to be a hand-written list of language strings under a comment
* claiming it was "every language in `SCOPE_RESOLVERS`" a claim nothing in
* the file could check, because the file never imported the registry. Adding a
* resolver to `pipeline/registry.ts` is two lines, neither of which is this
* one, so a seventeenth registered language would have shipped ungated and
* one, so a newly registered language would have shipped ungated and
* printed PASS. That is not a hypothetical failure mode: JavaScript reached
* `suffixResolve` with no index at all and measured 25 972 µs per import at
* 8000 files (PR #2911) for exactly as long as nothing gated it.
@ -2265,10 +2286,9 @@ if (CHECK && GC === null) {
* uses ten files away, and the same "one row per language" table
* `bench/cfg/measure.mjs` keeps.
*
* The mapping is many-to-one on purpose: `csharp` and `csharp_csproj` are two
* arms over one registered resolver, differing only in whether `csharpConfigs`
* is supplied, because the no-csproj arm returns before it can reach the leg
* #2902 indexed.
* The mapping is many-to-one only for C#: the configured arm reaches the
* csproj branch that the default arm cannot observe. PHP's sole arm carries
* its production Composer configuration directly.
*/
const LANG_REGISTRY = {
go: SupportedLanguages.Go,
@ -2457,7 +2477,7 @@ const SCALE_SHAPE = {
'one of them alone moves nothing in the others.',
};
/** The same, for the heap arm the four inputs that decide what it measures.
* Asserted for all seventeen, budgeted tier and bounded tier alike, and it is
* Asserted for all seventeen arms, budgeted tier and bounded tier alike, and it is
* the bounded tier that needs it most: a bound is a single comparison, so a
* probe swapped for one that reaches less is a bound over a smaller workload
* and there is no floor beside it to notice.
@ -2474,6 +2494,13 @@ const HEAP_SHAPE = {
'ceiling, floor, bound and ratio passing over an arm that changed workload. Deterministic: ' +
'a re-run will not change it.',
};
const PHP_HEAP_SHAPE = {
fields: [...HEAP_SHAPE.fields, 'resolution_config', 'external_probe', 'external_result'],
why:
HEAP_SHAPE.why +
' PHP also pins the Composer mapping and a suffix-matchable external decoy so the mapped ' +
'index path and the external fast gate remain separate observable arms.',
};
/** The same, for the `context` arm. All three fields are exact strings, not
* bounds: this arm has no measurement noise at all it resolves one import
* two ways over a three-file corpus so anything less than equality would be
@ -2496,7 +2523,7 @@ const CONTEXT_SHAPE = {
* a fifth parameter. */
const armShapes = (lang) => [
...SCALES.map((scale) => [scale, SCALE_SHAPE]),
['heap', HEAP_SHAPE],
['heap', lang === 'php' ? PHP_HEAP_SHAPE : HEAP_SHAPE],
...(CONTEXT_LANGS.includes(lang) ? [['context', CONTEXT_SHAPE]] : []),
];

View file

@ -49,13 +49,29 @@ export function resolvePhpImportInternal(
if (composerConfig) {
const sorted = getSortedPsr4(composerConfig);
const authoritativePsr4 =
composerConfig.authoritativePsr4 ?? new Set(sorted.map(([namespace]) => namespace));
let matchedAuthoritativeNamespace = false;
let hasAuthoritativeCatchAllNamespace = false;
const ownershipPath = normalized.replace(/^\/+/, '');
for (const [nsPrefix, dirPrefix] of sorted) {
const nsPrefixSlash = nsPrefix.replace(/\\/g, '/');
if (normalized.startsWith(nsPrefixSlash + '/') || normalized === nsPrefixSlash) {
const remainder = normalized.slice(nsPrefixSlash.length).replace(/^\//, '');
const nsPrefixSlash = nsPrefix.replace(/\\/g, '/').replace(/\/+$/, '');
const isCatchAll = nsPrefixSlash === '';
if (
isCatchAll ||
ownershipPath.startsWith(nsPrefixSlash + '/') ||
ownershipPath === nsPrefixSlash
) {
const isAuthoritative = authoritativePsr4.has(nsPrefix);
matchedAuthoritativeNamespace ||= isAuthoritative;
hasAuthoritativeCatchAllNamespace ||= isAuthoritative && isCatchAll;
const remainder = ownershipPath.slice(nsPrefixSlash.length).replace(/^\//, '');
// 1. Try class-style PSR-4: full path → file (e.g. App\Models\User → app/Models/User.php)
const filePath = dirPrefix + (remainder ? '/' + remainder : '') + '.php';
const mappedPath =
dirPrefix === '' ? remainder : dirPrefix + (remainder ? '/' + remainder : '');
const filePath = mappedPath + '.php';
if (allFiles.has(filePath)) return filePath;
if (index) {
const result = index.getInsensitive(filePath);
@ -64,45 +80,64 @@ export function resolvePhpImportInternal(
// 2. Function/constant fallback: strip last segment (symbol name), scan namespace directory.
// e.g. App\Models\getUser → directory app/Models/, find first .php file in that dir.
const lastSlash = remainder.lastIndexOf('/');
const nsDir = lastSlash >= 0 ? dirPrefix + '/' + remainder.slice(0, lastSlash) : dirPrefix;
// A root/catch-all mapping cannot safely infer a symbol's declaring
// file from an arbitrary sibling. The higher-level PHP resolver has
// parsed symbol-kind and declaration evidence for function/const
// imports; class imports must not inherit this directory heuristic.
if (!isCatchAll && dirPrefix !== '') {
const lastSlash = remainder.lastIndexOf('/');
const relativeNamespace = lastSlash >= 0 ? remainder.slice(0, lastSlash) : '';
const nsDir = relativeNamespace === '' ? dirPrefix : `${dirPrefix}/${relativeNamespace}`;
// Prefer SuffixIndex directory lookup (O(log n + matches)) over linear scan.
//
// An EMPTY bucket is a final answer, not a miss to retry with the scan
// below — which is what the `else` restores, and what this comment
// always claimed. Re-scanning on empty was the last per-import
// workspace traversal left in PHP resolution after #2901: any `use`
// matching a PSR-4 prefix whose directory holds no direct `.php` child
// (`App\Legacy\Ghost`) paid a full pass, measured at 201 traversals for
// 200 imports.
//
// The bucket is a superset of what the scan can find, for BOTH index
// shapes that reach here. A root-anchored direct child `nsDir/<x>.php`
// has its directory exactly equal to `nsDir`, and `nsDir` is always one
// of that directory's own suffixes — so the shared `dirMap` (keyed on
// every directory suffix) necessarily contains it, as does the
// root-anchored parity index `languages/php/import-target.ts` builds.
// Empty superset therefore implies empty scan, and control falls
// through to the next PSR-4 prefix exactly as before.
if (index) {
const candidates = index.getFilesInDir(nsDir, '.php');
if (candidates.length > 0) return candidates[0];
} else {
// Linear scan, only when a SuffixIndex is genuinely unavailable.
const nsDirPrefix = nsDir.endsWith('/') ? nsDir : nsDir + '/';
for (const f of allFiles) {
if (
f.startsWith(nsDirPrefix) &&
f.endsWith('.php') &&
!f.slice(nsDirPrefix.length).includes('/')
) {
return f;
// Prefer SuffixIndex directory lookup (O(log n + matches)) over linear scan.
//
// An EMPTY bucket is a final answer, not a miss to retry with the scan
// below — which is what the `else` restores, and what this comment
// always claimed. Re-scanning on empty was the last per-import
// workspace traversal left in PHP resolution after #2901: any `use`
// matching a PSR-4 prefix whose directory holds no direct `.php` child
// (`App\Legacy\Ghost`) paid a full pass, measured at 201 traversals for
// 200 imports.
//
// The bucket is a superset of what the scan can find, for BOTH index
// shapes that reach here. A root-anchored direct child `nsDir/<x>.php`
// has its directory exactly equal to `nsDir`, and `nsDir` is always one
// of that directory's own suffixes — so the shared `dirMap` (keyed on
// every directory suffix) necessarily contains it, as does the
// root-anchored parity index `languages/php/import-target.ts` builds.
// Empty superset therefore implies empty scan, and control falls
// through to the next PSR-4 prefix exactly as before.
if (index) {
const candidates = index.getFilesInDir(nsDir, '.php');
if (candidates.length > 0) return candidates[0];
} else {
// Linear scan, only when a SuffixIndex is genuinely unavailable.
const nsDirPrefix = nsDir.endsWith('/') ? nsDir : nsDir + '/';
for (const f of allFiles) {
if (
f.startsWith(nsDirPrefix) &&
f.endsWith('.php') &&
!f.slice(nsDirPrefix.length).includes('/')
) {
return f;
}
}
}
}
}
}
// A non-empty PSR-4 map is authoritative for namespaces it does not own.
// Preserve the existing mapped-namespace fallback behavior; #2962 is the
// conservative external-namespace gate, not a rewrite of mapped lookup.
// A catch-all owns every namespace, so its misses remain authoritative.
if (
authoritativePsr4.size > 0 &&
!composerConfig.hasUnmodeledAutoload &&
(!matchedAuthoritativeNamespace || hasAuthoritativeCatchAllNamespace)
) {
return null;
}
}
// Fallback: suffix matching (works without composer.json)

View file

@ -30,11 +30,103 @@ export interface GoModuleConfig {
export interface ComposerConfig {
/** Map of namespace prefix -> directory (e.g., "App\\" -> "app/") */
psr4: Map<string, string>;
/** Production `autoload.psr-4` prefixes that may gate external namespaces.
* Absent on legacy/manual configs, where every mapping remains authoritative. */
authoritativePsr4?: ReadonlySet<string>;
/** True when Composer also declares an autoload mechanism this resolver does not model. */
hasUnmodeledAutoload?: boolean;
/** PSR-4 entries sorted by namespace length descending (longest match wins).
* Cached once at config load time to avoid re-sorting on every import. */
psr4Sorted?: readonly [string, string][];
}
function normalizeComposerDirectory(baseDir: string, directory: string): string {
const normalizedBase = baseDir.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '');
const normalizedDirectory = directory
.replace(/\\/g, '/')
.replace(/^(?:\.\/)+/, '')
.replace(/\/+$/, '');
if (normalizedBase === '') return normalizedDirectory;
if (normalizedDirectory === '') return normalizedBase;
return path.posix.normalize(`${normalizedBase}/${normalizedDirectory}`);
}
/** Parse one Composer manifest without performing I/O. */
export function parseComposerConfig(value: unknown, baseDir = ''): ComposerConfig | null {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return null;
const composer = value as Record<string, unknown>;
const autoload = composer.autoload;
const autoloadDev = composer['autoload-dev'];
if (autoload === undefined && autoloadDev === undefined) return null;
const psr4 = new Map<string, string>();
const authoritativePsr4 = new Set<string>();
let hasUnmodeledAutoload = false;
const addSection = (sectionValue: unknown, authoritative: boolean): void => {
if (typeof sectionValue !== 'object' || sectionValue === null || Array.isArray(sectionValue)) {
return;
}
const section = sectionValue as Record<string, unknown>;
if ('psr-0' in section || 'classmap' in section) hasUnmodeledAutoload = true;
const rawPsr4 = section['psr-4'];
if (typeof rawPsr4 !== 'object' || rawPsr4 === null || Array.isArray(rawPsr4)) return;
for (const [namespace, directories] of Object.entries(rawPsr4)) {
const stringDirectories = Array.isArray(directories)
? directories.filter((entry): entry is string => typeof entry === 'string')
: typeof directories === 'string'
? [directories]
: [];
if (stringDirectories.length === 0) continue;
if (stringDirectories.length > 1) hasUnmodeledAutoload = true;
const normalizedNamespace = namespace.replace(/\\+$/, '');
const normalizedDirectory = normalizeComposerDirectory(baseDir, stringDirectories[0]);
const existing = psr4.get(normalizedNamespace);
if (existing !== undefined && existing !== normalizedDirectory) {
hasUnmodeledAutoload = true;
continue;
}
if (existing === undefined) psr4.set(normalizedNamespace, normalizedDirectory);
if (authoritative) authoritativePsr4.add(normalizedNamespace);
}
};
// Production mappings win duplicate prefixes. Development mappings remain
// usable for test code but do not establish authority for the external gate.
addSection(autoload, true);
addSection(autoloadDev, false);
return { psr4, authoritativePsr4, hasUnmodeledAutoload };
}
/** Merge package-local Composer manifests into one repository-relative config. */
export function mergeComposerConfigs(configs: readonly ComposerConfig[]): ComposerConfig | null {
if (configs.length === 0) return null;
const psr4 = new Map<string, string>();
const authoritativePsr4 = new Set<string>();
let hasUnmodeledAutoload = false;
for (const config of configs) {
hasUnmodeledAutoload ||= config.hasUnmodeledAutoload === true;
for (const [namespace, directory] of config.psr4) {
const existing = psr4.get(namespace);
if (existing !== undefined && existing !== directory) {
hasUnmodeledAutoload = true;
continue;
}
if (existing === undefined) psr4.set(namespace, directory);
}
for (const namespace of config.authoritativePsr4 ?? config.psr4.keys()) {
authoritativePsr4.add(namespace);
}
}
return { psr4, authoritativePsr4, hasUnmodeledAutoload };
}
/** C# project config parsed from .csproj files */
export interface CSharpProjectConfig {
/** Root namespace from <RootNamespace> or assembly name (default: project directory name) */
@ -161,22 +253,13 @@ export async function loadComposerConfig(repoRoot: string): Promise<ComposerConf
try {
const composerPath = path.join(repoRoot, 'composer.json');
const raw = await fs.readFile(composerPath, 'utf-8');
const composer = JSON.parse(raw);
const psr4Raw = composer.autoload?.['psr-4'] ?? {};
const psr4Dev = composer['autoload-dev']?.['psr-4'] ?? {};
const merged = { ...psr4Raw, ...psr4Dev };
const psr4 = new Map<string, string>();
for (const [ns, dir] of Object.entries(merged)) {
const nsNorm = (ns as string).replace(/\\+$/, '');
const dirNorm = (dir as string).replace(/\\/g, '/').replace(/\/+$/, '');
psr4.set(nsNorm, dirNorm);
}
const config = parseComposerConfig(JSON.parse(raw));
if (config === null) return null;
if (isDev) {
logger.info(`📦 Loaded ${psr4.size} PSR-4 mappings from composer.json`);
logger.info(`📦 Loaded ${config.psr4.size} PSR-4 mappings from composer.json`);
}
return { psr4 };
return config;
} catch {
return null;
}

View file

@ -21,9 +21,13 @@ import { resolvePhpImportInternal } from '../../import-resolvers/php.js';
import type { SuffixIndex } from '../../import-resolvers/utils.js';
import { perFileSet } from '../../import-resolvers/per-file-set.js';
import { getWorkspaceFileIndex } from '../../import-resolvers/workspace-file-index.js';
import type { ComposerConfig } from '../../language-config.js';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import {
mergeComposerConfigs,
parseComposerConfig,
type ComposerConfig,
} from '../../language-config.js';
import { readdirSync, readFileSync, type Dirent } from 'node:fs';
import { dirname, join, relative } from 'node:path';
export interface PhpResolveContext {
readonly fromFile: string;
@ -48,19 +52,18 @@ function namespaceDirectories(
if (composerConfig === null) return [...directories];
const normalizedTarget = normalizePhpPath(targetRaw);
const normalizedTarget = normalizePhpPath(targetRaw).replace(/^\/+/, '');
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 matchesNamespace =
normalizedPrefix === '' ||
normalizedTarget === normalizedPrefix ||
normalizedTarget.startsWith(`${normalizedPrefix}/`);
if (!matchesNamespace) continue;
const remainder = normalizedTarget.slice(normalizedPrefix.length).replace(/^\//, '');
const separator = remainder.lastIndexOf('/');
@ -82,21 +85,11 @@ function parentDirectory(filePath: string): string {
}
function directoryAliases(filePath: string): string[] {
const normalizedPath = normalizePhpPath(filePath);
const separator = normalizedPath.lastIndexOf('/');
if (separator < 0) return [''];
const parent = normalizedPath.slice(0, separator);
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];
return [parentDirectory(filePath)];
}
/**
* Directory alias the files under it, built once per pass.
* Exact repository-relative directory the files under it, built once per pass.
*
* A scope-resolution pass shares one stable `parsedFiles` array across imports,
* so the array identity is the memo key see `perFileSet`.
@ -302,42 +295,67 @@ const getPhpWorkspaceIndex = perFileSet((allFilePaths: ReadonlySet<string>): Php
// ─── loadResolutionConfig ──────────────────────────────────────────────────
/**
* Load and parse `composer.json` from the repo root. Returns a
* `ComposerConfig` object (PSR-4 namespace directory mappings) or
* `null` when no `composer.json` is present or it cannot be parsed.
* Load and parse repository and package-local `composer.json` manifests.
* Package mappings are rebased to repository-relative paths before merging.
*
* The result is threaded into each `resolvePhpImportInternal` call as
* the `composerConfig` argument.
*/
export function loadPhpComposerConfig(repoPath: string): ComposerConfig | null {
try {
const composerPath = join(repoPath, 'composer.json');
const raw = readFileSync(composerPath, 'utf8');
const parsed = JSON.parse(raw) as unknown;
if (typeof parsed !== 'object' || parsed === null) return null;
const skipDirectories = new Set([
'.git',
'.gitnexus',
'node_modules',
'vendor',
'dist',
'build',
'coverage',
]);
const pending = [repoPath];
const manifests: string[] = [];
let incomplete = false;
let visitedDirectories = 0;
const composer = parsed as Record<string, unknown>;
const autoload = composer['autoload'] as Record<string, unknown> | undefined;
if (autoload === undefined) return null;
const psr4Raw = (autoload['psr-4'] ?? {}) as Record<string, string | string[]>;
const psr4 = new Map<string, string>();
for (const [ns, dirs] of Object.entries(psr4Raw)) {
// namespace prefix ends with `\` — keep as-is; resolver strips it
const normalizedNs = ns.replace(/\\$/, '');
const dir = Array.isArray(dirs) ? dirs[0] : dirs;
if (typeof dir === 'string') {
// Normalize directory path (strip trailing slash)
const normalizedDir = dir.replace(/\/+$/, '');
psr4.set(normalizedNs, normalizedDir);
while (pending.length > 0) {
const directory = pending.pop();
if (directory === undefined) break;
if (++visitedDirectories > 20_000) {
incomplete = true;
break;
}
let entries: Dirent[];
try {
entries = readdirSync(directory, { withFileTypes: true }).sort((left, right) =>
left.name.localeCompare(right.name),
);
} catch {
incomplete = true;
continue;
}
for (const entry of entries) {
if (entry.isFile() && entry.name === 'composer.json') {
manifests.push(join(directory, entry.name));
} else if (entry.isDirectory() && !skipDirectories.has(entry.name)) {
pending.push(join(directory, entry.name));
}
}
return { psr4 };
} catch {
return null;
}
const configs: ComposerConfig[] = [];
for (const manifest of manifests.sort()) {
try {
const baseDir = normalizePhpPath(relative(repoPath, dirname(manifest)));
const config = parseComposerConfig(JSON.parse(readFileSync(manifest, 'utf8')), baseDir);
if (config !== null) configs.push(config);
} catch {
incomplete = true;
}
}
const merged = mergeComposerConfigs(configs);
if (merged === null) return null;
if (incomplete) merged.hasUnmodeledAutoload = true;
return merged;
}
// ─── resolvePhpImportTarget ────────────────────────────────────────────────
@ -434,11 +452,7 @@ export function resolvePhpImportTargetInternal(
...new Set(
directories.flatMap((directory) => {
const files = directoryIndex.get(normalizePhpPath(directory)) ?? [];
// A suffix alias can match directories under different roots (for
// example app/Models and vendor/pkg/app/Models). Picking either root
// would be a guess, so fail closed to the composer resolution instead.
const distinctParents = new Set(files.map((file) => parentDirectory(file.filePath)));
return distinctParents.size > 1 ? [] : files;
return files;
}),
),
];

View file

@ -28,11 +28,9 @@
* has stopped resolving anything at all, so counting alone would stay green
* while every PHP IMPORTS edge disappeared.
*
* On the one traversal PHP still pays per import in a specific case a PSR-4
* namespace whose directory has no direct `.php` children see the pinned
* residual arm at the bottom of the unit parity test. It lives in
* `import-resolvers/php.ts`, which #2901 does not touch, so the corpora here
* resolve through the legs that do reach the index.
* The no-Composer arm below separately pins a proper suffix hit and a root-file
* miss. That pair guards the PHP parity view itself; a raw shared-index handoff
* would make the root file resolve even though the traversal count stayed one.
*/
import { describe, it, expect } from 'vitest';
import { phpScopeResolver } from '../../src/core/ingestion/languages/php/scope-resolver.js';
@ -73,9 +71,8 @@ describe('PHP import resolution — index reuse across use-statements (#2901)',
for (let i = 0; i < 200; i++) {
// A PSR-4 class hit, a function import that falls back to the namespace
// directory, and a third-party namespace that misses. The miss is the
// expensive case: it matches no PSR-4 prefix and so walks every suffix ×
// every extension before returning null.
// directory, and a third-party namespace that the Composer authority
// gate rejects before suffix fallback.
resolved.push(resolveImportTarget('App\\Models\\User', FROM_FILE, files, COMPOSER));
resolved.push(resolveImportTarget('App\\Models\\getUser', FROM_FILE, files, COMPOSER));
resolved.push(resolveImportTarget(`Psr\\Log\\Missing${i}`, FROM_FILE, files, COMPOSER));
@ -99,12 +96,14 @@ describe('PHP import resolution — index reuse across use-statements (#2901)',
// that used to cost a `findIndex` pass per extension — as the only path.
for (let i = 0; i < 200; i++) {
resolved.push(resolveImportTarget('Legacy\\Helper', FROM_FILE, files, null));
resolved.push(resolveImportTarget('index', FROM_FILE, files, null));
resolved.push(resolveImportTarget(`Psr\\Log\\Missing${i}`, FROM_FILE, files, null));
}
expect(files.scans).toBe(1);
expect(resolved[0]).toBe('lib/Legacy/Helper.php');
expect(resolved[1]).toBeNull();
expect(resolved[2]).toBeNull();
});
it('a distinct file set gets its own index (no stale cross-run reuse)', () => {
@ -130,11 +129,9 @@ describe('PHP import resolution — index reuse across use-statements (#2901)',
'app/Services/Service00000.php',
);
// Suffix fallback: no PSR-4 prefix matches `Legacy`, so `suffixResolve`
// answers from the longest matching proper path suffix.
expect(resolveImportTarget('Legacy\\Helper', FROM_FILE, files, COMPOSER)).toBe(
'lib/Legacy/Helper.php',
);
// Composer's non-empty PSR-4 map is authoritative: an unmatched namespace
// belongs outside the repository and cannot fall through to a local suffix.
expect(resolveImportTarget('Legacy\\Helper', FROM_FILE, files, COMPOSER)).toBeNull();
// A root-level file is NOT reachable as a proper suffix — the pre-#2901
// behaviour the parity view preserves, and the single most likely thing a

View file

@ -292,12 +292,12 @@ const CASES: ReadonlyMap<SupportedLanguages, ConformanceCase> = new Map([
[
SupportedLanguages.PHP,
{
files: ['app/Models/User.php', 'lib/Legacy/Missing.php', 'app/Main.php'],
files: ['app/Ghost/Missing.php', 'app/Models/User.php', 'app/Main.php'],
fromFile: 'app/Main.php',
resolutionConfig: PHP_COMPOSER,
external: 'Vendor\\Ghost\\Missing',
decoy: 'lib/Legacy/Missing.php',
reachesDecoy: 'App\\Models\\User',
decoy: 'app/Ghost/Missing.php',
reachesDecoy: 'App\\Ghost\\Missing',
parsedImport: PHP_FUNCTION_IMPORT,
},
],
@ -374,7 +374,6 @@ const CASES: ReadonlyMap<SupportedLanguages, ConformanceCase> = new Map([
*/
const KNOWN_GAPS: ReadonlyMap<SupportedLanguages, string> = new Map<SupportedLanguages, string>([
[SupportedLanguages.Ruby, '`rails/generators` -> `lib/generators.rb`'],
[SupportedLanguages.PHP, '`Vendor\\Ghost\\Missing` -> `lib/Legacy/Missing.php`'],
[SupportedLanguages.Dart, '`package:http/http.dart` -> `lib/http.dart`'],
[SupportedLanguages.Swift, '`Foundation` -> `Sources/Foundation/Thing.swift`'],
[SupportedLanguages.C, '`stdio.h` -> `src/stdio.h`'],

View file

@ -351,6 +351,7 @@ const NESTED_PSR4 = composer([
['App\\Models', 'app/Domain'],
]);
const ROOT_PSR4 = composer([['App', '']]);
const CATCH_ALL_PSR4 = composer([['', 'src']]);
const TRAILING_SLASH_PSR4 = composer([['App', 'app/']]);
/**
@ -609,18 +610,38 @@ const HAND_CASES: readonly HandCase[] = [
expectedViaWorkspace: 'app/Models/User.php',
},
{
// KNOWN LIMITATION: an empty `dirPrefix` builds the class-style path as
// `'' + '/Models/User' + '.php'` = `/Models/User.php`, with a leading slash
// no repo-relative path has — so a root PSR-4 mapping never hits that leg,
// and `nsDir` comes out `/Models` which no directory bucket holds either.
// The answer is the suffix leg's, and only at path-part 2 (`/User.php`):
// `Models/User.php` is the whole path, invisible to `/Models/User.php`.
// An empty directory prefix maps the namespace directly to the repository
// root. The vendor decoy comes first so suffix fallback would choose it.
name: 'psr-4 mapped to the repo root',
files: ['Models/User.php'],
files: ['vendor/Models/User.php', 'Models/User.php'],
target: 'App\\Models\\User',
composer: ROOT_PSR4,
expected: 'Models/User.php',
expectedViaWorkspace: 'Models/User.php',
expectedViaWorkspace: 'vendor/Models/User.php',
},
{
name: 'leading namespace separator uses the mapped path',
files: ['vendor/App/Models/User.php', 'app/Models/User.php'],
target: '\\App\\Models\\User',
composer: APP_PSR4,
expected: 'app/Models/User.php',
expectedViaWorkspace: 'vendor/App/Models/User.php',
},
{
name: 'empty namespace prefix resolves beneath its configured directory',
files: ['vendor/Vendor/Ghost/Missing.php', 'src/Vendor/Ghost/Missing.php'],
target: 'Vendor\\Ghost\\Missing',
composer: CATCH_ALL_PSR4,
expected: 'src/Vendor/Ghost/Missing.php',
expectedViaWorkspace: 'vendor/Vendor/Ghost/Missing.php',
},
{
name: 'empty namespace prefix does not escape its configured directory',
files: ['legacy/Vendor/Ghost/Missing.php'],
target: 'Vendor\\Ghost\\Missing',
composer: CATCH_ALL_PSR4,
expected: null,
expectedViaWorkspace: 'legacy/Vendor/Ghost/Missing.php',
},
{
// KNOWN LIMITATION: a mapping kept with its trailing slash concatenates to
@ -996,14 +1017,14 @@ describe('PHP import-target parity with the pre-index implementation (#2901)', (
...workspaceHits.map((testCase) => testCase.expectedViaWorkspace),
]);
expect(scopeHits.length).toBe(31);
expect(workspaceHits.length).toBe(23);
expect(scopeHits.length).toBe(33);
expect(workspaceHits.length).toBe(26);
expect(distinct.size).toBeGreaterThan(20);
// The two adapters must not be the same assertion twice: `composer` and
// `context` are visible only through the ScopeResolver one.
expect(
HAND_CASES.filter((testCase) => testCase.expected !== testCase.expectedViaWorkspace).length,
).toBe(9);
).toBe(13);
});
it('agrees on every generated target × composer configuration', () => {

View file

@ -1,8 +1,17 @@
import type { ParsedFile, ParsedImport, SymbolDefinition } from 'gitnexus-shared';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
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';
import {
loadComposerConfig,
type ComposerConfig,
} from '../../../../src/core/ingestion/language-config.js';
import {
loadPhpComposerConfig,
resolvePhpImportTargetInternal,
} from '../../../../src/core/ingestion/languages/php/import-target.js';
const composerConfig: ComposerConfig = { psr4: new Map([['App', 'app']]) };
@ -32,9 +41,317 @@ const functionImport: ParsedImport = {
};
describe('resolvePhpImportTargetInternal declaration selection', () => {
it('rejects namespaces outside an authoritative PSR-4 map', () => {
const files = new Set(['app/Models/User.php', 'lib/Legacy/Missing.php']);
expect(
resolvePhpImportTargetInternal(
'Vendor\\Ghost\\Missing',
'app/Main.php',
files,
composerConfig,
),
).toBeNull();
expect(
resolvePhpImportTargetInternal('App\\Models\\User', 'app/Main.php', files, composerConfig),
).toBe('app/Models/User.php');
});
it('rejects ambiguous function and constant declaration fallbacks', () => {
const first = 'app/Ghost/First.php';
const second = 'app/Ghost/Second.php';
const parsedFiles = [
parsedFile(first, [
definition(first, 'Function', 'missing'),
definition(first, 'Variable', 'MISSING'),
]),
parsedFile(second, [
definition(second, 'Function', 'missing'),
definition(second, 'Variable', 'MISSING'),
]),
];
const files = new Set(parsedFiles.map((parsed) => parsed.filePath));
for (const [name, importedSymbolKind] of [
['missing', 'function'],
['MISSING', 'const'],
] as const) {
const parsedImport: ParsedImport = {
kind: 'named',
localName: name,
importedName: name,
targetRaw: `App\\Ghost\\${name}`,
importedSymbolKind,
};
expect(
resolvePhpImportTargetInternal(
parsedImport.targetRaw,
'app/Main.php',
files,
composerConfig,
{ parsedFiles, parsedImport },
),
).toBeNull();
}
});
it('preserves suffix fallback without authoritative namespace evidence', () => {
const files = new Set(['lib/Legacy/Missing.php']);
const importPath = 'Vendor\\Ghost\\Missing';
expect(resolvePhpImportTargetInternal(importPath, 'app/Main.php', files)).toBe(
'lib/Legacy/Missing.php',
);
expect(
resolvePhpImportTargetInternal(importPath, 'app/Main.php', files, { psr4: new Map() }),
).toBe('lib/Legacy/Missing.php');
expect(
resolvePhpImportTargetInternal(importPath, 'app/Main.php', files, {
psr4: new Map([['', 'src']]),
}),
).toBeNull();
expect(
resolvePhpImportTargetInternal(importPath, 'app/Main.php', files, {
psr4: new Map([['App', 'app']]),
hasUnmodeledAutoload: true,
}),
).toBe('lib/Legacy/Missing.php');
});
it('resolves catch-all PSR-4 class and function imports inside the configured root', () => {
const user = '/repo/src/Vendor/Models/User.php';
const helpers = '/repo/src/Vendor/Models/helpers.php';
const parsedFiles = [
parsedFile(user, [definition(user, 'Class', 'Vendor\\Models\\User')]),
parsedFile(helpers, [definition(helpers, 'Function', 'Vendor\\Models\\findUser')]),
];
const config: ComposerConfig = { psr4: new Map([['', '/repo/src']]) };
const files = new Set(parsedFiles.map((parsed) => parsed.filePath));
expect(
resolvePhpImportTargetInternal('Vendor\\Models\\User', '/repo/app/Main.php', files, config),
).toBe(user);
const parsedImport: ParsedImport = {
kind: 'named',
localName: 'findUser',
importedName: 'findUser',
targetRaw: 'Vendor\\Models\\findUser',
importedSymbolKind: 'function',
};
expect(
resolvePhpImportTargetInternal(parsedImport.targetRaw, '/repo/app/Main.php', files, config, {
parsedFiles,
parsedImport,
}),
).toBe(helpers);
});
it('does not suffix-resolve outside an authoritative catch-all directory', () => {
const decoy = '/repo/legacy/Vendor/Ghost/Missing.php';
expect(
resolvePhpImportTargetInternal(
'Vendor\\Ghost\\Missing',
'/repo/app/Main.php',
new Set([decoy]),
{ psr4: new Map([['', '/repo/src']]) },
),
).toBeNull();
});
it('does not fabricate a class edge from a root-mapped sibling file', () => {
const config: ComposerConfig = { psr4: new Map([['App', '']]) };
const first = new Set(['Sibling.php', 'Other.php']);
const reversed = new Set([...first].reverse());
expect(resolvePhpImportTargetInternal('App\\Missing', 'Main.php', first, config)).toBeNull();
expect(resolvePhpImportTargetInternal('App\\Missing', 'Main.php', reversed, config)).toBeNull();
});
it('keeps function and constant imports inside a relative catch-all root', () => {
const decoy = 'legacy/src/Vendor/Ghost/helpers.php';
const parsedFiles = [
parsedFile(decoy, [
definition(decoy, 'Function', 'Vendor\\Ghost\\missing'),
definition(decoy, 'Variable', 'Vendor\\Ghost\\MISSING'),
]),
];
const config: ComposerConfig = { psr4: new Map([['', 'src']]) };
const files = new Set([decoy]);
for (const [name, importedSymbolKind] of [
['missing', 'function'],
['MISSING', 'const'],
] as const) {
const parsedImport: ParsedImport = {
kind: 'named',
localName: name,
importedName: name,
targetRaw: `Vendor\\Ghost\\${name}`,
importedSymbolKind,
};
expect(
resolvePhpImportTargetInternal(parsedImport.targetRaw, 'app/Main.php', files, config, {
parsedFiles,
parsedImport,
}),
).toBeNull();
}
});
it('loads production and development PSR-4 mappings', () => {
const repo = mkdtempSync(join(tmpdir(), 'gitnexus-php-composer-'));
try {
writeFileSync(
join(repo, 'composer.json'),
JSON.stringify({
autoload: { 'psr-4': { 'App\\': 'app\\' }, classmap: ['legacy/'] },
'autoload-dev': { 'psr-4': { 'Tests\\': ['tests/', 'fallback-tests/'] } },
}),
);
const config = loadPhpComposerConfig(repo);
expect([...(config?.psr4.entries() ?? [])]).toEqual([
['App', 'app'],
['Tests', 'tests'],
]);
expect(config?.hasUnmodeledAutoload).toBe(true);
} finally {
rmSync(repo, { recursive: true, force: true });
}
});
it('normalizes leading dot segments and preserves catch-all array fallback', () => {
const repo = mkdtempSync(join(tmpdir(), 'gitnexus-php-composer-catch-all-'));
try {
writeFileSync(
join(repo, 'composer.json'),
JSON.stringify({ autoload: { 'psr-4': { '': ['./src/', './lib/'] } } }),
);
const config = loadPhpComposerConfig(repo);
expect(config?.psr4.get('')).toBe('src');
expect(config?.hasUnmodeledAutoload).toBe(true);
expect(
resolvePhpImportTargetInternal(
'Vendor\\Models\\User',
'app/Main.php',
new Set(['lib/Vendor/Models/User.php']),
config,
),
).toBe('lib/Vendor/Models/User.php');
} finally {
rmSync(repo, { recursive: true, force: true });
}
});
it('unions package-local Composer mappings using repository-relative roots', () => {
const repo = mkdtempSync(join(tmpdir(), 'gitnexus-php-composer-monorepo-'));
try {
mkdirSync(join(repo, 'packages', 'admin'), { recursive: true });
writeFileSync(
join(repo, 'composer.json'),
JSON.stringify({ autoload: { 'psr-4': { 'App\\': './src/' } } }),
);
writeFileSync(
join(repo, 'packages', 'admin', 'composer.json'),
JSON.stringify({ autoload: { 'psr-4': { 'Admin\\': './src/' } } }),
);
const config = loadPhpComposerConfig(repo);
expect([...(config?.psr4.entries() ?? [])]).toEqual([
['App', 'src'],
['Admin', 'packages/admin/src'],
]);
expect(
resolvePhpImportTargetInternal(
'Admin\\Controller',
'src/Main.php',
new Set(['packages/admin/src/Controller.php']),
config,
),
).toBe('packages/admin/src/Controller.php');
} finally {
rmSync(repo, { recursive: true, force: true });
}
});
it('does not let autoload-dev establish authority or override production mappings', () => {
const repo = mkdtempSync(join(tmpdir(), 'gitnexus-php-composer-dev-'));
try {
writeFileSync(
join(repo, 'composer.json'),
JSON.stringify({
autoload: { 'psr-4': { 'App\\': 'src/' } },
'autoload-dev': { 'psr-4': { 'App\\': 'tests/app/', 'Tests\\': 'tests/' } },
}),
);
const config = loadPhpComposerConfig(repo);
expect(config?.psr4.get('App')).toBe('src');
expect(config?.authoritativePsr4).toEqual(new Set(['App']));
writeFileSync(
join(repo, 'composer.json'),
JSON.stringify({ 'autoload-dev': { 'psr-4': { 'Tests\\': 'tests/' } } }),
);
const devOnly = loadPhpComposerConfig(repo);
expect(devOnly?.authoritativePsr4?.size).toBe(0);
expect(
resolvePhpImportTargetInternal(
'Vendor\\Ghost\\Missing',
'tests/Main.php',
new Set(['legacy/Vendor/Ghost/Missing.php']),
devOnly,
),
).toBe('legacy/Vendor/Ghost/Missing.php');
} finally {
rmSync(repo, { recursive: true, force: true });
}
});
it('fails open for unmodeled development autoload and ignores invalid PSR-4 sections', () => {
const repo = mkdtempSync(join(tmpdir(), 'gitnexus-php-composer-unmodeled-'));
try {
writeFileSync(
join(repo, 'composer.json'),
JSON.stringify({
autoload: { 'psr-4': [] },
'autoload-dev': { 'psr-0': { Legacy_: 'tests/legacy/' } },
}),
);
const config = loadPhpComposerConfig(repo);
expect(config?.psr4.size).toBe(0);
expect(config?.hasUnmodeledAutoload).toBe(true);
} finally {
rmSync(repo, { recursive: true, force: true });
}
});
it('keeps both Composer config loaders conservative for unmodeled autoload entries', async () => {
const repo = mkdtempSync(join(tmpdir(), 'gitnexus-php-composer-shared-'));
try {
writeFileSync(
join(repo, 'composer.json'),
JSON.stringify({
autoload: {
'psr-4': { 'App\\': './app/' },
files: ['src/helpers.php'],
},
}),
);
const config = await loadComposerConfig(repo);
expect([...(config?.psr4.entries() ?? [])]).toEqual([['App', 'app']]);
expect(config?.hasUnmodeledAutoload).toBe(false);
expect(loadPhpComposerConfig(repo)?.hasUnmodeledAutoload).toBe(false);
} finally {
rmSync(repo, { recursive: true, force: true });
}
});
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 user = 'app/Models/User.php';
const factory = 'app/Models/UserFactory.php';
const parsedFiles = [
parsedFile(user, [definition(user, 'Class', 'User')]),
parsedFile(factory, [definition(factory, 'Function', 'getUser')]),
@ -52,8 +369,8 @@ describe('resolvePhpImportTargetInternal declaration selection', () => {
});
it('reuses directory selection without leaking candidates across namespaces', () => {
const models = '/repo/app/Models/functions.php';
const services = '/repo/app/Services/functions.php';
const models = 'app/Models/functions.php';
const services = 'app/Services/functions.php';
const parsedFiles = [
parsedFile(models, [definition(models, 'Function', 'getUser')]),
parsedFile(services, [definition(services, 'Function', 'getUser')]),
@ -79,8 +396,8 @@ describe('resolvePhpImportTargetInternal declaration selection', () => {
});
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 first = 'app/Models/First.php';
const second = 'app/Models/Second.php';
const parsedFiles = [
parsedFile(first, [definition(first, 'Function', 'getUser')]),
parsedFile(second, [definition(second, 'Function', 'getUser')]),
@ -98,8 +415,8 @@ describe('resolvePhpImportTargetInternal declaration selection', () => {
});
it('never resolves into a different root that shares a directory suffix', () => {
const app = '/repo/app/Models/functions.php';
const vendor = '/repo/vendor/pkg/app/Models/helpers.php';
const app = 'app/Models/functions.php';
const vendor = 'vendor/pkg/app/Models/helpers.php';
const parsedFiles = [
parsedFile(app, []),
parsedFile(vendor, [definition(vendor, 'Function', 'getUser')]),
@ -117,8 +434,8 @@ describe('resolvePhpImportTargetInternal declaration selection', () => {
});
it('stays out of suffix-colliding roots even when both declare the function', () => {
const app = '/repo/app/Models/functions.php';
const vendor = '/repo/vendor/pkg/app/Models/helpers.php';
const app = 'app/Models/functions.php';
const vendor = 'vendor/pkg/app/Models/helpers.php';
const parsedFiles = [
parsedFile(app, [definition(app, 'Function', 'getUser')]),
parsedFile(vendor, [definition(vendor, 'Function', 'getUser')]),
@ -136,7 +453,7 @@ describe('resolvePhpImportTargetInternal declaration selection', () => {
});
it('resolves a constant only when its namespace directory has one candidate file', () => {
const constants = '/repo/app/Config/constants.php';
const constants = 'app/Config/constants.php';
const parsedFiles = [parsedFile(constants, [])];
const parsedImport: ParsedImport = {
kind: 'named',