/**
 * 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);
  });
});
