mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
* fix(php): gate imports by Composer autoload map * fix(php): handle Composer catch-all mappings * test(php): clarify Composer fallback coverage * bench(php): fold Composer into canonical arm --------- Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
144 lines
6.8 KiB
TypeScript
144 lines
6.8 KiB
TypeScript
/**
|
||
* Production-path regression guard for the PHP import-resolution index (#2901).
|
||
*
|
||
* PHP was the last language resolving imports with a full workspace scan per
|
||
* import. Both adapters in `languages/php/import-target.ts` materialized
|
||
* `[...allFilePaths]` twice per import and handed `resolvePhpImportInternal` an
|
||
* `index` of `undefined`, dropping it onto `suffixResolve`'s linear `findIndex`
|
||
* — a pass over every file per path-part × per extension, 98 ms per import at
|
||
* 20k files. They now read the shared `getWorkspaceFileIndex`
|
||
* (`import-resolvers/workspace-file-index.ts`), memoized on the `allFilePaths`
|
||
* Set identity via a WeakMap, through a PHP-specific parity view that keeps the
|
||
* three index-fed fast paths answering exactly what the scans answered (see the
|
||
* `#2901` header in `import-target.ts` — passing the raw shared index straight
|
||
* through MOVES IMPORTS edges, and `test/unit/scope-resolution/
|
||
* php-import-target-parity.test.ts` is the differential that proves this one
|
||
* does not).
|
||
*
|
||
* Resolution reaches that index through `phpScopeResolver.resolveImportTarget`
|
||
* — the orchestrator adapter — not by calling `resolvePhpImportTargetInternal`
|
||
* directly the way the unit parity test does. The adapter must therefore pass
|
||
* the Set THROUGH; a defensive copy (`new Set(allFilePaths)`) would hand a
|
||
* fresh WeakMap key per call and restore the per-import rebuild. Python hit
|
||
* exactly that (PR #1918 review P1), and the parity test cannot see it: it
|
||
* never crosses the adapter.
|
||
*
|
||
* The traversal-count assertions are the perf guard. They are paired with
|
||
* result assertions on purpose: a count of 1 is equally true of an adapter that
|
||
* has stopped resolving anything at all, so counting alone would stay green
|
||
* while every PHP IMPORTS edge disappeared.
|
||
*
|
||
* 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';
|
||
import type { ComposerConfig } from '../../src/core/ingestion/language-config.js';
|
||
import { CountingSet, expectDistinctFileSetsGetOwnIndex } from '../helpers/counting-file-set.js';
|
||
|
||
const { resolveImportTarget } = phpScopeResolver;
|
||
|
||
const FROM_FILE = 'app/Main.php';
|
||
|
||
/** The `composer.json` PSR-4 map `loadPhpComposerConfig` would have produced. */
|
||
const COMPOSER: ComposerConfig = { psr4: new Map([['App', 'app']]) };
|
||
|
||
/**
|
||
* A synthetic PSR-4 app: many service classes, plus the shapes the three
|
||
* index-fed legs answer — `app/Models/User.php` for the class-style whole-path
|
||
* hit, the populated `app/Models/` directory for the function-import fallback,
|
||
* and `lib/Legacy/Helper.php` for the suffix fallback that runs when no PSR-4
|
||
* prefix matches.
|
||
*/
|
||
function buildWorkspace(fileCount: number): CountingSet {
|
||
const files: string[] = [];
|
||
for (let i = 0; i < fileCount; i++) {
|
||
files.push(`app/Services/Service${String(i).padStart(5, '0')}.php`);
|
||
}
|
||
files.push('app/Models/User.php');
|
||
files.push('app/Models/functions.php');
|
||
files.push('lib/Legacy/Helper.php');
|
||
files.push('index.php');
|
||
files.push(FROM_FILE);
|
||
return new CountingSet(files);
|
||
}
|
||
|
||
describe('PHP import resolution — index reuse across use-statements (#2901)', () => {
|
||
it('builds the workspace index once for many imports over a stable file set', () => {
|
||
const files = buildWorkspace(300);
|
||
const resolved: (string | readonly string[] | null)[] = [];
|
||
|
||
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 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));
|
||
}
|
||
|
||
expect(files.scans).toBe(1);
|
||
|
||
// Paired result assertions — a count of 1 must not be the count of an
|
||
// adapter that resolves nothing.
|
||
expect(resolved[0]).toBe('app/Models/User.php');
|
||
expect(resolved[1]).toBe('app/Models/User.php');
|
||
expect(resolved[2]).toBeNull();
|
||
});
|
||
|
||
it('builds the workspace index once with no composer.json at all', () => {
|
||
const files = buildWorkspace(300);
|
||
const resolved: (string | readonly string[] | null)[] = [];
|
||
|
||
// `loadResolutionConfig` returns null when the repo has no composer.json,
|
||
// which skips the PSR-4 block entirely and leaves `suffixResolve` — the leg
|
||
// 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)', () => {
|
||
expectDistinctFileSetsGetOwnIndex({
|
||
resolveImportTarget,
|
||
buildWorkspace: () => buildWorkspace(20),
|
||
targetRaw: 'App\\Models\\User',
|
||
fromFile: FROM_FILE,
|
||
resolutionConfig: COMPOSER,
|
||
expected: 'app/Models/User.php',
|
||
expectedScans: 1,
|
||
});
|
||
});
|
||
|
||
it('still resolves real use-statements correctly (the perf test is not vacuous)', () => {
|
||
const files = buildWorkspace(5);
|
||
|
||
// PSR-4 class-style: `App\Models\User` → `app/Models/User.php`.
|
||
expect(resolveImportTarget('App\\Models\\User', FROM_FILE, files, COMPOSER)).toBe(
|
||
'app/Models/User.php',
|
||
);
|
||
expect(resolveImportTarget('App\\Services\\Service00000', FROM_FILE, files, COMPOSER)).toBe(
|
||
'app/Services/Service00000.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
|
||
// raw `getWorkspaceFileIndex().index` hand-off would have changed.
|
||
expect(resolveImportTarget('index', FROM_FILE, files, COMPOSER)).toBeNull();
|
||
|
||
// Third-party namespaces have no file in the repo.
|
||
expect(resolveImportTarget('Psr\\Log\\LoggerInterface', FROM_FILE, files, COMPOSER)).toBeNull();
|
||
});
|
||
});
|