Files
GitNexus/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts
T
e01f0912bc feat(cpp): migrate C++ to scope-based resolution model (#938) (#1520)
* 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>
2026-05-14 09:30:52 +01:00

1036 lines
39 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* `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;
}