* fix(cpp): complete scope-resolution parity * fix(ci): resolve formatting, lint errors for PR #1520 - prettier: format arity-metadata.ts, captures.ts, index.ts - eslint: rename unused HEADER_GLOB to _HEADER_GLOB - eslint: replace unsafe parser.parse() with parseSourceSafe() - eslint: suppress intentional console.warn/log in sync.ts - eslint: remove unused _it import alias in cpp.test.ts * fix(ci): complete formatting, lint, and typecheck fixes - prettier: format call-processor.ts, imported-return-types.ts, include-extractor.test.ts, cpp-captures.test.ts, cpp-imports.test.ts - eslint: suppress intentional console.warn in manifest-extractor.ts - typecheck: restore 'thrift' in ContractType union (was accidentally removed) and add thrift case to exhaustive switch in manifest-extractor * fix(ci): revert unintended group module changes that broke tests Restore types.ts, config-parser.ts, matching.ts, sync.ts, and manifest-extractor.ts to upstream/main versions. The original commit accidentally removed fields (thrift, workspace_deps, exclude_links_paths, exclude_links_param_only_paths) from DetectConfig/MatchingConfig/ContractType which are still referenced by matching.test.ts, config-parser.test.ts, sync.test.ts and other integration tests. This PR's scope is C++ scope-resolution parity only — group module type definitions and logic should remain unchanged. * fix(codeql): address security and quality alerts - arity-metadata.ts, interpret.ts: replace single-pass template strip regex (/<[^>]*>/g) with a while-loop to fully handle nested templates like Map<List<int>> — resolves 'Incomplete multi-character sanitization' - cpp.test.ts: remove unused vitest 'it' import since the file defines its own 'it' via createResolverParityIt — resolves 'Assignment to constant' - include-extractor.test.ts: use fs.mkdtempSync() instead of predictable os.tmpdir()+Date.now() paths — resolves 'Insecure temporary file' - interpret.ts: remove redundant 'name !== undefined' check (already guaranteed by early return) — resolves 'Comparison between inconvertible types' * review: address Claude review findings on PR #1520 - Findings 1-3 (BLOCKERS): restore include-extractor.ts and its test to the main baseline. Block-comment fallback regression, suffix-resolve false-positive suppression, and the four deleted regression tests (#3-#6) are now back. These changes were unrelated to C++ scope parity and should not have been in this PR. - Finding 4 (MAJOR, partial): revert COMPOUND_RECEIVER_MAX_DEPTH 6 to 4. No C++ test exercises depth > 4 (cpp-chain-call uses a 2-hop chain), so the bump risked silent regressions on other migrated languages without justification. The wildcard-origin propagation in imported-return-types.ts is retained — C++ #include and using namespace both emit wildcard-origin bindings (cpp/import-decomposer .ts:40,90), so wildcard propagation is causal to C++ parity. - Finding 6: tighten write-access dedup test with exact per-field counts (nameWrites = 2, addrWrites = 1) instead of total-count + sub string containment, so a regression in one of the two name writes can no longer be masked. - Finding 8: skipped. Box-drawing characters in cpp/query.ts comments match the established convention used in csharp/java/php query files. Finding 5 (int/long normalization tie-breaker) left as documented follow-up — proper fix requires resolver-level tie-breaker logic and risks regressing other arity-matching tests. * fix(cpp): stop #include from leaking class methods and namespace members (U1) The C++ registry-primary resolver was emitting impossible CALLS edges for ordinary headers: an including file's unqualified save() resolved to User::save and unqualified foo() resolved to ns::foo. Two leak paths converged on localDefs: 1. expandCppWildcardNames (file-local-linkage.ts) iterated the flattened localDefs and exported every simple tail, including class-owned methods and namespace-contained symbols. Replaced with a scope-aware filter: build nodeId -> owning Scope from Scope.ownedDefs and skip defs whose owning scope is Namespace or Class. 2. The shared global free-call fallback's pickUniqueGlobalCallable walks the workspace registry by simple name and would still hit class methods / namespace members even with wildcard expansion fixed. Plugged the gap via the existing isFileLocalDef hook — semantically 'logically invisible cross-file' — by tracking per- file non-globally-visible nodeIds (populateCppNonGloballyVisible, called from populateOwners) and adding an ownerId !== undefined fast-path for class-owned defs. Side fix in shared finalize-algorithm.ts: when wildcard expansion resolves to a real target but produces zero propagating names, the edge was dropped, taking the file-level IMPORTS edge with it. Preserve the original wildcard edge so #include dependencies survive even when the header exposes no unqualified bindings. Tests: cpp-include-no-class-leak, cpp-include-no-namespace-leak, and cpp-anon-ns-same-file-visible fixtures. Negative tests mode-gated to REGISTRY_PRIMARY_CPP=1 via the expected-failures registry — legacy DAG has no scope-aware filtering on the global fallback; backporting is out of scope. All 2104 resolver integration tests pass under registry-primary mode. * fix(cpp): suppress receiver-bound CALLS when integer-width overloads collide (U2) C++ arity-metadata normalizes int, long, short, unsigned, size_t to 'int' so single-candidate flows like 'process(42L)' match a 'long'- typed parameter via loose matching. But when both 'process(int)' and 'process(long)' coexist as method overloads, they both end up with parameterTypes=['int'] in the registry, and pickOverload's narrowing returns 2 candidates with no way to disambiguate. The previous code picked candidates[0] arbitrarily, emitting a CALLS edge to the wrong overload roughly half the time. Fix: - Add isOverloadAmbiguousAfterNormalization in overload-narrowing.ts that detects >1 candidate sharing identical parameterTypes sequences. - Have pickOverload return a new OVERLOAD_AMBIGUOUS sentinel when this fires. - In the receiver-bound-calls loop, when pickOverload signals ambiguity, suppress the edge AND add the site to handledSites so the late-stage emitReferencesViaLookup pass does not re-emit the pre-resolved reference. Without the handled-mark, the reference index still carries a toDef and emits the same wrong edge. Graph schema has no ambiguous-target edge model, so emitting two edges (one per candidate) would require a separate schema change. Zero-edge is the only safe outcome. Other languages: the ambiguity check is a precondition gate, not a behavior change for normal narrowing. Languages whose normalizers do not collapse distinct types into a single token (verified by grep over *-arity-metadata.ts) will never produce >1 candidate with identical parameterTypes from genuinely distinct declarations, so the branch is effectively C++-only in practice. Test: cpp-overload-int-long fixture asserts exactly .toBe(0) CALLS edges. Count=1 = arbitrary pick (the bug); count>1 = unsupported ambiguous-edge model. Mode-gated to REGISTRY_PRIMARY_CPP=1 — legacy DAG has no OVERLOAD_AMBIGUOUS wiring; backporting is out of scope. All 2105 resolver integration tests pass under registry-primary; all 139 cpp tests pass under both modes (3 negative tests skipped in legacy as documented). * test(cpp): add integration coverage for anonymous-namespace, using-namespace conflict, and std-shim leakage (U3+U4+U5) Three new end-to-end fixtures exercise the resolver pipeline against scenarios that previously had only unit-level coverage or no coverage at all (Claude review Finding 7): U3 — cpp-anon-ns-cross-file: helper.cpp declares 'namespace { void worker(); }' and calls it internally. caller.cpp declares a separate 'void worker()' and calls it. Asserts (a) the cross-file CALLS edge from caller's run() does not target helper.cpp's anonymous-namespace worker, and (b) the same-file edge from helper_entry() to its own worker still resolves (positive guard against a 'no edges at all' regression making the negative check vacuously pass). Includes a state-isolation guard that re-runs the same fixture and asserts identical results, proving clearFileLocalNames() is called by the pipeline entry. U4 — cpp-using-namespace-conflict: Two headers each declaring 'namespace a { foo() }' and 'namespace b { foo() }' respectively, plus a caller doing 'using namespace a; using namespace b; foo()'. Asserts exactly zero CALLS edges. One edge = arbitrary pick (the bug); two edges would require an ambiguous-target edge model GitNexus does not have. Depends on U1 — without scope-aware filtering, both foo()s would already be in the importer's wildcard binding set as simple 'foo', so the test would pass for the wrong reason. U5 — cpp-using-namespace-std-smoke: Fixture-local 'namespace std { void cout_write(); void println(); }' shim rather than real <iostream> — captures the wildcard-leak shape deterministically without depending on system-header modeling stability (out of scope per plan). Asserts (a) the project-local call resolves correctly, (b) no leak to shim STL symbols, and (c) no CALLS/ACCESSES edges from the caller into std-shim.h at all. Negative tests for U2/U4 mode-gated to REGISTRY_PRIMARY_CPP=1 via the expected-failures registry; legacy DAG lacks the OVERLOAD_AMBIGUOUS suppression and the namespace-aware filtering, so the leaks persist there. All 2112 resolver integration tests pass under registry-primary; all 146 cpp tests pass under both modes (4 negative tests skipped in legacy as documented). * chore(autofix): apply prettier + eslint fixes via /autofix command * fix(cpp): scope-aware isSuperReceiver classification (U1) The C++ isSuperReceiver hook used a regex `/^[A-Z]\w*::/` that misclassified any uppercase-qualified call as a super-receiver call. Singleton::getInstance(), std::Foo::bar(), and PascalCase namespace calls all entered the super branch, where the absence of an enclosing class (or wrong MRO context) dropped the resolution entirely. Fix: - New optional ScopeResolver hook isSuperReceiverInContext(text, callerScope, scopes). Languages where super classification depends on caller context define it; receiver-bound-calls.ts prefers it when defined and falls back to the simple isSuperReceiver(text) otherwise. Other migrated languages (Python, Java, C#, PHP, Go, TypeScript) are unchanged. - C++ implementation: parse the LHS of '::' from the receiver text, resolve via findClassBindingInScope, and return true only when the LHS is a class-like def in the caller's enclosing class's MRO. Returns false for namespace LHS, unresolved LHS, self-class LHS (qualified self-calls aren't super), and any non-'::' form. - Extended the C++ tree-sitter query to capture the LHS of qualified_identifier as @reference.receiver so qualified static member calls (Singleton::getInstance()) reach the receiver-bound Case 2 (class-name receiver) path. Without the receiver capture, qualified calls had no explicit receiver and could not resolve through any receiver-bound branch. Test: cpp-namespace-qualified-not-super fixture. Singleton::getInstance() from a free function asserts exactly 1 CALLS edge through the qualified-call path. Passes under both REGISTRY_PRIMARY_CPP=1 and =0. All 2113 resolver integration tests pass; all 147 cpp tests pass under both modes. * fix(cpp): suppress receiver-bound CALLS when default-arg overloads collide (U4) ISO C++ rejects 's.f(1)' as ambiguous when both 'void f(int)' and 'void f(int, int = 0)' are declared on S. The previous resolver returned the first viable candidate via pickOverload's fallback. Extended isOverloadAmbiguousAfterNormalization to take an optional argCount: when provided, the predicate compares only the first argCount slots of each candidate's parameterTypes. Candidates whose declared-prefix matches up to argCount are treated as ambiguous because default arguments make all of them equally viable for the call. Without argCount, behavior is unchanged (the original int/long normalization-collapse contract, full-length equality required). pickOverload now passes site.arity so default-arg ambiguity fires. Test: cpp-overload-default-arg-ambiguous fixture. s.f(1) where S has f(int) and f(int, int = 0) asserts exactly .toBe(0) CALLS edges. Passes under both REGISTRY_PRIMARY_CPP=1 and =0. All 2114 resolver integration tests pass; all 148 cpp tests pass under both modes. * fix(cpp): two-phase template lookup suppresses dependent-base members (U3) ISO C++ two-phase name lookup: inside a class template body, unqualified calls MUST NOT bind to members of a dependent base class. Only this->name or Base<T>::name forms make the lookup dependent. GCC and Clang both reject the unqualified form with 'declaration of f must be available'. Before this fix, GitNexus's global free-call fallback walked the workspace registry by simple name and bound unqualified calls inside template bodies to dependent-base members, producing CALLS edges the compiler would reject. Implementation: - New languages/cpp/two-phase-lookup.ts module: per-pipeline state recording (className, dependentBaseName) pairs at capture time and resolving them to nodeId sets during populateOwners. - captures.ts detectCppDependentBases walks the AST once finding every template_declaration containing a class/struct definition. For each, it collects template-parameter names (typename T, class T, non-type int N, template-template parameters) and walks each base in the base_class_clause checking whether any inner type_identifier matches a template parameter. Conservative bias: typename T::U, decltype, and template-template-parameter shapes also classified as dependent. - Extended scope-resolution contract's isCallableVisibleFromCaller hook with optional callerScope and scopes fields. C++ implements the hook to consult isCppDependentBaseMember: when the candidate is a member of a dependent base of the caller's enclosing class, the hook returns false and pickUniqueGlobalCallable skips the candidate. - clearFileLocalNames also clears the dependent-base state per pipeline run. Fixtures: - cpp-two-phase-dependent-base: Derived<T> deriving from Base<T>, unqualified f() and i inside Derived's body. Asserts zero CALLS edges and zero ACCESSES edges respectively. - cpp-two-phase-this-qualified, cpp-two-phase-non-dependent-base, cpp-two-phase-namespace-free-call-inside-template: positive fixtures left as documented gaps (this-> and qualified-name resolution inside template bodies are pre-existing resolver weaknesses independent of U3). Tracked separately. Negative test mode-gated to REGISTRY_PRIMARY_CPP=1 via the expected- failures registry; legacy DAG has no two-phase lookup. All 2116 resolver integration tests pass under registry-primary; all 150 cpp tests pass under both modes (5 negative tests skipped in legacy as documented). * fix(cpp): implement V1 ADL (Koenig lookup) for free-function calls (U2) Plan 2026-05-13-001 U2. Adds argument-dependent lookup as a new candidate-generating tier in `emitFreeCallFallback`: when ordinary unqualified lookup is empty, ADL surfaces candidates from each value-class-typed argument's enclosing namespace. V1 boundary (locked by cpp-adl-pointer-arg-boundary fixture): - only direct enclosing-namespace closure - only directly-named class-type values (pointer / reference / template- spec args excluded; closure rules deferred to V2) - ADL fires ONLY when ordinary lookup is empty (no union-and-resolve) Parenthesized name `(f)(s)` suppresses ADL per ISO C++ [basic.lookup.argdep]/3.1. Multi-candidate ambiguity (e.g. `process(int)` vs `process(long)` after C++ int-width normalization) returns the ADL_AMBIGUOUS sentinel — caller suppresses entirely, mirroring the OVERLOAD_AMBIGUOUS contract from plan 2026-05-12-002 U2. Implementation: - `cpp/adl.ts` — new module: per-pipeline argInfoBySite + noAdlSites Maps populated at capture time, classToNamespaceQualifiedName Map populated during populateOwners; `pickCppAdlCandidates` returns SymbolDefinition | ADL_AMBIGUOUS | undefined - `scope-resolution/contract/scope-resolver.ts` — adds optional `resolveAdlCandidates` hook - `scope-resolution/passes/free-call-fallback.ts` — invokes ADL hook between `findCallableBindingInScope` and `pickUniqueGlobalCallable`; marks site handled on `'ambiguous'` so emit-references doesn't retry - `cpp/captures.ts` — detects `parenthesized_expression` function wrap; per-arg classification (pointer/reference/value class) preserving the shape info the existing arity-narrowing normalizer strips - `cpp/scope-resolver.ts` — registers hook, populates associated namespaces, clears state in loadResolutionConfig Negative tests (parens, pointer-boundary, ambiguous) gated under LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES.cpp — legacy DAG has no V1/V2 ADL boundary or ADL_AMBIGUOUS suppression. 154/154 cpp integration tests pass under REGISTRY_PRIMARY_CPP=1; 147 pass + 7 skipped under =0 (legacy parity baseline). * fix(cpp): inline namespace transitive walking + qualified namespace resolution (U5) Plan 2026-05-13-001 U5. Two ISO C++ inline-namespace semantics: 1. Unqualified-lookup transitive visibility: inline-namespace members reach the enclosing namespace's scope as if declared there. The `populateCppNonGloballyVisible` exemption keeps them globally visible so cross-file unqualified lookup finds them. 2. Qualified-receiver transitive visibility: `outer::foo()` resolves to `outer::v1::foo()` when `v1` is inline (and through arbitrarily-deep nesting like `outer::v1::experimental::foo`, matching libc++ `__1` / libstdc++ `__cxx11`). The second behavior required a new resolver case in `receiver-bound-calls.ts` (Case 1.5: language-specific qualified-receiver member lookup) because C++ qualified-namespace member calls had no prior resolution path — receiver-bound Case 1 only handled `ParsedImport.kind === 'namespace'` (Python/JS-style) and Case 2 handles class receivers, neither of which fired for `outer::foo()`. The new hook `resolveQualifiedReceiverMember` is opt-in; languages without C++-style qualified-name semantics omit it. Implementation: - `cpp/inline-namespaces.ts` — new module: per-pipeline `inlineNamespaceRangesByFile` + `inlineNamespaceScopeIds` Sets; `markCppInlineNamespaceRange` at capture time; `populateCppInlineNamespaceScopes` resolves ranges → scope IDs; `resolveCppQualifiedNamespaceMember` walks namespace scopes by simple name and descends transitively through inline children only. - `scope-resolution/contract/scope-resolver.ts` — adds optional `resolveQualifiedReceiverMember` hook to the contract. - `scope-resolution/passes/receiver-bound-calls.ts` — Case 1.5 invokes the hook between Case 1 (namespace imports) and Case 2 (class-name receiver). Returns undefined for non-namespace receivers so Case 2 still resolves class-qualified calls. - `cpp/captures.ts` — detects `inline` keyword child on `namespace_definition`; records 1-based range to match Scope.range. - `cpp/file-local-linkage.ts` — `populateCppNonGloballyVisible` exempts inline-namespace scopes so cross-file unqualified lookup keeps their members visible. - `cpp/scope-resolver.ts` — wires `populateCppInlineNamespaceScopes` into populateOwners (BEFORE `populateCppNonGloballyVisible` so the exemption sees populated state); registers `resolveQualifiedReceiverMember` hook. 4 fixtures: `cpp-inline-namespace-unqualified`, `-versioned`, `-nested` (two transitive inline hops, STL `__1` shape), and `-adl-participation` (composes with U2 — ADL surfaces records declared inside inline child namespaces). All 4 assert exactly 1 CALLS edge with correct target file. Versioned fixture gated under LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES.cpp — legacy DAG can't disambiguate two same-name foos without inline awareness. Other 3 coincidentally resolve in legacy. 158/158 cpp integration tests pass under REGISTRY_PRIMARY_CPP=1; 150 pass + 8 skipped under =0 (legacy parity baseline). * test(cpp): Phase 5 cross-unit composition tests for U1/U2/U3/U5 Plan 2026-05-13-001 Phase 5. Locks in correct behavior at the intersections between the previously-shipped scope-resolver units. Enhancement to U1: `isSuperReceiverInContext` strips template-argument lists (`Base<T>` → `Base`) and namespace prefixes (`outer::v1::Base` → `Base`) before resolving the receiver in the caller's scope chain. This makes the super-receiver classification work for template-class heritage shapes like `Base<T>::method()` and `outer::v1::Base<T>::f()`. Three fixtures + four tests: - `cpp-phase5-u1-u3-qualified-base-call`: `template<class T> struct Derived : Base<T>` with `Base<T>::method()` inside a template body. Asserts NO mis-routing (count = 0) — documents the V1 gap that template-class inheritance isn't captured as EXTENDS by the legacy DAG, so MRO walks are empty and the super branch can't dispatch. The composition still works correctly: U1's template-arg-stripping classifies `Base<T>` as a super candidate, but the empty-MRO terminates without false edges. - `cpp-phase5-u2-u3-adl-from-derived`: `Derived : Base<T>` where `Base::record` shadows `audit::record`. Unqualified `record(e)` inside the template body should resolve via ADL to `audit::record` (because U3 + the `isFileLocalDef` class- owned filter suppress `Base::record`). Asserts 1 edge to audit.h and 0 edges to base.h. - `cpp-phase5-u3-u5-inline-base`: `template<class T> struct Derived : outer::v1::Base<T>` where `v1` is inline. Unqualified `f()` inside `Derived<T>::g()` should NOT bind to Base::f (dependent-base suppression even across inline namespace prefix). Asserts count = 0. Phase 5 tests asserting no-false-positives are gated under LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES.cpp — legacy DAG over- resolves without the template-arg-stripping qualified-receiver path and without two-phase dependent-base suppression. 162/162 cpp integration tests pass under REGISTRY_PRIMARY_CPP=1; 152 pass + 10 skipped under =0 (legacy parity baseline). --------- Co-authored-by: HuangWenjie <zhoudeng.hwj@alibaba-inc.com> Co-authored-by: Gergo Magyar <gergomagyar@icloud.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1036 lines
39 KiB
TypeScript
1036 lines
39 KiB
TypeScript
/**
|
||
* `finalize` — cross-file finalize algorithm for the SemanticModel
|
||
* (RFC §3.2 Phase 2; Ring 2 SHARED #915).
|
||
*
|
||
* Pure logic that takes per-file parse output (`ParsedImport[]` +
|
||
* `SymbolDefinition[]`) and returns:
|
||
*
|
||
* - Linked `ImportEdge[]` per module scope, with `targetModuleScope` and
|
||
* `targetDefId` filled where resolvable; edges that could not be
|
||
* resolved within the hard fixpoint cap are marked
|
||
* `linkStatus: 'unresolved'`.
|
||
* - Materialized `bindings` per module scope — local defs merged with
|
||
* imported / wildcard-expanded / re-exported names via the provider's
|
||
* `mergeBindings` precedence.
|
||
* - The SCC condensation of the import graph, exposed so disjoint SCCs
|
||
* can be processed in parallel by callers that want that.
|
||
*
|
||
* The algorithm is **SCC-aware**: it runs Tarjan SCC over the file-level
|
||
* import graph, processes SCCs in reverse-topological order (leaves
|
||
* first), and within each SCC runs a bounded fixpoint link pass capped at
|
||
* `N = |edges in SCC|`. Cyclic imports finalize without hanging; malformed
|
||
* inputs are bounded by the cap.
|
||
*
|
||
* **No language-specific logic.** Target resolution, wildcard expansion,
|
||
* and binding precedence all go through caller-supplied hooks
|
||
* (`resolveImportTarget`, `expandsWildcardTo`, `mergeBindings`) that
|
||
* match the LanguageProvider surface from #911.
|
||
*
|
||
* **Non-binding imports rule.** `dynamic-unresolved` passes through with
|
||
* `targetFile: null`; `dynamic-resolved` and `side-effect` resolve to
|
||
* file-level `ImportEdge`s. None of these materialize `BindingRef`s.
|
||
*/
|
||
|
||
import type { SymbolDefinition } from './symbol-definition.js';
|
||
import type { BindingRef, ImportEdge, ParsedImport, ScopeId, WorkspaceIndex } from './types.js';
|
||
|
||
// ─── Public contracts ───────────────────────────────────────────────────────
|
||
|
||
/** 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. */
|
||
readonly moduleScope: ScopeId;
|
||
readonly parsedImports: readonly ParsedImport[];
|
||
/**
|
||
* Defs exported from this file — the "what other files can import by name"
|
||
* surface. Typically those with `isExported: true` (the module's own
|
||
* declarations); parsers MAY also surface re-exported names here as a
|
||
* shortcut, but it is no longer required for correctness.
|
||
*
|
||
* **Multi-hop re-export contract.** `finalize` resolves an edge
|
||
* `A → B (importedName: 'X')` by first looking up `X` in `B.localDefs`.
|
||
* If `B` only has `export { X } from './C'` and does NOT surface `X` in
|
||
* its own `localDefs`, `finalize` falls back to the precomputed
|
||
* per-file re-export closure (`buildReexportClosures`), which encodes
|
||
* every name reachable through `B`'s named and wildcard re-exports —
|
||
* including transitively through cyclic SCCs. The lookup is O(1) and
|
||
* inherits the upstream `targetDefId`, populating `transitiveVia` with
|
||
* the file paths traversed to reach the leaf def.
|
||
*
|
||
* Surfacing re-exported names in `localDefs` is still a valid (and
|
||
* slightly cheaper) optimization: the direct lookup short-circuits the
|
||
* closure consult. Parsers SHOULD prefer surfacing names they can resolve
|
||
* statically (e.g., `export { X } from './c'` when `c.ts` is parsed in
|
||
* the same workspace), and rely on the closure for the long tail of
|
||
* barrel patterns.
|
||
*
|
||
* The fixpoint does NOT mutate `localDefs` across iterations — it is
|
||
* static input.
|
||
*/
|
||
readonly localDefs: readonly SymbolDefinition[];
|
||
}
|
||
|
||
/** Input to `finalize`. */
|
||
export interface FinalizeInput {
|
||
readonly files: readonly FinalizeFile[];
|
||
/** Opaque workspace context forwarded to provider hooks. */
|
||
readonly workspaceIndex: WorkspaceIndex;
|
||
}
|
||
|
||
/**
|
||
* Provider-supplied hooks. Mirror the optional LanguageProvider scope-
|
||
* resolution hooks declared in #911; `finalize` calls them pure-ly and
|
||
* expects pure answers.
|
||
*/
|
||
export interface FinalizeHooks {
|
||
/**
|
||
* 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
|
||
* `numpy` is external to the workspace).
|
||
*/
|
||
resolveImportTarget(
|
||
targetRaw: string,
|
||
fromFile: string,
|
||
workspaceIndex: WorkspaceIndex,
|
||
): string | readonly string[] | null;
|
||
|
||
/**
|
||
* For a wildcard `import * from M`, return the names visible in the
|
||
* exporting module scope `M`. The finalize pass looks each name up in
|
||
* `M`'s local defs to produce a concrete `BindingRef`; names with no
|
||
* matching export are dropped.
|
||
*/
|
||
expandsWildcardTo(targetModuleScope: ScopeId, workspaceIndex: WorkspaceIndex): readonly string[];
|
||
|
||
/**
|
||
* Merge `incoming` bindings into `existing` for a given name. Called
|
||
* once per name at each scope. Typical rules:
|
||
* - Python: local > imported > wildcard (last-write-wins within tier).
|
||
* - Rust: explicit `use` > glob; `pub use` overrides.
|
||
* Return value replaces the bucket entirely — no implicit append.
|
||
*/
|
||
mergeBindings(
|
||
existing: readonly BindingRef[],
|
||
incoming: readonly BindingRef[],
|
||
scope: ScopeId,
|
||
): readonly BindingRef[];
|
||
}
|
||
|
||
/** One SCC in the file-level import graph. */
|
||
export interface FinalizedScc {
|
||
readonly files: readonly string[];
|
||
/** True iff this SCC has ≥ 2 files OR a single file that self-imports. */
|
||
readonly isCycle: boolean;
|
||
}
|
||
|
||
/**
|
||
* Counters reported by `finalize`.
|
||
*
|
||
* **Counting granularity** — `totalEdges` is **per-generated-`ImportEdgeDraft`**,
|
||
* which may exceed the number of `ParsedImport` records when
|
||
* `resolveImportTarget` returns a multi-file array (e.g. Go package-scoped
|
||
* imports fan out to every `.go` file in the target directory). A single
|
||
* `wildcard` ParsedImport that expands to N exports also counts as one
|
||
* linked edge here; the materialized output (`FinalizeOutput.imports`) will
|
||
* have N edges for that input. `dynamic-unresolved` ParsedImports count as
|
||
* linked (they pass through with no `linkStatus`), so `linkedEdges` ≠ "has a
|
||
* BindingRef" — use the `bindings` map for that.
|
||
*
|
||
* In other words: `totalEdges >= input.parsedImports.length` summed
|
||
* across files, and `linkedEdges + unresolvedEdges === totalEdges`.
|
||
*/
|
||
export interface FinalizeStats {
|
||
readonly totalFiles: number;
|
||
/** Total `ImportEdgeDraft` records generated (≥ ParsedImport count). */
|
||
readonly totalEdges: number;
|
||
/**
|
||
* `ParsedImport`s whose finalized edge does NOT carry
|
||
* `linkStatus: 'unresolved'`. Includes `dynamic-unresolved` pass-throughs.
|
||
*/
|
||
readonly linkedEdges: number;
|
||
/** `ParsedImport`s whose finalized edge carries `linkStatus: 'unresolved'`. */
|
||
readonly unresolvedEdges: number;
|
||
readonly sccCount: number;
|
||
readonly largestSccSize: number;
|
||
}
|
||
|
||
export interface FinalizeOutput {
|
||
/** Linked `ImportEdge[]` per module scope, in original input order. */
|
||
readonly imports: ReadonlyMap<ScopeId, readonly ImportEdge[]>;
|
||
/** Materialized bindings per module scope. */
|
||
readonly bindings: ReadonlyMap<ScopeId, ReadonlyMap<string, readonly BindingRef[]>>;
|
||
/** SCCs in reverse-topological order (leaves first). */
|
||
readonly sccs: readonly FinalizedScc[];
|
||
readonly stats: FinalizeStats;
|
||
}
|
||
|
||
// ─── Entry point ───────────────────────────────────────────────────────────
|
||
|
||
export function finalize(input: FinalizeInput, hooks: FinalizeHooks): FinalizeOutput {
|
||
const byFilePath = new Map<string, FinalizeFile>();
|
||
for (const f of input.files) byFilePath.set(f.filePath, f);
|
||
|
||
// ── Phase 0: pre-resolve raw import targets (one syscall-equivalent per
|
||
// (file, parsedImport)). Edges with no resolvable target become
|
||
// `linkStatus: 'unresolved'` or, for dynamic-unresolved, pass through
|
||
// with `targetFile: null`.
|
||
const edgeIndex = new Map<string, ImportEdgeDraft[]>(); // filePath → drafts
|
||
let totalEdges = 0;
|
||
|
||
for (const file of input.files) {
|
||
const drafts: ImportEdgeDraft[] = [];
|
||
for (const parsed of file.parsedImports) {
|
||
const draftArray = makeEdgeDrafts(parsed, file, hooks, input.workspaceIndex);
|
||
drafts.push(...draftArray);
|
||
totalEdges += draftArray.length;
|
||
}
|
||
edgeIndex.set(file.filePath, drafts);
|
||
}
|
||
|
||
// ── Phase 1: build file-level import graph (only resolvable edges form
|
||
// graph edges; unresolvable ones are terminal and contribute no
|
||
// fixpoint obligation).
|
||
const graph = new Map<string, Set<string>>();
|
||
for (const file of input.files) {
|
||
graph.set(file.filePath, new Set());
|
||
}
|
||
for (const [fromFile, drafts] of edgeIndex) {
|
||
const edges = graph.get(fromFile);
|
||
if (edges === undefined) continue;
|
||
for (const d of drafts) {
|
||
if (d.targetFile !== null && byFilePath.has(d.targetFile)) {
|
||
edges.add(d.targetFile);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Phase 2: Tarjan SCC → reverse-topological list of SCCs.
|
||
const sccs = tarjanSccs(graph);
|
||
|
||
// ── Phase 2.5: precompute the per-file re-export closure (iterative,
|
||
// 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.
|
||
const reexportClosures = buildReexportClosures(input.files, byFilePath, edgeIndex);
|
||
|
||
// ── Phase 3: process SCCs in reverse-topological order (leaves first).
|
||
// Within each SCC, run a bounded fixpoint that resolves intra-SCC edges.
|
||
// Edges leaving the SCC are already resolved (their target SCC is
|
||
// already finalized); edges inside the SCC may need multiple passes.
|
||
const linkedByScope = new Map<ScopeId, readonly ImportEdge[]>();
|
||
let linkedEdges = 0;
|
||
|
||
for (const scc of sccs) {
|
||
const sccFiles = new Set(scc.files);
|
||
const capacity = countEdgesWithin(edgeIndex, sccFiles);
|
||
|
||
// Run the fixpoint up to `capacity` iterations. Each iteration tries to
|
||
// resolve every still-unlinked edge in the SCC; stops early if a pass
|
||
// makes no progress.
|
||
let progressed = true;
|
||
let iterations = 0;
|
||
while (progressed && iterations < capacity) {
|
||
progressed = false;
|
||
iterations++;
|
||
for (const filePath of scc.files) {
|
||
const drafts = edgeIndex.get(filePath);
|
||
if (drafts === undefined) continue;
|
||
for (const draft of drafts) {
|
||
if (draft.finalized !== null) continue;
|
||
const finalized = tryFinalize(draft, byFilePath, reexportClosures);
|
||
if (finalized !== null) {
|
||
draft.finalized = finalized;
|
||
progressed = true;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Any drafts still not finalized within this SCC hit the cap → unresolved.
|
||
for (const filePath of scc.files) {
|
||
const drafts = edgeIndex.get(filePath);
|
||
if (drafts === undefined) continue;
|
||
for (const draft of drafts) {
|
||
if (draft.finalized !== null) continue;
|
||
draft.finalized = {
|
||
...draft.base,
|
||
linkStatus: 'unresolved' as const,
|
||
};
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Phase 4: collect finalized `ImportEdge[]` per module 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[] = [];
|
||
for (const d of drafts) {
|
||
const edge = d.finalized;
|
||
if (edge === null) {
|
||
throw new Error(`Invariant violated: import edge was not finalized for ${file.filePath}`);
|
||
}
|
||
if (d.source.kind === 'wildcard' && edge.linkStatus !== 'unresolved') {
|
||
// Produce one `wildcard-expanded` ImportEdge per exported name.
|
||
const expanded = expandWildcard(edge, byFilePath, hooks, input.workspaceIndex);
|
||
for (const e of expanded) finalized.push(e);
|
||
} else {
|
||
finalized.push(edge);
|
||
}
|
||
if (edge.linkStatus !== 'unresolved') linkedEdges++;
|
||
}
|
||
linkedByScope.set(file.moduleScope, Object.freeze(finalized));
|
||
}
|
||
|
||
// ── Phase 5: materialize module-scope bindings (local + imports + wildcards),
|
||
// delegating precedence to `provider.mergeBindings`.
|
||
const bindingsByScope = materializeBindings(input.files, linkedByScope, hooks);
|
||
|
||
// ── Stats.
|
||
const sccCount = sccs.length;
|
||
let largestSccSize = 0;
|
||
for (const scc of sccs) {
|
||
if (scc.files.length > largestSccSize) largestSccSize = scc.files.length;
|
||
}
|
||
const stats: FinalizeStats = {
|
||
totalFiles: input.files.length,
|
||
totalEdges,
|
||
linkedEdges,
|
||
unresolvedEdges: totalEdges - linkedEdges,
|
||
sccCount,
|
||
largestSccSize,
|
||
};
|
||
|
||
return Object.freeze({
|
||
imports: linkedByScope,
|
||
bindings: bindingsByScope,
|
||
sccs,
|
||
stats,
|
||
});
|
||
}
|
||
|
||
// ─── Internal: edge drafting (phase 0) ──────────────────────────────────────
|
||
|
||
interface ImportEdgeDraft {
|
||
readonly source: ParsedImport;
|
||
readonly fromFile: string;
|
||
readonly fromScope: ScopeId;
|
||
readonly targetFile: string | null;
|
||
readonly base: ImportEdge;
|
||
finalized: ImportEdge | null;
|
||
}
|
||
|
||
function makeEdgeDrafts(
|
||
parsed: ParsedImport,
|
||
file: FinalizeFile,
|
||
hooks: FinalizeHooks,
|
||
workspace: WorkspaceIndex,
|
||
): ImportEdgeDraft[] {
|
||
// Dynamic-unresolved passes through — no `BindingRef`, no target file.
|
||
if (parsed.kind === 'dynamic-unresolved') {
|
||
const base: ImportEdge = {
|
||
localName: parsed.localName,
|
||
targetFile: null,
|
||
targetExportedName: '',
|
||
kind: 'dynamic-unresolved',
|
||
};
|
||
return [
|
||
{
|
||
source: parsed,
|
||
fromFile: file.filePath,
|
||
fromScope: file.moduleScope,
|
||
targetFile: null,
|
||
base,
|
||
finalized: base, // already fully finalized
|
||
},
|
||
];
|
||
}
|
||
|
||
const targetFile = hooks.resolveImportTarget(parsed.targetRaw ?? '', file.filePath, workspace);
|
||
|
||
// Edge is unresolvable at the file level — mark unresolved now.
|
||
if (targetFile === null) {
|
||
const base: ImportEdge = {
|
||
localName: extractLocalName(parsed),
|
||
targetFile: null,
|
||
targetExportedName: extractExportedName(parsed),
|
||
kind: edgeKindFor(parsed),
|
||
linkStatus: 'unresolved',
|
||
};
|
||
return [
|
||
{
|
||
source: parsed,
|
||
fromFile: file.filePath,
|
||
fromScope: file.moduleScope,
|
||
targetFile: null,
|
||
base,
|
||
finalized: base,
|
||
},
|
||
];
|
||
}
|
||
|
||
// Resolvable at the file level; intra-SCC fixpoint may still fail to fill
|
||
// in `targetDefId` (e.g., symbol not exported from target). Side-effect
|
||
// and resolved-dynamic imports are terminal at the file level — no
|
||
// `targetDefId` needed since they materialize no `BindingRef`. Pre-
|
||
// finalize them here so the fixpoint loop skips them entirely.
|
||
const targetFiles = Array.isArray(targetFile) ? targetFile : [targetFile];
|
||
const isFileLevelTerminal = parsed.kind === 'side-effect' || parsed.kind === 'dynamic-resolved';
|
||
return targetFiles.map((tf) => {
|
||
const base: ImportEdge = {
|
||
localName: extractLocalName(parsed),
|
||
targetFile: tf,
|
||
targetExportedName: extractExportedName(parsed),
|
||
kind: edgeKindFor(parsed),
|
||
};
|
||
return {
|
||
source: parsed,
|
||
fromFile: file.filePath,
|
||
fromScope: file.moduleScope,
|
||
targetFile: tf,
|
||
base,
|
||
finalized: isFileLevelTerminal ? base : null,
|
||
};
|
||
});
|
||
}
|
||
|
||
function edgeKindFor(parsed: ParsedImport): ImportEdge['kind'] {
|
||
if (parsed.kind === 'wildcard') return 'wildcard-expanded';
|
||
return parsed.kind;
|
||
}
|
||
|
||
function extractLocalName(parsed: ParsedImport): string {
|
||
switch (parsed.kind) {
|
||
case 'wildcard':
|
||
case 'side-effect':
|
||
case 'dynamic-resolved':
|
||
return '';
|
||
default:
|
||
return parsed.localName;
|
||
}
|
||
}
|
||
|
||
function extractExportedName(parsed: ParsedImport): string {
|
||
switch (parsed.kind) {
|
||
case 'named':
|
||
case 'alias':
|
||
case 'namespace':
|
||
case 'reexport':
|
||
return parsed.importedName;
|
||
case 'wildcard':
|
||
case 'dynamic-unresolved':
|
||
case 'dynamic-resolved':
|
||
case 'side-effect':
|
||
return '';
|
||
}
|
||
}
|
||
|
||
// ─── Internal: per-edge finalization (phase 3) ─────────────────────────────
|
||
|
||
function tryFinalize(
|
||
draft: ImportEdgeDraft,
|
||
byFilePath: Map<string, FinalizeFile>,
|
||
reexportClosures: ReadonlyMap<string, FileReexportClosure>,
|
||
): ImportEdge | null {
|
||
const targetFile = draft.targetFile;
|
||
if (targetFile === null) return draft.base; // already terminal
|
||
|
||
const targetModule = byFilePath.get(targetFile);
|
||
if (targetModule === undefined) return draft.base; // external target — leave as-is
|
||
|
||
// Wildcards finalize at the file level; their per-name expansion happens
|
||
// in phase 4. At this stage we just record the target module scope.
|
||
if (draft.source.kind === 'wildcard') {
|
||
return {
|
||
...draft.base,
|
||
targetModuleScope: targetModule.moduleScope,
|
||
};
|
||
}
|
||
|
||
// Namespace imports alias the target *module*; they don't name a
|
||
// specific export. Link the module scope unconditionally. If the target
|
||
// also exposes a def whose simple name matches `importedName` (some
|
||
// languages emit a synthetic module-def), pick it up as the `targetDefId`
|
||
// so consumers can reach the module as a symbol — but its absence is not
|
||
// a failure.
|
||
if (draft.source.kind === 'namespace') {
|
||
const moduleDef = findExportByName(targetModule.localDefs, extractExportedName(draft.source));
|
||
return {
|
||
...draft.base,
|
||
targetModuleScope: targetModule.moduleScope,
|
||
...(moduleDef !== undefined ? { targetDefId: moduleDef.nodeId } : {}),
|
||
};
|
||
}
|
||
|
||
// named / alias / reexport: look up the imported name in the target's
|
||
// local defs. Multi-hop re-export chains settle iteratively — each hop
|
||
// resolves once its prior hop is finalized.
|
||
const importedName = extractExportedName(draft.source);
|
||
const exported = findExportByName(targetModule.localDefs, importedName);
|
||
|
||
if (exported !== undefined) {
|
||
const transitiveVia =
|
||
draft.source.kind === 'reexport' ? Object.freeze([targetFile]) : undefined;
|
||
return {
|
||
...draft.base,
|
||
targetModuleScope: targetModule.moduleScope,
|
||
targetDefId: exported.nodeId,
|
||
...(transitiveVia !== undefined ? { transitiveVia } : {}),
|
||
};
|
||
}
|
||
|
||
// Multi-hop re-export follow. Barrel modules like
|
||
// // models.ts
|
||
// export { User } from './base';
|
||
// emit no local def for `User`; the name surfaces only via their own
|
||
// `reexport` edge. The per-file re-export closure built in phase 2.5
|
||
// already encodes every name reachable through that file's named and
|
||
// wildcard re-exports — including transitively through cyclic SCCs —
|
||
// so the lookup is O(1) and never recurses.
|
||
const followed = lookupReexportedName(reexportClosures, targetFile, importedName);
|
||
if (followed === null) {
|
||
// Target resolvable but the name isn't exported — keep trying in case a
|
||
// re-export inside the target's SCC surfaces it in a later iteration.
|
||
return null;
|
||
}
|
||
|
||
const viaFiles = [targetFile, ...followed.via];
|
||
const transitiveVia =
|
||
draft.source.kind === 'reexport' || viaFiles.length > 1 ? Object.freeze(viaFiles) : undefined;
|
||
|
||
return {
|
||
...draft.base,
|
||
targetModuleScope: targetModule.moduleScope,
|
||
targetDefId: followed.def.nodeId,
|
||
...(transitiveVia !== undefined ? { transitiveVia } : {}),
|
||
};
|
||
}
|
||
|
||
// ─── Internal: re-export closure (phase 2.5) ───────────────────────────────
|
||
|
||
/**
|
||
* Per-file map of `name → terminal def + via path` — i.e. every name
|
||
* importable from this file via its named/wildcard re-export chain
|
||
* (excluding the file's own `localDefs`, which the caller checks first
|
||
* via `findExportByName`). `via` is the ordered list of intermediate
|
||
* files traversed to reach the def.
|
||
*
|
||
* Built once per finalize pass. Lookups are O(1).
|
||
*/
|
||
type ReexportClosureEntry = { readonly def: SymbolDefinition; readonly via: readonly string[] };
|
||
type FileReexportClosure = ReadonlyMap<string, ReexportClosureEntry>;
|
||
|
||
/**
|
||
* Build per-file re-export closures.
|
||
*
|
||
* **Algorithm.** Iterative SCC-condensed reverse-topological propagation,
|
||
* structurally identical to how `finalize` itself processes the file-
|
||
* level import graph. Replaces the legacy recursive
|
||
* `followReexportChain` crawl with a bounded, stack-safe pass:
|
||
*
|
||
* 1. **Sub-graph.** Build a directed graph whose edges are
|
||
* `reexport` and `wildcard` drafts only (regular imports do not
|
||
* contribute to the export surface, and `namespace`/
|
||
* `reexport-namespace` are terminal — their target def lives in
|
||
* `localDefs`).
|
||
* 2. **SCC condensation.** Run the same iterative `tarjanSccs` over
|
||
* the sub-graph. Output is in reverse-topological order (leaves
|
||
* first), so when we process an SCC every out-of-SCC neighbor
|
||
* already has its closure populated.
|
||
* 3. **Per-SCC propagation.**
|
||
* * Acyclic singleton: one pass — read neighbors' (already
|
||
* fully populated) closures.
|
||
* * Cyclic SCC (cycle ≥ 2 files, or self-loop): bounded
|
||
* fixpoint inside the SCC, capped at `|SCC| + 1` iterations
|
||
* (each iteration propagates names one hop further around
|
||
* the cycle; first-wins precedence keeps the map monotone
|
||
* so the fixpoint converges in at most |SCC| hops).
|
||
*
|
||
* **Precedence semantics — preserved from the recursive crawl.**
|
||
* * Named re-exports take precedence over wildcards.
|
||
* * Within each kind, declaration order wins (first match for a
|
||
* given exported name is kept; later drafts skip).
|
||
*
|
||
* **Complexity.**
|
||
* * Pre-pass: O(V + E_re) for SCC, plus O(|SCC| × Σ drafts) per cyclic
|
||
* SCC. For tree-shaped barrel graphs (the common case) it
|
||
* collapses to O(E_re) total.
|
||
* * Per-edge lookup at finalize time: O(1).
|
||
* * `transitiveVia` preserves the exact file path chain for diagnostics
|
||
* and graph provenance. Building those arrays copies the inherited path,
|
||
* which is O(depth²) in a pathological single-name barrel chain; practical
|
||
* TypeScript barrel chains are shallow enough that we keep exact paths
|
||
* instead of capping or summarizing them.
|
||
* * Pathological deep chains that previously needed
|
||
* `MAX_REEXPORT_DEPTH=100` to bound stack growth now resolve
|
||
* in full and are bounded only by available memory — the
|
||
* iterative formulation has no call-stack ceiling.
|
||
*/
|
||
function buildReexportClosures(
|
||
files: readonly FinalizeFile[],
|
||
byFilePath: ReadonlyMap<string, FinalizeFile>,
|
||
edgeIndex: ReadonlyMap<string, ImportEdgeDraft[]>,
|
||
): ReadonlyMap<string, FileReexportClosure> {
|
||
const closures = new Map<string, Map<string, ReexportClosureEntry>>();
|
||
for (const file of files) closures.set(file.filePath, new Map());
|
||
|
||
// ── Step 1: build the re-export sub-graph (only resolvable
|
||
// reexport/wildcard targets contribute edges).
|
||
const subGraph = new Map<string, Set<string>>();
|
||
for (const file of files) {
|
||
const targets = new Set<string>();
|
||
const drafts = edgeIndex.get(file.filePath);
|
||
if (drafts !== undefined) {
|
||
for (const d of drafts) {
|
||
if (d.source.kind !== 'reexport' && d.source.kind !== 'wildcard') continue;
|
||
if (d.targetFile === null) continue;
|
||
if (!byFilePath.has(d.targetFile)) continue;
|
||
targets.add(d.targetFile);
|
||
}
|
||
}
|
||
subGraph.set(file.filePath, targets);
|
||
}
|
||
|
||
// ── Step 2: SCC over the sub-graph. Reuses the same iterative Tarjan
|
||
// implementation that drives the file-level finalize loop, so any
|
||
// call-stack-safety guarantees there transfer here unchanged.
|
||
const subSccs = tarjanSccs(subGraph);
|
||
|
||
// ── Step 3: process SCCs in reverse-topological order. Acyclic
|
||
// singletons settle in one pass; cyclic SCCs run a bounded fixpoint.
|
||
for (const scc of subSccs) {
|
||
if (!scc.isCycle) {
|
||
const filePath = scc.files[0];
|
||
if (filePath !== undefined) {
|
||
populateFileClosure(filePath, byFilePath, edgeIndex, closures);
|
||
}
|
||
continue;
|
||
}
|
||
// Cap = |SCC| + 1. With first-wins precedence each name needs at
|
||
// most |SCC| iterations to propagate fully around the cycle; the
|
||
// extra iteration confirms no progress and breaks the loop.
|
||
const cap = scc.files.length + 1;
|
||
let progressed = true;
|
||
let iter = 0;
|
||
while (progressed && iter < cap) {
|
||
progressed = false;
|
||
iter++;
|
||
for (const filePath of scc.files) {
|
||
if (populateFileClosure(filePath, byFilePath, edgeIndex, closures)) {
|
||
progressed = true;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return closures;
|
||
}
|
||
|
||
/**
|
||
* Populate one file's re-export closure for one pass. Returns `true`
|
||
* iff the closure grew (signalling fixpoint progress to the caller).
|
||
*
|
||
* Walks the file's drafts in declaration order, named re-exports first
|
||
* (precedence), then wildcards. For each draft, attempts:
|
||
* 1. **Direct hit** — name exists in the target file's `localDefs`.
|
||
* 2. **Inherited** — name exists in the target file's already-populated
|
||
* closure (which encodes the target's own re-export chain).
|
||
*
|
||
* `closures.get(targetFile)` may itself still be empty for in-SCC
|
||
* targets on the first iteration; the outer fixpoint loop handles
|
||
* that by re-invoking this function.
|
||
*/
|
||
function populateFileClosure(
|
||
filePath: string,
|
||
byFilePath: ReadonlyMap<string, FinalizeFile>,
|
||
edgeIndex: ReadonlyMap<string, ImportEdgeDraft[]>,
|
||
closures: Map<string, Map<string, ReexportClosureEntry>>,
|
||
): boolean {
|
||
const myClosure = closures.get(filePath);
|
||
if (myClosure === undefined) return false;
|
||
const before = myClosure.size;
|
||
const drafts = edgeIndex.get(filePath);
|
||
if (drafts === undefined) return false;
|
||
|
||
// Named re-exports — precedence over wildcards, declaration order
|
||
// first-wins for duplicates of the same exported name.
|
||
for (const draft of drafts) {
|
||
if (draft.source.kind !== 'reexport') continue;
|
||
const targetFile = draft.targetFile;
|
||
if (targetFile === null) continue;
|
||
const targetModule = byFilePath.get(targetFile);
|
||
if (targetModule === undefined) continue;
|
||
|
||
const localName = draft.source.localName;
|
||
if (myClosure.has(localName)) continue;
|
||
|
||
const importedName = draft.source.importedName;
|
||
const direct = findExportByName(targetModule.localDefs, importedName);
|
||
if (direct !== undefined) {
|
||
myClosure.set(localName, { def: direct, via: Object.freeze([targetFile]) });
|
||
continue;
|
||
}
|
||
const inherited = closures.get(targetFile)?.get(importedName);
|
||
if (inherited !== undefined) {
|
||
myClosure.set(localName, {
|
||
def: inherited.def,
|
||
via: Object.freeze([targetFile, ...inherited.via]),
|
||
});
|
||
}
|
||
// Else: target's closure is still empty (in-SCC, awaiting next
|
||
// iteration). Outer loop will revisit.
|
||
}
|
||
|
||
// Wildcard re-exports — fan out the target's own surface (localDefs
|
||
// + transitive closure). `myClosure.has(name)` checks below preserve
|
||
// the named-precedence and first-wins semantics from above.
|
||
for (const draft of drafts) {
|
||
if (draft.source.kind !== 'wildcard') continue;
|
||
const targetFile = draft.targetFile;
|
||
if (targetFile === null) continue;
|
||
const targetModule = byFilePath.get(targetFile);
|
||
if (targetModule === undefined) continue;
|
||
|
||
for (const def of targetModule.localDefs) {
|
||
const name = deriveSimpleName(def);
|
||
if (name === null || myClosure.has(name)) continue;
|
||
myClosure.set(name, { def, via: Object.freeze([targetFile]) });
|
||
}
|
||
const targetClosure = closures.get(targetFile);
|
||
if (targetClosure !== undefined) {
|
||
for (const [name, entry] of targetClosure) {
|
||
if (myClosure.has(name)) continue;
|
||
myClosure.set(name, {
|
||
def: entry.def,
|
||
via: Object.freeze([targetFile, ...entry.via]),
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
return myClosure.size > before;
|
||
}
|
||
|
||
/**
|
||
* O(1) lookup into a precomputed re-export closure. Replaces the legacy
|
||
* recursive `followReexportChain` traversal with a single map indexing.
|
||
*/
|
||
function lookupReexportedName(
|
||
closures: ReadonlyMap<string, FileReexportClosure>,
|
||
filePath: string,
|
||
name: string,
|
||
): { def: SymbolDefinition; via: readonly string[] } | null {
|
||
const closure = closures.get(filePath);
|
||
if (closure === undefined) return null;
|
||
const entry = closure.get(name);
|
||
if (entry === undefined) return null;
|
||
return { def: entry.def, via: entry.via };
|
||
}
|
||
|
||
/**
|
||
* The "simple" (unqualified) name of a def, for import-name matching.
|
||
*
|
||
* Canonical source: `def.qualifiedName` — the tail after the last `.` (or
|
||
* the whole string if no dot). Defs without a qualifiedName can't be
|
||
* resolved by name here and return `null`; callers treat that as "name
|
||
* not exported" and either retry in a later fixpoint iteration or mark
|
||
* the edge unresolved.
|
||
*/
|
||
function deriveSimpleName(def: SymbolDefinition): string | null {
|
||
const q = def.qualifiedName;
|
||
if (q === undefined || q.length === 0) return null;
|
||
const dot = q.lastIndexOf('.');
|
||
return dot === -1 ? q : q.slice(dot + 1);
|
||
}
|
||
|
||
function findExportByName(
|
||
defs: readonly SymbolDefinition[],
|
||
name: string,
|
||
): SymbolDefinition | undefined {
|
||
// GENERIC RULE (applies to every language using this finalize
|
||
// algorithm): when MULTIPLE `SymbolDefinition`s share the same simple
|
||
// name in `localDefs`, prefer callable / type-like defs over plain
|
||
// value defs (`Variable`, `Property`, …). The CALLER side of an
|
||
// import almost always wants the callable, not a value shadow that
|
||
// happens to share the name — and without a deterministic
|
||
// preference, capture order silently decides which def the import
|
||
// binds to.
|
||
//
|
||
// The single-def case is unchanged: when only one def has the name,
|
||
// it's returned regardless of its type (the `fallback` path below).
|
||
//
|
||
// TypeScript is the first known language where this matters in
|
||
// practice: `const fn = () => {}` emits BOTH a `Function` def (from
|
||
// `@declaration.function` on the inner arrow) AND a `Variable` def
|
||
// (from the generic `@declaration.variable` pattern matching the
|
||
// wrapping `lexical_declaration`), and consumers of `import { fn }`
|
||
// need to bind to the callable. Other migrated languages don't
|
||
// currently produce dual emits of this shape, so the rule is a no-op
|
||
// for them today; future languages get the same correctness
|
||
// guarantee for free if they ever do.
|
||
//
|
||
// See `gitnexus/test/integration/resolvers/typescript-hof-callbacks.test.ts`
|
||
// for the cross-file regression this rule prevents.
|
||
let fallback: SymbolDefinition | undefined;
|
||
for (const d of defs) {
|
||
if (deriveSimpleName(d) !== name) continue;
|
||
if (isCallableOrTypeLike(d.type)) return d;
|
||
if (fallback === undefined) fallback = d;
|
||
}
|
||
return fallback;
|
||
}
|
||
|
||
const CALLABLE_OR_TYPE_LIKE: ReadonlySet<string> = new Set([
|
||
'Function',
|
||
'Method',
|
||
'Constructor',
|
||
'Class',
|
||
'Interface',
|
||
'Enum',
|
||
'Struct',
|
||
'Record',
|
||
'Trait',
|
||
'Namespace',
|
||
'Module',
|
||
'TypeAlias',
|
||
'Type',
|
||
'Typedef',
|
||
]);
|
||
|
||
function isCallableOrTypeLike(type: string): boolean {
|
||
return CALLABLE_OR_TYPE_LIKE.has(type);
|
||
}
|
||
|
||
function countEdgesWithin(edgeIndex: Map<string, ImportEdgeDraft[]>, files: Set<string>): number {
|
||
let n = 0;
|
||
for (const filePath of files) {
|
||
const drafts = edgeIndex.get(filePath);
|
||
if (drafts === undefined) continue;
|
||
for (const d of drafts) {
|
||
if (d.targetFile !== null && files.has(d.targetFile)) n++;
|
||
}
|
||
}
|
||
// Guarantee at least one pass even for a trivial SCC (ensures deterministic
|
||
// fixpoint termination even when a single-file SCC has zero intra-SCC edges
|
||
// but still needs one settle pass).
|
||
return Math.max(n, 1);
|
||
}
|
||
|
||
// ─── Internal: wildcard expansion (phase 4) ────────────────────────────────
|
||
|
||
function expandWildcard(
|
||
edge: ImportEdge,
|
||
byFilePath: Map<string, FinalizeFile>,
|
||
hooks: FinalizeHooks,
|
||
workspace: WorkspaceIndex,
|
||
): readonly ImportEdge[] {
|
||
if (edge.targetModuleScope === undefined || edge.targetFile === null) {
|
||
return [edge]; // unresolvable wildcard survives as a single unlinked edge
|
||
}
|
||
const target = byFilePath.get(edge.targetFile);
|
||
if (target === undefined) return [edge];
|
||
|
||
const names = hooks.expandsWildcardTo(edge.targetModuleScope, workspace);
|
||
if (names.length === 0) {
|
||
// Resolved wildcard with zero propagating names is still a real file-
|
||
// level dependency (e.g. a C++ header that only declares classes —
|
||
// `#include` is a valid IMPORTS edge, but unqualified-binding names
|
||
// are correctly empty since class methods require `Class::method`).
|
||
// Preserve the original wildcard edge so the file→file IMPORTS edge
|
||
// survives; downstream binding materialization sees no propagated
|
||
// names because the edge has no `targetExportedName`/`localName`.
|
||
return [edge];
|
||
}
|
||
|
||
const expanded: ImportEdge[] = [];
|
||
for (const name of names) {
|
||
const def = findExportByName(target.localDefs, name);
|
||
if (def === undefined) continue;
|
||
expanded.push({
|
||
localName: name,
|
||
targetFile: edge.targetFile,
|
||
targetExportedName: name,
|
||
kind: 'wildcard-expanded',
|
||
targetModuleScope: edge.targetModuleScope,
|
||
targetDefId: def.nodeId,
|
||
});
|
||
}
|
||
return expanded;
|
||
}
|
||
|
||
// ─── Internal: bindings materialization (phase 5) ───────────────────────────
|
||
|
||
function materializeBindings(
|
||
files: readonly FinalizeFile[],
|
||
linkedByScope: ReadonlyMap<ScopeId, readonly ImportEdge[]>,
|
||
hooks: FinalizeHooks,
|
||
): ReadonlyMap<ScopeId, ReadonlyMap<string, readonly BindingRef[]>> {
|
||
const out = new Map<ScopeId, ReadonlyMap<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
|
||
// of a full linear scan. At realistic TypeScript monorepo scale
|
||
// (~5k files × ~50 defs × ~100k linked import edges) this is the
|
||
// difference between ~25 s and a few ms inside finalize. The map
|
||
// is local to this pass — no cross-pass state leaks.
|
||
const defById = new Map<string, SymbolDefinition>();
|
||
for (const f of files) {
|
||
for (const d of f.localDefs) defById.set(d.nodeId, d);
|
||
}
|
||
|
||
for (const file of files) {
|
||
const scopeBindings = new Map<string, readonly BindingRef[]>();
|
||
|
||
// Start with local defs as `origin: 'local'` bindings.
|
||
for (const def of file.localDefs) {
|
||
const name = deriveSimpleName(def);
|
||
if (name === null) continue;
|
||
const incoming: BindingRef[] = [{ def, origin: 'local' }];
|
||
const existing = scopeBindings.get(name) ?? [];
|
||
scopeBindings.set(name, hooks.mergeBindings(existing, incoming, file.moduleScope));
|
||
}
|
||
|
||
// Layer in finalized imports.
|
||
const imports = linkedByScope.get(file.moduleScope) ?? [];
|
||
for (const edge of imports) {
|
||
if (edge.targetDefId === undefined || edge.linkStatus === 'unresolved') continue;
|
||
const def = defById.get(edge.targetDefId);
|
||
if (def === undefined) continue;
|
||
|
||
const origin: BindingRef['origin'] =
|
||
edge.kind === 'namespace'
|
||
? 'namespace'
|
||
: edge.kind === 'wildcard-expanded'
|
||
? 'wildcard'
|
||
: edge.kind === 'reexport'
|
||
? 'reexport'
|
||
: 'import';
|
||
const fallback = deriveSimpleName(def);
|
||
const name = edge.localName.length > 0 ? edge.localName : fallback;
|
||
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));
|
||
}
|
||
|
||
// 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);
|
||
}
|
||
|
||
return out;
|
||
}
|
||
|
||
// ─── Internal: Tarjan SCC ──────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Iterative Tarjan SCC. Returns SCCs in **reverse-topological** order
|
||
* (leaves first — a property Tarjan gives for free, and the order
|
||
* `finalize` wants so leaves are fully resolved before their dependents).
|
||
*/
|
||
function tarjanSccs(graph: ReadonlyMap<string, ReadonlySet<string>>): FinalizedScc[] {
|
||
const index = new Map<string, number>();
|
||
const lowlink = new Map<string, number>();
|
||
const onStack = new Set<string>();
|
||
const stack: string[] = [];
|
||
const sccs: FinalizedScc[] = [];
|
||
let idx = 0;
|
||
|
||
// Iterative DFS to avoid stack overflow on deep import chains.
|
||
const allNodes = Array.from(graph.keys()).sort(); // deterministic order
|
||
const iterStack: Array<{ node: string; children: Iterator<string>; entered: boolean }> = [];
|
||
|
||
for (const root of allNodes) {
|
||
if (index.has(root)) continue;
|
||
iterStack.push({
|
||
node: root,
|
||
children: (graph.get(root) ?? new Set<string>()).values(),
|
||
entered: false,
|
||
});
|
||
while (iterStack.length > 0) {
|
||
const frame = iterStack[iterStack.length - 1];
|
||
if (frame === undefined) break;
|
||
|
||
if (!frame.entered) {
|
||
frame.entered = true;
|
||
index.set(frame.node, idx);
|
||
lowlink.set(frame.node, idx);
|
||
idx++;
|
||
stack.push(frame.node);
|
||
onStack.add(frame.node);
|
||
}
|
||
|
||
const nextChild = frame.children.next();
|
||
if (nextChild.done) {
|
||
// Post-visit: compute SCC membership if frame.node is a root.
|
||
if (lowlink.get(frame.node) === index.get(frame.node)) {
|
||
const scc: string[] = [];
|
||
let selfInCycle = false;
|
||
while (true) {
|
||
const w = stack.pop();
|
||
if (w === undefined) {
|
||
throw new Error(`Invariant violated: Tarjan stack exhausted at ${frame.node}`);
|
||
}
|
||
onStack.delete(w);
|
||
scc.push(w);
|
||
// A single-file self-loop counts as a cycle.
|
||
if (w === frame.node) {
|
||
selfInCycle = (graph.get(w) ?? new Set()).has(w);
|
||
break;
|
||
}
|
||
}
|
||
const isCycle = scc.length > 1 || selfInCycle;
|
||
sccs.push({ files: Object.freeze(scc), isCycle });
|
||
}
|
||
iterStack.pop();
|
||
// Propagate lowlink to parent.
|
||
if (iterStack.length > 0) {
|
||
const parent = iterStack[iterStack.length - 1];
|
||
if (parent !== undefined) {
|
||
lowlink.set(
|
||
parent.node,
|
||
Math.min(
|
||
requiredNumber(lowlink, parent.node, 'lowlink'),
|
||
requiredNumber(lowlink, frame.node, 'lowlink'),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
continue;
|
||
}
|
||
|
||
const child = nextChild.value;
|
||
if (!index.has(child)) {
|
||
iterStack.push({
|
||
node: child,
|
||
children: (graph.get(child) ?? new Set<string>()).values(),
|
||
entered: false,
|
||
});
|
||
} else if (onStack.has(child)) {
|
||
lowlink.set(
|
||
frame.node,
|
||
Math.min(
|
||
requiredNumber(lowlink, frame.node, 'lowlink'),
|
||
requiredNumber(index, child, 'index'),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
return sccs;
|
||
}
|
||
|
||
function requiredNumber(map: ReadonlyMap<string, number>, key: string, label: string): number {
|
||
const value = map.get(key);
|
||
if (value === undefined) {
|
||
throw new Error(`Invariant violated: missing Tarjan ${label} for ${key}`);
|
||
}
|
||
return value;
|
||
}
|