mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-08 22:22:52 +00:00
Merge lexical import provenance into flow split
This commit is contained in:
commit
3aa22a3db8
18 changed files with 540 additions and 103 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -193,5 +193,6 @@ export const rustScopeResolver: ScopeResolver = {
|
|||
hoistTypeBindingsToModule: true,
|
||||
propagatesReturnTypesAcrossImports: true,
|
||||
allowGlobalFreeCallFallback: true,
|
||||
importsBindAtLexicalScope: true,
|
||||
isGlobalNameFallbackPlausible: rustIsGlobalNameFallbackPlausible,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue