GitNexus/gitnexus/test/unit/scope-resolution/import-target-index-parity.test.ts
azizur100389 5fc518d2cb
fix(dart): resolve package imports by pubspec identity (#3369)
* fix(dart): resolve package imports by pubspec identity

* fix(dart): keep package-identity edges out of the cycle check

Pubspec identity edges invalidate importers when a manifest changes. They cannot form an init cycle, so the cycle query excludes them before the row cap. Discovery reads each manifest once, with a size bound, and resolution shares one package-URI parser with those edges.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3369)

- Reject package URIs with an empty library path so they do not emit identity edges
- Skip the pubspec permission test where chmod cannot deny reads
- Document that the Dart heap probe is not a uniqueTarget spelling

Note: pre-existing failure in test/unit/incremental-index-extension-dml-gate.test.ts not addressed by this PR.
Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(dart): list package directories through a no-follow descriptor

A directory replaced by a symlink between the parent listing and the next visit must not be traversed. The walk opens it with O_DIRECTORY|O_NOFOLLOW and lists that inode.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore(autofix): apply prettier + eslint fixes via /autofix command

* fix(mcp): keep the Dart identity reason out of MCP startup

The cycle query still excludes the same reason string. The constant now lives with the other non-initializing import reasons, so MCP startup does not load a language provider.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3369)

Open discovered pubspecs and child directories through the parent directory inode on Linux, so replacing that directory with a symlink cannot redirect the walk.

Note: pre-existing failure in test/unit/incremental-index-extension-dml-gate.test.ts (worker pool startup timeout) not addressed by this PR.
Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(dart): reject Windows junctions during pubspec walk

* Address PR review feedback (#3369)

Refuse pubspec discovery that cannot set O_NOFOLLOW, and verify macOS child opens against the pinned directory chain instead of reopening a mutable path.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(dart): bound live pubspec descriptors and close the macOS check-then-open

A deep directory chain held one descriptor per level until open failed with EMFILE, and macOS child opens statted the path before using it. Refuse the next directory at 64 live handles, and stat only the descriptor opened with O_NOFOLLOW.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(dart): open pubspecs non-blocking so a FIFO cannot hang discovery

A listed pubspec can be replaced by a FIFO before open. O_RDONLY alone waits inside open for a writer, so the file-type check never runs. O_NONBLOCK returns immediately and the walk rejects the non-regular file.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(dart): cap names read from each pubspec directory

readdir kept every entry before the visit budget could run, so one huge directory could allocate without bound. Read the listing one name at a time and fail closed past 100,000 entries.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(dart): reject a pubspec that grows while its descriptor is read

The size cap was taken from the stat before the read, so a file that grew in that window could be parsed from a short prefix. Re-stat the same descriptor afterward and fail closed when the size no longer matches the bytes captured.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ci): rebaseline the Dart scope-capture fingerprint for package-import fixtures

The benchmark hashes every dart-* fixture. The new package-import corpus adds six Dart files and 33 capture groups. Parking that directory restores the previous fingerprint, so this is corpus growth, not a capture change.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-09-26 13:21:09 +01:00

912 lines
35 KiB
TypeScript

/**
* Differential harness for the import-target index hoist (#2877 go, #2878
* csharp, #2879 dart, #2880 ruby).
*
* Each of those resolvers answered its lookups with a full `allFilePaths` scan
* per import — Ruby went further and rebuilt a whole `buildSuffixIndex` per
* `require`. Replacing the scans with a per-run index is a pure performance
* change ONLY if every implicit tie-break survives, and those tie-breaks are
* expressed through Set-iteration order and `indexOf` positions rather than
* through anything the type system or the existing tests can see:
*
* - Go sorts the root-package leg and does NOT sort the package-dir leg;
* - Go and C# answer when the file's PARENT directory ends with the queried
* segment. Both took the FIRST occurrence of `/<segment>/` until #2881, so a
* directory nested inside a same-named one did not match;
* - C#'s `resolveDirectMatch` lets a whole-path match win over a suffix match
* found EARLIER in iteration order, while `resolveByProgressiveStripping`
* takes whichever comes first;
* - Dart's relative suffix fallback compares raw workspace paths. Package
* identity changed in #2963 and is covered by separate boundary tests.
*
* So this file keeps copies of the pre-change implementations and asserts the
* new ones agree with them on a deterministic corpus built to force exactly
* those cases. The copies are the specification; if a future change makes one of
* these fail, the resolver's OUTPUT moved and the graph's edges move with it.
*
* They were verbatim until #2881, which deliberately changed one rule and so had
* to edit them too. Read them now as an independent re-derivation of the CURRENT
* spec, not as a frozen record of what shipped before the hoist — a weaker claim,
* and the reason the hand-built arm below pins ABSOLUTE expectations as well as
* differential ones: a differential where both sides were edited together proves
* only self-consistency.
*
* The second half asserts the index is built once per file set rather than once
* per import, by counting how often the Set is iterated. It is the DETERMINISTIC
* guard against a scan reintroduced beside a reused index, which the benchmark
* provably cannot see: a full workspace scan on 1-in-32 imports scores 1.458
* against a 1.8 scaling budget and 1.736 ms against a 4 ms ceiling — it passes
* everything — while this counter reads 14 instead of 1. Timing gates catch the
* constant factor; this catches the scan. Kotlin (#2872) is covered there too.
*
* It is NOT the guard for PR #1918 review finding P1. That failure — a
* defensive `new Set(allFilePaths)` in the orchestrator ADAPTER, handing a fresh
* `WeakMap` key per call — lives one layer above everything in this file, which
* calls the resolver functions directly. Inserting that copy into
* `<lang>/scope-resolver.ts` leaves every arm here green. The adapter is guarded
* by the per-language reuse tests and the shared import-target reuse contract.
*/
import { describe, expect, it } from 'vitest';
import type {
ParsedFile,
ParsedImport,
ScopeId,
SymbolDefinition,
WorkspaceIndex,
} from 'gitnexus-shared';
import { resolveGoImportTarget } from '../../../src/core/ingestion/languages/go/import-target.js';
import { resolveDartImportTarget } from '../../../src/core/ingestion/languages/dart/import-target.js';
import { resolveRubyImportTarget } from '../../../src/core/ingestion/languages/ruby/import-target.js';
import {
resolveCsharpImportTarget,
type CsharpResolveContext,
} from '../../../src/core/ingestion/languages/csharp/import-target.js';
import { resolveKotlinImportTarget } from '../../../src/core/ingestion/languages/kotlin/import-target.js';
import {
clearKotlinPackageFacts,
setKotlinPackageFact,
} from '../../../src/core/ingestion/languages/kotlin/package-facts.js';
import { resolveRubyImportInternal } from '../../../src/core/ingestion/import-resolvers/ruby.js';
import { buildSuffixIndex } from '../../../src/core/ingestion/import-resolvers/utils.js';
import { isHeritageMarker } from '../../../src/core/ingestion/utils/heritage-marker.js';
import { csharpSuffixFallbackAllowed } from '../../../src/core/ingestion/csharp-namespace-gate.js';
import { DART_HERITAGE_PREFIX } from '../../../src/core/ingestion/languages/dart/interpret.js';
import { CountingSet, countedParsedFilesWith } from '../../helpers/counting-file-set.js';
// ─── pre-change implementations, minus the rule #2881 removed ────────────────
function legacyFindRootPackageFiles(allFilePaths: ReadonlySet<string>): string[] {
const result: string[] = [];
for (const raw of allFilePaths) {
const normalized = raw.replace(/\\/g, '/');
if (normalized.includes('/')) continue;
if (!normalized.endsWith('.go') || normalized.endsWith('_test.go')) continue;
result.push(raw);
}
return result.sort();
}
function legacyFindAllFilesInPkgDir(allFilePaths: ReadonlySet<string>, pkgPath: string): string[] {
const pkgDir = '/' + pkgPath + '/';
const result: string[] = [];
for (const raw of allFilePaths) {
const normalized = '/' + raw.replace(/\\/g, '/');
if (!normalized.includes(pkgDir)) continue;
if (!normalized.endsWith('.go') || normalized.endsWith('_test.go')) continue;
// `lastIndexOf` since #2881: `pkgDir` is '/'-anchored on both sides, so the
// LAST occurrence is the file's own parent. `indexOf` asked for the first,
// which made `a/pkg/b/pkg/x.go` not a member of `pkg`.
const afterPkg = normalized.substring(normalized.lastIndexOf(pkgDir) + pkgDir.length);
if (!afterPkg.includes('/')) result.push(raw);
}
return result;
}
function legacyResolveGoImportTarget(
targetRaw: string,
allFilePaths: ReadonlySet<string>,
modulePath: string | undefined,
): string | readonly string[] | null {
if (!targetRaw) return null;
if (modulePath !== undefined) {
const ownedByModule = targetRaw === modulePath || targetRaw.startsWith(`${modulePath}/`);
if (!ownedByModule) return null;
const relativePkg = targetRaw === modulePath ? '' : targetRaw.slice(modulePath.length + 1);
const files =
relativePkg === ''
? legacyFindRootPackageFiles(allFilePaths)
: legacyFindAllFilesInPkgDir(allFilePaths, relativePkg);
if (files.length > 0) return files;
return null;
}
const parts = targetRaw.split('/').filter(Boolean);
for (let i = 0; i < parts.length - 1; i++) {
const files = legacyFindAllFilesInPkgDir(allFilePaths, parts.slice(i).join('/'));
if (files.length > 0) return files;
}
return null;
}
function legacyResolveDartRelative(
rel: string,
fromFile: string,
allFilePaths: ReadonlySet<string>,
): string | null {
const normFrom = fromFile.replace(/\\/g, '/');
const fromDir = normFrom.includes('/') ? normFrom.slice(0, normFrom.lastIndexOf('/')) : '';
const parts = fromDir.length > 0 ? fromDir.split('/') : [];
for (const seg of rel.replace(/\\/g, '/').split('/')) {
if (seg === '' || seg === '.') continue;
if (seg === '..') parts.pop();
else parts.push(seg);
}
const target = parts.join('/');
if (allFilePaths.has(target)) return target;
for (const fp of allFilePaths) {
if (fp === target || fp.endsWith('/' + target)) return fp;
}
return null;
}
function legacyResolveDartImportTarget(
targetRaw: string,
fromFile: string,
allFilePaths: ReadonlySet<string>,
): string | readonly string[] | null {
if (targetRaw.startsWith(DART_HERITAGE_PREFIX)) return null;
if (targetRaw === '') return null;
if (targetRaw.startsWith('dart:')) return null;
if (targetRaw.startsWith('package:')) {
const slash = targetRaw.indexOf('/');
if (slash === -1) return null;
const relPath = targetRaw.slice(slash + 1);
for (const candidate of [`lib/${relPath}`, relPath]) {
for (const fp of allFilePaths) {
if (fp === candidate || fp.endsWith('/' + candidate)) return fp;
}
}
return null;
}
return legacyResolveDartRelative(targetRaw, fromFile, allFilePaths);
}
function legacyResolveRubyBare(
targetRaw: string,
allFilePaths: ReadonlySet<string>,
): string | null {
const normalizedFileList = [...allFilePaths].map((f) => f.replace(/\\/g, '/'));
const allFileList = [...allFilePaths];
const index = buildSuffixIndex(normalizedFileList, allFileList);
return resolveRubyImportInternal(targetRaw, normalizedFileList, allFileList, index);
}
function legacyResolveRubyRelative(
targetRaw: string,
fromDir: string,
allFilePaths: ReadonlySet<string>,
): string | null {
const segments = (fromDir ? fromDir + '/' + targetRaw : targetRaw).split('/');
const resolved: string[] = [];
for (const seg of segments) {
if (seg === '' || seg === '.') continue;
if (seg === '..') resolved.pop();
else resolved.push(seg);
}
const resolvedPath = resolved.join('/');
const rbFile = `${resolvedPath}.rb`;
if (allFilePaths.has(rbFile)) return rbFile;
const indexFile = `${resolvedPath}/index.rb`;
if (allFilePaths.has(indexFile)) return indexFile;
if (resolvedPath.endsWith('.rb') && allFilePaths.has(resolvedPath)) return resolvedPath;
return null;
}
function legacyResolveRubyImportTarget(
targetRaw: string,
fromFile: string,
allFilePaths: ReadonlySet<string>,
): string | readonly string[] | null {
if (!targetRaw) return null;
if (isHeritageMarker(targetRaw)) return null;
const fromNormalized = fromFile.replace(/\\/g, '/');
const fromDir = fromNormalized.includes('/')
? fromNormalized.slice(0, fromNormalized.lastIndexOf('/'))
: '';
if (targetRaw.startsWith('./') || targetRaw.startsWith('../')) {
return legacyResolveRubyRelative(targetRaw, fromDir, allFilePaths);
}
return legacyResolveRubyBare(targetRaw, allFilePaths);
}
function legacyFindDirectChild(
allFilePaths: ReadonlySet<string>,
dirSegment: string,
): string | null {
// Since #2881 this is plain "the file's parent directory ends with
// `dirSegment`". The `atRoot`-then-`indexOf` pair it replaces expressed the
// same thing PLUS "…and that occurrence is the first", which is the half that
// was removed; the segment anchoring the leading '/' provided is kept by
// testing `'/' + dir + '/'` against `'/' + dirSegment + '/'`.
const needle = `/${dirSegment}/`;
for (const raw of allFilePaths) {
const f = raw.replace(/\\/g, '/');
if (!f.endsWith('.cs')) continue;
const lastSlash = f.lastIndexOf('/');
if (lastSlash < 0) continue;
if (!`/${f.slice(0, lastSlash)}/`.endsWith(needle)) continue;
return raw;
}
return null;
}
function legacyResolveDirectMatch(
allFilePaths: ReadonlySet<string>,
pathLike: string,
): string | null {
const exactName = `${pathLike}.cs`;
const nestedSuffix = `/${exactName}`;
let suffixFile: string | null = null;
for (const raw of allFilePaths) {
const f = raw.replace(/\\/g, '/');
if (!f.endsWith('.cs')) continue;
if (f === exactName) return raw;
if (suffixFile === null && f.endsWith(nestedSuffix)) suffixFile = raw;
}
if (suffixFile !== null) return suffixFile;
return legacyFindDirectChild(allFilePaths, pathLike);
}
function legacyResolveByProgressiveStripping(
allFilePaths: ReadonlySet<string>,
pathLike: string,
): string | null {
const segments = pathLike.split('/').filter(Boolean);
for (let skip = 1; skip < segments.length; skip++) {
const tail = segments.slice(skip).join('/');
if (tail === '') continue;
const tailFile = `${tail}.cs`;
const tailSuffix = `/${tailFile}`;
let tailFileMatch: string | null = null;
for (const raw of allFilePaths) {
const f = raw.replace(/\\/g, '/');
if (!f.endsWith('.cs')) continue;
if (f === tailFile || f.endsWith(tailSuffix)) {
tailFileMatch = raw;
break;
}
}
if (tailFileMatch !== null) return tailFileMatch;
const child = legacyFindDirectChild(allFilePaths, tail);
if (child !== null) return child;
}
return null;
}
/** The no-csproj leg, which is the half #2878 moved onto the index. */
function legacyResolveCsharpNoCsproj(
targetRaw: string,
allFilePaths: ReadonlySet<string>,
): string | null {
if (targetRaw === '') return null;
const pathLike = targetRaw.replace(/\./g, '/');
if (!csharpSuffixFallbackAllowed(targetRaw, undefined)) return null;
const direct = legacyResolveDirectMatch(allFilePaths, pathLike);
if (direct !== null) return direct;
return legacyResolveByProgressiveStripping(allFilePaths, pathLike);
}
// ─── current implementations, called the way the orchestrator calls them ─────
function csharp(targetRaw: string, allFilePaths: ReadonlySet<string>): string | null {
const ws: CsharpResolveContext = { fromFile: 'App/Program.cs', allFilePaths };
const parsedImport: ParsedImport = {
kind: 'namespace',
localName: '_',
importedName: '_',
targetRaw,
};
return resolveCsharpImportTarget(parsedImport, ws as unknown as WorkspaceIndex);
}
// ─── deterministic corpus ────────────────────────────────────────────────────
/** Murmur3 finalizer — a reproducible stand-in for `Math.random()`. */
function mix(n: number): number {
let x = n >>> 0;
x = Math.imul(x ^ (x >>> 16), 0x85ebca6b) >>> 0;
x = Math.imul(x ^ (x >>> 13), 0xc2b2ae35) >>> 0;
return (x ^ (x >>> 16)) >>> 0;
}
/**
* Directory shapes, chosen so the corpus contains every case where the two
* candidate predicates disagree: a directory name nested inside itself
* (`pkg/pkg`, `a/pkg/b/pkg`), the same leaf under several parents (collision
* tie-breaks), an absolute-rooted layout, and the repo root.
*
* The nested shapes were originally here to prove the "does the dir end with
* the segment" rewrite was NOT safe, because the shipped predicate additionally
* required the first `indexOf` occurrence. #2881 removed that requirement and
* made the ends-with form the shipped one, so these shapes now pin the removal
* instead — same shapes, opposite verdict, and still the only ones that can
* tell the two apart.
*/
const DIRS = [
'',
'pkg',
'a/pkg',
'b/pkg',
'pkg/pkg',
'a/pkg/b/pkg',
'internal/models',
'x/internal/models',
'lib',
'lib/src',
'vendor/lib',
'/repo/pkg',
'/repo/internal/models',
'Models',
'App/Models',
'Models/Models',
];
const STEMS = ['main', 'models', 'util', 'client', 'index', 'pkg', 'lib', 'server'];
function corpus(seed: number, extension: string, fileCount: number): Set<string> {
const files = new Set<string>();
for (let i = 0; i < fileCount; i++) {
const a = mix(seed * 7919 + i);
const b = mix(a ^ 0x9e3779b9);
const dir = DIRS[a % DIRS.length];
const stem = STEMS[b % STEMS.length];
// A slice of Go files are `_test.go`, which the package leg must exclude.
const suffix = extension === '.go' && b % 5 === 0 ? '_test.go' : extension;
const rel = `${stem}${suffix}`;
files.add(dir === '' ? rel : `${dir}/${rel}`);
}
// Windows-shaped paths: the resolvers differ on whether they normalize, and
// that difference is part of what must not move.
files.add(`win\\dir\\thing${extension}`);
return files;
}
const GO_TARGETS = [
'',
'fmt',
'os',
'pkg',
'a/pkg',
'pkg/pkg',
'b/pkg',
'internal/models',
'x/internal/models',
'example.com/mod',
'example.com/mod/pkg',
'example.com/mod/internal/models',
'example.com/mod/nope',
'github.com/org/repo/pkg',
'github.com/org/repo/internal/models',
'golang.org/x/sync/errgroup',
'lib/src',
'win/dir',
];
const DART_TARGETS = [
'',
'dart:core',
// Package identity intentionally changed in #2963; legacy parity applies
// only to relative imports. Package boundaries have dedicated regressions.
'../../lib/models.dart',
'../../lib/src/models.dart',
'../../models.dart',
'../util.dart',
'models.dart',
'./models.dart',
'../models.dart',
'../../lib/util.dart',
'src/models.dart',
'win\\dir\\thing.dart',
`${DART_HERITAGE_PREFIX}Foo`,
];
const RUBY_TARGETS = [
'',
'json',
'models',
'util',
'pkg/models',
'internal/models',
'./models',
'../util',
'./index',
'lib/src/client',
'Models',
// Addresses the corpus's `win\dir\thing.rb`. Without a target that reaches
// it, deleting the `raw.replace(/\\/g, '/')` normalization in
// `workspace-file-index.ts` moves no assertion here: the backslash file is in
// every corpus but nothing asked for it. Spelled with forward slashes because
// that is how a `require` is written; the normalization is what bridges the
// two spellings.
'win/dir/thing',
];
const CSHARP_TARGETS = [
'',
'System',
'System.Threading.Tasks',
'Models',
'App.Models',
'Models.Models',
'App.Models.Client',
'Internal.Models',
'X.Internal.Models',
'Lib.Src',
'Pkg.Pkg',
// The C# twin of the Ruby entry above: addresses the corpus's
// `win\dir\thing.cs` so the deletion of `workspace-file-index.ts`'s
// normalization has something to break. Lowercase because the corpus spells
// the file that way and the C# direct/suffix lookups are case-SENSITIVE
// (`getInsensitive` is only reached from the csproj leg).
'win.dir.thing',
];
const DART_FROM_FILES = ['lib/main.dart', 'lib/src/main.dart', '/repo/pkg/main.dart', 'main.dart'];
const RUBY_FROM_FILES = ['lib/main.rb', 'lib/src/main.rb', 'a/pkg/main.rb', 'main.rb'];
// ─── parity ──────────────────────────────────────────────────────────────────
describe('import-target index hoist — output parity with the pre-change scans', () => {
it('go: resolveGoImportTarget (#2877)', () => {
let checked = 0;
for (let repo = 0; repo < 40; repo++) {
const files = corpus(repo, '.go', 6 + (repo % 25));
for (const modulePath of [undefined, 'example.com/mod']) {
const config = modulePath === undefined ? undefined : { modulePath };
for (const target of GO_TARGETS) {
const actual = resolveGoImportTarget(target, 'main.go', files, config);
const expected = legacyResolveGoImportTarget(target, files, modulePath);
// Array identity AND order: Go returns the whole list as the edge's
// targets, so a reordering is an observable change.
expect(actual, `go ${target} module=${modulePath} repo=${repo}`).toEqual(expected);
checked++;
}
}
}
expect(checked).toBe(40 * 2 * GO_TARGETS.length);
});
it('dart: resolveDartImportTarget (#2879)', () => {
let checked = 0;
for (let repo = 0; repo < 40; repo++) {
const files = corpus(repo, '.dart', 6 + (repo % 25));
for (const fromFile of DART_FROM_FILES) {
for (const target of DART_TARGETS) {
const actual = resolveDartImportTarget(target, fromFile, files);
const expected = legacyResolveDartImportTarget(target, fromFile, files);
expect(actual, `dart ${target} from=${fromFile} repo=${repo}`).toEqual(expected);
checked++;
}
}
}
expect(checked).toBe(40 * DART_FROM_FILES.length * DART_TARGETS.length);
});
it('ruby: resolveRubyImportTarget (#2880)', () => {
let checked = 0;
for (let repo = 0; repo < 40; repo++) {
const files = corpus(repo, '.rb', 6 + (repo % 25));
for (const fromFile of RUBY_FROM_FILES) {
for (const target of RUBY_TARGETS) {
const actual = resolveRubyImportTarget(target, fromFile, files);
const expected = legacyResolveRubyImportTarget(target, fromFile, files);
expect(actual, `ruby ${target} from=${fromFile} repo=${repo}`).toEqual(expected);
checked++;
}
}
}
expect(checked).toBe(40 * RUBY_FROM_FILES.length * RUBY_TARGETS.length);
});
it('csharp: the no-csproj path (#2878)', () => {
let checked = 0;
for (let repo = 0; repo < 40; repo++) {
const files = corpus(repo, '.cs', 6 + (repo % 25));
for (const target of CSHARP_TARGETS) {
const actual = csharp(target, files);
const expected = legacyResolveCsharpNoCsproj(target, files);
expect(actual, `csharp ${target} repo=${repo}`).toEqual(expected);
checked++;
}
}
expect(checked).toBe(40 * CSHARP_TARGETS.length);
});
/**
* Hand-built layouts for the tie-breaks the generated corpus cannot reach —
* every one of these was verified to FAIL against a plausible "simplified"
* rewrite of the resolver it covers. `new Set([...])` preserves insertion
* order, which IS the tie-break for most of them.
*/
const HANDBUILT: {
lang: 'go' | 'dart' | 'ruby' | 'csharp';
why: string;
files: string[];
target: string;
fromFile?: string;
modulePath?: string;
}[] = [
{
lang: 'csharp',
why: 'whole-path match wins over a suffix match found EARLIER in Set order',
files: ['a/Models.cs', 'Models.cs'],
target: 'Models',
},
{
lang: 'csharp',
why: 'no whole-path file: first suffix match in Set order wins',
files: ['b/Models.cs', 'a/Models.cs'],
target: 'Models',
},
{
lang: 'csharp',
why: 'direct child of a NESTED namespace dir beats the root-level one when it comes first',
files: ['App/Models/First.cs', 'Models/Second.cs'],
target: 'Models',
},
{
lang: 'csharp',
why: 'a namespace dir nested inside itself DOES answer the query (#2881)',
files: ['Models/Models/User.cs'],
target: 'Models',
},
{
lang: 'csharp',
why: 'progressive prefix stripping reaches the tail namespace dir',
files: ['Models/User.cs'],
target: 'CrossFile.Models',
},
{
// `normToRaw` keeps the FIRST raw path per normalized key
// (`workspace-file-index.ts`), mirroring the `for (const raw of
// allFilePaths)` scan it replaced. No GENERATED corpus file set contains
// a normalization twin, so flipping that to last-wins moved nothing —
// the rule lived in a comment. Two spellings of one path, and only the
// first-wins reading returns the backslash one.
lang: 'csharp',
why: 'normToRaw keeps the FIRST raw path that normalizes to a key, not the last',
files: ['App\\Models.cs', 'App/Models.cs'],
target: 'App.Models',
},
{
lang: 'go',
why: 'root package leg is SORTED',
files: ['b.go', 'a.go', 'c_test.go'],
target: 'example.com/mod',
modulePath: 'example.com/mod',
},
{
lang: 'go',
why: 'package-dir leg is NOT sorted — it keeps Set order',
files: ['x/pkg/b.go', 'x/pkg/a.go'],
target: 'example.com/mod/x/pkg',
modulePath: 'example.com/mod',
},
{
lang: 'go',
why: 'two directories share the suffix: results interleave back into Set order',
files: ['a/pkg/one.go', 'b/a/pkg/two.go', 'a/pkg/three.go'],
target: 'a/pkg',
},
{
lang: 'go',
why: 'a package dir nested inside itself DOES answer the query (#2881)',
files: ['a/pkg/b/pkg/x.go'],
// Addressed through the MODULE leg as the single segment `pkg`, not as
// `a/pkg`. `a/pkg` never reached the first-occurrence branch this case
// was named for: `'/a/pkg/b/pkg/'.endsWith('/a/pkg/')` is already false,
// so the `endsWith` form agreed with the old predicate and the case
// passed either way. With `pkg`, `endsWith('/pkg/')` is TRUE and ONLY the
// "…and that occurrence is the FIRST" half rejected it — which is exactly
// why this case is the one that flips, and why it is still the case that
// tells the two predicates apart. The module leg is required because the
// GOPATH cascade skips single-segment targets.
target: 'example.com/mod/pkg',
modulePath: 'example.com/mod',
},
{
lang: 'go',
why: '_test.go files are a different package and never match',
files: ['x/pkg/a_test.go'],
target: 'x/pkg',
},
{
lang: 'dart',
why: 'relative suffix lookup keeps the importer directory prefix',
files: ['a/models.dart', 'z/lib/models.dart'],
target: 'models.dart',
},
{
lang: 'dart',
why: 'relative root lookup reaches a nested suffix',
files: ['a/models.dart'],
target: 'models.dart',
fromFile: 'main.dart',
},
{
lang: 'dart',
why: 'paths are matched RAW — a backslash path is not normalized into a hit',
files: ['win\\dir\\thing.dart'],
target: 'dir/thing.dart',
},
{
lang: 'dart',
why: 'relative targets normalize backslashes before exact membership lookup',
files: ['lib/dir/thing.dart'],
target: 'dir\\thing.dart',
},
{
lang: 'dart',
why: 'relative import prefers the exact path over an earlier suffix hit',
files: ['z/lib/src/models.dart', 'lib/src/models.dart'],
target: './models.dart',
fromFile: 'lib/src/main.dart',
},
{
lang: 'ruby',
why: 'bare require suffix match keeps its first-in-order winner',
files: ['a/json.rb', 'json.rb'],
target: 'json',
},
{
lang: 'ruby',
why: 'require_relative resolves against the importer dir before any suffix match',
files: ['z/lib/util.rb', 'lib/util.rb'],
target: './util',
fromFile: 'lib/main.rb',
},
];
it.each(HANDBUILT)('$lang hand-built: $why', ({ lang, files, target, fromFile, modulePath }) => {
const set = new Set(files);
if (lang === 'go') {
const config = modulePath === undefined ? undefined : { modulePath };
expect(resolveGoImportTarget(target, 'main.go', set, config)).toEqual(
legacyResolveGoImportTarget(target, set, modulePath),
);
return;
}
if (lang === 'dart') {
const from = fromFile ?? 'lib/main.dart';
expect(resolveDartImportTarget(target, from, set)).toEqual(
legacyResolveDartImportTarget(target, from, set),
);
return;
}
if (lang === 'ruby') {
const from = fromFile ?? 'lib/main.rb';
expect(resolveRubyImportTarget(target, from, set)).toEqual(
legacyResolveRubyImportTarget(target, from, set),
);
return;
}
expect(csharp(target, set)).toEqual(legacyResolveCsharpNoCsproj(target, set));
});
it('every hand-built layout resolves to something (they pin a winner, not a null)', () => {
// `toEqual(null) === toEqual(null)` would make the arm above pass for the
// wrong reason. Only the "must NOT match" layouts may be null. The two
// nested-inside-itself layouts left this set in #2881: they now resolve, so
// they are held to the same "pin a winner" bar as everything else, which is
// a stronger assertion than the null they used to carry.
const mustBeNull = new Set([
'_test.go files are a different package and never match',
'paths are matched RAW — a backslash path is not normalized into a hit',
]);
for (const c of HANDBUILT) {
const set = new Set(c.files);
const got =
c.lang === 'go'
? legacyResolveGoImportTarget(c.target, set, c.modulePath)
: c.lang === 'dart'
? legacyResolveDartImportTarget(c.target, c.fromFile ?? 'lib/main.dart', set)
: c.lang === 'ruby'
? legacyResolveRubyImportTarget(c.target, c.fromFile ?? 'lib/main.rb', set)
: legacyResolveCsharpNoCsproj(c.target, set);
if (mustBeNull.has(c.why)) expect(got, c.why).toBeNull();
else expect(got, c.why).not.toBeNull();
}
});
it('the corpus actually exercises the resolvers (the parity arms are not vacuous)', () => {
// A corpus that resolved nothing would make every arm above pass on
// `null === null`. Pin a floor on real hits per language.
const hits = { go: 0, dart: 0, ruby: 0, csharp: 0 };
for (let repo = 0; repo < 40; repo++) {
const go = corpus(repo, '.go', 6 + (repo % 25));
for (const t of GO_TARGETS) {
if (resolveGoImportTarget(t, 'main.go', go) !== null) {
hits.go++;
}
}
const dart = corpus(repo, '.dart', 6 + (repo % 25));
for (const t of DART_TARGETS) {
if (resolveDartImportTarget(t, 'lib/src/main.dart', dart) !== null) hits.dart++;
}
const ruby = corpus(repo, '.rb', 6 + (repo % 25));
for (const t of RUBY_TARGETS) {
if (resolveRubyImportTarget(t, 'lib/src/main.rb', ruby) !== null) hits.ruby++;
}
const cs = corpus(repo, '.cs', 6 + (repo % 25));
for (const t of CSHARP_TARGETS) {
if (csharp(t, cs) !== null) hits.csharp++;
}
}
// Historical corpus counts: go 366, dart 75, ruby 259, csharp 220. Ruby and
// C# gained 40 each from the `win\dir\thing.<ext>` targets — one per repo,
// which is also the floor those two arms now defend. #2881 moved go 364 ->
// 366 and csharp 196 -> 220, from the corpus's `pkg/pkg`, `a/pkg/b/pkg` and
// `Models/Models` directories: those now answer their own name. The floors
// are deliberately NOT raised to lock that in — they exist to catch an arm
// that stopped resolving at all, and a revert of #2881 is caught precisely
// by the differential arms above, which compare against the real resolver.
expect(hits.go).toBeGreaterThan(300);
expect(hits.dart).toBeGreaterThan(60);
expect(hits.ruby).toBeGreaterThan(220);
expect(hits.csharp).toBeGreaterThan(160);
});
});
// ─── index reuse ─────────────────────────────────────────────────────────────
/**
* `CountingSet` (`test/helpers/counting-file-set.ts`) counts full traversals of
* the file set by every entry point — `for…of`, spread, `forEach`, `values`,
* `keys`, `entries`. Each index build in these resolvers walks the set exactly
* once, so on a stable set the scan count is the number of index builds PLUS
* any scan reintroduced beside a reused index, which is the mutation the
* benchmark cannot see.
*
* The number is not a complete scan census, and the docstring here used to
* claim it was. It watches the SET; the resolvers hold materialized arrays of
* the same file list (`WorkspaceFileIndex.normalized` / `.all`, Dart's basename
* buckets, `PackageDirIndex.filesByDir`), and a scan over one of those arrays
* moves nothing here. Closing that would mean instrumenting production or
* proxying an index internal for a test; neither is in place, so treat these
* arms as covering set-level scans only.
*
* These arms drive the resolver FUNCTIONS directly, which is one layer below
* the `new Set(allFilePaths)` hazard they are sometimes cited for: production
* reaches the resolvers through `<lang>ScopeResolver.resolveImportTarget`, and
* a defensive copy inserted in that adapter leaves every arm below green. The
* adapter-level guards are the per-language reuse tests and
* `test/unit/scope-resolution/import-target-index-reuse.contract.test.ts`.
*/
function countingCorpus(seed: number, extension: string): CountingSet {
return new CountingSet(corpus(seed, extension, 200));
}
describe('import-target index hoist — built once per file set, not once per import', () => {
it('go builds one index for many imports (#2877)', () => {
const files = countingCorpus(1, '.go');
for (let i = 0; i < 200; i++) {
resolveGoImportTarget(`example.com/mod/repo${i}/pkg`, 'main.go', files, {
modulePath: 'example.com/mod',
});
}
// One pass: the package-dir index (root files are collected in the same pass).
expect(files.scans).toBe(1);
});
it('dart builds one index for many imports (#2879)', () => {
const files = countingCorpus(2, '.dart');
for (let i = 0; i < 200; i++) {
resolveDartImportTarget(`pkg${i}/ghost${i}.dart`, 'lib/main.dart', files);
}
expect(files.scans).toBe(1);
});
it('ruby builds one index for many requires (#2880)', () => {
const files = countingCorpus(3, '.rb');
for (let i = 0; i < 200; i++) {
resolveRubyImportTarget(`ghost${i}/missing`, 'lib/main.rb', files);
}
expect(files.scans).toBe(1);
});
it('csharp builds one index per structure for many usings (#2878)', () => {
const files = countingCorpus(4, '.cs');
for (let i = 0; i < 200; i++) {
csharp(`Ghost${i}.Missing.Deep`, files);
}
// Two passes: the shared workspace/suffix index and the namespace-dir index.
expect(files.scans).toBe(2);
});
it('kotlin builds one declared-package index for many imports (#2872, #2960)', () => {
const paths = ['flat/TargetSource.kt', 'app/App.kt'];
const files = new CountingSet(paths);
const counted = countedParsedFilesWith(paths, (filePath): ParsedFile => {
const moduleScope = `module:${filePath}` as ScopeId;
const name = filePath === 'flat/TargetSource.kt' ? 'Target' : 'App';
const def: SymbolDefinition = {
nodeId: `Class:${filePath}:${name}`,
filePath,
type: 'Class',
qualifiedName: name,
};
return {
filePath,
moduleScope,
scopes: [
{
id: moduleScope,
parent: null,
kind: 'Module',
range: { startLine: 1, startCol: 0, endLine: 1, endCol: 1 },
filePath,
bindings: new Map([[name, [{ def, origin: 'local' }]]]),
ownedDefs: [def],
},
],
parsedImports: [],
localDefs: [def],
referenceSites: [],
};
});
clearKotlinPackageFacts();
setKotlinPackageFact('flat/TargetSource.kt', {
status: 'known',
packageName: 'com.example',
});
setKotlinPackageFact('app/App.kt', { status: 'known', packageName: 'app' });
try {
const workspace = {
fromFile: 'app/App.kt',
allFilePaths: files,
parsedFiles: counted.parsedFiles,
} as unknown as WorkspaceIndex;
for (let i = 0; i < 200; i++) {
resolveKotlinImportTarget(
{
kind: 'named',
localName: 'X',
importedName: 'X',
targetRaw: `ghost${i}.deep.Missing`,
},
workspace,
);
}
expect(
resolveKotlinImportTarget(
{
kind: 'named',
localName: 'Target',
importedName: 'Target',
targetRaw: 'com.example.Target',
},
workspace,
),
).toBe('flat/TargetSource.kt');
expect(files.scans).toBe(0);
expect(counted.reads()).toBe(paths.length);
} finally {
clearKotlinPackageFacts();
}
});
it('a distinct file set gets its own index (no stale cross-run reuse)', () => {
const a = countingCorpus(5, '.go');
const b = countingCorpus(6, '.go');
for (let i = 0; i < 20; i++) {
resolveGoImportTarget('github.com/org/repo/pkg', 'main.go', a, undefined);
resolveGoImportTarget('github.com/org/repo/pkg', 'main.go', b, undefined);
}
expect(a.scans).toBe(1);
expect(b.scans).toBe(1);
});
});