Merge lexical import provenance into flow split

This commit is contained in:
Abhinav Pandey 2026-09-06 11:08:14 +05:30
commit 3aa22a3db8
No known key found for this signature in database
18 changed files with 540 additions and 103 deletions

View file

@ -39,7 +39,7 @@ import type { BindingRef, ImportEdge, ParsedImport, ScopeId, WorkspaceIndex } fr
/** Per-file input for the finalize pass. */
export interface FinalizeFile {
readonly filePath: string;
/** The module scope id for this file; owns the finalized imports + bindings. */
/** Default binding scope for imports without lexical provenance or opt-in. */
readonly moduleScope: ScopeId;
readonly parsedImports: readonly ParsedImport[];
/**
@ -84,6 +84,10 @@ export interface FinalizeInput {
* expects pure answers.
*/
export interface FinalizeHooks {
/** Bind imports at their extracted lexical scope. Missing provenance retains
* the legacy module-scope behavior. Opt-in: lexical position and language
* import-binding semantics are distinct facts. */
readonly importsBindAtLexicalScope?: boolean;
/**
* Resolve a raw import target to the concrete file path that owns it.
* Return `null` when no target file is resolvable (e.g., `np.foo` when
@ -269,10 +273,18 @@ export function finalize(input: FinalizeInput, hooks: FinalizeHooks): FinalizeOu
// SCC-condensed). Eliminates the recursive crawl that the per-edge
// `tryFinalize` call site used to do; lookups are O(1) afterwards.
// See `buildReexportClosures` for the algorithm.
// A local import is a dependency of its file, not a re-export of that file.
const moduleEdgeIndex = new Map<string, ImportEdgeDraft[]>();
for (const file of input.files) {
moduleEdgeIndex.set(
file.filePath,
(edgeIndex.get(file.filePath) ?? []).filter((d) => d.fromScope === file.moduleScope),
);
}
const ambiguityByFile = collectAmbiguityByFile(
input.files,
byFilePath,
edgeIndex,
moduleEdgeIndex,
hooks.wildcardCollisionIsAmbiguous === true,
hooks.namedImportsBindTopLevelOnly === true,
);
@ -284,7 +296,7 @@ export function finalize(input: FinalizeInput, hooks: FinalizeHooks): FinalizeOu
const reexportClosures = buildReexportClosures(
input.files,
byFilePath,
edgeIndex,
moduleEdgeIndex,
ambiguousByFile,
topLevelOnly,
);
@ -349,12 +361,12 @@ export function finalize(input: FinalizeInput, hooks: FinalizeHooks): FinalizeOu
}
}
// ── Phase 4: collect finalized `ImportEdge[]` per module scope, preserving
// ── Phase 4: collect finalized `ImportEdge[]` per binding scope, preserving
// input order within each file, and wildcard-expand where applicable.
for (const file of input.files) {
const drafts = edgeIndex.get(file.filePath);
if (drafts === undefined) continue;
const finalized: ImportEdge[] = [];
const finalizedByScope = new Map<ScopeId, ImportEdge[]>([[file.moduleScope, []]]);
// Names this file's `export *` sources collide on (see
// `collectAmbiguousWildcards`). Their expanded edges are dropped here, so
// the file's own module scope does not bind an arbitrary winner either —
@ -363,6 +375,11 @@ export function finalize(input: FinalizeInput, hooks: FinalizeHooks): FinalizeOu
// `import-resolved` guess.
const ambiguousHere = ambiguousByFile.get(file.filePath) ?? EMPTY_NAME_SET;
for (const d of drafts) {
let finalized = finalizedByScope.get(d.fromScope);
if (finalized === undefined) {
finalized = [];
finalizedByScope.set(d.fromScope, finalized);
}
const edge = d.finalized;
if (edge === null) {
throw new Error(`Invariant violated: import edge was not finalized for ${file.filePath}`);
@ -371,7 +388,12 @@ export function finalize(input: FinalizeInput, hooks: FinalizeHooks): FinalizeOu
// Produce one `wildcard-expanded` ImportEdge per exported name.
const expanded = expandWildcard(edge, byFilePath, hooks, input.workspaceIndex);
for (const e of expanded) {
if (e.kind === 'wildcard-expanded' && ambiguousHere.has(e.localName)) continue;
if (
d.fromScope === file.moduleScope &&
e.kind === 'wildcard-expanded' &&
ambiguousHere.has(e.localName)
)
continue;
finalized.push(e);
}
} else {
@ -379,10 +401,11 @@ export function finalize(input: FinalizeInput, hooks: FinalizeHooks): FinalizeOu
}
if (edge.linkStatus !== 'unresolved') linkedEdges++;
}
linkedByScope.set(file.moduleScope, Object.freeze(finalized));
for (const [scopeId, edges] of finalizedByScope)
linkedByScope.set(scopeId, Object.freeze(edges));
}
// ── Phase 5: materialize module-scope bindings (local + imports + wildcards),
// ── Phase 5: materialize bindings (local + scoped imports + wildcards),
// delegating precedence to `provider.mergeBindings`.
const bindingsByScope = materializeBindings(input.files, linkedByScope, hooks);
@ -427,6 +450,10 @@ function makeEdgeDrafts(
hooks: FinalizeHooks,
workspace: WorkspaceIndex,
): ImportEdgeDraft[] {
const fromScope =
hooks.importsBindAtLexicalScope === true
? (parsed.declaredAtScope ?? file.moduleScope)
: file.moduleScope;
// Dynamic-unresolved passes through — no `BindingRef`, no target file.
if (parsed.kind === 'dynamic-unresolved') {
const base: ImportEdge = {
@ -439,7 +466,7 @@ function makeEdgeDrafts(
{
source: parsed,
fromFile: file.filePath,
fromScope: file.moduleScope,
fromScope,
targetFile: null,
base,
finalized: base, // already fully finalized
@ -469,7 +496,7 @@ function makeEdgeDrafts(
{
source: parsed,
fromFile: file.filePath,
fromScope: file.moduleScope,
fromScope,
targetFile: null,
base,
finalized: base,
@ -505,7 +532,7 @@ function makeEdgeDrafts(
return {
source: parsed,
fromFile: file.filePath,
fromScope: file.moduleScope,
fromScope,
targetFile: tf,
base,
finalized: isFileLevelTerminal ? base : null,
@ -1557,7 +1584,7 @@ function materializeBindings(
linkedByScope: ReadonlyMap<ScopeId, readonly ImportEdge[]>,
hooks: FinalizeHooks,
): ReadonlyMap<ScopeId, ReadonlyMap<string, readonly BindingRef[]>> {
const out = new Map<ScopeId, ReadonlyMap<string, readonly BindingRef[]>>();
const buckets = new Map<ScopeId, Map<string, readonly BindingRef[]>>();
// Build a `nodeId → SymbolDefinition` index once across all files
// (O(N_files × D_defs)) so the per-edge lookup below is O(1) instead
@ -1582,8 +1609,17 @@ function materializeBindings(
scopeBindings.set(name, hooks.mergeBindings(existing, incoming, file.moduleScope));
}
// Layer in finalized imports.
const imports = linkedByScope.get(file.moduleScope) ?? [];
buckets.set(file.moduleScope, scopeBindings);
}
// Layer imports into their binding scope; lexical locals already live in
// the scope tree and must not be copied into unrelated scope buckets.
for (const [scopeId, imports] of linkedByScope) {
let scopeBindings = buckets.get(scopeId);
if (scopeBindings === undefined) {
scopeBindings = new Map();
buckets.set(scopeId, scopeBindings);
}
for (const edge of imports) {
if (edge.targetDefId === undefined || edge.linkStatus === 'unresolved') continue;
const def = defById.get(edge.targetDefId);
@ -1602,15 +1638,18 @@ function materializeBindings(
if (name === null) continue;
const incoming: BindingRef[] = [{ def, origin, via: edge }];
const existing = scopeBindings.get(name) ?? [];
scopeBindings.set(name, hooks.mergeBindings(existing, incoming, file.moduleScope));
scopeBindings.set(name, hooks.mergeBindings(existing, incoming, scopeId));
}
}
const out = new Map<ScopeId, ReadonlyMap<string, readonly BindingRef[]>>();
for (const [scopeId, scopeBindings] of buckets) {
// Freeze nested buckets for immutability.
const frozen = new Map<string, readonly BindingRef[]>();
for (const [name, refs] of scopeBindings) {
frozen.set(name, Object.freeze(refs.slice()));
}
out.set(file.moduleScope, frozen);
out.set(scopeId, frozen);
}
return out;

View file

@ -107,7 +107,14 @@ export type CaptureMatch = Readonly<Record<string, Capture>>;
* produced when `expandsWildcardTo` materializes a wildcard against target
* exports a provider must never emit it at parse time.
*/
export type ParsedImport =
export type ParsedImport = ParsedImportSyntax & {
/** Lexical location retained by extraction, independently of execution timing.
* Absent for legacy or synthesized imports. Binding semantics remain opt-in
* through FinalizeHooks.importsBindAtLexicalScope. */
readonly declaredAtScope?: ScopeId;
};
type ParsedImportSyntax =
/**
* Per-name import without rename.
*

View file

@ -1,7 +1,8 @@
{
"_comment": "Per-language baselines for bench/scope-capture/measure.mjs --check. fingerprint = order-independent sha256 over the lang-resolution/<lang>-* fixture corpus + a 20-entity synthetic source (correctness gate; re-baseline intentionally on a legitimate capture change). scaling_budget = max allowed (t800/t250)/(800/250); ~1.0 is linear, ~3.2 is quadratic. The synthetic source is now HERITAGE-BEARING for every language (each Entity extends/implements/embeds/uses-trait/conforms-to a shared base) so the #1951 @reference.inherits synth is gated at scale, not just the base capture loop. All languages thread the tree-sitter captured node instead of re-deriving it with findNodeAtRange(tree.rootNode,...) per match, so all are linear (go #1915, python #1918, ruby/php/rust/csharp #1951, java #1956).",
"go": {
"fingerprint": "9c554a9d698a2b79fb419852daadca87b8aae88180cceabf9c8d82f3e3300f2e",
"fingerprint": "990e4921a7ef0e6aa4d7da92b671de91fa2621cb27918adb27742740dc6ad902",
"_rebaselined_3190": "Corrected go-method-enrichment/app.go to share package animal with its declarations; its capture count is now 16 rather than 15. No Go emitter change. Scaling budget unchanged.",
"scaling_budget": 1.5,
"_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior 3d4e32e7490c830516126e28931827949baa3594cb521f7a3d8dcfed95b6018a -> 57b3c55135af8d2af33b9a7c4bf89796a7bee5b5822b402a2dea91af7232cf4a; scaling 1.058 < 1.5.",
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: provider-owned callable assignment/copy/formal/argument/invoke facts with invocation/constructor-result suppression. Prior 09ecd94911b830f52fa8807560abcbd79f163d02a2072870c1a59297e9a326e1 -> 3d4e32e7490c830516126e28931827949baa3594cb521f7a3d8dcfed95b6018a; scaling 1.039 < 1.5.",
@ -171,7 +172,8 @@
"capture_groups_fp": 680
},
"typescript": {
"fingerprint": "fed04ed1d5db112387781e405da208ae6b3ab803889773b0455be96f01b893ff",
"fingerprint": "60e75bbe846f7200005f12c4d95c32e82d9d3feae9f2fd594e484670762b82b0",
"_rebaselined_3190": "Capture matches now retain explicit ESM export/private evidence, including synthesized default HOCs; CommonJS surfaces remain undecided. Capture group counts unchanged. Scaling budget unchanged.",
"scaling_budget": 1.5,
"_rebaselined_2934_import_type_only": "#2934: `import-decomposer.ts` attaches a presence-only `@import.type-only` synthetic capture to specifiers `tsc` erases, so `check --cycles` can stop counting type-only edges as initialization cycles. DIGEST DRIFT ONLY, NOT A CAPTURE-SET CHANGE \u2014 the tag is added to import matches that already existed, never a new match, the same shape as the #2747 receiver-chain rebaseline. Every count is unchanged: capture_groups_fp 2414, fixture_count 155, capture_groups_small/large 4503/14403 (those measure the SYNTHETIC scaling source, which has no imports at all). The fingerprint moves because `canonicalizeMatch` in measure.mjs hashes every TAG on every match, synthetics included, so one extra presence-only tag on an existing match rewrites that match's canonical string. Attribution is exact, not inferred: neutralizing ONLY the `m['@import.type-only'] = \u2026` assignment in import-decomposer.ts and re-running returns the fingerprint to c2fbf8a89e5686dd\u2026 byte-for-byte, so nothing else in the TypeScript capture stream moved. All 14 other languages report ok. Scaling 0.997 < 1.5. NOTE ON THE CONTROL: javascript did not move (2026993b\u2026, 43 fixtures), but it is a WEAK control here \u2014 `import type` is TypeScript-only syntax, so a JS corpus cannot express the construct and could not have drifted either way. It evidences no collateral damage, not the correctness of the TS change; the exact-attribution check above is what does that. Prior c2fbf8a89e5686dd1ff3659b20d41d8b05ebcc9790356e3653ee0c8ca5d365c8 -> f719163eb03a447c9e40ca316a905dd76cee82192a75a403df478ebbdc13e98f.",
"_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior 27f937bfb47d4bded316ea3c785ff659c8cd88a5761d928f113477a08c802c78 -> e05446620c5b80b7aae291cfdf32f693580fada2ae687124769b04a0c03bfe63; scaling 0.983 < 1.5.",
@ -197,7 +199,8 @@
"_rebaselined_1432_member_call_callee_name": "#1432 (Zig): the shared callable-flow reader no longer names a callee by simple name for a MEMBER call (`@callable-flow.direct-callee-name` requires a direct designator: `f(x)`, `ns.f(x)`), and a member call is a field-stored-callable invoke only when a MEMBER store (`o.f = handler`) or a declared callable-typed field is visible - a same-named plain binding no longer gates it. CAPTURE-EMISSION CHANGE, not fixture growth (fixture_count unchanged). Drift: `await svc.verify<GuestPayload>(token, ...)` (typescript-generic-calls/src/guest.ts, member call) and `initializer()(() => {...})` (typescript-hof-callbacks/src/store.ts, call-of-call) lose `direct-callee-name`. `await verifyToken<AdminPayload>(token, ...)` (admin.ts/auth.ts) KEEPS `direct-callee-name|verifyToken`: tree-sitter-typescript parses `await f<T>(x)` as call_expression(function: await_expression(f), type_arguments, ...), and wrappedExpression now unwraps `await_expression` so the direct designator survives as it does for the un-awaited spelling. capture_groups_fp 2465 (unchanged). Prior 05d1dadd6c9ef35c74079fa50f341b1b36e4fb02c9a89dd1b59f32b7cfd5e633 -> fed04ed1d5db112387781e405da208ae6b3ab803889773b0455be96f01b893ff."
},
"javascript": {
"fingerprint": "2026993b81b873839dd2ef8797d9c14d9c48516b2b57b05ac17d8d43f2f4eba3",
"fingerprint": "b916c7072b30d09b4949803810604830ea1aca1cfdda0d9d9312f7cb22f7a10a",
"_rebaselined_3190": "Capture matches now retain explicit ESM export/private evidence, including synthesized default HOCs; CommonJS surfaces remain undecided. Capture group counts unchanged. Scaling budget unchanged.",
"scaling_budget": 1.5,
"_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior b59fe8135b6a31a12bc3f872b224054b16592588153ae3661d03958d787c76f3 -> 479927409bbdd9852a36172c8260aa56df260e99129a7a9c20a0d1903dd5538b; scaling 1.050 < 1.5.",
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: lexical callable bindings, direct-callee argument metadata, and invocation-result suppression. Prior 917a9cd975ba035bdad71fdb70cd72eeddec58c25797e5a1addfa6172808a55c -> b59fe8135b6a31a12bc3f872b224054b16592588153ae3661d03958d787c76f3; scaling 1.093 < 1.5.",

View file

@ -200,6 +200,7 @@ function collectReferenceSites(parsedFiles: readonly ParsedFile[]) {
*/
function withDefaultHooks(partial: Partial<FinalizeHooks>): FinalizeHooks {
return {
importsBindAtLexicalScope: partial.importsBindAtLexicalScope === true,
resolveImportTarget: partial.resolveImportTarget ?? (() => null),
isNamespaceImport: partial.isNamespaceImport,
wildcardCollisionIsAmbiguous: partial.wildcardCollisionIsAmbiguous === true,

View file

@ -19,7 +19,7 @@
* to the caller's module before comparison.
*/
import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared';
import type { ParsedFile, Scope, ScopeId, SymbolDefinition } from 'gitnexus-shared';
import {
modulePathReaches,
stripExtension,
@ -35,6 +35,10 @@ const RUST_CRATE_ROOT_DIRS: ReadonlySet<string> = new Set(['src', 'tests', 'benc
/** Path prefixes of a `use` that name a root rather than a module segment. */
const RUST_USE_ROOT_PREFIXES: ReadonlySet<string> = new Set(['crate', '$crate']);
// One scope lookup per immutable parsed-file snapshot, not per fallback site.
// Weak keys release both the snapshot and its index at the end of ingestion.
const scopeLookupByFile = new WeakMap<ParsedFile, ReadonlyMap<ScopeId, Scope>>();
/**
* The module path a Rust file provides, as a `/`-joined path.
*
@ -70,7 +74,11 @@ function rustUsePathOf(targetRaw: string, callerFilePath: string): string {
export function rustIsGlobalNameFallbackPlausible(ctx: {
readonly callerParsed: ParsedFile;
readonly candidate: SymbolDefinition;
readonly site: { readonly name: string; readonly rawQualifiedName?: string };
readonly site: {
readonly name: string;
readonly rawQualifiedName?: string;
readonly inScope?: ScopeId;
};
}): boolean {
if (ctx.candidate.filePath === ctx.callerParsed.filePath) return true;
// A PATH-QUALIFIED call (`User::new(...)`, `crate::a::helper()`) reaches this
@ -86,7 +94,31 @@ export function rustIsGlobalNameFallbackPlausible(ctx: {
if (candidateModule === '') return true;
const candidateName = rustSimpleNameOf(ctx.candidate);
// Imports are lexical evidence, not a file-wide allowlist. Legacy/synthetic
// imports without a scope receipt retain the previous conservative behavior.
let visibleScopes: Set<ScopeId> | undefined;
if (ctx.site.inScope !== undefined) {
let scopes = scopeLookupByFile.get(ctx.callerParsed);
if (scopes === undefined) {
scopes = new Map(ctx.callerParsed.scopes.map((scope) => [scope.id, scope]));
scopeLookupByFile.set(ctx.callerParsed, scopes);
}
visibleScopes = new Set();
let current: ScopeId | null = ctx.site.inScope;
while (current !== null && !visibleScopes.has(current)) {
visibleScopes.add(current);
const scope = scopes.get(current);
if (scope === undefined || scope.kind === 'Namespace') break;
current = scope.parent;
}
}
for (const imp of ctx.callerParsed.parsedImports) {
if (
imp.declaredAtScope !== undefined &&
visibleScopes !== undefined &&
!visibleScopes.has(imp.declaredAtScope)
)
continue;
const usePath = rustUsePathOf(imp.targetRaw, ctx.callerParsed.filePath);
// Only a glob introduces every bare item of a module. A named import must
// match both the candidate's original name and the call's local spelling.

View file

@ -193,5 +193,6 @@ export const rustScopeResolver: ScopeResolver = {
hoistTypeBindingsToModule: true,
propagatesReturnTypesAcrossImports: true,
allowGlobalFreeCallFallback: true,
importsBindAtLexicalScope: true,
isGlobalNameFallbackPlausible: rustIsGlobalNameFallbackPlausible,
};

View file

@ -1038,8 +1038,7 @@ function pass3CollectImports(
if (provider.interpretImport === undefined) return;
// Hoisted: the capability is a property of the language, identical for every
// match in the file. A provider that declares its imports do not execute
// where they are written (C/C++ `#include`, Rust `use`, COBOL `COPY`) skips
// the position walk entirely — position cannot defer something that never
// where they are written skips the execution-deferral walk — position cannot defer something that never
// runs, and marking one deferred would hide a real cycle. Absent reads as
// `true`, so an undeclared provider is unchanged. See
// `LanguageProvider.importsExecuteWhereWritten`.
@ -1050,14 +1049,22 @@ function pass3CollectImports(
const parsed = provider.interpretImport(match);
if (parsed === null) continue;
// The statement's own position, resolved to the innermost scope holding
// it. An unlocatable anchor leaves the import unmarked, which reads as
// it. Provenance is retained independently of execution timing. An
// unlocatable anchor leaves the import unmarked, which reads as
// "runs at initialization" — the fail-safe direction, since it can only
// make `check --cycles` over-report.
const inScopeId = positionCanDefer
? positionIndex.atPosition(filePath, anchor.range.startLine, anchor.range.startCol)
: undefined;
const deferred = inScopeId !== undefined && runsOnlyWhenCalled(scopeTree, inScopeId);
parsedImports.push(deferred ? { ...parsed, runsOnlyWhenCalled: true } : parsed);
const inScopeId = positionIndex.atPosition(
filePath,
anchor.range.startLine,
anchor.range.startCol,
);
const deferred =
positionCanDefer && inScopeId !== undefined && runsOnlyWhenCalled(scopeTree, inScopeId);
parsedImports.push({
...parsed,
...(inScopeId !== undefined ? { declaredAtScope: inScopeId } : {}),
...(deferred ? { runsOnlyWhenCalled: true } : {}),
});
}
}

View file

@ -847,6 +847,10 @@ export interface ScopeResolver {
*/
readonly allowGlobalFreeCallFallback?: boolean;
/** Opt in to binding imports at their extracted lexical scope, rather than
* publishing every file's imports at module scope. */
readonly importsBindAtLexicalScope?: boolean;
/**
* Two `wildcard` re-exports that both DECLARE a name make it AMBIGUOUS in
* this language ECMAScript `export *` semantics, where the module simply
@ -918,6 +922,8 @@ export interface ScopeResolver {
readonly site: {
readonly name: string;
readonly rawQualifiedName?: string;
/** Lexical call-site scope; absent only for legacy/synthetic hook callers. */
readonly inScope?: ScopeId;
};
}) => boolean;

View file

@ -629,7 +629,11 @@ export function emitFreeCallFallback(
candidate: fnDef,
parsedFileOf: parsedFileByPath(),
sourceTextOf: options.sourceTextOf,
site: { name: site.name, rawQualifiedName: site.rawQualifiedName },
site: {
name: site.name,
rawQualifiedName: site.rawQualifiedName,
inScope: site.inScope,
},
}) === false
) {
// The language proved this call impossible. Mark the site handled so

View file

@ -120,19 +120,10 @@ export function followChainPostFinalize(
* typeBindings the in-extractor pass-4 ran before propagation and
* missed any chain whose terminal lived in a foreign file.
*
* Scope-chain concern (verified 2026-04-21): `pythonImportOwningScope`
* documents that function-local `from x import y` binds `y` to the
* inner function scope, which would make a module-only write miss
* non-module importers. In practice `finalize-algorithm` hoists those
* bindings into `indexes.bindings[moduleScope]` regardless of where
* the `import` statement appears the integration fixture
* `python-function-local-import-chain` exercises a chained
* receiver-bound call `u = get_user(); u.save()` inside a function
* body and emits the expected `do_work → User.save` edge. The
* module-scope write is sufficient today. If finalize routing ever
* changes to honor the hook's per-scope contract, this pass must
* iterate `indexes.bindings` over every scope and mirror into the
* binding-owning scope's `typeBindings`, not just the module's.
* Finalize can retain lexical import ownership. Mirror into each binding's
* owning scope, never hoist local import-derived types into the file scope.
* Module ordering remains SCC-based; non-module chains are followed after
* all import bindings have been mirrored.
*/
export function propagateImportedReturnTypes(
parsedFiles: readonly ParsedFile[],
@ -140,6 +131,7 @@ export function propagateImportedReturnTypes(
index: WorkspaceResolutionIndex,
): void {
const moduleScopeByFile = index.moduleScopeByFile;
const scopesByFile = new Map(parsedFiles.map((file) => [file.filePath, file.scopes]));
// Walk SCCs in reverse-topological order (`indexes.sccs` is leaves-
// first per `tarjanSccs`). For each file we mirror import bindings
@ -167,42 +159,44 @@ export function propagateImportedReturnTypes(
// import-derived typeBinding mirror. Both helpers fast-path when
// no augmentations exist for the scope, so the common case is
// allocation-free. See I8.
for (const localName of namesAtScope(importerModule.id, indexes)) {
// Skip if importer already has a typeBinding for this name —
// an explicit local annotation must win over import-derived.
if (importerModule.typeBindings.has(localName)) continue;
for (const importerScope of scopesByFile.get(filePath) ?? [importerModule]) {
for (const localName of namesAtScope(importerScope.id, indexes)) {
// Skip if importer already has a typeBinding for this name —
// an explicit local annotation must win over import-derived.
if (importerScope.typeBindings.has(localName)) continue;
const refs = lookupBindingsAt(importerModule.id, localName, indexes);
for (const ref of refs) {
if (ref.origin !== 'import' && ref.origin !== 'reexport' && ref.origin !== 'wildcard')
continue;
const sourceModule = moduleScopeByFile.get(ref.def.filePath);
if (sourceModule === undefined) continue;
const refs = lookupBindingsAt(importerScope.id, localName, indexes);
for (const ref of refs) {
if (ref.origin !== 'import' && ref.origin !== 'reexport' && ref.origin !== 'wildcard')
continue;
const sourceModule = moduleScopeByFile.get(ref.def.filePath);
if (sourceModule === undefined) continue;
// The source file's typeBinding is keyed by the def's simple
// name (e.g. `get_user`), not the importer's local alias.
const qn = ref.def.qualifiedName;
if (qn === undefined) continue;
const dot = qn.lastIndexOf('.');
const sourceName = dot === -1 ? qn : qn.slice(dot + 1);
// The source file's typeBinding is keyed by the def's simple
// name (e.g. `get_user`), not the importer's local alias.
const qn = ref.def.qualifiedName;
if (qn === undefined) continue;
const dot = qn.lastIndexOf('.');
const sourceName = dot === -1 ? qn : qn.slice(dot + 1);
const sourceTypeRef = sourceModule.typeBindings.get(sourceName);
if (sourceTypeRef === undefined) continue;
const sourceTypeRef = sourceModule.typeBindings.get(sourceName);
if (sourceTypeRef === undefined) continue;
// Chain-follow inside the source module so we mirror the
// terminal type, not an intermediate intra-source reference.
const terminal = followChainPostFinalize(sourceTypeRef, sourceModule.id, indexes);
// Chain-follow inside the source module so we mirror the
// terminal type, not an intermediate intra-source reference.
const terminal = followChainPostFinalize(sourceTypeRef, sourceModule.id, indexes);
// Mutating typeBindings is safe because draftToScope
// produced a non-frozen Map (Contract Invariant I3/I8).
(importerModule.typeBindings as Map<string, TypeRef>).set(localName, terminal);
// First-write-wins for the local alias: if the same
// `localName` was registered multiple times via
// `mergeBindings` (rare; happens with conflicting
// re-exports), only the first ref with a usable
// typeBinding source is mirrored. Conflict resolution
// among multiple sources is the merger's job, not ours.
break;
// Mutating typeBindings is safe because draftToScope
// produced a non-frozen Map (Contract Invariant I3/I8).
(importerScope.typeBindings as Map<string, TypeRef>).set(localName, terminal);
// First-write-wins for the local alias: if the same
// `localName` was registered multiple times via
// `mergeBindings` (rare; happens with conflicting
// re-exports), only the first ref with a usable
// typeBinding source is mirrored. Conflict resolution
// among multiple sources is the merger's job, not ours.
break;
}
}
}

View file

@ -721,6 +721,7 @@ export function runScopeResolution(
const resolutionConfig = input.resolutionConfig;
const finalized = finalizeScopeModel(parsedFiles, {
hooks: {
importsBindAtLexicalScope: provider.importsBindAtLexicalScope === true,
resolveImportTarget: (targetRaw, fromFile, _workspaceIndex, parsedImport) =>
provider.resolveImportTarget(targetRaw, fromFile, allFilePaths, resolutionConfig, {
parsedFiles,

View file

@ -735,7 +735,11 @@ import { copyV8CacheIfPresent, tryLoadV8Cache, writeV8CacheFile } from './v8-sid
// v93: Zig call captures inside a comptime-false branch carry
// `@reference.static-gated` (feat/zig-static-gated-edges); the site gains
// `staticGated` and the CALLS edge a BOOLEAN column.
const SCHEMA_BUMP = 93;
// v94: ParsedImport retains declaredAtScope and export evidence changes in
// #3190. Old durable ParsedFiles lack the facts needed for scoped binding;
// invalidate both stores so warm indexing actually applies the correction.
// Re-check against main before merge (currently 93).
const SCHEMA_BUMP = 94;
const GITNEXUS_PKG_VERSION = (() => {
try {
// package.json sits at gitnexus/package.json — two levels up from

View file

@ -252,8 +252,8 @@
"digest": "ad4cfabcd53397ab664ba4bb05df4e1a803455b5c255a19e2ed283c6523a1660"
},
"go-method-enrichment/app.go": {
"captureGroups": 15,
"digest": "35929a31f6921feb074f50385aa91879e93f25b88d7f50774fddcfbb7b4e8539"
"captureGroups": 16,
"digest": "b2ab0936ab28e22d0333119838c929ec6e3d460cc0be8d27aa4d178b9f6452d3"
},
"go-mixed-chain/cmd/main.go": {
"captureGroups": 22,

View file

@ -0,0 +1,73 @@
import { describe, expect, it } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { getRelationships, runPipelineFromRepo, writeFixtureRepo } from './helpers.js';
async function callsFor(
source: string,
targetSource = 'pub fn uniqueScopeHelper() {}\n',
targetName = 'uniqueScopeHelper',
) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-rust-import-scope-'));
try {
writeFixtureRepo(dir, {
'Cargo.toml': '[package]\nname = "scope-test"\nversion = "0.1.0"\nedition = "2021"\n',
'src/lib.rs': source,
'src/target.rs': targetSource,
});
const result = await runPipelineFromRepo(dir, () => {});
return getRelationships(result, 'CALLS').filter((edge) => edge.target === targetName);
} finally {
fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
}
}
describe('Rust import scope through full ingestion', () => {
it('preserves method calls on the result of a function-local imported factory', async () => {
const calls = await callsFor(
`
mod target;
pub fn allowed() {
use crate::target::make_user;
let user = make_user();
user.save();
}
`,
'pub struct User {}\nimpl User { pub fn save(&self) {} }\npub fn make_user() -> User { User {} }\n',
'save',
);
expect(calls.filter((edge) => edge.source === 'allowed')).toHaveLength(1);
});
it('an import in one inline module cannot bind or authorize calls in its sibling', async () => {
const calls = await callsFor(`
mod target;
mod importing {
use crate::target::uniqueScopeHelper;
pub fn allowed() { uniqueScopeHelper(); }
}
mod sibling {
pub fn denied() { uniqueScopeHelper(); }
}
`);
expect(calls.filter((edge) => edge.source === 'allowed')).toHaveLength(1);
expect(calls.filter((edge) => edge.source === 'allowed')[0]!.rel.reason).toBe(
'import-resolved',
);
expect(calls.filter((edge) => edge.source === 'denied')).toEqual([]);
});
it('function-local alias imports remain usable inside that function, not its sibling', async () => {
const calls = await callsFor(`
mod target;
pub fn allowed() {
use crate::target::uniqueScopeHelper as localHelper;
localHelper();
}
pub fn denied() { localHelper(); }
`);
expect(calls.filter((edge) => edge.source === 'allowed')).toHaveLength(1);
expect(calls.filter((edge) => edge.source === 'denied')).toEqual([]);
});
});

View file

@ -260,12 +260,13 @@ describe('PARSE_CACHE_VERSION', () => {
// Moved 92 -> 93 for #3161 (Zig static gating): call captures inside a
// comptime-false branch gain the `@reference.static-gated` marker, a
// parse-time fact a warm cache from an earlier head would replay without.
it('pins SCHEMA_BUMP to 93 so concurrent bumps cannot silently collide (#2766, #3015, #3088, #2885, #3128, #2865, #3130, #1432, #3161)', () => {
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(93);
// 93 -> 94 (#3190): lexical import provenance and corrected export evidence.
it('pins SCHEMA_BUMP to 94 so concurrent bumps cannot silently collide (#3190)', () => {
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(94);
expect(PARSE_CACHE_BUCKET_COUNT).toBe(128);
for (const taken of [
59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81,
82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92,
82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93,
]) {
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).not.toBe(taken);
}

View file

@ -0,0 +1,159 @@
import { describe, expect, it } from 'vitest';
import { buildScopeTree, finalize, type FinalizeHooks, type ParsedFile } from 'gitnexus-shared';
import { createKnowledgeGraph } from '../../../src/core/graph/graph.js';
import { emitImportEdges } from '../../../src/core/ingestion/scope-resolution/graph-bridge/imports-to-edges.js';
import { extractParsedFile } from '../../../src/core/ingestion/scope-extractor-bridge.js';
import { rustProvider } from '../../../src/core/ingestion/languages/rust.js';
import { rustIsGlobalNameFallbackPlausible } from '../../../src/core/ingestion/languages/rust/name-fallback-visibility.js';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import {
persistParsedFileChunk,
loadParsedFilesForPaths,
} from '../../../src/storage/parsedfile-store.js';
function extract(source: string, filePath = 'src/caller.rs'): ParsedFile {
const parsed = extractParsedFile(rustProvider, source, filePath);
if (parsed === undefined) throw new Error(`Failed extraction: ${filePath}`);
return parsed;
}
const source = `
mod importing {
use crate::target::helper;
pub fn allowed() { helper(); }
}
mod sibling {
pub fn denied() { helper(); }
}
`;
function link(parsed: ParsedFile, lexical = true) {
const target = extract('pub fn helper() {}', 'src/target.rs');
const hooks: FinalizeHooks = {
resolveImportTarget: (raw: string) =>
raw.startsWith('crate::target') ? target.filePath : null,
expandsWildcardTo: () => ['helper'],
mergeBindings: (existing, incoming) => [...existing, ...incoming],
importsBindAtLexicalScope: lexical,
};
return { target, out: finalize({ files: [parsed, target], workspaceIndex: undefined }, hooks) };
}
describe('import lexical provenance', () => {
it('keeps file dependency edges when import binding moves into a local scope', () => {
const parsed = extract(source);
const { target, out } = link(parsed);
const graph = createKnowledgeGraph();
emitImportEdges(
graph,
out.imports,
buildScopeTree([...parsed.scopes, ...target.scopes]),
'scope import',
);
const dependencies = graph.relationships.filter((edge) => edge.type === 'IMPORTS');
expect(dependencies).toHaveLength(1);
expect(dependencies[0]!.sourceId).toBe('File:src/caller.rs');
expect(dependencies[0]!.targetId).toBe('File:src/target.rs');
});
it('does not promote a local wildcard into the importing file export closure', () => {
const parsed = extract('mod inner { use crate::target::*; }');
const target = extract('pub fn helper() {}', 'src/target.rs');
const consumer = extract('use crate::caller::helper;', 'src/consumer.rs');
const out = finalize(
{ files: [parsed, target, consumer], workspaceIndex: undefined },
{
importsBindAtLexicalScope: true,
resolveImportTarget: (raw) =>
raw.startsWith('crate::target') ? target.filePath : parsed.filePath,
expandsWildcardTo: () => ['helper'],
mergeBindings: (existing, incoming) => [...existing, ...incoming],
},
);
expect(out.bindings.get(parsed.parsedImports[0]!.declaredAtScope!)?.has('helper')).toBe(true);
expect(out.imports.get(consumer.moduleScope)?.[0]?.linkStatus).toBe('unresolved');
expect(out.bindings.get(consumer.moduleScope)?.has('helper')).toBe(false);
});
it('survives the worker/disk ParsedFile round-trip', async () => {
const parsed = extract(source);
const dir = await mkdtemp(path.join(tmpdir(), 'gn-import-provenance-'));
try {
await persistParsedFileChunk(dir, 'imports', [parsed]);
const loaded = await loadParsedFilesForPaths(dir, new Set([parsed.filePath]));
const restored = loaded.get(parsed.filePath)!;
expect(restored.parsedImports).toEqual(parsed.parsedImports);
const scopeId = restored.parsedImports[0]!.declaredAtScope!;
expect(link(restored).out.bindings.get(scopeId)?.has('helper')).toBe(true);
expect(link(restored).out.bindings.get(restored.moduleScope)?.has('helper')).toBe(false);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
it('retains the declaration scope even for non-executing imports', () => {
const parsed = extract(source);
const imported = parsed.parsedImports[0]!;
expect(imported.declaredAtScope).toBeDefined();
expect(imported.declaredAtScope).not.toBe(parsed.moduleScope);
expect(parsed.scopes.find((scope) => scope.id === imported.declaredAtScope)?.kind).toBe(
'Namespace',
);
expect(imported.runsOnlyWhenCalled).toBeUndefined();
expect(JSON.parse(JSON.stringify(imported)).declaredAtScope).toBe(imported.declaredAtScope);
});
it.each([
'use crate::target::helper;',
'use crate::target::helper as alias;',
'use crate::target::*;',
])('binds %s only in the importing scope', (declaration) => {
const parsed = extract(source.replace('use crate::target::helper;', declaration));
const { out } = link(parsed);
const scope = parsed.parsedImports[0]!.declaredAtScope!;
const name = declaration.includes('alias') ? 'alias' : 'helper';
expect(out.bindings.get(scope)?.get(name)?.[0]?.origin).toMatch(/import|wildcard/);
expect(out.bindings.get(parsed.moduleScope)?.has(name)).toBe(false);
expect(out.imports.get(scope)).toHaveLength(1);
expect(out.stats.totalEdges).toBe(1);
expect(out.stats.linkedEdges).toBe(1);
});
it('preserves file-level binding for resolvers that have not opted in', () => {
const parsed = extract(source);
expect(link(parsed, false).out.bindings.get(parsed.moduleScope)?.has('helper')).toBe(true);
});
it('preserves legacy imports without a scope receipt', () => {
const parsed = extract(source);
const legacy = {
...parsed,
parsedImports: [
{
kind: 'named' as const,
localName: 'helper',
importedName: 'helper',
targetRaw: 'crate::target::helper',
},
],
};
expect(link(legacy).out.bindings.get(parsed.moduleScope)?.has('helper')).toBe(true);
});
it('does not use a sibling inline module import to authorize a guess', () => {
const parsed = extract(source);
const { target } = link(parsed);
const sites = parsed.referenceSites.filter(
(site) => site.kind === 'call' && site.name === 'helper',
);
expect(sites).toHaveLength(2);
const candidate = target.localDefs.find((def) => def.qualifiedName === 'helper')!;
expect(
rustIsGlobalNameFallbackPlausible({ callerParsed: parsed, candidate, site: sites[0]! }),
).toBe(true);
expect(
rustIsGlobalNameFallbackPlausible({ callerParsed: parsed, candidate, site: sites[1]! }),
).toBe(false);
});
});

View file

@ -187,29 +187,59 @@ describe('Python imports — interpretImport', () => {
it('case 10: `import numpy` → namespace import', () => {
const f = parse('import numpy\n');
expect(f.parsedImports).toEqual([
{ kind: 'namespace', localName: 'numpy', importedName: 'numpy', targetRaw: 'numpy' },
{
kind: 'namespace',
localName: 'numpy',
importedName: 'numpy',
targetRaw: 'numpy',
declaredAtScope: f.moduleScope,
},
]);
});
it('case 11: `import numpy as np` → namespace import with rename', () => {
const f = parse('import numpy as np\n');
expect(f.parsedImports).toEqual([
{ kind: 'namespace', localName: 'np', importedName: 'numpy', targetRaw: 'numpy' },
{
kind: 'namespace',
localName: 'np',
importedName: 'numpy',
targetRaw: 'numpy',
declaredAtScope: f.moduleScope,
},
]);
});
it('case 12: `import a.b.c` exposes the leading segment as the local name', () => {
const f = parse('import a.b.c\n');
expect(f.parsedImports).toEqual([
{ kind: 'namespace', localName: 'a', importedName: 'a.b.c', targetRaw: 'a.b.c' },
{
kind: 'namespace',
localName: 'a',
importedName: 'a.b.c',
targetRaw: 'a.b.c',
declaredAtScope: f.moduleScope,
},
]);
});
it('case 13: `import a, b as c` decomposes into one ParsedImport per name', () => {
const f = parse('import a, b as c\n');
expect(f.parsedImports).toEqual([
{ kind: 'namespace', localName: 'a', importedName: 'a', targetRaw: 'a' },
{ kind: 'namespace', localName: 'c', importedName: 'b', targetRaw: 'b' },
{
kind: 'namespace',
localName: 'a',
importedName: 'a',
targetRaw: 'a',
declaredAtScope: f.moduleScope,
},
{
kind: 'namespace',
localName: 'c',
importedName: 'b',
targetRaw: 'b',
declaredAtScope: f.moduleScope,
},
]);
});
@ -218,7 +248,14 @@ describe('Python imports — interpretImport', () => {
// `reexportsName`: Python republishes the name as `<module>.x`, so it must
// enter the re-export closure for `from <module> import x` elsewhere.
expect(f.parsedImports).toEqual([
{ kind: 'named', localName: 'x', importedName: 'x', targetRaw: 'm', reexportsName: true },
{
kind: 'named',
localName: 'x',
importedName: 'x',
targetRaw: 'm',
reexportsName: true,
declaredAtScope: f.moduleScope,
},
]);
});
@ -232,6 +269,7 @@ describe('Python imports — interpretImport', () => {
alias: 'y',
targetRaw: 'm',
reexportsName: true,
declaredAtScope: f.moduleScope,
},
]);
});
@ -239,21 +277,51 @@ describe('Python imports — interpretImport', () => {
it('case 16: `from m import x, y, z` decomposes into three ParsedImports', () => {
const f = parse('from m import x, y, z\n');
expect(f.parsedImports).toEqual([
{ kind: 'named', localName: 'x', importedName: 'x', targetRaw: 'm', reexportsName: true },
{ kind: 'named', localName: 'y', importedName: 'y', targetRaw: 'm', reexportsName: true },
{ kind: 'named', localName: 'z', importedName: 'z', targetRaw: 'm', reexportsName: true },
{
kind: 'named',
localName: 'x',
importedName: 'x',
targetRaw: 'm',
reexportsName: true,
declaredAtScope: f.moduleScope,
},
{
kind: 'named',
localName: 'y',
importedName: 'y',
targetRaw: 'm',
reexportsName: true,
declaredAtScope: f.moduleScope,
},
{
kind: 'named',
localName: 'z',
importedName: 'z',
targetRaw: 'm',
reexportsName: true,
declaredAtScope: f.moduleScope,
},
]);
});
it('case 17: `from m import *` → wildcard', () => {
const f = parse('from m import *\n');
expect(f.parsedImports).toEqual([{ kind: 'wildcard', targetRaw: 'm' }]);
expect(f.parsedImports).toEqual([
{ kind: 'wildcard', targetRaw: 'm', declaredAtScope: f.moduleScope },
]);
});
it('case 18: PEP-328 dotted relative import `from .pkg import x`', () => {
const f = parse('from .pkg import x\n');
expect(f.parsedImports).toEqual([
{ kind: 'named', localName: 'x', importedName: 'x', targetRaw: '.pkg', reexportsName: true },
{
kind: 'named',
localName: 'x',
importedName: 'x',
targetRaw: '.pkg',
reexportsName: true,
declaredAtScope: f.moduleScope,
},
]);
});
@ -266,6 +334,7 @@ describe('Python imports — interpretImport', () => {
importedName: 'x',
targetRaw: '..pkg.sub',
reexportsName: true,
declaredAtScope: f.moduleScope,
},
]);
});
@ -291,6 +360,7 @@ describe('Python imports — function-local', () => {
importedName: 'X',
targetRaw: 'm',
runsOnlyWhenCalled: true,
declaredAtScope: scopesByKind(f, 'Function')[0]!.id,
},
]);
});
@ -305,7 +375,13 @@ describe('Python imports — function-local', () => {
// initialization order. The two facts are separate on purpose — this is
// the case where suppression and deferral disagree.
expect(f.parsedImports).toEqual([
{ kind: 'named', localName: 'X', importedName: 'X', targetRaw: 'm' },
{
kind: 'named',
localName: 'X',
importedName: 'X',
targetRaw: 'm',
declaredAtScope: scopesByKind(f, 'Class')[0]!.id,
},
]);
});
@ -317,9 +393,30 @@ describe('Python imports — function-local', () => {
'if TYPE_CHECKING:\n from m import A\ntry:\n from m import B\nexcept ImportError:\n B = None\nfor _ in r:\n from m import C\n',
);
expect(f.parsedImports).toEqual([
{ kind: 'named', localName: 'A', importedName: 'A', targetRaw: 'm', reexportsName: true },
{ kind: 'named', localName: 'B', importedName: 'B', targetRaw: 'm', reexportsName: true },
{ kind: 'named', localName: 'C', importedName: 'C', targetRaw: 'm', reexportsName: true },
{
kind: 'named',
localName: 'A',
importedName: 'A',
targetRaw: 'm',
reexportsName: true,
declaredAtScope: f.moduleScope,
},
{
kind: 'named',
localName: 'B',
importedName: 'B',
targetRaw: 'm',
reexportsName: true,
declaredAtScope: f.moduleScope,
},
{
kind: 'named',
localName: 'C',
importedName: 'C',
targetRaw: 'm',
reexportsName: true,
declaredAtScope: f.moduleScope,
},
]);
});
});

View file

@ -329,7 +329,7 @@ describe('Pass 3: raw imports', () => {
interpretImport: () => named,
}),
);
expect(result.parsedImports).toEqual([named]);
expect(result.parsedImports).toEqual([{ ...named, declaredAtScope: result.moduleScope }]);
});
it('drops imports when `interpretImport` returns null', () => {
@ -443,7 +443,7 @@ describe('Pass 3: runsOnlyWhenCalled', () => {
'a.ts',
mockProvider({ interpretImport: () => named }),
);
expect(result.parsedImports).toEqual([named]);
expect(result.parsedImports).toEqual([{ ...named, declaredAtScope: result.moduleScope }]);
});
// ─── The provider capability that opts out of the position rule ──────────
@ -474,8 +474,10 @@ describe('Pass 3: runsOnlyWhenCalled', () => {
'a.c',
mockProvider({ interpretImport: () => named, importsExecuteWhereWritten: false }),
);
// Byte-identical to the un-deferred shape, not merely `!== true`.
expect(result.parsedImports).toEqual([named]);
// Scope provenance survives, without adding the execution-deferral flag.
expect(result.parsedImports).toEqual([
{ ...named, declaredAtScope: 'scope:a.c#2:0-99:0:Function' },
]);
});
it('the identical captures ARE marked for a provider that does not declare it', () => {
@ -488,7 +490,9 @@ describe('Pass 3: runsOnlyWhenCalled', () => {
];
expect(
extract(captures, 'a.ts', mockProvider({ interpretImport: () => named })).parsedImports,
).toEqual([{ ...named, runsOnlyWhenCalled: true }]);
).toEqual([
{ ...named, declaredAtScope: 'scope:a.ts#2:0-99:0:Function', runsOnlyWhenCalled: true },
]);
// Absent must mean `true`, not merely "not false" — the default is the
// safe direction (position defers), and only an explicit `false` withholds
// deferral. Spelling `true` therefore has to behave exactly like absent.
@ -498,7 +502,9 @@ describe('Pass 3: runsOnlyWhenCalled', () => {
'a.ts',
mockProvider({ interpretImport: () => named, importsExecuteWhereWritten: true }),
).parsedImports,
).toEqual([{ ...named, runsOnlyWhenCalled: true }]);
).toEqual([
{ ...named, declaredAtScope: 'scope:a.ts#2:0-99:0:Function', runsOnlyWhenCalled: true },
]);
});
});
@ -925,7 +931,9 @@ describe('end-to-end fixture (all 5 passes together)', () => {
expect(fn.bindings.get('save')).toBeDefined();
// Import collected.
expect(result.parsedImports).toEqual([parsedImport]);
expect(result.parsedImports).toEqual([
{ ...parsedImport, declaredAtScope: result.moduleScope },
]);
// Type binding attached to function scope.
expect(fn.typeBindings.get('name')?.rawName).toBe('string');