GitNexus/gitnexus/test/helpers/counting-file-set.ts
azizur100389 b77d6f662b
Some checks failed
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / Classify release event (push) Waiting to run
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
Skill copy sync / shipped skills drift guard (push) Has been cancelled
fix(kotlin): resolve imports from declared packages (#2990)
2026-08-18 20:47:30 -07:00

493 lines
21 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.

/**
* A `Set<string>` that counts how many times it is TRAVERSED in full — the one
* measuring instrument behind every import-target index-reuse guard
* (`test/unit/scope-resolution/import-target-index-parity.test.ts`,
* `test/unit/scope-resolution/import-target-index-reuse.contract.test.ts`, and
* the per-language `test/integration/<lang>-import-index-reuse.test.ts` files).
*
* ## Why a counting Set rather than a production build counter
*
* Kotlin and Python used to count index BUILDS, through a counter module that
* shipped in production for no reason but this observation (deleted in #2909).
* A build count catches the per-import rebuild, but it is blind to a scan added
* BESIDE a reused index: the cache still hits, the count still reads 1. Counting
* traversals of the file set instead needs no production surface at all and
* catches both failures with one number:
*
* - an adapter that copies the set (`new Set(allFilePaths)`) hands a fresh
* `WeakMap` key per import, so the count rises to the import count;
* - a scan reintroduced next to the index raises the count by one per scan.
*
* The second is the case the benchmark provably cannot see: a full workspace
* scan on 1-in-32 imports passes every timing arm in `bench/import-target/`
* (measured, see `baselines.json` `_blind_spot`) while this counter reads 14
* instead of 1.
*
* ## Why every traversal entry point is overridden
*
* `for…of`, spread and `new Set(x)` go through `[Symbol.iterator]`, but
* `forEach`, `values`, `keys` and `entries` walk the same elements without
* touching it. Overriding only `[Symbol.iterator]` would let a reintroduced
* scan spelled `allFilePaths.forEach(…)` or `[...allFilePaths.values()]` sit
* under the guard uncounted. `Set.prototype[Symbol.iterator]` and
* `Set.prototype.values` are the same function object in the spec, but the
* `super.*` lookups below resolve on `Set.prototype`, not on this subclass, so
* a single traversal is still counted exactly once.
*
* ## What it does NOT see
*
* Only traversals of the SET. Once an index has materialized the file list into
* an array (`WorkspaceFileIndex.normalized` / `.all`, Dart's basename buckets,
* `PackageDirIndex.filesByDir`), a scan over that array is invisible here.
* Guarding that would mean either instrumenting production or proxying an index
* internal; see the header of the parity test for why neither is in place.
*
* It is equally blind to the OTHER per-file-set key the orchestrator threads —
* `ImportResolutionContext.parsedFiles`, the fifth argument of
* `resolveImportTarget`. PHP's `filesByDirectory` memo (`languages/php/
* import-target.ts`) is keyed on that array, not on this Set, so defeating it
* rebuilds a `Map<dirAlias, ParsedFile[]>` per import at O(files × depth)
* without moving this counter by one. `countedParsedFiles` below is the
* instrument for that channel.
*
* `instanceof Set` still holds, which matters: C#'s `narrowContext` rejects a
* workspace context whose `allFilePaths` is not a `Set`, so a plain object with
* a counter would silently resolve nothing and every assertion would pass on
* `null === null`.
*
* `expectDistinctFileSetsGetOwnIndex` below is the one arm of those guards that
* is identical in every language once the four values that differ are named, so
* it lives here beside the instrument it reads rather than in each guard. The
* `ChainMemoArm` section at the bottom applies the same rule to the guards that
* watch a MEMO instead of a scan count — the two Python importer-chain guards,
* which this instrument provably cannot see (their headers say why) and which
* were arm-for-arm the same suite written twice.
*/
import { expect } from 'vitest';
import type { ParsedFile, ParsedImport } from 'gitnexus-shared';
import type { ScopeResolver } from '../../src/core/ingestion/scope-resolution/contract/scope-resolver.js';
export class CountingSet extends Set<string> {
/** Full traversals of this set, by any entry point. */
scans = 0;
override [Symbol.iterator](): SetIterator<string> {
this.scans++;
return super[Symbol.iterator]();
}
override values(): SetIterator<string> {
this.scans++;
return super.values();
}
override keys(): SetIterator<string> {
this.scans++;
return super.keys();
}
override entries(): SetIterator<[string, string]> {
this.scans++;
return super.entries();
}
override forEach(
callbackfn: (value: string, value2: string, set: Set<string>) => void,
thisArg?: unknown,
): void {
this.scans++;
super.forEach(callbackfn, thisArg);
}
}
/**
* The `CountingSet` of the OTHER per-file-set key: the `parsedFiles` array the
* orchestrator passes as `resolveImportTarget`'s fifth argument
* (`scope-resolution/pipeline/run.ts`). PHP, Java, Kotlin and Python memoize or
* read structures keyed by that array's identity without traversing the path
* Set, so without this the whole `context` channel is unmeasured.
*
* ## Element reads, not traversal entry points
*
* `CountingSet` can override the five ways a `Set` is walked and be done. An
* array has no such closed list: `for…of`, `forEach`, `map`, `filter`,
* `flatMap`, `reduce`, `find`, `some`, `every`, `indexOf` and a bare
* `for (let i = 0; i < a.length; i++)` all walk the same elements, and the last
* one goes through no method at all. Overriding a chosen subset would build in
* exactly the blind spot this instrument exists to remove — PHP's builder is a
* `for…of` today and one refactor away from an index loop.
*
* So the trap is on the read of an own indexed element. Every route above goes
* through it, including the index loop, and nothing else does: `length`,
* method lookups and `Symbol.iterator` are not counted. A full pass over N
* files therefore reads exactly N, and the number is a function of the file
* count and the number of passes — never of wall time.
*
* ## It counts THIS array only
*
* Reads of arrays DERIVED from it — the `ParsedFile[]` buckets inside PHP's
* directory index, the `candidateFiles` list filtered per import — are
* invisible, and deliberately so. That per-import work is bounded by the
* candidate set rather than by the workspace, so counting it would make the
* count grow with the import count for correct code and there would be no
* property left to assert.
*
* The default `ParsedFile`s are minimal on purpose. Callers whose resolver
* needs semantic package or binding evidence supply `makeParsedFile`; this
* keeps the counter reusable without weakening those positive fixtures.
*/
export interface CountedFileList {
/** Pass as `ImportResolutionContext.parsedFiles`. Stable identity, so it is
* a usable `perFileSet` key for the whole run. */
readonly parsedFiles: readonly ParsedFile[];
/** Reads of an own indexed element of `parsedFiles`, by any route. */
readonly reads: () => number;
}
/** Own array indices — `'0'`, `'12'`; not `'length'`, `'-1'` or `'01'`. */
const ARRAY_INDEX = /^(?:0|[1-9][0-9]*)$/;
/**
* A counted `parsedFiles` workspace for `filePaths`, one minimal `ParsedFile`
* each, in order. Build a FRESH one per run: the indexes are memoized on the
* array's identity, so two runs sharing one would have the second read the
* first's index and report zero.
*/
export function countedParsedFiles(filePaths: readonly string[]): CountedFileList {
return countedParsedFilesWith(filePaths, (filePath) => ({
filePath,
moduleScope: `module:${filePath}`,
scopes: [],
parsedImports: [],
localDefs: [],
referenceSites: [],
}));
}
/** A counted workspace whose files carry caller-supplied semantic evidence. */
export function countedParsedFilesWith(
filePaths: readonly string[],
makeParsedFile: (filePath: string) => ParsedFile,
): CountedFileList {
const backing: ParsedFile[] = filePaths.map(makeParsedFile);
let reads = 0;
const counting = new Proxy(backing, {
get(target, key, receiver): unknown {
reads += typeof key === 'string' && ARRAY_INDEX.test(key) ? 1 : 0;
return Reflect.get(target, key, receiver);
},
});
return { parsedFiles: counting, reads: () => reads };
}
/**
* Everything that differs between the per-language spellings of the
* distinct-file-set arm. Nothing else about that arm varies, which is why it is
* a parameter list rather than four copies.
*/
export interface DistinctFileSetArm {
/**
* The orchestrator adapter under test — `<lang>ScopeResolver
* .resolveImportTarget`, NOT the language's `resolve<Lang>ImportTarget`. The
* adapter is the surface the unit parity test cannot reach, and the surface a
* defensive `new Set(allFilePaths)` copy would break.
*/
readonly resolveImportTarget: ScopeResolver['resolveImportTarget'];
/**
* Builds one workspace. Called twice, and must return a FRESH `CountingSet`
* each time: the two sets being distinct objects is the whole subject of the
* arm, since the indexes are memoized on Set identity via a `WeakMap`.
*/
readonly buildWorkspace: () => CountingSet;
/** The import spelling to resolve, as it would appear in source. */
readonly targetRaw: string;
/** The importing file the target is resolved against. */
readonly fromFile: string;
/**
* The resolver's `resolutionConfig` argument (Go's `{ modulePath }`). Not
* optional: the languages that take none pass `undefined` in the open, so a
* call site never hides which adapters read this channel behind an omission.
*/
readonly resolutionConfig: unknown;
/**
* What `targetRaw` must resolve to — a path for the string-returning
* resolvers, a path list for Go. Never `null`: the pairing rule below exists
* precisely because an adapter that has stopped resolving anything returns
* `null` and still posts a perfect scan count, so a `null` expectation would
* reinstate the hole it closes.
*/
readonly expected: string | readonly string[];
/** Traversals of ONE file set that full reuse permits: one per index built. */
readonly expectedScans: number;
}
/** Resolutions driven against each file set before the counts are read. */
const DISTINCT_FILE_SET_REPEATS = 20;
/**
* Assert that two independently built file sets each get their own index, built
* once — no stale reuse of one set's index for the other, and no rebuild per
* import within either.
*
* The repeats are driven bare, following the equivalent arm in
* `test/unit/scope-resolution/import-target-index-parity.test.ts`: asserting
* inside the loop restates one bit of information forty times. The two asserted
* resolutions afterwards are the pairing rule the guards' headers state — a
* scan count must never be the count of an adapter that resolves nothing.
*/
export function expectDistinctFileSetsGetOwnIndex(arm: DistinctFileSetArm): void {
const a = arm.buildWorkspace();
const b = arm.buildWorkspace();
for (let i = 0; i < DISTINCT_FILE_SET_REPEATS; i++) {
arm.resolveImportTarget(arm.targetRaw, arm.fromFile, a, arm.resolutionConfig);
arm.resolveImportTarget(arm.targetRaw, arm.fromFile, b, arm.resolutionConfig);
}
expect(arm.resolveImportTarget(arm.targetRaw, arm.fromFile, a, arm.resolutionConfig)).toEqual(
arm.expected,
);
expect(arm.resolveImportTarget(arm.targetRaw, arm.fromFile, b, arm.resolutionConfig)).toEqual(
arm.expected,
);
expect(a.scans).toBe(arm.expectedScans);
expect(b.scans).toBe(arm.expectedScans);
}
// ─── Python import shapes ────────────────────────────────────────────────────
/**
* `from <targetRaw> import Widget`. The shape that makes
* `resolvePythonImportTarget` run the package-attribute probe
* (`pythonFileExportsName`, the `context.parsedFiles` reader) ahead of the
* submodule fallback — so it is the shape that re-enters the resolver and pays
* the importer's chain TWICE, and the shape `pythonImportedSubmoduleTarget`
* fires for.
*
* The default the adapter synthesizes when `context` is absent is a `namespace`
* import, and that shape never reaches the probe. A guard that means to measure
* either leg therefore has to pass this one explicitly.
*/
export const pythonNamedImport = (targetRaw: string): ParsedImport => ({
kind: 'named',
localName: 'Widget',
importedName: 'Widget',
targetRaw,
});
/** `import <targetRaw>` — the single-walk shape, and the adapter's default. */
export const pythonNamespaceImport = (targetRaw: string): ParsedImport => ({
kind: 'namespace',
localName: '_',
importedName: '_',
targetRaw,
});
/**
* ONE array per file that uses it, never a fresh `[]` per call:
* `parsedFileByPath` memoizes on its identity, and a new array per import would
* mint a `WeakMap` key per import for a channel these guards are not measuring
* (`countedParsedFiles` above is the instrument for that one). Empty, so
* `pythonFileExportsName` answers false and the package-vs-submodule precedence
* never fires — the walk, not the precedence, is what the numbers measure.
*/
export const NO_PARSED_FILES: readonly ParsedFile[] = [];
// ─── the Python importer-chain memo guards ───────────────────────────────────
/**
* What one resolution answered, as the chain-memo arms read it: a path, a path
* list (the `ScopeResolver` signature allows one), or `null`. The two values
* the non-vacuity pairing rule counts are `arm.hitResult` and `null`.
*/
export type ChainMemoResult = string | readonly string[] | null;
/**
* Everything that differs between the two Python importer-chain memo guards:
* `test/unit/import-resolvers/python-importer-prefixes.test.ts`
* (`bareImportPrefixesByDir`) and
* `test/unit/scope-resolution/python/python-importer-ancestors.test.ts`
* (`ancestorsByDir`).
*
* The two memos hold DIFFERENT SEQUENCES under the same key — self included or
* not, workspace root included or not, empty components kept or dropped; see
* `importerBarePrefixes`'s header for why neither guard can be deleted in
* favour of the other. But each guard is the same four arms over the same
* importer corpus once these four values are named, so the arms live here and
* each guard supplies its own four.
*/
export interface ChainMemoArm {
/** The memo under test, read off the pass's per-file-set index. */
readonly memoOf: (files: ReadonlySet<string>) => ReadonlyMap<string, readonly string[]>;
/**
* Drives a production surface `perImporter` times from `fromFile`, with
* spellings that reach the memo, and answers what each call resolved to.
* Exactly one call per invocation must answer `arm.hitResult`, and at least
* one must answer `null`. Must pass `files` THROUGH: both memos are keyed on
* its identity, so a copy here would measure nothing.
*/
readonly drive: (
files: Set<string>,
fromFile: string,
perImporter: number,
) => readonly ChainMemoResult[];
/**
* The verbatim pre-change chain builder, which is the specification: the memo
* agreeing with it is what makes the change a hoist rather than a behaviour
* change.
*/
readonly legacyChain: (fromFile: string) => readonly string[];
/** What the one must-resolve spelling in `drive` answers, once per importer. */
readonly hitResult: string;
}
/** Every file that issues an import in the chain-memo arms. */
export const CHAIN_MEMO_IMPORTERS: readonly string[] = [
'svc/a/one.py',
'svc/a/two.py',
'svc/b/one.py',
'deep/x/y/z/one.py',
'root.py',
];
/** Four directories for those five importers — `svc/a` holds two of them. */
export const CHAIN_MEMO_IMPORTER_DIRS: readonly string[] = ['svc/a', 'svc/b', 'deep/x/y/z', ''];
/** The directory two importers share, which is where identity is measured. */
const SHARED_DIR = 'svc/a';
const SHARED_DIR_IMPORTERS: readonly string[] = ['svc/a/one.py', 'svc/a/two.py'];
/** Imports one directory issues before its chain's identity is re-read. */
const CHAIN_IDENTITY_REPEATS = 40;
/** A sorted copy, so a key set is compared without depending on fill order. */
export const sortedStrings = (values: Iterable<string>): string[] => [...values].sort();
/**
* The importer directory both memos are keyed on, derived exactly as the
* pre-change inline code derived it — `''` for a path with no separator.
*/
const importerDirOf = (fromFile: string): string =>
fromFile.replace(/\\/g, '/').split('/').slice(0, -1).join('/');
/**
* The path-shape space an importer chain has to be correct over, as ONE table
* both guards run: they enumerate the same space and nothing kept the two
* copies in lockstep.
*
* Each `why` names the SHAPE, not what either chain does with it, because the
* two chains do different things with several of these rows — the bare-prefix
* chain KEEPS the empty component an absolute path or a doubled separator
* produces and the ancestor chain drops it, and that difference decides real
* resolutions. Which is why every arm below compares against the guard's own
* `legacyChain` rather than against a shared expectation.
*/
export const IMPORTER_PATH_SHAPES: readonly { readonly fromFile: string; readonly why: string }[] =
[
{ fromFile: 'svc/a/one.py', why: 'a two-component directory' },
{ fromFile: 'deep/x/y/z/one.py', why: 'a four-component directory' },
{ fromFile: 'root.py', why: 'a workspace-root importer' },
{ fromFile: '/abs/svc/a/one.py', why: 'an absolute path (leading empty component)' },
{ fromFile: 'svc//a/one.py', why: 'a doubled separator (empty component)' },
{ fromFile: 'svc\\a\\one.py', why: 'Windows separators' },
{ fromFile: 'trailing/', why: 'a path ending in a separator' },
];
/**
* The gate: N imports from five importers over four directories leave FOUR
* entries, for every N. That is "the chain work is O(1) amortized after the
* first import from a given directory", stated as a number. A chain rebuilt per
* import cannot be memoized at all (size 0); a chain keyed on the importing
* FILE reads five.
*
* Paired with the non-vacuity assertions every guard in this family states: a
* perfect memo count is equally true of an adapter that resolves nothing.
*/
export function expectOneChainPerImporterDir(
arm: ChainMemoArm,
files: Set<string>,
perImporter: number,
): void {
const resolved: ChainMemoResult[] = [];
for (const fromFile of CHAIN_MEMO_IMPORTERS) {
resolved.push(...arm.drive(files, fromFile, perImporter));
}
expect(arm.memoOf(files).size).toBe(CHAIN_MEMO_IMPORTER_DIRS.length);
expect(sortedStrings(arm.memoOf(files).keys())).toEqual(sortedStrings(CHAIN_MEMO_IMPORTER_DIRS));
expect(resolved.filter((value) => value === arm.hitResult)).toHaveLength(
CHAIN_MEMO_IMPORTERS.length,
);
expect(resolved.filter((value) => value === null).length).toBeGreaterThan(0);
}
/**
* The stored-object arm: a memo that stores a FRESH chain on every import posts
* a perfect size while doing all of the work again, so the size gate above is
* paired with reference identity across many later imports from the same
* directory — issued from BOTH files in it, so a chain keyed on the importing
* file would be replaced rather than reused.
*
* Contents are asserted FIRST: `toBe` against an absent entry would pass on
* `undefined === undefined` if the memo were deleted outright.
*/
export function expectSameChainObjectReused(arm: ChainMemoArm, files: Set<string>): void {
const [firstImporter] = SHARED_DIR_IMPORTERS;
arm.drive(files, firstImporter, 1);
const first = arm.memoOf(files).get(SHARED_DIR);
expect(first).toEqual(arm.legacyChain(firstImporter));
for (const fromFile of SHARED_DIR_IMPORTERS) {
arm.drive(files, fromFile, CHAIN_IDENTITY_REPEATS);
}
expect(arm.memoOf(files).get(SHARED_DIR)).toBe(first);
}
/**
* The legacy-equality arm for one path shape: what the memo stored under
* `fromFile`'s directory is what the pre-change inline code built for it.
*
* Returns the memoized chain, so a guard whose memo feeds a SECOND consumer can
* go on to assert that consumer's derived form of it.
*/
export function expectMemoizedChainMatchesLegacy(
arm: ChainMemoArm,
files: Set<string>,
fromFile: string,
): readonly string[] {
arm.drive(files, fromFile, 1);
const chain = arm.memoOf(files).get(importerDirOf(fromFile));
expect(chain).toEqual(arm.legacyChain(fromFile));
return chain ?? [];
}
/**
* The distinct-file-set arm: two independently built file sets each get their
* own memo — equal in content, never the same object, neither leaking into the
* other. The two are driven interleaved, so a memo keyed on anything but the
* Set's identity shows up here as a SHARED entry rather than as a stale one.
*/
export function expectDistinctFileSetsGetOwnChainMemo(
arm: ChainMemoArm,
a: Set<string>,
b: Set<string>,
perImporter: number,
): void {
for (const fromFile of CHAIN_MEMO_IMPORTERS) {
arm.drive(a, fromFile, perImporter);
arm.drive(b, fromFile, perImporter);
}
const memoA = arm.memoOf(a);
const memoB = arm.memoOf(b);
expect(memoA).not.toBe(memoB);
expect(memoA.get(SHARED_DIR)).not.toBe(memoB.get(SHARED_DIR));
expect(memoA.get(SHARED_DIR)).toEqual(memoB.get(SHARED_DIR));
expect(memoA.size).toBe(CHAIN_MEMO_IMPORTER_DIRS.length);
expect(memoB.size).toBe(CHAIN_MEMO_IMPORTER_DIRS.length);
}