GitNexus/gitnexus/test/unit/scope-resolution/php-import-target-parity.test.ts
azizur100389 e87b1c3ffd
fix(php): gate imports by Composer autoload map (#2987)
* 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>
2026-08-25 07:56:41 +01:00

1244 lines
51 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Differential harness for the PHP import-target 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 and then passed `resolvePhpImportInternal` an
* `index` of `undefined`, dropping it onto `suffixResolve`'s linear `findIndex`
* — one pass over every file per path-part × per extension.
*
* Unlike #2877#2880, handing that function the SHARED `SuffixIndex` is not a
* hoist. `resolvePhpImportInternal` reads the index at three sites and all
* three answer a different question than the scan they short-circuit, so the
* fix passes a PARITY view instead (see the `#2901` header in
* `import-target.ts`). This file is the proof that the view is faithful: it
* holds verbatim copies of the pre-change implementations
* (`git show HEAD~:gitnexus/src/core/ingestion/languages/php/import-target.ts`)
* and asserts the shipped ones agree with them everywhere.
*
* The corpus is built to force the three divergences, because ordinary PHP
* imports do not show them — a plain `use App\Models\User;` against one
* matching file agrees under either index:
*
* 1. PSR-4 class-style is `allFiles.has(filePath)`, an exact whole-path test,
* and the raw index would add a case-insensitive SUFFIX probe beside it —
* so the corpus contains `vendor/**` mirrors that differ only in case.
* 2. The namespace-directory scan is anchored at the repo root
* (`f.startsWith(nsDir + '/')`), the raw index's `getFilesInDir` is keyed
* on every directory SUFFIX — so the corpus contains
* `vendor/pkg/app/Models/` beside `app/Models/`.
* 3. `suffixResolve`'s scan tests `endsWith('/' + S)` and therefore can only
* match a PROPER suffix, while `buildSuffixIndex` indexes the whole path
* too — so the corpus contains root-level files and paths that are
* themselves the suffix another file carries, in BOTH iteration orders.
*
* Set iteration order is the tie-break for every one of those, which is why the
* generated corpus is emitted in a fixed order and several hand cases appear
* twice with the two files swapped. Nothing here is random.
*
* Every hand case additionally pins ABSOLUTE `expected` /
* `expectedViaWorkspace` literals, for the reason spelled out under the
* verbatim-copy banner below: the differential cannot fail on anything the two
* sides share, and they share `resolvePhpImportInternal` itself.
*
* This file calls the resolver functions directly, so it does NOT guard PR
* #1918 review finding P1 — a defensive `new Set(allFilePaths)` in
* `php/scope-resolver.ts` leaves every arm here green. That is
* `test/integration/php-import-index-reuse.test.ts`.
*/
import { describe, expect, it } from 'vitest';
import type { ParsedFile, ParsedImport, SymbolDefinition, WorkspaceIndex } from 'gitnexus-shared';
import {
resolvePhpImportTarget,
resolvePhpImportTargetInternal,
type PhpResolveContext,
} from '../../../src/core/ingestion/languages/php/import-target.js';
import { resolvePhpImportInternal } from '../../../src/core/ingestion/import-resolvers/php.js';
import { buildSuffixIndex } from '../../../src/core/ingestion/import-resolvers/utils.js';
import type { ComposerConfig } from '../../../src/core/ingestion/language-config.js';
import type { ImportResolutionContext } from '../../../src/core/ingestion/scope-resolution/contract/scope-resolver.js';
import { CountingSet } from '../../helpers/counting-file-set.js';
// ─── verbatim pre-change implementation ──────────────────────────────────────
// Copied from `git show HEAD~:gitnexus/src/core/ingestion/languages/php/
// import-target.ts`. `resolvePhpImportInternal` is NOT copied — it is imported
// from the shipped source, and #2901 (`67307cc91`) DID change it: 41 lines,
// including the `if (index) … else` split that moved the namespace-directory
// scan out of the empty-bucket path.
//
// Passing `index: undefined` still reaches the pre-change behaviour through that
// new `else`, so the legacy side remains a faithful stand-in for the one
// function — but it is a stand-in built out of the code under test. The `..`
// guard, the PSR-4 prefix loop, `allFiles.has`, the `nsDir` computation and the
// `suffixResolve` call are LITERALLY SHARED with the current side, so an edit to
// any of them moves both sides identically and `expect(current).toBe(legacy)`
// stays green. That is a real weakness of importing rather than copying, and it
// is why every hand case below also pins absolute literals: the differential
// proves the index hoist preserved behaviour, the literals prove the behaviour
// being preserved is the one the case is named for.
function legacyNormalizePhpPath(value: string): string {
return value.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '');
}
function legacyNamespaceDirectories(
targetRaw: string,
composerConfig: ComposerConfig | null,
resolved: string | null,
): string[] {
const directories = new Set<string>();
if (resolved !== null) {
const normalizedResolved = legacyNormalizePhpPath(resolved);
const separator = normalizedResolved.lastIndexOf('/');
if (separator >= 0) directories.add(normalizedResolved.slice(0, separator));
}
if (composerConfig === null) return [...directories];
const normalizedTarget = legacyNormalizePhpPath(targetRaw);
const mappings = [...composerConfig.psr4.entries()].sort((left, right) => {
const lengthDifference = right[0].length - left[0].length;
return lengthDifference !== 0 ? lengthDifference : left[0].localeCompare(right[0]);
});
for (const [namespacePrefix, directoryPrefix] of mappings) {
const normalizedPrefix = legacyNormalizePhpPath(namespacePrefix);
if (
normalizedTarget !== normalizedPrefix &&
!normalizedTarget.startsWith(`${normalizedPrefix}/`)
) {
continue;
}
const remainder = normalizedTarget.slice(normalizedPrefix.length).replace(/^\//, '');
const separator = remainder.lastIndexOf('/');
const relativeNamespace = separator >= 0 ? remainder.slice(0, separator) : '';
directories.add(
legacyNormalizePhpPath(
relativeNamespace === '' ? directoryPrefix : `${directoryPrefix}/${relativeNamespace}`,
),
);
break;
}
return [...directories];
}
const legacyPhpDirectoryIndexCache = new WeakMap<
readonly ParsedFile[],
ReadonlyMap<string, readonly ParsedFile[]>
>();
function legacyParentDirectory(filePath: string): string {
const normalizedPath = legacyNormalizePhpPath(filePath);
const separator = normalizedPath.lastIndexOf('/');
return separator < 0 ? '' : normalizedPath.slice(0, separator);
}
function legacyDirectoryAliases(filePath: string): string[] {
const normalizedPath = legacyNormalizePhpPath(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];
}
function legacyFilesByDirectory(
parsedFiles: readonly ParsedFile[],
): ReadonlyMap<string, readonly ParsedFile[]> {
const cached = legacyPhpDirectoryIndexCache.get(parsedFiles);
if (cached) return cached;
const mutable = new Map<string, ParsedFile[]>();
for (const parsed of parsedFiles) {
for (const directory of legacyDirectoryAliases(parsed.filePath)) {
const files = mutable.get(directory) ?? [];
files.push(parsed);
mutable.set(directory, files);
}
}
legacyPhpDirectoryIndexCache.set(parsedFiles, mutable);
return mutable;
}
function legacyResolvePhpImportTarget(
parsedImport: ParsedImport,
workspaceIndex: WorkspaceIndex,
): string | null {
// The shipped adapter spells this guard `ctx === undefined || ...`; CodeQL
// flags that as a comparison between inconvertible types (`WorkspaceIndex` is
// an object type, never `undefined`). Optional chaining is the same guard at
// runtime — an undefined index still fails the `typeof` test and returns null
// — so the copy stays behaviourally verbatim.
const ctx = workspaceIndex as PhpResolveContext;
if (
typeof (workspaceIndex as { fromFile?: unknown } | undefined)?.fromFile !== 'string' ||
!((workspaceIndex as { allFilePaths?: unknown } | undefined)?.allFilePaths instanceof Set)
) {
return null;
}
if (parsedImport.kind === 'dynamic-unresolved') return null;
if (parsedImport.targetRaw === null || parsedImport.targetRaw === '') return null;
const allFiles = ctx.allFilePaths as Set<string>;
const normalizedFileList = [...allFiles].map((f) => f.replace(/\\/g, '/'));
const allFileList = [...allFiles];
return resolvePhpImportInternal(
parsedImport.targetRaw,
null, // composerConfig not available through LanguageProvider path
allFiles,
normalizedFileList,
allFileList,
undefined,
);
}
function legacyResolvePhpImportTargetInternal(
targetRaw: string,
_fromFile: string,
allFilePaths: ReadonlySet<string>,
resolutionConfig?: unknown,
context?: ImportResolutionContext,
): string | null {
if (targetRaw === '') return null;
const composerConfig =
resolutionConfig !== undefined && resolutionConfig !== null
? (resolutionConfig as ComposerConfig)
: null;
const allFiles = allFilePaths as Set<string>;
const normalizedFileList = [...allFiles].map((f) => f.replace(/\\/g, '/'));
const allFileList = [...allFiles];
const resolved = resolvePhpImportInternal(
targetRaw,
composerConfig,
allFiles,
normalizedFileList,
allFileList,
undefined,
);
const parsedImport = context?.parsedImport;
const symbolKind =
parsedImport?.kind === 'named' || parsedImport?.kind === 'alias'
? parsedImport.importedSymbolKind
: undefined;
if (
context === undefined ||
parsedImport === undefined ||
(symbolKind !== 'function' && symbolKind !== 'const')
) {
return resolved;
}
const importedName = targetRaw.replace(/\\/g, '/').split('/').filter(Boolean).at(-1);
if (importedName === undefined) return resolved;
const directories = legacyNamespaceDirectories(targetRaw, composerConfig, resolved);
const directoryIndex = legacyFilesByDirectory(context.parsedFiles);
const candidateFiles = [
...new Set(
directories.flatMap((directory) => {
const files = directoryIndex.get(legacyNormalizePhpPath(directory)) ?? [];
const distinctParents = new Set(files.map((file) => legacyParentDirectory(file.filePath)));
return distinctParents.size > 1 ? [] : files;
}),
),
];
const expectedType = symbolKind === 'function' ? 'Function' : 'Variable';
const declaringFiles = candidateFiles.filter((parsed) =>
parsed.localDefs.some((def) => {
if (def.type !== expectedType) return false;
const simpleName = (def.qualifiedName ?? '').split(/[\\.]/).at(-1);
return simpleName === importedName;
}),
);
if (declaringFiles.length > 1) return null;
if (declaringFiles.length === 1) return declaringFiles[0].filePath;
if (symbolKind === 'const' && candidateFiles.length === 1) return candidateFiles[0].filePath;
return resolved;
}
// ─── fixtures ────────────────────────────────────────────────────────────────
/**
* One differential case. `files` is emitted in the listed order and that order
* is the tie-break under test, so cases that exist to pin a tie appear twice
* with the order reversed rather than relying on one arrangement.
*/
interface Case {
readonly name: string;
readonly files: readonly string[];
readonly target: string;
readonly composer?: ComposerConfig;
readonly parsedImport?: ParsedImport;
readonly defs?: ReadonlyMap<string, readonly [SymbolDefinition['type'], string][]>;
}
/**
* A hand case plus the two literals it must produce. Required, not optional:
* the generated sweep is a pure differential by design, but a hand case exists
* to pin one named behaviour and cannot do that without saying what it is.
*/
interface HandCase extends Case {
/**
* What the ScopeResolver adapter (`resolvePhpImportTargetInternal`) returns.
* That is the path with `composerConfig` and the function/const declaration
* leg, so it is the one PSR-4 and `context` actually reach.
*/
readonly expected: string | null;
/**
* What the LanguageProvider adapter (`resolvePhpImportTarget`) returns. It
* hard-codes `composerConfig: null` and takes no `ImportResolutionContext`,
* so for every case carrying a `composer` or a `parsedImport` this is the
* plain `suffixResolve` answer and differs from `expected`.
*/
readonly expectedViaWorkspace: string | null;
}
function composer(entries: readonly (readonly [string, string])[]): ComposerConfig {
return { psr4: new Map(entries) };
}
function definition(
filePath: string,
type: SymbolDefinition['type'],
name: string,
): SymbolDefinition {
return { nodeId: `def:${filePath}:${type}:${name}`, filePath, type, qualifiedName: name };
}
function parsedFilesFor(testCase: Case): readonly ParsedFile[] {
return testCase.files.map(
(filePath) =>
({
filePath,
localDefs: (testCase.defs?.get(filePath) ?? []).map(([type, name]) =>
definition(filePath, type, name),
),
}) as ParsedFile,
);
}
function namedImport(
targetRaw: string,
kind: 'function' | 'const' | 'class',
localName: string,
): ParsedImport {
return {
kind: 'named',
localName,
importedName: localName,
targetRaw,
importedSymbolKind: kind,
} as ParsedImport;
}
const APP_PSR4 = composer([['App', 'app']]);
const SRC_PSR4 = composer([['App', 'src']]);
const NESTED_PSR4 = composer([
['App', 'app'],
['App\\Models', 'app/Domain'],
]);
const ROOT_PSR4 = composer([['App', '']]);
const CATCH_ALL_PSR4 = composer([['', 'src']]);
const TRAILING_SLASH_PSR4 = composer([['App', 'app/']]);
/**
* A deterministic multi-root workspace. Every root carries the same relative
* layout so that a suffix-keyed lookup and a root-anchored scan disagree about
* which root wins, and `Vendor`/`vendor` differ only in case.
*/
function generatedFiles(): string[] {
const files: string[] = [];
for (let i = 0; i < 12; i++) {
files.push(`vendor/pkg${i % 3}/app/Models/Entity${i}.php`);
files.push(`app/Models/Entity${i}.php`);
files.push(`app/Services/Service${i}.php`);
files.push(`src/App/Legacy/Entity${i}.php`);
files.push(`APP/models/entity${i}.php`);
files.push(`Entity${i}.php`);
files.push(`app/Helpers/helpers${i}.phtml`);
files.push(`packages/mod${i}/src/Widget.php`);
files.push(`app\\Windows\\Entity${i}.php`);
}
files.push('index.php');
files.push('app/Models/User.php');
files.push('app/Models/functions.php');
files.push('app/Config/constants.php');
return files;
}
const GENERATED_FILES = generatedFiles();
/** Targets swept across `GENERATED_FILES`: hits, near-misses and full misses. */
function generatedTargets(): string[] {
const targets: string[] = [];
for (let i = 0; i < 12; i++) {
targets.push(`App\\Models\\Entity${i}`);
targets.push(`app\\models\\entity${i}`);
targets.push(`Entity${i}`);
targets.push(`Models\\Entity${i}`);
targets.push(`App\\Legacy\\Entity${i}`);
targets.push(`App\\Services\\Service${i}`);
targets.push(`Widget`);
targets.push(`Symfony\\Component\\Console\\Command${i}`);
targets.push(`App\\Models\\helper${i}`);
targets.push(`\\App\\Models\\Entity${i}`);
targets.push(`App/Models/Entity${i}`);
}
targets.push('index');
targets.push('App\\Models\\User');
targets.push('..\\App\\Models\\User');
targets.push('App');
return targets;
}
const GENERATED_CASES: readonly Case[] = generatedTargets().flatMap((target) =>
[undefined, APP_PSR4, SRC_PSR4, NESTED_PSR4, ROOT_PSR4, TRAILING_SLASH_PSR4].map(
(config, configIndex) => ({
name: `generated ${target} · composer#${configIndex}`,
files: GENERATED_FILES,
target,
composer: config,
}),
),
);
/**
* Hand-built cases, one per tie-break the index could have moved.
*
* Every `expected` / `expectedViaWorkspace` below was derived by hand from
* `resolvePhpImportInternal` + `suffixResolve` and then confirmed against both
* implementations. Two rules do most of the work and are worth stating once:
*
* - `suffixResolve`'s PATH-PART loop is OUTER and its EXTENSION loop is inner,
* so a longer suffix always beats a shorter one no matter which extensions
* are involved; within one path-part it is first-in-Set-order and
* case-INSENSITIVE (the scan's `endsWith(p)` disjunct is subsumed by its
* `toLowerCase().endsWith(...)` one).
* - the PSR-4 namespace-directory fallback is NOT gated on the imported symbol
* kind. It fires for a class import too, whenever the class-style path
* misses, and returns the FIRST `.php` file directly in the namespace
* directory — see the "known limitation" note atop `import-resolvers/php.ts`.
* Cases that lean on it are marked; their answers are order-dependent in
* general and deterministic here only because the fixture pins the order.
*/
const HAND_CASES: readonly HandCase[] = [
// ── divergence 3: whole-path vs proper suffix ────────────────────────────
{
// `Foo.php` IS the suffix, not a file carrying it, and `endsWith('/Foo.php')`
// can only match a PROPER suffix. Unresolvable — the behaviour the parity
// view exists to preserve.
name: 'root-level file is not a proper suffix of itself',
files: ['Foo.php', 'src/Bar.php'],
target: 'Foo',
expected: null,
expectedViaWorkspace: null,
},
{
// `App/Models/User.php` is invisible at path-part 0 (whole path), so both
// files compete at part 1 on `/Models/User.php` and Set order decides.
name: 'whole-path match loses to an earlier proper-suffix match',
files: ['vendor/x/Models/User.php', 'App/Models/User.php'],
target: 'App\\Models\\User',
expected: 'vendor/x/Models/User.php',
expectedViaWorkspace: 'vendor/x/Models/User.php',
},
{
name: 'whole-path match loses to a later proper-suffix match too',
files: ['App/Models/User.php', 'vendor/x/Models/User.php'],
target: 'App\\Models\\User',
expected: 'App/Models/User.php',
expectedViaWorkspace: 'App/Models/User.php',
},
{
// Still not found as a whole path — found at part 1, as `Models/User.php`.
name: 'whole path is the only candidate at all',
files: ['App/Models/User.php'],
target: 'App\\Models\\User',
expected: 'App/Models/User.php',
expectedViaWorkspace: 'App/Models/User.php',
},
{
// A whole-path candidate the scan must skip, plus TWO proper-suffix
// candidates behind it — so the skip has to land on the first of them and
// not merely on "some other file". Both are hit at path-part 0.
name: 'whole-path match skipped, first of several proper-suffix matches wins',
files: ['App/Models/User.php', 'one/App/Models/User.php', 'two/App/Models/User.php'],
target: 'App\\Models\\User',
expected: 'one/App/Models/User.php',
expectedViaWorkspace: 'one/App/Models/User.php',
},
{
name: 'whole-path match skipped, first of several proper-suffix matches wins, reversed',
files: ['two/App/Models/User.php', 'App/Models/User.php', 'one/App/Models/User.php'],
target: 'App\\Models\\User',
expected: 'two/App/Models/User.php',
expectedViaWorkspace: 'two/App/Models/User.php',
},
{
name: 'root-level file with a namespace-shaped import',
files: ['index.php', 'Kernel.php'],
target: 'Kernel',
expected: null,
expectedViaWorkspace: null,
},
// ── divergence 3: case-sensitive hit must not outrank an earlier ci hit ──
{
// `a/FOO.php` matches `/Foo.php` case-insensitively and comes first, so the
// exact-case `b/Foo.php` behind it never gets a turn.
name: 'lowercase file first, exact-case file second',
files: ['a/FOO.php', 'b/Foo.php'],
target: 'Foo',
expected: 'a/FOO.php',
expectedViaWorkspace: 'a/FOO.php',
},
{
name: 'exact-case file first, lowercase file second',
files: ['b/Foo.php', 'a/FOO.php'],
target: 'Foo',
expected: 'b/Foo.php',
expectedViaWorkspace: 'b/Foo.php',
},
{
name: 'case tie across a multi-segment suffix',
files: ['vendor/x/models/user.php', 'app/Models/User.php'],
target: 'Models\\User',
expected: 'vendor/x/models/user.php',
expectedViaWorkspace: 'vendor/x/models/user.php',
},
{
name: 'case tie across a multi-segment suffix, reversed',
files: ['app/Models/User.php', 'vendor/x/models/user.php'],
target: 'Models\\User',
expected: 'app/Models/User.php',
expectedViaWorkspace: 'app/Models/User.php',
},
// ── divergence 1: PSR-4 class-style is an exact whole-path test ──────────
{
// The only case in this group that the class-style `allFiles.has` leg
// actually answers: `App\Models\User` + `App => app` is exactly
// `app/Models/User.php`. Everything below it misses that leg and falls
// through, which is what makes the group interesting.
name: 'psr-4 exact hit',
files: ['app/Models/User.php'],
target: 'App\\Models\\User',
composer: APP_PSR4,
expected: 'app/Models/User.php',
// No composer on this path, so it comes from `/Models/User.php` at part 1.
expectedViaWorkspace: 'app/Models/User.php',
},
{
// PSR-4 is case-sensitive by spec, so the exact leg correctly misses
// `app/models/user.php`, the namespace directory `app/Models` does not
// exist either, and the answer comes from the case-INSENSITIVE suffix
// fallback. Loose, but it is the shipped behaviour and predates #2901.
name: 'psr-4 target differs from the file only by case',
files: ['app/models/user.php'],
target: 'App\\Models\\User',
composer: APP_PSR4,
expected: 'app/models/user.php',
expectedViaWorkspace: 'app/models/user.php',
},
{
// KNOWN LIMITATION: the class-style path `src/Models/User.php` misses, and
// the namespace-directory fallback then answers a CLASS import with "first
// `.php` in `src/Models/`" — here `src/Models/user.php`, which is only
// coincidentally the right file. `other/Models/User.php` is never reached.
name: 'psr-4 mapped dir differs from the namespace, file differs by case',
files: ['src/Models/user.php', 'other/Models/User.php'],
target: 'App\\Models\\User',
composer: SRC_PSR4,
expected: 'src/Models/user.php',
expectedViaWorkspace: 'src/Models/user.php',
},
{
// `src/Models/` does not exist at the repo root, so the root-anchored
// directory bucket is empty and the suffix leg finds the vendor copy at
// path-part 1. A directory index keyed on suffixes would have answered it
// one leg earlier — same file here, different file in the case below.
name: 'psr-4 mapped dir exists only under vendor, by case-insensitive suffix',
files: ['vendor/pkg/src/Models/User.php'],
target: 'App\\Models\\User',
composer: SRC_PSR4,
expected: 'vendor/pkg/src/Models/User.php',
expectedViaWorkspace: 'vendor/pkg/src/Models/User.php',
},
{
// The mapped directory (`src/lib`) shares no segment with the namespace
// (`App`), so the class-style probe and the `suffixResolve` fallback name
// two DIFFERENT files. Only the exact-`has` leg is supposed to see the
// first one; the raw index's case-insensitive suffix probe reaches it.
// Correct answer: the suffix leg's `other/Models/User.php`, first in order.
name: 'psr-4 mapped dir path and namespace path name different files',
files: ['other/Models/User.php', 'vendor/one/src/lib/Models/user.php'],
target: 'App\\Models\\User',
composer: composer([['App', 'src/lib']]),
expected: 'other/Models/User.php',
expectedViaWorkspace: 'other/Models/User.php',
},
{
name: 'psr-4 mapped dir path and namespace path name different files, reversed',
files: ['vendor/one/src/lib/Models/user.php', 'other/Models/User.php'],
target: 'App\\Models\\User',
composer: composer([['App', 'src/lib']]),
expected: 'vendor/one/src/lib/Models/user.php',
expectedViaWorkspace: 'vendor/one/src/lib/Models/user.php',
},
{
// `App\Models => app/Domain` sorts before `App => app` (longer key), so the
// class-style leg hits `app/Domain/User.php` and never considers
// `app/Models/User.php`. The two adapters legitimately disagree here: with
// no composer there is no longest-prefix rule and the suffix leg answers
// `/Models/User.php` instead.
name: 'psr-4 longest-prefix mapping wins',
files: ['app/Domain/User.php', 'app/Models/User.php'],
target: 'App\\Models\\User',
composer: NESTED_PSR4,
expected: 'app/Domain/User.php',
expectedViaWorkspace: 'app/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: ['vendor/Models/User.php', 'Models/User.php'],
target: 'App\\Models\\User',
composer: ROOT_PSR4,
expected: '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
// `app//Models/User.php`, which misses every leg. `loadPhpComposerConfig`
// strips trailing slashes, so production never builds this config — the
// arm pins what happens if one ever reaches the resolver. The answer is
// again the plain suffix leg's.
name: 'psr-4 dir prefix carries a trailing slash',
files: ['app/Models/User.php'],
target: 'App\\Models\\User',
composer: TRAILING_SLASH_PSR4,
expected: 'app/Models/User.php',
expectedViaWorkspace: 'app/Models/User.php',
},
// ── divergence 2: namespace-directory scan is root-anchored ──────────────
{
// The witness for divergence 2: `app/Models` is also a SUFFIX of
// `vendor/pkg/app/Models`, and the vendor file comes first in Set order, so
// a suffix-keyed directory index answers `vendor/pkg/app/Models/Zed.php`.
// Root-anchored, only `app/Models/Aaa.php` is in the bucket.
name: 'namespace dir: root-anchored candidate beats a suffix-matching vendor dir',
files: ['vendor/pkg/app/Models/Zed.php', 'app/Models/Aaa.php'],
target: 'App\\Models\\getUser',
composer: APP_PSR4,
expected: 'app/Models/Aaa.php',
// Without composer there is no namespace-directory leg at all, and
// `getUser` is not a file, so nothing matches.
expectedViaWorkspace: null,
},
{
// Same witness from the other side: with the root-anchored bucket empty the
// vendor mirror is unreachable, where a suffix-keyed one would return it.
name: 'namespace dir: only a suffix-matching vendor dir exists',
files: ['vendor/pkg/app/Models/Zed.php'],
target: 'App\\Models\\getUser',
composer: APP_PSR4,
expected: null,
expectedViaWorkspace: null,
},
{
// KNOWN LIMITATION, pinned rather than endorsed: "first `.php` file in the
// namespace directory" is Set-iteration order, so `Bbb` beats the
// alphabetically-earlier `Aaa`. Deterministic here only because the fixture
// fixes the insertion order; in a real repo it follows the walker's.
name: 'namespace dir: several candidates, first in order wins',
files: ['app/Models/Bbb.php', 'app/Models/Aaa.php', 'app/Models/Ccc.php'],
target: 'App\\Models\\getUser',
composer: APP_PSR4,
expected: 'app/Models/Bbb.php',
expectedViaWorkspace: null,
},
{
name: 'namespace dir: nested subdirectory is not a direct child',
files: ['app/Models/Nested/Deep.php'],
target: 'App\\Models\\getUser',
composer: APP_PSR4,
expected: null,
expectedViaWorkspace: null,
},
{
name: 'namespace dir: non-php sibling is skipped',
files: ['app/Models/notes.md', 'app/Models/Aaa.php'],
target: 'App\\Models\\getUser',
composer: APP_PSR4,
expected: 'app/Models/Aaa.php',
expectedViaWorkspace: null,
},
{
// KNOWN LIMITATION, the multi-segment half of the trailing-slash bug: the
// remainder `Models/getUser` has a separator, so `nsDir` is built as
// `'app/' + '/' + 'Models'` = `app//Models` and matches no directory. Not
// reachable from a parsed `composer.json` (trailing slashes are stripped).
name: 'namespace dir with a trailing-slash mapping',
files: ['app/Models/Aaa.php'],
target: 'App\\Models\\getUser',
composer: TRAILING_SLASH_PSR4,
expected: null,
expectedViaWorkspace: null,
},
{
// `nsDir` keeps the mapping's trailing slash when the remainder has no
// separator, so the directory bucket must be keyed without it. `nsDir` is
// `app/` here, and `app/bootstrap.php` is its only direct `.php` child —
// `app/Models/User.php` lives one level down.
name: 'namespace dir IS the trailing-slash mapping',
files: ['app/bootstrap.php', 'app/Models/User.php'],
target: 'App\\getUser',
composer: TRAILING_SLASH_PSR4,
expected: 'app/bootstrap.php',
expectedViaWorkspace: null,
},
{
// KNOWN LIMITATION: with `App => ''` the namespace directory is the repo
// root, and neither the bucket (built from `lastIndexOf('/')`, so root files
// are in no directory) nor the scan it mirrors (`startsWith('/')`) can see
// `User.php`. A root-mapped function import is unresolvable.
name: 'namespace dir at the repo root',
files: ['User.php', 'nested/Other.php'],
target: 'App\\getUser',
composer: ROOT_PSR4,
expected: null,
expectedViaWorkspace: null,
},
// ── raw vs normalized paths ──────────────────────────────────────────────
{
// Matched on the normalized path, returned RAW.
name: 'backslash file paths',
files: ['src\\App\\Models\\User.php'],
target: 'App\\Models\\User',
expected: 'src\\App\\Models\\User.php',
expectedViaWorkspace: 'src\\App\\Models\\User.php',
},
{
// `allFiles.has('app/Models/User.php')` is a miss (the Set holds the
// backslash spelling) and the directory bucket is keyed on raw paths, which
// have no `/` at all — so only the normalized suffix leg can answer.
name: 'backslash file paths under a psr-4 mapping',
files: ['app\\Models\\User.php'],
target: 'App\\Models\\User',
composer: APP_PSR4,
expected: 'app\\Models\\User.php',
expectedViaWorkspace: 'app\\Models\\User.php',
},
{
// Same, minus a suffix leg that can match: `getUser` is not a file.
name: 'backslash namespace dir candidate',
files: ['app\\Models\\Aaa.php'],
target: 'App\\Models\\getUser',
composer: APP_PSR4,
expected: null,
expectedViaWorkspace: null,
},
// ── extension order and misses ───────────────────────────────────────────
{
name: 'extension order: .php before .phtml at the same depth',
files: ['x/User.phtml', 'y/User.php'],
target: 'User',
expected: 'y/User.php',
expectedViaWorkspace: 'y/User.php',
},
{
// The path-part loop is OUTER, so the full `App/Models/User` + `.php` is
// tried before any extension is tried against the bare `User` — the `.ts`
// file never gets a turn even though `.ts` precedes `.php` in `EXTENSIONS`.
// (Renamed: the old name, "a .ts file shadows a deeper .php file", claimed
// the opposite of what this resolves to. Writing the literal down is what
// surfaced that.)
name: 'extension order: the outer path-part loop beats the inner extension list',
files: ['x/User.ts', 'y/App/Models/User.php'],
target: 'App\\Models\\User',
expected: 'y/App/Models/User.php',
expectedViaWorkspace: 'y/App/Models/User.php',
},
{
name: 'plain miss',
files: ['src/App/Models/User.php'],
target: 'Other\\Thing',
expected: null,
expectedViaWorkspace: null,
},
{
// Refused by `if (normalized.includes('..')) return null` before any index
// is consulted. Without that guard the suffix leg resolves this to
// `app/Models/User.php` at path-part 1 — on BOTH sides, so the literal is
// the only assertion here that can see the guard disappear.
name: 'path traversal is rejected',
files: ['app/Models/User.php'],
target: '..\\Models\\User',
expected: null,
expectedViaWorkspace: null,
},
{
name: 'empty file set',
files: [],
target: 'App\\Models\\User',
composer: APP_PSR4,
expected: null,
expectedViaWorkspace: null,
},
{
name: 'single-segment miss',
files: ['app/Models/User.php'],
target: 'Nope',
expected: null,
expectedViaWorkspace: null,
},
// ── function / const leg (context-driven) ────────────────────────────────
//
// Only the ScopeResolver adapter takes an `ImportResolutionContext`, so
// `expectedViaWorkspace` is the no-composer suffix answer throughout — `null`
// for every one of them, because a symbol name is not a file name.
{
// The namespace-directory leg answers `app/Models/User.php` (first in the
// bucket, and the wrong file); the declaration search then overrides it with
// the file that actually declares `getUser`. That override is the whole
// point of the leg, so pinning the literal is what proves it ran.
name: 'function import with a unique declaration',
files: ['app/Models/User.php', 'app/Models/UserFactory.php'],
target: 'App\\Models\\getUser',
composer: APP_PSR4,
parsedImport: namedImport('App\\Models\\getUser', 'function', 'getUser'),
defs: new Map([
['app/Models/User.php', [['Class', 'User'] as const]],
['app/Models/UserFactory.php', [['Function', 'getUser'] as const]],
]),
expected: 'app/Models/UserFactory.php',
expectedViaWorkspace: null,
},
{
// Two declarations of the same name: `declaringFiles.length > 1` returns
// null outright, rather than falling back to the namespace-directory answer.
name: 'function import with duplicate declarations fails closed',
files: ['app/Models/First.php', 'app/Models/Second.php'],
target: 'App\\Models\\getUser',
composer: APP_PSR4,
parsedImport: namedImport('App\\Models\\getUser', 'function', 'getUser'),
defs: new Map([
['app/Models/First.php', [['Function', 'getUser'] as const]],
['app/Models/Second.php', [['Function', 'getUser'] as const]],
]),
expected: null,
expectedViaWorkspace: null,
},
{
// KNOWN LIMITATION: the `app/Models` directory ALIAS spans two roots, so
// `distinctParents.size > 1` empties the candidate list and the leg falls
// back to `resolved` — `app/Models/functions.php`, which does NOT declare
// `getUser`. The file that does (`vendor/pkg/app/Models/helpers.php`) is
// never returned. Failing closed here means "keep the composer answer",
// not "return null".
name: 'function import across suffix-colliding roots',
files: ['app/Models/functions.php', 'vendor/pkg/app/Models/helpers.php'],
target: 'App\\Models\\getUser',
composer: APP_PSR4,
parsedImport: namedImport('App\\Models\\getUser', 'function', 'getUser'),
defs: new Map([['vendor/pkg/app/Models/helpers.php', [['Function', 'getUser'] as const]]]),
expected: 'app/Models/functions.php',
expectedViaWorkspace: null,
},
{
// PHP constants are not emitted as local definitions, so `declaringFiles` is
// always empty and the single-candidate rule decides.
name: 'const import with a single candidate file',
files: ['app/Config/constants.php'],
target: 'App\\Config\\MAX_USERS',
composer: APP_PSR4,
parsedImport: namedImport('App\\Config\\MAX_USERS', 'const', 'MAX_USERS'),
defs: new Map(),
expected: 'app/Config/constants.php',
expectedViaWorkspace: null,
},
{
// KNOWN LIMITATION: the single-candidate rule declines, but the fallback is
// `resolved` — itself "first `.php` in `app/Config/`", i.e. the same Set
// order the rule is documented as refusing to inherit. Declining changes
// which code picks the file, not whether order picks it.
name: 'const import with several candidate files',
files: ['app/Config/constants.php', 'app/Config/more.php'],
target: 'App\\Config\\MAX_USERS',
composer: APP_PSR4,
parsedImport: namedImport('App\\Config\\MAX_USERS', 'const', 'MAX_USERS'),
defs: new Map(),
expected: 'app/Config/constants.php',
expectedViaWorkspace: null,
},
{
// `importedSymbolKind: 'class'` returns before the declaration leg, so this
// is the plain PSR-4 class-style hit — and the one context-carrying case
// whose LanguageProvider answer is not null.
name: 'class import ignores the declaration leg',
files: ['app/Models/User.php'],
target: 'App\\Models\\User',
composer: APP_PSR4,
parsedImport: namedImport('App\\Models\\User', 'class', 'User'),
defs: new Map([['app/Models/User.php', [['Class', 'User'] as const]]]),
expected: 'app/Models/User.php',
expectedViaWorkspace: 'app/Models/User.php',
},
];
function runBoth(testCase: Case): {
readonly legacy: string | null;
readonly current: string | null;
} {
const legacyFiles = new Set(testCase.files);
const currentFiles = new Set(testCase.files);
const parsedFiles = parsedFilesFor(testCase);
const context: ImportResolutionContext | undefined =
testCase.parsedImport === undefined
? undefined
: { parsedFiles, parsedImport: testCase.parsedImport };
return {
legacy: legacyResolvePhpImportTargetInternal(
testCase.target,
'app/Main.php',
legacyFiles,
testCase.composer,
context,
),
current: resolvePhpImportTargetInternal(
testCase.target,
'app/Main.php',
currentFiles,
testCase.composer,
context,
),
};
}
function runBothWorkspaceAdapter(testCase: Case): {
readonly legacy: string | null;
readonly current: string | null;
} {
const parsedImport = testCase.parsedImport ?? namedImport(testCase.target, 'class', 'Imported');
const legacyIndex: PhpResolveContext = {
fromFile: 'app/Main.php',
allFilePaths: new Set(testCase.files),
};
const currentIndex: PhpResolveContext = {
fromFile: 'app/Main.php',
allFilePaths: new Set(testCase.files),
};
return {
legacy: legacyResolvePhpImportTarget(parsedImport, legacyIndex as WorkspaceIndex),
current: resolvePhpImportTarget(parsedImport, currentIndex as WorkspaceIndex),
};
}
// ─── the differential ────────────────────────────────────────────────────────
describe('PHP import-target parity with the pre-index implementation (#2901)', () => {
// Three assertions per arm, and each answers a different question.
// `current === legacy` proves the index hoist behaviour-preserving, but it
// is blind to every line the two sides SHARE — including all of
// `resolvePhpImportInternal`, which is imported rather than copied. Pinning
// the literal on both sides is what makes the arm able to fail on a change
// there, and what says the case still exercises the behaviour it is named
// for rather than having decayed into `null === null`.
it.each(HAND_CASES.map((testCase) => [testCase.name, testCase] as const))(
'ScopeResolver adapter agrees: %s',
(_name, testCase) => {
const { legacy, current } = runBoth(testCase);
expect(legacy).toBe(testCase.expected);
expect(current).toBe(testCase.expected);
expect(current).toBe(legacy);
},
);
it.each(HAND_CASES.map((testCase) => [testCase.name, testCase] as const))(
'LanguageProvider adapter agrees: %s',
(_name, testCase) => {
const { legacy, current } = runBothWorkspaceAdapter(testCase);
expect(legacy).toBe(testCase.expectedViaWorkspace);
expect(current).toBe(testCase.expectedViaWorkspace);
expect(current).toBe(legacy);
},
);
/**
* Non-vacuity, the way the Java and COBOL harnesses state it: a table of
* literals is only a specification if enough of them are real paths. 32 of
* the 86 arms above legitimately expect `null` (a resolver miss is a real
* answer and must be pinned like any other), so this fixes the balance rather
* than letting a corpus that quietly stopped matching pass as one that never
* matched.
*/
it('the hand corpus pins real paths, not only misses', () => {
const scopeHits = HAND_CASES.filter((testCase) => testCase.expected !== null);
const workspaceHits = HAND_CASES.filter((testCase) => testCase.expectedViaWorkspace !== null);
const distinct = new Set([
...scopeHits.map((testCase) => testCase.expected),
...workspaceHits.map((testCase) => testCase.expectedViaWorkspace),
]);
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(13);
});
it('agrees on every generated target × composer configuration', () => {
const disagreements = GENERATED_CASES.filter((testCase) => {
const { legacy, current } = runBoth(testCase);
return legacy !== current;
}).map((testCase) => testCase.name);
expect(disagreements).toEqual([]);
});
it('agrees on every generated target through the LanguageProvider adapter', () => {
const disagreements = GENERATED_CASES.filter((testCase) => {
const { legacy, current } = runBothWorkspaceAdapter(testCase);
return legacy !== current;
}).map((testCase) => testCase.name);
expect(disagreements).toEqual([]);
});
/**
* The corpus is only a specification if it actually exercises the three
* divergences. Each of these resolves to a DIFFERENT file (or from null to a
* file) when `resolvePhpImportInternal` is handed the raw shared index
* instead of the parity view, so a future edit that quietly drops one of the
* corrections cannot pass the arms above by also deleting its witness.
*/
it('the corpus contains a witness for each of the three divergences', () => {
const witnesses = [
'root-level file is not a proper suffix of itself',
'whole-path match loses to an earlier proper-suffix match',
'lowercase file first, exact-case file second',
'psr-4 mapped dir path and namespace path name different files',
'namespace dir: root-anchored candidate beats a suffix-matching vendor dir',
'namespace dir: only a suffix-matching vendor dir exists',
];
const byName = new Map(HAND_CASES.map((testCase) => [testCase.name, testCase]));
const notWitnessed = witnesses.filter((name) => {
const testCase = byName.get(name);
if (testCase === undefined) return true;
const files = [...testCase.files];
const normalized = files.map((file) => file.replace(/\\/g, '/'));
// The raw index — exactly what a "just pass getWorkspaceFileIndex().index
// through" fix would have handed the resolver.
const rawIndexResult = resolvePhpImportInternal(
testCase.target,
testCase.composer ?? null,
new Set(files),
normalized,
files,
buildSuffixIndex(normalized, files),
);
return rawIndexResult === runBoth(testCase).legacy;
});
expect(notWitnessed).toEqual([]);
});
it('resolves real PHP imports (the differential is not vacuous)', () => {
const files = new Set([
'app/Models/User.php',
'app/Services/UserService.php',
'app/Models/functions.php',
]);
expect(
resolvePhpImportTargetInternal('App\\Models\\User', 'app/Main.php', files, APP_PSR4),
).toBe('app/Models/User.php');
expect(
resolvePhpImportTargetInternal('App\\Services\\UserService', 'app/Main.php', files, APP_PSR4),
).toBe('app/Services/UserService.php');
expect(
resolvePhpImportTargetInternal('Nope\\Missing', 'app/Main.php', files, APP_PSR4),
).toBeNull();
});
});
// ─── index reuse at the resolver level ───────────────────────────────────────
describe('PHP import-target index reuse (#2901)', () => {
/**
* Counts iterations of the file-set Set. This is the resolver-level half of
* the guard — a rescan reintroduced INSIDE the resolver. The adapter-level
* copy hazard is `test/integration/php-import-index-reuse.test.ts`.
*/
it('iterates the file set once for many imports with no composer.json', () => {
const files = new CountingSet(GENERATED_FILES);
const results: (string | null)[] = [];
for (const target of generatedTargets()) {
results.push(resolvePhpImportTargetInternal(target, 'app/Main.php', files, undefined));
}
expect(files.scans).toBe(1);
// No composer.json, so this is pure `suffixResolve`: the earliest file in
// Set order carrying `App/Models/Entity0.php` as a proper suffix wins, and
// the corpus deliberately puts the vendor mirror first.
expect(results[0]).toBe('vendor/pkg0/app/Models/Entity0.php');
expect(results.some((result) => result === null)).toBe(true);
});
it('iterates the file set once for many PSR-4 imports', () => {
const files = new CountingSet(GENERATED_FILES);
const results: (string | null)[] = [];
for (let i = 0; i < 12; i++) {
// Class-style hit (`allFiles.has`), namespace-directory fallback, and a
// third-party namespace that matches no PSR-4 prefix — the three legs
// that answer from the index or return before reaching one.
results.push(
resolvePhpImportTargetInternal(`App\\Models\\Entity${i}`, 'app/Main.php', files, APP_PSR4),
);
results.push(
resolvePhpImportTargetInternal(`App\\Models\\helper${i}`, 'app/Main.php', files, APP_PSR4),
);
results.push(
resolvePhpImportTargetInternal(`Psr\\Log\\Missing${i}`, 'app/Main.php', files, APP_PSR4),
);
}
expect(files.scans).toBe(1);
expect(results[0]).toBe('app/Models/Entity0.php');
expect(results[1]).toBe('app/Models/Entity0.php');
expect(results[2]).toBeNull();
});
/**
* `nsDir` keeps a PSR-4 mapping's trailing slash, while the directory bucket
* is keyed on the raw path's own parent (no trailing slash). Getting that
* wrong is invisible to every result assertion — the empty bucket just falls
* through to the scan, which returns the same file — so only the count sees
* it.
*/
it('answers a trailing-slash PSR-4 namespace directory from the index', () => {
const files = new CountingSet(['app/bootstrap.php', 'app/Models/User.php']);
const results: (string | null)[] = [];
for (let i = 0; i < 5; i++) {
results.push(
resolvePhpImportTargetInternal(
`App\\getUser${i}`,
'app/Main.php',
files,
TRAILING_SLASH_PSR4,
),
);
}
expect(files.scans).toBe(1);
expect(results[0]).toBe('app/bootstrap.php');
});
/**
* The last per-import traversal in PHP resolution, now closed.
*
* `resolvePhpImportInternal` used to run its namespace-directory scan
* whenever `getFilesInDir` came back EMPTY, not merely when no index was
* supplied — despite the comment above it saying "only when SuffixIndex
* unavailable":
*
* if (index) { const c = index.getFilesInDir(nsDir, '.php');
* if (c.length > 0) return c[0]; }
* for (const f of allFiles) { ... } // ran even WITH an index
*
* An empty bucket is the correct answer, so the scan could only ever confirm
* it — at the cost of one full pass for every import whose namespace matches
* a PSR-4 prefix but whose directory holds no direct `.php` child
* (`App\Legacy\…` here: `app/Legacy/` does not exist). Measured at 11
* traversals for 10 imports.
*
* The scan is now in the `else`, which is safe because the bucket is a
* SUPERSET of what the scan can find: a root-anchored direct child
* `nsDir/<x>.php` has its directory exactly equal to `nsDir`, and a
* directory is always one of its own suffixes — so both the shared
* suffix-keyed `dirMap` and this file's root-anchored parity index contain
* it. Empty superset implies empty scan.
*
* The results below are unchanged by that: these imports resolve through the
* later suffix leg, and the namespace-directory pass was pure waste.
*/
it('no longer scans per import when the PSR-4 namespace directory is empty', () => {
const files = new CountingSet(GENERATED_FILES);
const results: (string | null)[] = [];
for (let i = 0; i < 10; i++) {
results.push(
resolvePhpImportTargetInternal(`App\\Legacy\\Entity${i}`, 'app/Main.php', files, APP_PSR4),
);
}
// One build, and nothing per import. Was `1 + 10` before the `else`.
expect(files.scans).toBe(1);
// Paired result assertion: a traversal count of 1 must not be the count of
// a resolver that stopped answering. These resolve via the suffix leg.
expect(results.every((result) => result === 'src/App/Legacy/Entity0.php')).toBe(false);
expect(results[0]).toBe('src/App/Legacy/Entity0.php');
});
it('a distinct file set gets its own index', () => {
const a = new CountingSet(['app/Models/User.php']);
const b = new CountingSet(['lib/Other.php']);
expect(resolvePhpImportTargetInternal('App\\Models\\User', 'app/Main.php', a, undefined)).toBe(
'app/Models/User.php',
);
expect(
resolvePhpImportTargetInternal('App\\Models\\User', 'app/Main.php', b, undefined),
).toBeNull();
expect(resolvePhpImportTargetInternal('Other', 'app/Main.php', b, undefined)).toBe(
'lib/Other.php',
);
expect(a.scans).toBe(1);
expect(b.scans).toBe(1);
});
});