/** * A `Set` 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/-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` 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 { /** Full traversals of this set, by any entry point. */ scans = 0; override [Symbol.iterator](): SetIterator { this.scans++; return super[Symbol.iterator](); } override values(): SetIterator { this.scans++; return super.values(); } override keys(): SetIterator { this.scans++; return super.keys(); } override entries(): SetIterator<[string, string]> { this.scans++; return super.entries(); } override forEach( callbackfn: (value: string, value2: string, set: Set) => 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 — `ScopeResolver * .resolveImportTarget`, NOT the language's `resolveImportTarget`. 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 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 ` — 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) => ReadonlyMap; /** * 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, 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[] => [...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, 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): 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, 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, b: Set, 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); }