mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
Merge branch 'main' into feat/java-const-route-resolver
This commit is contained in:
commit
a1ea58ef61
9 changed files with 732 additions and 235 deletions
File diff suppressed because one or more lines are too long
|
|
@ -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]] : []),
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}),
|
||||
),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`'],
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue