diff --git a/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts b/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts index f4514c78b..a5b13d26c 100644 --- a/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts +++ b/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts @@ -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(); + 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([[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, hooks: FinalizeHooks, ): ReadonlyMap> { - const out = new Map>(); + const buckets = new Map>(); // 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>(); + for (const [scopeId, scopeBindings] of buckets) { // Freeze nested buckets for immutability. const frozen = new Map(); for (const [name, refs] of scopeBindings) { frozen.set(name, Object.freeze(refs.slice())); } - out.set(file.moduleScope, frozen); + out.set(scopeId, frozen); } return out; diff --git a/gitnexus-shared/src/scope-resolution/types.ts b/gitnexus-shared/src/scope-resolution/types.ts index 94a085daa..c4de7bb04 100644 --- a/gitnexus-shared/src/scope-resolution/types.ts +++ b/gitnexus-shared/src/scope-resolution/types.ts @@ -107,7 +107,14 @@ export type CaptureMatch = Readonly>; * 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. * diff --git a/gitnexus/bench/scope-capture/baselines.json b/gitnexus/bench/scope-capture/baselines.json index 272d081c0..3eed94d42 100644 --- a/gitnexus/bench/scope-capture/baselines.json +++ b/gitnexus/bench/scope-capture/baselines.json @@ -1,7 +1,8 @@ { "_comment": "Per-language baselines for bench/scope-capture/measure.mjs --check. fingerprint = order-independent sha256 over the lang-resolution/-* 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(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(token, ...)` (admin.ts/auth.ts) KEEPS `direct-callee-name|verifyToken`: tree-sitter-typescript parses `await f(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.", diff --git a/gitnexus/src/core/ingestion/finalize-orchestrator.ts b/gitnexus/src/core/ingestion/finalize-orchestrator.ts index 986d1cc2b..4a0e99f6b 100644 --- a/gitnexus/src/core/ingestion/finalize-orchestrator.ts +++ b/gitnexus/src/core/ingestion/finalize-orchestrator.ts @@ -200,6 +200,7 @@ function collectReferenceSites(parsedFiles: readonly ParsedFile[]) { */ function withDefaultHooks(partial: Partial): FinalizeHooks { return { + importsBindAtLexicalScope: partial.importsBindAtLexicalScope === true, resolveImportTarget: partial.resolveImportTarget ?? (() => null), isNamespaceImport: partial.isNamespaceImport, wildcardCollisionIsAmbiguous: partial.wildcardCollisionIsAmbiguous === true, diff --git a/gitnexus/src/core/ingestion/languages/rust/name-fallback-visibility.ts b/gitnexus/src/core/ingestion/languages/rust/name-fallback-visibility.ts index fbe1a64b9..42e0704f2 100644 --- a/gitnexus/src/core/ingestion/languages/rust/name-fallback-visibility.ts +++ b/gitnexus/src/core/ingestion/languages/rust/name-fallback-visibility.ts @@ -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 = 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 = 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>(); + /** * 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 | 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. diff --git a/gitnexus/src/core/ingestion/languages/rust/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/rust/scope-resolver.ts index 54f625bb3..71fb697db 100644 --- a/gitnexus/src/core/ingestion/languages/rust/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/rust/scope-resolver.ts @@ -193,5 +193,6 @@ export const rustScopeResolver: ScopeResolver = { hoistTypeBindingsToModule: true, propagatesReturnTypesAcrossImports: true, allowGlobalFreeCallFallback: true, + importsBindAtLexicalScope: true, isGlobalNameFallbackPlausible: rustIsGlobalNameFallbackPlausible, }; diff --git a/gitnexus/src/core/ingestion/scope-extractor.ts b/gitnexus/src/core/ingestion/scope-extractor.ts index ebc8e72b2..d31a36d7f 100644 --- a/gitnexus/src/core/ingestion/scope-extractor.ts +++ b/gitnexus/src/core/ingestion/scope-extractor.ts @@ -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 } : {}), + }); } } diff --git a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts index 255474376..e024a1123 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts @@ -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; diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts index 94f6ccd69..132ce4c53 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts @@ -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 diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/imported-return-types.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/imported-return-types.ts index 157348c5b..0736bb53c 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/imported-return-types.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/imported-return-types.ts @@ -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).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).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; + } } } diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts index 16756bf42..a5fa81f2e 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts @@ -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, diff --git a/gitnexus/src/storage/parse-cache.ts b/gitnexus/src/storage/parse-cache.ts index 743b810b4..10266ec4e 100644 --- a/gitnexus/src/storage/parse-cache.ts +++ b/gitnexus/src/storage/parse-cache.ts @@ -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 diff --git a/gitnexus/test/fixtures/go-captures-golden/expected-captures.json b/gitnexus/test/fixtures/go-captures-golden/expected-captures.json index c8c5d6efe..e860db2a6 100644 --- a/gitnexus/test/fixtures/go-captures-golden/expected-captures.json +++ b/gitnexus/test/fixtures/go-captures-golden/expected-captures.json @@ -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, diff --git a/gitnexus/test/integration/resolvers/rust-import-scope.test.ts b/gitnexus/test/integration/resolvers/rust-import-scope.test.ts new file mode 100644 index 000000000..44b2d39fb --- /dev/null +++ b/gitnexus/test/integration/resolvers/rust-import-scope.test.ts @@ -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([]); + }); +}); diff --git a/gitnexus/test/unit/incremental-parse-cache.test.ts b/gitnexus/test/unit/incremental-parse-cache.test.ts index f6627e94a..114384478 100644 --- a/gitnexus/test/unit/incremental-parse-cache.test.ts +++ b/gitnexus/test/unit/incremental-parse-cache.test.ts @@ -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); } diff --git a/gitnexus/test/unit/scope-resolution/import-scope-provenance.test.ts b/gitnexus/test/unit/scope-resolution/import-scope-provenance.test.ts new file mode 100644 index 000000000..b2a9fa778 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/import-scope-provenance.test.ts @@ -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); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/python/python-fixtures.test.ts b/gitnexus/test/unit/scope-resolution/python/python-fixtures.test.ts index 6dc18960e..252556cf2 100644 --- a/gitnexus/test/unit/scope-resolution/python/python-fixtures.test.ts +++ b/gitnexus/test/unit/scope-resolution/python/python-fixtures.test.ts @@ -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 `.x`, so it must // enter the re-export closure for `from 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, + }, ]); }); }); diff --git a/gitnexus/test/unit/scope-resolution/scope-extractor.test.ts b/gitnexus/test/unit/scope-resolution/scope-extractor.test.ts index 9e2ac3c4e..33c8cab51 100644 --- a/gitnexus/test/unit/scope-resolution/scope-extractor.test.ts +++ b/gitnexus/test/unit/scope-resolution/scope-extractor.test.ts @@ -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');