feat(ingestion): TypeScript registry-primary scope resolution (Ring 3) (#1050)

* feat(ingestion): TypeScript registry-primary scope resolution (Ring 3)

- Add TypeScript ScopeResolver stack (query/captures/interpret, import decomposition, hooks, arity, merge, receiver binding) and register in SCOPE_RESOLVERS.

- Harden shared compound receiver and receiver-bound CALLS pass for map for-of tuple bindings, dotted typeRef shapes, and callable-alias fallbacks.

- Flip TypeScript into MIGRATED_LANGUAGES; refresh AGENTS.md and type-resolution-system.md.

- Shared finalize-algorithm updates for cross-file scope parity.

- Tests: TS scope-resolution unit suite; legacy call-processor suite forces REGISTRY_PRIMARY_TYPESCRIPT=0; registry-primary flag test opts out TS in override scenario.

Made-with: Cursor

* fix(ingestion): SCC-ordered cross-file return-type propagation + multi-hop re-export resolution

Fix CI failures on PR #1050 (TypeScript registry-primary migration) by
making `propagateImportedReturnTypes` deterministic via reverse-
topological SCC ordering and updating the multi-hop re-export contract
to match `followReexportChain` behavior.

Why: the legacy pass mirrored an intermediate ref instead of the
terminal type when an importer was processed before its source module
had its own typeBindings chain-followed (4-file alias chain regression
in `ts-simple` fixture: `models.User -> service.user -> app.user`
collapsed to `getUser` instead of `User`). Reverse-topological walk of
`indexes.sccs` (leaves first) lets every importer see the source's
already-followed terminal type in a single pass.

Changes:
- `imported-return-types.ts`: rewrite to walk SCCs leaves-first, chain-
  follow the source module's typeBindings BEFORE mirroring, and chain-
  follow the importer's typeBindings AFTER mirroring. Cyclic SCCs
  reach a partial fixpoint (no convergence guarantee, ts-circular only
  asserts no-throw).
- `finalize-algorithm.ts`: docstring update on `FinalizeFile.localDefs`
  to reflect that `followReexportChain` resolves multi-hop re-exports
  through barrels even when intermediates do not surface the name -
  surfacing is now a static optimization, not a correctness requirement.
- `contract/scope-resolver.ts` Invariant I3: explicitly document the
  SCC ordering requirement.
- `pipeline/run.ts`: split PROF timer into `finalize` and `propagate`
  so the pass's cost is observable independently.
- `ARCHITECTURE.md` Performance notes: describe SCC-ordered propagation.
- `imported-return-types.ts`: expand chain-depth comment (2x effective
  depth from pre/post follow), add multi-ref break rationale, add
  `ts-simple` motivating-fixture pointer.

Tests:
- `finalize-algorithm.test.ts`: add 4 cases (3-hop chain, cyclic
  re-export visited-set guard, wildcard re-export fall-through,
  multi-source first-match-wins); fix misleading shared nodeId in the
  thick variant; rename and update the multi-hop test for the new
  contract (transitiveVia assertion on the thin variant).
- `imported-return-types.test.ts` (NEW): unit tests for the SCC pass
  pinning topological collapse, local-annotation guard, missing-source
  skip, and cyclic-SCC no-throw.
- `cross-file-binding.test.ts` + `ts-deep-alias-chain` fixture (NEW):
  5-file integration regression guard for SCC-ordered propagation
  through 4 module boundaries.

Validation: 865 scope-resolution + cross-file tests pass on Windows;
typecheck clean across both packages; only pre-existing Swift overload
failures remain (verified on PR base commit, environmental).

Made-with: Cursor

* fix(ingestion): address PR #1050 review findings — side-effect imports, resolve-cache perf, adapter signature

Three independent fixes surfaced by the production-readiness review of
the TypeScript registry-primary scope-resolution migration (RFC #909
Ring 3). All three pass under both REGISTRY_PRIMARY_TYPESCRIPT=0 and =1.

1. Side-effect imports were silently dropped (correctness regression).
   The legacy DAG emitted IMPORTS edges for `import './polyfill'` because
   its tree-sitter query matches `(import_statement source: (string))`
   regardless of clause. The new registry-primary path returned `[]`
   from `splitImportStatement()` for clause-less imports, so no
   ParsedImport / ImportEdge was ever produced — silent file-level edge
   loss. Add a generic 'side-effect' variant to `ParsedImport` and
   `ImportEdge['kind']` in `gitnexus-shared`; finalize resolves the
   target file and pre-finalizes the edge (no `targetDefId`, no
   `BindingRef`) so the SCC fixpoint loop skips it. The TypeScript
   provider now emits + interprets the new kind end-to-end. The
   variant is intentionally generic so other languages (Rust
   `use foo as _`, Python module-init) can adopt it.

2. Per-import re-derivation in `resolveImportTarget` (perf regression).
   The TS adapter built `new Set(allFilePaths)` on every call and let
   `resolveTsImportTarget` re-derive `allFileList` /
   `normalizedFileList` and discard the `resolveCache`. For a workspace
   with N files and M imports that's O(N × M) work per pass. Wrap the
   adapter in a closure that memoizes all five derived values keyed on
   the orchestrator's `ReadonlySet` identity; reset only when the set
   reference changes (start of new pass). New cost: O(N + M).

3. Misleading fake `ParsedImport` in the adapter (architecture).
   The adapter constructed `{ kind: 'named', localName: '_',
   importedName: '_', targetRaw }` to call `resolveTsImportTarget`,
   even though only `targetRaw` and the structural-typed context are
   read. Extract `resolveTsTarget(targetRaw, ctx)` so the adapter has
   an honest signature; `resolveTsImportTarget` still works for other
   callers. Also extract `narrowTsContext` for the type narrowing.

Tests: - New 4-file fixture `typescript-side-effect-imports` with two
    side-effect imports + one named import.
  - New "TypeScript side-effect imports" describe in
    `test/integration/resolvers/typescript.test.ts` (parity-gated by
    `ci-scope-parity.yml` — runs under both flag states).
  - Updated 2 unit tests to expect 1 side-effect ParsedImport and 4
    `@import.statement` matches (was 0 / 3).
  - 785 / 785 TS scope-resolution tests pass under both
    REGISTRY_PRIMARY_TYPESCRIPT=0 and =1.
Made-with: Cursor

* fix(scope): address Codex adversarial review findings on PR #1050

Four findings from the Codex adversarial review broke registry-primary
TypeScript resolution for common patterns. All four now have unit and
integration regression coverage that pass under both
`REGISTRY_PRIMARY_TYPESCRIPT=0` (legacy DAG) and the default
registry-primary path.

[high] tsconfig path aliases dropped:
Threaded `tsconfigPaths` through ScopeResolver via a new opaque
`resolutionConfig` parameter and a `loadResolutionConfig(repoPath)`
hook. The orchestrator (`scopeResolutionPhase` + `runScopeResolution`)
loads it once per workspace pass and forwards into every
`resolveImportTarget` call. TypeScript resolver now resolves
`@/services/user` style imports through the standard resolver's alias
branch.

[high] TSX parsed with the wrong grammar:
`emitTsScopeCaptures` now picks the parser/query by `filePath`
(`.tsx` -> TSX grammar) and validates cached trees against the
expected grammar via the new exported `tsCachedTreeMatchesGrammar`
helper. Stale TS-grammar trees for `.tsx` files no longer leak through
the scope query.

[medium] Literal dynamic imports never linked:
Added `kind: 'dynamic-resolved'` to `ParsedImport` and `ImportEdge`.
The decomposer emits a synthetic `@import.literal` capture for
string-literal dynamic imports; the interpreter maps that to
`dynamic-resolved`; finalize pre-finalizes it as a file-level terminal
(same shape as `side-effect`). `import('./feature')` now produces a
real IMPORTS edge under the registry-primary path. Legacy DAG keeps
its existing behavior — the new integration assertion is gated behind
the flag.

[medium] Namespace re-exports invisible from barrels:
The decomposer now emits TWO captures for `export * as ns from './m'`
— the existing `reexport-namespace` import draft AND a synthetic
`@declaration.namespace` capture (via `buildNamespaceDeclarationMatch`).
The latter creates a Namespace `SymbolDefinition` in the barrel's
`localDefs`, so downstream `import { ns } from './barrel'` resolves
through `findExportByName`.

Regression fixtures under `gitnexus/test/fixtures/lang-resolution/`:
- typescript-tsconfig-aliases (`@/` alias)
- typescript-tsx-jsx (Button.tsx + App.tsx with JSX)
- typescript-dynamic-import (`await import('./feature')`)
- typescript-reexport-namespace (`export * as Models from './base'`)

Validation:
- gitnexus-shared builds clean
- gitnexus typecheck clean
- 385/385 TS scope-resolution tests pass under both
  `REGISTRY_PRIMARY_TYPESCRIPT=0` and default

Made-with: Cursor

* perf(scope): O(1) defById lookup + bounded re-export depth (PR #1050 round 3)

Addresses the round-3 PR #1050 reviews (Claude adversarial + xkonjin):
both flagged the existing O(N²) `findDefById` linear scan in
`materializeBindings` and the unbounded recursion in
`followReexportChain` as production-readiness blockers for TypeScript
monorepos. Both fixes land alongside their regression tests under
both `REGISTRY_PRIMARY_TYPESCRIPT=0` and the default registry-primary
path.

[high] materializeBindings O(N_files × N_defs × N_edges) → O(N_defs + N_edges):
Build a `nodeId → SymbolDefinition` index map once at the top of
`materializeBindings` (one O(N_defs) pass), then replace the per-edge
`findDefById(files, edge.targetDefId)` linear scan with an O(1)
`defById.get(edge.targetDefId)` lookup. Also drop the now-unused
`findDefById` helper. At realistic TypeScript monorepo scale (~5k
files × ~50 defs/file × ~100k linked import edges) this is the
difference between ~25 s and a few ms inside finalize. Regression
test in `finalize-algorithm.test.ts` builds 200 leaf files +
1 consumer importing one symbol from each, asserts every binding
materializes correctly.

[medium] followReexportChain unbounded recursion:
The existing `visited` set caps depth at `O(N_files)` but allows
recursion proportional to barrel-chain depth, mismatching the
explicit "Iterative DFS to avoid stack overflow" policy in
`tarjanSccs`. Added a `MAX_REEXPORT_DEPTH = 100` constant and a
`depth` parameter to `followReexportChain` (defaults to 0); each
recursive call passes `depth + 1` and the function returns `null`
when the cap is exceeded. 100 is comfortably above any realistic
hand-authored barrel chain (typical depth 1-5; auto-generated
barrels rarely exceed 20) while staying well below JS engine call
stack limits. Regression test wires a 200-link reexport chain and
verifies the crawl terminates cleanly with `linkStatus: 'unresolved'`
(no terminal def reachable within the budget).

[low] synthesizeInstanceofNarrowings bare-identifier-only limitation:
xkonjin's review #4 noted that the LHS narrowing only handles bare
identifiers (`if (x instanceof Foo)`), not member expressions
(`if (user.address instanceof Address)`). Added a JSDoc note
explaining the constraint and pointing readers at field-type
resolution as the workaround for member-chain receivers.

Validation:
- gitnexus-shared builds clean
- gitnexus typecheck clean
- 413/413 tests pass under both flag states for finalize-algorithm +
  TS unit + TS integration suites
- 972/972 tests pass across full scope-resolution + Python +
  C# integration smoke (no cross-language regression)

Made-with: Cursor

* refactor(finalize): replace recursive followReexportChain with SCC-condensed iterative closure

The legacy `followReexportChain` walked re-export drafts via mutual
recursion guarded by a per-call visited set + a `MAX_REEXPORT_DEPTH`
ceiling. Recursion is fragile (call-stack ceiling, no bound on depth
that's actually meaningful), so this replaces it with a structurally
better algorithm: a precomputed per-file re-export closure built by
running Tarjan SCC over the re-export sub-graph and propagating names
in reverse-topological order with a bounded intra-SCC fixpoint.

Algorithm (`buildReexportClosures` in finalize-algorithm.ts):

  1. Sub-graph: build the directed graph of `reexport` + `wildcard`
     drafts only (regular/namespace/dynamic imports do not contribute).
  2. SCC condensation: run the same iterative `tarjanSccs` already
     used for the file-level import graph; output is in reverse-topo
     order so out-of-SCC neighbors are always already-finalized.
  3. Per-SCC propagation:
       - Acyclic singleton: one pass populates from neighbors' closures.
       - Cyclic SCC: bounded fixpoint capped at |SCC|+1 iterations.
         With first-wins precedence the closure map is monotone, so
         each name needs at most |SCC| hops to traverse the cycle.

Precedence (preserved from the recursive crawl):
  - Named re-exports take precedence over wildcards.
  - Within each kind, declaration order wins.

Lookup at finalize time becomes O(1) (`lookupReexportedName`), down
from O(chain_depth × drafts) per consult and recursive at that.

Properties vs the legacy implementation:
  - Stack-safe by construction; no `MAX_REEXPORT_DEPTH` guard needed.
  - 1000-hop barrel chains now resolve in full (legacy capped at 100
    and surfaced anything deeper as `unresolved`).
  - Cycles handled structurally via SCC, not via per-call visited set.
  - Same observable semantics: every existing test passes unchanged.

Tests:
  - Replace the obsolete `MAX_REEXPORT_DEPTH (200-hop chain stops
    cleanly without stack overflow)` test (which asserted the OLD
    bug — that deep chains failed to resolve) with a positive
    1000-hop test that asserts full resolution + accurate
    `transitiveVia`. Proves both the recursion is gone AND the
    closure correctly inherits the leaf def across all hops.
  - Update commentary on adjacent re-export tests to reference the
    closure mechanism.
  - Update `FinalizeFile.localDefs` JSDoc + import-decomposer.ts
    inline doc to point at `buildReexportClosures` instead of the
    removed function name.

Validation: - gitnexus-shared builds cleanly.
  - gitnexus typechecks cleanly.
  - 28/28 finalize-algorithm.test.ts tests pass (incl. new 1000-hop).
  - 801/801 TypeScript scope-resolution tests pass under default
    (registry-primary) AND `REGISTRY_PRIMARY_TYPESCRIPT=0` (legacy DAG).
  - 404/404 Python + C# integration tests pass — no regression in
    cross-language consumers of the shared `finalize`.
Made-with: Cursor

* fix(scope): remove non-null assertions from scope resolution

Made-with: Cursor

* fix(scope): address TypeScript review follow-ups

Made-with: Cursor

* fix(scope): address TypeScript import review follow-ups

Add regression coverage for non-binding import edges and circular TypeScript bindings so PR #1050 review concerns stay visible without changing runtime semantics.

Made-with: Cursor
This commit is contained in:
Gergő Magyar 2026-04-26 08:23:08 +01:00 committed by GitHub
parent 13abf192e0
commit ab077b4c29
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
56 changed files with 6626 additions and 155 deletions

View file

@ -1,7 +1,7 @@
<!-- version: 1.6.0 -->
<!-- Last updated: 2026-04-20 -->
<!-- version: 1.7.0 -->
<!-- Last updated: 2026-04-23 -->
Last reviewed: 2026-04-20
Last reviewed: 2026-04-23
**Project:** GitNexus · **Environment:** dev · **Maintainer:** repository maintainers (see GitHub)
@ -40,7 +40,7 @@ Commands and gotchas live under **Repo reference** below and in **[CONTRIBUTING.
- **[ARCHITECTURE.md](ARCHITECTURE.md)**, **[CONTRIBUTING.md](CONTRIBUTING.md)**, **[GUARDRAILS.md](GUARDRAILS.md)**
- **Call-resolution DAG (legacy path):** See ARCHITECTURE.md § Call-Resolution DAG. Typed 6-stage DAG inside the `parse` phase; language-specific behavior behind `inferImplicitReceiver` / `selectDispatch` hooks on `LanguageProvider`. Shared code in `gitnexus/src/core/ingestion/` must not name languages. Types: `gitnexus/src/core/ingestion/call-types.ts`.
- **Scope-resolution pipeline (RFC #909 Ring 3):** See ARCHITECTURE.md § Scope-Resolution Pipeline. Replaces the legacy DAG for languages in `MIGRATED_LANGUAGES` (currently Python). A language plugs in by implementing `ScopeResolver` (`scope-resolution/contract/scope-resolver.ts`) and registering it in `SCOPE_RESOLVERS`. CI parity gate runs BOTH paths per migrated language on every PR.
- **Scope-resolution pipeline (RFC #909 Ring 3):** See ARCHITECTURE.md § Scope-Resolution Pipeline. Replaces the legacy DAG for languages in `MIGRATED_LANGUAGES` (see `registry-primary-flag.ts`). A language plugs in by implementing `ScopeResolver` (`scope-resolution/contract/scope-resolver.ts`) and registering it in `SCOPE_RESOLVERS`. CI parity gate runs BOTH paths per migrated language on every PR.
- **Cursor:** `.cursor/index.mdc` (always-on); `.cursor/rules/*.mdc` (glob-scoped). Legacy `.cursorrules` deprecated.
- **GitNexus:** skills in `.claude/skills/gitnexus/`; MCP rules in `gitnexus:start` block below.
@ -48,6 +48,7 @@ Commands and gotchas live under **Repo reference** below and in **[CONTRIBUTING.
| Date | Version | Change |
|------|---------|--------|
| 2026-04-23 | 1.7.0 | TypeScript added to `MIGRATED_LANGUAGES` (registry-primary call resolution by default). |
| 2026-04-20 | 1.6.0 | Added scope-resolution pipeline pointer (RFC #909 Ring 3); Python migrated to registry-primary. |
| 2026-04-19 | 1.5.0 | Cross-repo impact (#794): `impact`/`query`/`context` accept `repo: "@<group>"` + `service`. Removed `group_query`/`group_contracts`/`group_status` MCP tools; added `gitnexus://group/{name}/contracts` and `gitnexus://group/{name}/status` resources. |
| 2026-04-16 | 1.4.0 | Fixed: web UI description, pre-commit behavior, MCP tools (7->16), added gitnexus-shared, removed stale vite-plugin-wasm gotcha. |

View file

@ -340,6 +340,7 @@ CI auto-discovers the set via `tsx`. No workflow edit required.
- **Cross-phase Tree cache**: parse phase writes Trees into `scopeTreeCache` (separate from the chunk-local `astCache`) ONLY for languages with `emitScopeCaptures`. Scope-resolution reads from it to skip the second parse. Cleared at end of the phase. Workers leave the cache empty — Trees can't cross MessageChannels; cache miss = fresh parse. `PROF_SCOPE_RESOLUTION=1` emits hit/miss counters and a worker-engaged warning.
- **Typed relationship iteration**: heritage + MRO walk only the EXTENDS / IMPLEMENTS / HAS_METHOD edges via `iterRelationshipsByType`, not the full relationship map.
- **Workspace-resolution-index**: O(1) `findOwnedMember` / `findExportedDef` / `classScopeByDefId` built once per run.
- **SCC-ordered cross-file return-type propagation** (PR #1050): `propagateImportedReturnTypes` walks `indexes.sccs` in reverse-topological order (leaves first), so multi-hop alias chains like `models.User → service.user → app.user` collapse to the terminal class in a single linear pass. Within each importer, the source module's `typeBindings` is chain-followed BEFORE mirroring (so we mirror terminal types, not intermediate refs), and the importer's own `typeBindings` is chain-followed AFTER mirroring (so local `const x = importedFn()` resolves before downstream importers run). Cyclic SCCs reach a partial fixpoint within a single pass without iterating to convergence — see the `ts-circular` cross-file-binding fixture which only asserts pipeline-no-throw. PROF output (`PROF_SCOPE_RESOLUTION=1`) splits `finalize` from `propagate` so quadratic regressions in the chain-follow surface independently.
---

View file

@ -26,9 +26,9 @@
* (`resolveImportTarget`, `expandsWildcardTo`, `mergeBindings`) that
* match the LanguageProvider surface from #911.
*
* **Dynamic imports rule.** `kind === 'dynamic-unresolved'` passes through
* as an `ImportEdge { kind: 'dynamic-unresolved', targetFile: null }`
* with no `BindingRef`. They are parse-time signals, not linkable targets.
* **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';
@ -45,20 +45,28 @@ export interface FinalizeFile {
/**
* Defs exported from this file the "what other files can import by name"
* surface. Typically those with `isExported: true` (the module's own
* declarations) plus, for multi-hop re-export chains, the re-exported
* names the parser chose to surface here.
* 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 looking up `X` in `B.localDefs`. If B
* only has `export { X } from './C'` and the parser *does not* include
* `X` in `B.localDefs`, A's edge hits the fixpoint cap and is marked
* `linkStatus: 'unresolved'`. The fixpoint does NOT mutate `localDefs`
* across iterations it is static input.
* `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.
*
* Parsers that want multi-hop re-export chains to settle end-to-end must
* include re-exported names in the intermediate file's `localDefs` (with
* the original `DefId` of the source symbol). This keeps the algorithm
* O(1) per lookup and avoids graph-crawl during finalize.
* 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[];
}
@ -186,7 +194,8 @@ export function finalize(input: FinalizeInput, hooks: FinalizeHooks): FinalizeOu
graph.set(file.filePath, new Set());
}
for (const [fromFile, drafts] of edgeIndex) {
const edges = graph.get(fromFile)!;
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);
@ -197,6 +206,12 @@ export function finalize(input: FinalizeInput, hooks: FinalizeHooks): FinalizeOu
// ── 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
@ -217,10 +232,11 @@ export function finalize(input: FinalizeInput, hooks: FinalizeHooks): FinalizeOu
progressed = false;
iterations++;
for (const filePath of scc.files) {
const drafts = edgeIndex.get(filePath)!;
const drafts = edgeIndex.get(filePath);
if (drafts === undefined) continue;
for (const draft of drafts) {
if (draft.finalized !== null) continue;
const finalized = tryFinalize(draft, byFilePath);
const finalized = tryFinalize(draft, byFilePath, reexportClosures);
if (finalized !== null) {
draft.finalized = finalized;
progressed = true;
@ -231,7 +247,8 @@ export function finalize(input: FinalizeInput, hooks: FinalizeHooks): FinalizeOu
// Any drafts still not finalized within this SCC hit the cap → unresolved.
for (const filePath of scc.files) {
const drafts = edgeIndex.get(filePath)!;
const drafts = edgeIndex.get(filePath);
if (drafts === undefined) continue;
for (const draft of drafts) {
if (draft.finalized !== null) continue;
draft.finalized = {
@ -245,10 +262,14 @@ export function finalize(input: FinalizeInput, hooks: FinalizeHooks): FinalizeOu
// ── 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)!;
const drafts = edgeIndex.get(file.filePath);
if (drafts === undefined) continue;
const finalized: ImportEdge[] = [];
for (const d of drafts) {
const edge = d.finalized!;
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);
@ -327,14 +348,11 @@ function makeEdgeDraft(
// Edge is unresolvable at the file level — mark unresolved now.
if (targetFile === null) {
const edgeKind = parsed.kind === 'wildcard' ? 'wildcard-expanded' : parsed.kind;
const localName = parsed.kind === 'wildcard' ? '' : parsed.localName;
const targetExportedName = extractExportedName(parsed);
const base: ImportEdge = {
localName,
localName: extractLocalName(parsed),
targetFile: null,
targetExportedName,
kind: edgeKind,
targetExportedName: extractExportedName(parsed),
kind: edgeKindFor(parsed),
linkStatus: 'unresolved',
};
return {
@ -348,26 +366,43 @@ function makeEdgeDraft(
}
// Resolvable at the file level; intra-SCC fixpoint may still fail to fill
// in `targetDefId` (e.g., symbol not exported from target).
const edgeKind = parsed.kind === 'wildcard' ? 'wildcard-expanded' : parsed.kind;
const localName = parsed.kind === 'wildcard' ? '' : parsed.localName;
const targetExportedName = extractExportedName(parsed);
// 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 base: ImportEdge = {
localName,
localName: extractLocalName(parsed),
targetFile,
targetExportedName,
kind: edgeKind,
targetExportedName: extractExportedName(parsed),
kind: edgeKindFor(parsed),
};
const isFileLevelTerminal = parsed.kind === 'side-effect' || parsed.kind === 'dynamic-resolved';
return {
source: parsed,
fromFile: file.filePath,
fromScope: file.moduleScope,
targetFile,
base,
finalized: null,
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':
@ -377,6 +412,8 @@ function extractExportedName(parsed: ParsedImport): string {
return parsed.importedName;
case 'wildcard':
case 'dynamic-unresolved':
case 'dynamic-resolved':
case 'side-effect':
return '';
}
}
@ -386,6 +423,7 @@ function extractExportedName(parsed: ParsedImport): string {
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
@ -423,22 +461,265 @@ function tryFinalize(
const importedName = extractExportedName(draft.source);
const exported = findExportByName(targetModule.localDefs, importedName);
if (exported === undefined) {
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 transitiveVia = draft.source.kind === 'reexport' ? Object.freeze([targetFile]) : undefined;
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: exported.nodeId,
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.
*
@ -522,6 +803,17 @@ function materializeBindings(
): 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[]>();
@ -538,10 +830,7 @@ function materializeBindings(
const imports = linkedByScope.get(file.moduleScope) ?? [];
for (const edge of imports) {
if (edge.targetDefId === undefined || edge.linkStatus === 'unresolved') continue;
// Every def the importing file needs to reach is in some other file's
// `localDefs`; walk all files to find it. In practice we could index
// this, but at finalize-time N(files) is small per workspace pass.
const def = findDefById(files, edge.targetDefId);
const def = defById.get(edge.targetDefId);
if (def === undefined) continue;
const origin: BindingRef['origin'] =
@ -571,15 +860,6 @@ function materializeBindings(
return out;
}
function findDefById(files: readonly FinalizeFile[], defId: string): SymbolDefinition | undefined {
for (const f of files) {
for (const d of f.localDefs) {
if (d.nodeId === defId) return d;
}
}
return undefined;
}
// ─── Internal: Tarjan SCC ──────────────────────────────────────────────────
/**
@ -607,7 +887,8 @@ function tarjanSccs(graph: ReadonlyMap<string, ReadonlySet<string>>): FinalizedS
entered: false,
});
while (iterStack.length > 0) {
const frame = iterStack[iterStack.length - 1]!;
const frame = iterStack[iterStack.length - 1];
if (frame === undefined) break;
if (!frame.entered) {
frame.entered = true;
@ -625,7 +906,10 @@ function tarjanSccs(graph: ReadonlyMap<string, ReadonlySet<string>>): FinalizedS
const scc: string[] = [];
let selfInCycle = false;
while (true) {
const w = stack.pop()!;
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.
@ -640,8 +924,16 @@ function tarjanSccs(graph: ReadonlyMap<string, ReadonlySet<string>>): FinalizedS
iterStack.pop();
// Propagate lowlink to parent.
if (iterStack.length > 0) {
const parent = iterStack[iterStack.length - 1]!;
lowlink.set(parent.node, Math.min(lowlink.get(parent.node)!, lowlink.get(frame.node)!));
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;
}
@ -654,10 +946,24 @@ function tarjanSccs(graph: ReadonlyMap<string, ReadonlySet<string>>): FinalizedS
entered: false,
});
} else if (onStack.has(child)) {
lowlink.set(frame.node, Math.min(lowlink.get(frame.node)!, index.get(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;
}

View file

@ -182,6 +182,42 @@ export type ParsedImport =
readonly localName: string;
/** Source text of the unresolved expression when available; `null` otherwise. */
readonly targetRaw: string | null;
}
/**
* Lazy / dynamic import whose target IS a static string literal at parse
* time, so it can be linked to a concrete `targetFile`. No local name
* binding is materialized `import('./m')` returns `Promise<Module>` and
* any consumer-visible names appear via subsequent `.then(({ X }) => …)`
* destructuring, which is outside the static-import surface. The edge
* exists for module-reachability and impact analysis (so editing `./m`
* still flags the dynamic importer as affected).
*
* Providers MUST only emit this kind when `targetRaw` is a literal
* string they can hand to `resolveImportTarget`; expression arguments
* stay `dynamic-unresolved`.
*
* Examples:
* - JS `import('./feature')` `{ kind: 'dynamic-resolved', targetRaw: './feature' }`
* - JS `await import('@scope/pkg/sub')` `{ kind: 'dynamic-resolved', targetRaw: '@scope/pkg/sub' }`
*/
| {
readonly kind: 'dynamic-resolved';
readonly targetRaw: string;
}
/**
* Bare-source / side-effect import that introduces no local name binding
* but still establishes a file-level dependency. Resolves to a concrete
* `targetFile` via `resolveImportTarget` and produces a filefile
* `ImportEdge` for module-reachability and impact analysis, with no
* `BindingRef` materialized.
*
* Examples:
* - JS / TS `import './polyfill'` `{ kind: 'side-effect', targetRaw: './polyfill' }`
* - Rust `use foo::bar as _` side-effect (binding hidden under `_`)
*/
| {
readonly kind: 'side-effect';
readonly targetRaw: string;
};
/**
@ -253,7 +289,9 @@ export interface ImportEdge {
| 'namespace'
| 'wildcard-expanded'
| 'reexport'
| 'dynamic-unresolved';
| 'dynamic-unresolved'
| 'dynamic-resolved'
| 'side-effect';
/** Re-export chain, for provenance (e.g., `['./y']` when re-exported via `./y`). */
readonly transitiveVia?: readonly string[];
/** Set to `'unresolved'` when the SCC fixpoint could not link this edge. */

View file

@ -44,6 +44,17 @@ import {
javascriptCallConfig,
} from '../call-extractors/configs/typescript-javascript.js';
import { createHeritageExtractor } from '../heritage-extractors/generic.js';
import {
emitTsScopeCaptures,
interpretTsImport,
interpretTsTypeBinding,
tsBindingScopeFor,
tsImportOwningScope,
tsReceiverBinding,
typescriptMergeBindings,
typescriptArityCompatibility,
resolveTsImportTarget,
} from './typescript/index.js';
/**
* TypeScript/JavaScript: arrow_function and function_expression get their name
@ -185,6 +196,25 @@ export const typescriptProvider = defineLanguage({
classExtractor: createClassExtractor(typescriptClassConfig),
heritageExtractor: createHeritageExtractor(SupportedLanguages.TypeScript),
builtInNames: BUILT_INS,
// ── RFC #909 Ring 3: scope-based resolution hooks (RFC §5) ──────────
// TypeScript is the third migration after Python and C#. See
// ./typescript/index.ts for the full per-hook rationale and the
// canonical capture vocabulary in ./typescript/query.ts
// (TYPESCRIPT_SCOPE_QUERY constant).
emitScopeCaptures: emitTsScopeCaptures,
interpretImport: interpretTsImport,
interpretTypeBinding: interpretTsTypeBinding,
bindingScopeFor: tsBindingScopeFor,
importOwningScope: tsImportOwningScope,
// Merge precedence is decided from BindingRef origin + declaration
// space only. The central finalizer already calls this per (scope,
// name), so the Scope object itself intentionally does not affect
// TypeScript declaration merging.
mergeBindings: (_scope, bindings) => typescriptMergeBindings(bindings),
receiverBinding: tsReceiverBinding,
arityCompatibility: typescriptArityCompatibility,
resolveImportTarget: resolveTsImportTarget,
});
export const javascriptProvider = defineLanguage({

View file

@ -0,0 +1,113 @@
/**
* Extract TypeScript arity metadata from a method-like tree-sitter node
* `method_definition`, `method_signature`, `abstract_method_signature`,
* `function_declaration`, `generator_function_declaration`, or
* `function_signature` (overload signature).
*
* Reuses `typescriptMethodConfig.extractParameters` so scope-extracted defs
* carry the same arity semantics as the legacy parse-worker path:
* - Rest parameters (`...args: T[]`) collapse `parameterCount` to
* `undefined`, which `typescriptArityCompatibility` treats as
* "max unknown" the candidate stays eligible at
* `argCount >= required` (mirrors Python `*args` / C# `params`).
* - Optional (`p?: T`) and defaulted (`p: T = …`) parameters both
* contribute to `optionalCount`;
* `requiredParameterCount = total optionalCount`.
* - `parameterTypes` collects declared type-annotation text for
* overload narrowing; TypeScript supports function overloading
* (`function f(x: string); function f(x: number); function f(x) {}`),
* so populated types let the registry disambiguate same-arity
* siblings by declared types.
* - A literal `'params'` marker is appended for variadic methods so
* `typescriptArityCompatibility` can detect rest params without
* re-reading the AST.
*
* ## Generics stripping
*
* TypeScript parameter types frequently contain generic instantiations
* (`User<string>`, `Array<User>`, `Promise<User[]>`). For overload
* narrowing by declared type, we want the "head" name `User`,
* `Array`, `Promise` so `arity-metadata` applies a light strip to
* each `parameterTypes[i]`:
*
* - `Foo<Bar>` `Foo`
* - `Foo<Bar, Baz>` `Foo`
* - `Foo[]` `Foo`
* - `Foo<Bar>[]` `Foo`
* - `Foo<Bar<Baz>>` `Foo` (greedy strip the outermost once)
* - plain `Foo` `Foo`
*
* We do NOT strip unions / intersections at this layer those stay
* intact because the registry's overload narrowing is a string
* equality check; union types shouldn't match anything and we prefer
* "unknown" to "accidental match". `undefined` / `null` in unions
* (TS strict mode) is handled by `interpret.ts`'s `stripNullableUnion`
* when the name would be consumed as a receiver type that path is
* separate from this arity-metadata path.
*
* Generic type parameters on the function itself (`function f<T>(x: T)`)
* do NOT enter here the method extractor reads the `parameters`
* field only, which contains value parameters, not type parameters.
*/
import type { SyntaxNode } from '../../utils/ast-helpers.js';
import { typescriptMethodConfig } from '../../method-extractors/configs/typescript-javascript.js';
interface TsArityMetadata {
readonly parameterCount: number | undefined;
readonly requiredParameterCount: number | undefined;
readonly parameterTypes: readonly string[] | undefined;
}
export function computeTsArityMetadata(fnNode: SyntaxNode): TsArityMetadata {
const params = typescriptMethodConfig.extractParameters?.(fnNode) ?? [];
let hasRest = false;
let optionalCount = 0;
const types: string[] = [];
for (const p of params) {
if (p.isVariadic) hasRest = true;
else if (p.isOptional) optionalCount++;
const t = p.type !== null && p.type !== undefined ? stripGenericsAndArraySuffix(p.type) : '';
types.push(t);
}
if (hasRest) types.push('params');
const total = params.length;
const parameterCount = hasRest ? undefined : total;
const requiredParameterCount = hasRest ? undefined : total - optionalCount;
// Only emit parameterTypes when at least one param carries a non-
// empty type name. An array of all empty strings adds noise to the
// registry without aiding narrowing — callers treat absence as
// "types unknown".
const hasAnyType = types.some((t) => t !== '' && t !== 'params');
const parameterTypes = hasAnyType || hasRest ? (types.length > 0 ? types : undefined) : undefined;
return {
parameterCount,
requiredParameterCount,
parameterTypes,
};
}
/**
* Light generic + array-suffix strip used only for registry overload
* narrowing. See file-level JSDoc for the exact transformation table.
*
* Handles nesting greedily at the outermost level:
* `Foo<Bar<Baz>>[]` strip `[]` `Foo<Bar<Baz>>`, then strip
* outermost `<>` `Foo`.
*/
function stripGenericsAndArraySuffix(raw: string): string {
let t = raw.trim();
// Repeatedly peel trailing `[]` pairs, then peel the outermost `<…>`
// block once. We don't loop the `<>` peel since nesting is rare and
// the head name is already reached after one peel.
while (t.endsWith('[]')) t = t.slice(0, -2).trim();
const lt = t.indexOf('<');
if (lt > 0 && t.endsWith('>')) {
t = t.slice(0, lt).trim();
}
return t;
}

View file

@ -0,0 +1,61 @@
/**
* TypeScript arity check, accommodating rest parameters and optional
* (`p?: T`) / defaulted (`p: T = …`) parameters.
*
* TypeScript-specific semantics vs C#:
*
* - **Optional** `p?: T` collapses to `isOptional` in the extractor
* and contributes to `optionalCount`, so `requiredParameterCount`
* excludes it. Same wire shape as a default-valued parameter.
* - **Rest** `...args: T[]` makes `parameterCount` undefined (max
* unknown) and `parameterTypes` carries a literal `'params'` marker
* so this hook can detect variadic calls without re-reading the AST
* (mirrors the C# convention for cross-language consistency).
* - **Generics** function-level generic type parameters (`<T, U>`)
* do NOT count toward arity; the method-extractor reads the
* `parameters` field and ignores `type_parameters`, so generic
* count never enters the metadata.
*
* The metadata shape (`parameterCount`, `requiredParameterCount`,
* `parameterTypes`) is synthesized by `arity-metadata.ts` and stored
* on `SymbolDefinition`. This file consumes that metadata.
*
* Verdicts:
* - `'compatible'` `requiredParameterCount <= argCount <=
* parameterCount`, OR the def has rest params
* (any `argCount >= required`).
* - `'incompatible'` argCount is below required, OR above max with
* no rest params.
* - `'unknown'` metadata is absent / incomplete (treated as
* neutral by the registry).
*
* `'incompatible'` is a soft signal in `Registry.lookup` (penalized
* but still considered when no compatible candidate exists), per
* RFC §4.
*/
import type { Callsite, SymbolDefinition } from 'gitnexus-shared';
export function typescriptArityCompatibility(
def: SymbolDefinition,
callsite: Callsite,
): 'compatible' | 'unknown' | 'incompatible' {
const max = def.parameterCount;
const min = def.requiredParameterCount;
if (max === undefined && min === undefined) return 'unknown';
const argCount = callsite.arity;
if (!Number.isFinite(argCount) || argCount < 0) return 'unknown';
// Variadic detection: the `arity-metadata` synthesizer appends the
// literal `'params'` marker to `parameterTypes` when the def has a
// rest parameter, to avoid re-parsing the AST here.
const hasRest =
def.parameterTypes !== undefined &&
def.parameterTypes.some((t) => t === 'params' || t.startsWith('params '));
if (min !== undefined && argCount < min) return 'incompatible';
if (max !== undefined && argCount > max && !hasRest) return 'incompatible';
return 'compatible';
}

View file

@ -0,0 +1,32 @@
/**
* Dev-mode counters for the TypeScript cross-phase scope-captures parse cache.
*
* Gated by `PROF_SCOPE_RESOLUTION=1`. In production the module-level `PROF`
* constant is `false` and V8 folds every increment site into dead code, so the
* hot path in `captures.ts` stays branch-free.
*
* Extracted from `captures.ts` so the production hot-path module doesn't carry
* a module-global counter and its reset/export surface.
*/
const PROF = process.env.PROF_SCOPE_RESOLUTION === '1';
let CACHE_HITS = 0;
let CACHE_MISSES = 0;
export function recordCacheHit(): void {
if (PROF) CACHE_HITS++;
}
export function recordCacheMiss(): void {
if (PROF) CACHE_MISSES++;
}
export function getTypescriptCaptureCacheStats(): { hits: number; misses: number } {
return { hits: CACHE_HITS, misses: CACHE_MISSES };
}
export function resetTypescriptCaptureCacheStats(): void {
CACHE_HITS = 0;
CACHE_MISSES = 0;
}

View file

@ -0,0 +1,501 @@
/**
* `emitScopeCaptures` for TypeScript.
*
* Drives the TypeScript scope query against tree-sitter-typescript and groups
* raw matches into `CaptureMatch[]` for the central extractor. Layers
* synthesized streams on top:
*
* 1. **Import decomposition** each `import_statement` / re-export is
* re-emitted with `@import.kind/source/name/alias/typeOnly` markers so
* `interpretTsImport` can recover the `ParsedImport` shape without
* re-parsing raw text (see `import-decomposer.ts`). Unit 2 adds this;
* until then, raw `@import.statement` matches flow through as-is.
* 2. **Dynamic imports** `import('./m')` is re-emitted as a
* decomposed `@import.statement` with `@import.kind=dynamic` so the
* central extractor treats it uniformly with static imports.
* 3. **Function-decl arity metadata** (Unit 5) `@declaration.parameter-count`
* / `@declaration.required-parameter-count` / `@declaration.parameter-types`
* synthesized onto function-like declarations so the registry can narrow
* overloads.
* 4. **Callsite arity metadata** (Unit 5) `@reference.arity` /
* `@reference.parameter-types` on every callsite.
* 5. **Receiver-binding synthesis** (Unit 3) `this` type anchors on
* instance methods, with arrow-function lexical-this walk-up.
*
* Pure given the input source text. No I/O, no globals consulted.
*/
import type { Capture, CaptureMatch } from 'gitnexus-shared';
import {
findNodeAtRange,
nodeToCapture,
syntheticCapture,
type SyntaxNode,
} from '../../utils/ast-helpers.js';
import { splitImportStatement } from './import-decomposer.js';
import { getTsParser, getTsScopeQuery, tsCachedTreeMatchesGrammar } from './query.js';
import { recordCacheHit, recordCacheMiss } from './cache-stats.js';
import { synthesizeTsReceiverBinding } from './receiver-binding.js';
import { computeTsArityMetadata } from './arity-metadata.js';
/** tree-sitter-typescript node types for function-like scopes that may
* carry a synthesized `this` binding. Kept in sync with the
* `@scope.function` patterns in `query.ts`. */
const FUNCTION_NODE_TYPES = [
'method_definition',
'method_signature',
'abstract_method_signature',
'arrow_function',
'function_expression',
'function_declaration',
'generator_function_declaration',
'function_signature',
] as const;
/** Declaration anchors that carry function-like arity metadata. */
const FUNCTION_DECL_TAGS = ['@declaration.method', '@declaration.function'] as const;
/** Callsite anchors that should carry `@reference.arity` + param types. */
const CALL_TAGS = [
'@reference.call.free',
'@reference.call.member',
'@reference.call.constructor',
] as const;
function pickFirstDefined(grouped: CaptureMatch, tags: readonly string[]): Capture | undefined {
for (const tag of tags) {
const cap = grouped[tag];
if (cap !== undefined) return cap;
}
return undefined;
}
/**
* Drop `@reference.read.member` matches whose underlying `member_expression`
* is NOT actually a read context:
*
* 1. The member_expression is the `function:` of a `call_expression`
* (it's a call, already captured as `@reference.call.member`).
* 2. The member_expression is the `constructor:` of a `new_expression`
* (already captured as `@reference.call.constructor.qualified`).
* 3. The member_expression is the `left:` of an `assignment_expression` /
* `augmented_assignment_expression` (it's a write, already captured
* as `@reference.write.member`).
* 4. The member_expression is the `function:` of an `await_expression`
* being called (handled by the member-call capture).
*
* Returns `true` when the capture should be kept as a read reference,
* `false` when it should be dropped.
*/
function shouldEmitReadMember(memberNode: SyntaxNode): boolean {
const parent = memberNode.parent;
if (parent === null) return true;
switch (parent.type) {
case 'call_expression':
return parent.childForFieldName('function')?.id !== memberNode.id;
case 'new_expression':
return parent.childForFieldName('constructor')?.id !== memberNode.id;
case 'assignment_expression':
case 'augmented_assignment_expression':
return parent.childForFieldName('left')?.id !== memberNode.id;
default:
return true;
}
}
export function emitTsScopeCaptures(
sourceText: string,
filePath: string,
cachedTree?: unknown,
): readonly CaptureMatch[] {
// Skip the parse when the caller (parse phase's scopeTreeCache) already
// produced a Tree for this source. Cache miss = re-parse, same as before.
// The cachedTree parameter is typed as `unknown` at the LanguageProvider
// contract layer; cast here at the use site.
//
// Grammar selection: `.tsx` files are parsed with the TSX grammar,
// `.ts` files with the TypeScript grammar. The two grammars have
// separate node-type id spaces, so a Query compiled against one
// cannot match a Tree produced by the other. We validate the cached
// tree's grammar against the file extension and fall back to a
// fresh parse if they disagree (e.g. a worker-mode parse landed
// with the wrong grammar pinned).
let tree = cachedTree as ReturnType<ReturnType<typeof getTsParser>['parse']> | undefined;
if (tree !== undefined && !tsCachedTreeMatchesGrammar(tree, filePath)) {
tree = undefined;
}
if (tree === undefined) {
tree = getTsParser(filePath).parse(sourceText);
recordCacheMiss();
} else {
recordCacheHit();
}
const rawMatches = getTsScopeQuery(filePath).matches(tree.rootNode);
const out: CaptureMatch[] = [];
for (const m of rawMatches) {
// Group captures by their tag name. Tree-sitter strips the leading
// `@`; we put it back so the central extractor's prefix lookups
// (`@scope.`, `@declaration.`, …) work.
const grouped: Record<string, Capture> = {};
for (const c of m.captures) {
const tag = '@' + c.name;
grouped[tag] = nodeToCapture(tag, c.node);
}
if (Object.keys(grouped).length === 0) continue;
// Decompose each `import_statement` / re-export `export_statement`
// so `interpretTsImport` sees the kind/source/name/alias markers
// it consumes. The raw query anchor carries only @import.statement.
// Side-effect imports emit a non-binding marker so finalize can keep
// the file-level dependency.
if (grouped['@import.statement'] !== undefined) {
const stmtCapture = grouped['@import.statement'];
const stmtNode =
findNodeAtRange(tree.rootNode, stmtCapture.range, 'import_statement') ??
findNodeAtRange(tree.rootNode, stmtCapture.range, 'export_statement');
if (stmtNode !== null) {
const decomposed = splitImportStatement(stmtNode);
for (const d of decomposed) out.push(d);
}
// If decomposition yielded nothing (malformed/bare anchor), drop
// the match. Emitting a bare
// @import.statement without kind/source would confuse the
// central extractor.
continue;
}
// Dynamic imports — decompose via the same path. `@import.dynamic`
// is anchored on a `call_expression`, which the decomposer's
// `splitDynamicImport` branch consumes.
if (grouped['@import.dynamic'] !== undefined) {
const dynCapture = grouped['@import.dynamic'];
const callNode = findNodeAtRange(tree.rootNode, dynCapture.range, 'call_expression');
if (callNode !== null) {
const decomposed = splitImportStatement(callNode);
for (const d of decomposed) out.push(d);
}
continue;
}
// Filter out `@reference.read.member` matches whose AST parent tells
// us they are actually calls / writes / constructor invocations. The
// tree-sitter pattern is context-free and matches every member_expression;
// we rely on this emit-side filter so the query stays simple.
if (grouped['@reference.read.member'] !== undefined) {
const anchor = grouped['@reference.read.member'];
const memberNode = findNodeAtRange(tree.rootNode, anchor.range, 'member_expression');
if (memberNode === null || !shouldEmitReadMember(memberNode)) {
continue;
}
}
// Synthesize arity metadata on function-like declaration anchors
// before pushing the match. The registry uses these to narrow
// overloads — TypeScript supports overload signatures via
// function_signature, so `parameterTypes` is populated when
// available.
const declAnchor = pickFirstDefined(grouped, FUNCTION_DECL_TAGS);
if (declAnchor !== undefined) {
const fnNode = findFunctionNode(tree.rootNode, declAnchor.range);
if (fnNode !== null) {
const arity = computeTsArityMetadata(fnNode);
if (arity.parameterCount !== undefined) {
grouped['@declaration.parameter-count'] = syntheticCapture(
'@declaration.parameter-count',
fnNode,
String(arity.parameterCount),
);
}
if (arity.requiredParameterCount !== undefined) {
grouped['@declaration.required-parameter-count'] = syntheticCapture(
'@declaration.required-parameter-count',
fnNode,
String(arity.requiredParameterCount),
);
}
if (arity.parameterTypes !== undefined) {
grouped['@declaration.parameter-types'] = syntheticCapture(
'@declaration.parameter-types',
fnNode,
JSON.stringify(arity.parameterTypes),
);
}
}
}
// Synthesize `@reference.arity` on every callsite so the registry's
// arity filter can narrow overloads. Count the `argument` named
// children of the backing `arguments` node. TypeScript constructor
// calls use `new_expression`; regular calls use `call_expression`.
const callAnchor = pickFirstDefined(grouped, CALL_TAGS);
if (callAnchor !== undefined && grouped['@reference.arity'] === undefined) {
const callNode =
findNodeAtRange(tree.rootNode, callAnchor.range, 'call_expression') ??
findNodeAtRange(tree.rootNode, callAnchor.range, 'new_expression');
if (callNode !== null) {
const argList = callNode.childForFieldName('arguments');
const args: SyntaxNode[] =
argList === null
? []
: argList.namedChildren.filter(
(c): c is SyntaxNode => c !== null && c.type !== 'comment',
);
grouped['@reference.arity'] = syntheticCapture(
'@reference.arity',
callNode,
String(args.length),
);
const argTypes = args.map((arg) => inferArgType(arg));
grouped['@reference.parameter-types'] = syntheticCapture(
'@reference.parameter-types',
callNode,
JSON.stringify(argTypes),
);
}
}
out.push(grouped);
// Synthesize `this` receiver type-bindings on every function-like
// scope that is structurally a class member. `receiver-binding.ts`
// handles the walk-up (method, method_signature, abstract
// signature, arrow/function-expression assigned to a class field).
// Arrow functions nested inside method bodies rely on scope-chain
// lookup instead of synthesis — covered by `tsReceiverBinding`.
const scopeFnAnchor = grouped['@scope.function'];
if (scopeFnAnchor !== undefined) {
const fnNode = findFunctionNode(tree.rootNode, scopeFnAnchor.range);
if (fnNode !== null) {
const synth = synthesizeTsReceiverBinding(fnNode);
if (synth !== null) out.push(synth);
}
}
}
// Synthesize object-destructuring type bindings. The tree-sitter query
// alone can't express "give me the field NAME and the RHS identifier
// together" in a way that produces usable @type-binding.name /
// @type-binding.type captures, so we walk `variable_declarator` nodes
// whose `name:` is an `object_pattern` and synthesize per-field
// bindings keyed to the receiver-path `rhsName.fieldName`. The
// compound-receiver resolver's Case 3b then walks that path when the
// destructured local is used as a receiver (e.g. `address.save()`).
synthesizeDestructuringBindings(tree.rootNode, out);
synthesizeForOfMapTupleBindings(tree.rootNode, out);
synthesizeInstanceofNarrowings(tree.rootNode, out);
return out;
}
/**
* Walk the AST and synthesize type-binding captures for object
* destructuring of the form `const { field } = rhs` or
* `const { field: alias } = rhs`. Pushes one synthetic CaptureMatch
* per destructured identifier with:
*
* - `@type-binding.name` the local identifier
* - `@type-binding.type` the compound path `rhs.field`
* - `@type-binding.destructured` anchor
*
* Only fires when the RHS is a bare identifier more complex RHS
* shapes (call_expression, member_expression) resolve via the normal
* type-alias + chain-follow paths on the RHS first, then the field
* walk catches the destructured identifier on a second fixpoint pass.
* Left as a follow-up optimization.
*/
function synthesizeDestructuringBindings(root: SyntaxNode, out: CaptureMatch[]): void {
const stack: SyntaxNode[] = [root];
for (;;) {
const node = stack.pop();
if (node === undefined) break;
for (const child of node.namedChildren) {
if (child !== null) stack.push(child);
}
if (node.type !== 'variable_declarator') continue;
const nameNode = node.childForFieldName('name');
const valueNode = node.childForFieldName('value');
if (nameNode === null || valueNode === null) continue;
if (nameNode.type !== 'object_pattern') continue;
if (valueNode.type !== 'identifier') continue;
const rhsName = valueNode.text;
for (const fieldNode of nameNode.namedChildren) {
if (fieldNode === null) continue;
if (fieldNode.type === 'shorthand_property_identifier_pattern') {
// `const { address } = user`
const localName = fieldNode.text;
out.push({
'@type-binding.name': syntheticCapture('@type-binding.name', fieldNode, localName),
'@type-binding.type': syntheticCapture(
'@type-binding.type',
fieldNode,
`${rhsName}.${localName}`,
),
'@type-binding.destructured': syntheticCapture(
'@type-binding.destructured',
fieldNode,
fieldNode.text,
),
});
} else if (fieldNode.type === 'pair_pattern') {
// `const { address: addr } = user`
const key = fieldNode.childForFieldName('key');
const value = fieldNode.childForFieldName('value');
if (key === null || value === null) continue;
if (value.type !== 'identifier') continue;
const fieldName = key.text;
const localName = value.text;
out.push({
'@type-binding.name': syntheticCapture('@type-binding.name', value, localName),
'@type-binding.type': syntheticCapture(
'@type-binding.type',
fieldNode,
`${rhsName}.${fieldName}`,
),
'@type-binding.destructured': syntheticCapture(
'@type-binding.destructured',
fieldNode,
fieldNode.text,
),
});
}
}
}
}
/**
* `for (const [k, v] of mapId)` over a `Map<K,V>` synthesize per-slot
* type bindings so `v` resolves like a `Map` iterator tuple element.
* Uses sentinel `__MAP_TUPLE_i__:rhs` consumed by compound-receiver.
*/
function synthesizeForOfMapTupleBindings(root: SyntaxNode, out: CaptureMatch[]): void {
const stack: SyntaxNode[] = [root];
for (;;) {
const node = stack.pop();
if (node === undefined) break;
for (const child of node.namedChildren) {
if (child !== null) stack.push(child);
}
if (node.type !== 'for_in_statement') continue;
const left = node.childForFieldName('left');
const right = node.childForFieldName('right');
if (left === null || right === null) continue;
if (left.type !== 'array_pattern' || right.type !== 'identifier') continue;
const rhs = right.text;
let slot = 0;
for (const child of left.namedChildren) {
if (child === null || child.type !== 'identifier') continue;
const localName = child.text;
out.push({
'@type-binding.name': syntheticCapture('@type-binding.name', child, localName),
'@type-binding.type': syntheticCapture(
'@type-binding.type',
child,
`__MAP_TUPLE_${slot}__:${rhs}`,
),
'@type-binding.map-tuple-entry': syntheticCapture(
'@type-binding.map-tuple-entry',
child,
String(slot),
),
});
slot++;
}
}
}
/**
* `if (x instanceof User) { x.save() }` synthesize a `User` type binding
* for `x` anchored in the consequence block so scope-chain lookup inside
* the then-branch sees the narrowed type.
*
* **Known limitation:** the LHS must be a bare `identifier` and the RHS
* an `identifier`/`type_identifier`. Member-expression LHS such as
* `if (user.address instanceof Address)` is intentionally NOT synthesized
* narrowing a property-access target requires a stable storage key
* the binding layer can hold, which member chains don't supply. Field-
* type resolution covers the common case for those receivers via
* declared types instead.
*/
function synthesizeInstanceofNarrowings(root: SyntaxNode, out: CaptureMatch[]): void {
const stack: SyntaxNode[] = [root];
for (;;) {
const node = stack.pop();
if (node === undefined) break;
for (const child of node.namedChildren) {
if (child !== null) stack.push(child);
}
if (node.type !== 'if_statement') continue;
const cond = node.childForFieldName('condition');
if (cond === null) continue;
const inner = cond.type === 'parenthesized_expression' ? cond.namedChildren[0] : cond;
if (inner === null || inner.type !== 'binary_expression') continue;
const op = inner.childForFieldName('operator');
const left = inner.childForFieldName('left');
const right = inner.childForFieldName('right');
if (op === null || left === null || right === null) continue;
if (op.type !== 'instanceof') continue;
if (left.type !== 'identifier') continue;
if (right.type !== 'identifier' && right.type !== 'type_identifier') continue;
const varName = left.text;
const typeName = right.text;
const cons = node.childForFieldName('consequence');
if (cons === null) continue;
out.push({
'@type-binding.name': syntheticCapture('@type-binding.name', cons, varName),
'@type-binding.type': syntheticCapture('@type-binding.type', right, typeName),
'@type-binding.instanceof-narrow': syntheticCapture(
'@type-binding.instanceof-narrow',
cons,
'1',
),
});
}
}
/** Infer a TypeScript argument expression's static type from literal
* shapes. Returns `''` when the arg has no statically-derivable type
* (identifiers, member accesses, etc.) consumers treat unknown as
* any-match during overload narrowing. */
function inferArgType(argNode: SyntaxNode): string {
switch (argNode.type) {
case 'number':
return 'number';
case 'string':
case 'template_string':
return 'string';
case 'true':
case 'false':
return 'boolean';
case 'null':
return 'null';
case 'undefined':
return 'undefined';
case 'array':
return 'Array';
case 'object':
return 'object';
case 'regex':
return 'RegExp';
case 'new_expression': {
const ctor = argNode.childForFieldName('constructor');
return ctor?.text ?? '';
}
default:
return '';
}
}
/** Find the first TypeScript function-like node at the given range.
* The `@scope.function` anchor range covers the whole node, but the
* tag alone doesn't identify which node type among the many TS
* function-likes. */
function findFunctionNode(rootNode: SyntaxNode, range: Capture['range']): SyntaxNode | null {
for (const nodeType of FUNCTION_NODE_TYPES) {
const n = findNodeAtRange(rootNode, range, nodeType);
if (n !== null) return n;
}
return null;
}

View file

@ -0,0 +1,438 @@
/**
* Decompose a TypeScript `import_statement` / re-export `export_statement` /
* dynamic `call_expression(import)` into one `CaptureMatch` per imported
* name.
*
* Why split here? The `LanguageProvider.interpretImport` contract is
* one `ParsedImport` per call. Tree-sitter delivers
*
* import D, { X as Y, type Z } from './m'
*
* as a single `import_statement` match, so without decomposition we'd
* lose names. The synthesized markers (`@import.kind` / `@import.name`
* / `@import.alias` / `@import.source`) carry everything
* `interpretTsImport` needs to recover the `ParsedImport` shape
* see `interpret.ts`.
*
* Kinds we emit and how `interpret.ts` maps them to `ParsedImport`:
*
* - `default` : `import D from './m'` alias (importedName=default)
* - `named` : `import { X } from './m'` named
* - `named-alias` : `import { X as Y } from './m'` alias
* - `namespace` : `import * as N from './m'` namespace
* - `reexport` : `export { X } from './m'` reexport
* - `reexport-alias` : `export { X as Y } from './m'` reexport (with alias)
* - `reexport-wildcard` : `export * from './m'` wildcard
* - `reexport-namespace` : `export * as ns from './m'` namespace (local=ns,imported=source)
* - `dynamic` : `import('./m')` / `import(x)` dynamic-resolved or dynamic-unresolved
*
* Type-only constructs (`import type { X }`, `import { type X }`,
* `export type { X }`) emit the same kinds as runtime forms at the
* TypeScript scope-resolution layer, types and values share the same
* lookup; runtime-emission is a downstream concern.
*
* Side-effect imports (`import './polyfill'`) produce a single match
* with `kind: 'side-effect'`. The shared finalize algorithm resolves
* the target file and emits a file-level IMPORTS edge, but
* materializes no `BindingRef` (matching the legacy DAG, which counts
* `import './polyfill'` as a module-reachability dependency only).
*/
import type { Capture, CaptureMatch } from 'gitnexus-shared';
import {
findChild,
nodeToCapture,
syntheticCapture,
type SyntaxNode,
} from '../../utils/ast-helpers.js';
type ImportKind =
| 'default'
| 'named'
| 'named-alias'
| 'namespace'
| 'reexport'
| 'reexport-alias'
| 'reexport-wildcard'
| 'reexport-namespace'
| 'dynamic'
| 'side-effect';
interface ImportSpec {
readonly kind: ImportKind;
/** Module path as written (quotes stripped): `./m`, `numpy`, `@scope/pkg`.
* `null` only for dynamic imports whose argument isn't a string literal. */
readonly source: string | null;
/** Imported name from the source (or `''` when N/A, e.g. default imports
* use `'default'`, wildcards use `'*'`). */
readonly name: string;
/** Local alias — only present for aliased forms. */
readonly alias?: string;
/** Node to anchor the synthesized captures (for range + match provenance). */
readonly atNode: SyntaxNode;
/** Set on `dynamic` kind imports when the argument is a string literal
* enables `interpretTsImport` to emit `dynamic-resolved`. */
readonly literalSource?: boolean;
}
/**
* Decompose an import anchor. Handles three node types:
*
* - `import_statement` : all static import forms (incl. side-effect)
* - `export_statement` (w/ source) : re-exports
* - `call_expression` (import fn) : dynamic `import()`
*/
export function splitImportStatement(stmtNode: SyntaxNode): CaptureMatch[] {
if (stmtNode.type === 'import_statement') return splitImport(stmtNode);
if (stmtNode.type === 'export_statement') return splitReexport(stmtNode);
if (stmtNode.type === 'call_expression') return splitDynamicImport(stmtNode);
return [];
}
// ─── static imports ─────────────────────────────────────────────────────
function splitImport(stmtNode: SyntaxNode): CaptureMatch[] {
// `import_statement` shape:
// import_clause? "from" string (static form with bindings)
// string (side-effect `import './m'`)
//
// The `source` field is the string literal — we strip its surrounding
// quotes. An import without an `import_clause` child is side-effect
// only and still emits one non-binding match.
const source = extractSource(stmtNode);
if (source === null) return [];
const importClause = findChild(stmtNode, 'import_clause');
if (importClause === null) {
// `import './polyfill'` — no clause, no local binding. Emit a
// side-effect match so the finalize layer still produces a
// file-level IMPORTS edge (parity with the legacy DAG).
return [
buildImportMatch(stmtNode, {
kind: 'side-effect',
source,
name: '',
atNode: stmtNode,
}),
];
}
const out: CaptureMatch[] = [];
// An import_clause can have any combination of:
// - leading identifier (default import)
// - namespace_import (* as N)
// - named_imports ({ X, Y as Z })
for (let i = 0; i < importClause.namedChildCount; i++) {
const child = importClause.namedChild(i);
if (child === null) continue;
if (child.type === 'identifier') {
// Default import: `import D from './m'`.
out.push(
buildImportMatch(stmtNode, {
kind: 'default',
source,
name: 'default',
alias: child.text,
atNode: child,
}),
);
continue;
}
if (child.type === 'namespace_import') {
// `* as N` — the identifier child is the local binding.
const aliasId = findChild(child, 'identifier');
if (aliasId !== null) {
out.push(
buildImportMatch(stmtNode, {
kind: 'namespace',
source,
name: source,
alias: aliasId.text,
atNode: child,
}),
);
}
continue;
}
if (child.type === 'named_imports') {
for (let j = 0; j < child.namedChildCount; j++) {
const spec = child.namedChild(j);
if (spec === null || spec.type !== 'import_specifier') continue;
const decomposed = decomposeNamedSpecifier(spec, source, stmtNode);
if (decomposed !== null) out.push(decomposed);
}
continue;
}
// Other children (e.g. `type` keyword token for `import type { ... }`)
// are ignored — they carry no per-specifier info; we fold type-only
// semantics into the same emitted kinds.
}
return out;
}
/**
* Decompose a single `import_specifier` into one match. Handles:
*
* - `{ X }` named
* - `{ X as Y }` named-alias
* - `{ type X }` named (type-only; same shape)
* - `{ type X as Y }` named-alias (type-only)
*/
function decomposeNamedSpecifier(
spec: SyntaxNode,
source: string,
stmtNode: SyntaxNode,
): CaptureMatch | null {
// `import_specifier` layout:
// name: identifier
// alias: identifier? (only when `as` is present)
// plus an optional `type` keyword token in front (per-specifier type-only)
//
// tree-sitter-typescript exposes `name` and `alias` as named fields.
// If `name` is absent, fail closed rather than guessing positionally:
// binding the alias as the imported name would invert the edge.
const nameNode = spec.childForFieldName('name');
const aliasNode = spec.childForFieldName('alias');
if (nameNode === null) return null;
const name = nameNode.text;
if (aliasNode !== null && aliasNode.startIndex !== nameNode.startIndex) {
return buildImportMatch(stmtNode, {
kind: 'named-alias',
source,
name,
alias: aliasNode.text,
atNode: spec,
});
}
return buildImportMatch(stmtNode, {
kind: 'named',
source,
name,
atNode: spec,
});
}
// ─── re-exports ──────────────────────────────────────────────────────────
function splitReexport(stmtNode: SyntaxNode): CaptureMatch[] {
// `export_statement` with a `source:` field is a re-export. Forms:
//
// export { X, Y as Z } from './m' → export_clause children
// export * from './m' → no clause
// export * as ns from './m' → namespace_export child
// export type { X } from './m' → same clause path
//
// Local `export { X }` (no `from`) is visibility metadata, not an
// import; the captures-layer query guards with a `source: (string)`
// predicate so we always have a source here — but we defend
// structurally anyway.
const source = extractSource(stmtNode);
if (source === null) return [];
const exportClause = findChild(stmtNode, 'export_clause');
if (exportClause !== null) {
const out: CaptureMatch[] = [];
for (let i = 0; i < exportClause.namedChildCount; i++) {
const spec = exportClause.namedChild(i);
if (spec === null || spec.type !== 'export_specifier') continue;
const decomposed = decomposeReexportSpecifier(spec, source, stmtNode);
if (decomposed !== null) out.push(decomposed);
}
return out;
}
// `export * as ns from './m'` — tree-sitter-typescript emits a
// `namespace_export` child whose identifier is the local re-export
// name. Two facts are emitted:
//
// 1. An `@import.statement` (kind `reexport-namespace`) so finalize
// knows the barrel imports `./m` as `ns` (binds `ns` locally
// inside the barrel for consumers like `barrel.ts` calling
// `ns.X()`).
// 2. A synthetic `@declaration.namespace` so the central
// scope-extractor adds a `Namespace` SymbolDefinition for `ns`
// to the barrel's `localDefs`. Without this, downstream files
// doing `import { ns } from './barrel'` cannot resolve `ns`:
// `findExportByName` and the precomputed re-export closure only
// consult `localDefs` / `reexport` / `wildcard` drafts, never
// `namespace`-kind imports. The synthetic declaration fixes that
// without growing the shared finalizer's surface.
const namespaceExport = findChild(stmtNode, 'namespace_export');
if (namespaceExport !== null) {
const aliasId = findChild(namespaceExport, 'identifier');
if (aliasId !== null) {
return [
buildImportMatch(stmtNode, {
kind: 'reexport-namespace',
source,
name: source,
alias: aliasId.text,
atNode: namespaceExport,
}),
buildNamespaceDeclarationMatch(namespaceExport, aliasId),
];
}
}
// `export * from './m'` — no clause, no namespace_export. The bare
// `*` token is the only remaining marker; we don't need to inspect
// it since the shape alone says "wildcard".
return [
buildImportMatch(stmtNode, {
kind: 'reexport-wildcard',
source,
name: '*',
atNode: stmtNode,
}),
];
}
function decomposeReexportSpecifier(
spec: SyntaxNode,
source: string,
stmtNode: SyntaxNode,
): CaptureMatch | null {
const nameNode = spec.childForFieldName('name');
const aliasNode = spec.childForFieldName('alias');
if (nameNode === null) return null;
const name = nameNode.text;
if (aliasNode !== null && aliasNode.startIndex !== nameNode.startIndex) {
return buildImportMatch(stmtNode, {
kind: 'reexport-alias',
source,
name,
alias: aliasNode.text,
atNode: spec,
});
}
return buildImportMatch(stmtNode, {
kind: 'reexport',
source,
name,
atNode: spec,
});
}
// ─── dynamic imports ─────────────────────────────────────────────────────
function splitDynamicImport(callNode: SyntaxNode): CaptureMatch[] {
// `call_expression` shape for dynamic imports:
// function: (import) — named leaf node in tree-sitter-typescript
// arguments: (arguments (string) ...) — first arg is the path
//
// When the argument is a string literal, preserve its value. When it's
// anything else (variable, template literal, member access), surface
// the raw text for diagnostics and let `interpretTsImport` emit
// `dynamic-unresolved` with a `targetRaw` hint.
const args = callNode.childForFieldName('arguments');
if (args === null) {
return [
buildImportMatch(callNode, {
kind: 'dynamic',
source: null,
name: '',
atNode: callNode,
}),
];
}
const firstArg = args.namedChild(0);
if (firstArg === null) {
return [
buildImportMatch(callNode, {
kind: 'dynamic',
source: null,
name: '',
atNode: callNode,
}),
];
}
if (firstArg.type === 'string') {
const source = stripQuotes(firstArg.text);
return [
buildImportMatch(callNode, {
kind: 'dynamic',
source,
name: '',
atNode: callNode,
literalSource: true,
}),
];
}
// Non-literal argument — preserve source text so downstream
// diagnostics show what the user wrote.
return [
buildImportMatch(callNode, {
kind: 'dynamic',
source: firstArg.text,
name: '',
atNode: callNode,
}),
];
}
// ─── helpers ─────────────────────────────────────────────────────────────
function extractSource(stmtNode: SyntaxNode): string | null {
// Both `import_statement` and `export_statement` expose the module
// path through the `source:` field. It's typed as `string` in the
// grammar; we strip its surrounding quotes.
const sourceField = stmtNode.childForFieldName('source');
if (sourceField === null || sourceField.type !== 'string') return null;
return stripQuotes(sourceField.text);
}
function stripQuotes(raw: string): string {
const trimmed = raw.trim();
if (trimmed.length < 2) return trimmed;
const first = trimmed.charAt(0);
const last = trimmed.charAt(trimmed.length - 1);
if (
(first === '"' && last === '"') ||
(first === "'" && last === "'") ||
(first === '`' && last === '`')
) {
return trimmed.slice(1, -1);
}
return trimmed;
}
function buildImportMatch(stmtNode: SyntaxNode, spec: ImportSpec): CaptureMatch {
const m: Record<string, Capture> = {
'@import.statement': nodeToCapture('@import.statement', stmtNode),
'@import.kind': syntheticCapture('@import.kind', spec.atNode, spec.kind),
'@import.name': syntheticCapture('@import.name', spec.atNode, spec.name),
};
if (spec.source !== null) {
m['@import.source'] = syntheticCapture('@import.source', spec.atNode, spec.source);
}
if (spec.alias !== undefined) {
m['@import.alias'] = syntheticCapture('@import.alias', spec.atNode, spec.alias);
}
if (spec.literalSource === true) {
m['@import.literal'] = syntheticCapture('@import.literal', spec.atNode, '');
}
return m;
}
/** Synthesize a `@declaration.namespace` match for `export * as ns from './m'`.
* The central scope-extractor turns this into a `SymbolDefinition` of type
* `Namespace` in the barrel's `localDefs`, which makes `findExportByName`
* resolve `ns` for downstream `import { ns } from './barrel'` consumers. */
function buildNamespaceDeclarationMatch(
namespaceExportNode: SyntaxNode,
aliasId: SyntaxNode,
): CaptureMatch {
return {
'@declaration.namespace': nodeToCapture('@declaration.namespace', namespaceExportNode),
'@declaration.name': nodeToCapture('@declaration.name', aliasId),
};
}

View file

@ -0,0 +1,100 @@
/**
* Adapter from `(ParsedImport, WorkspaceIndex)` concrete file path.
*
* Delegates to the existing standard-strategy resolver
* (`resolveImportPath`) so tsconfig path aliases (`@/`, `~/`, ) and
* suffix-based resolution follow the same rules as the legacy path.
*
* The `WorkspaceIndex` is opaque at the shared contract layer; we
* narrow it to a TypeScript-shaped context that carries `fromFile` +
* the full `allFilePaths` set + the optional `tsconfigPaths` the
* resolver reads.
*
* Returning `null` lets the finalize algorithm mark the edge as
* `linkStatus: 'unresolved'`.
*/
import type { ParsedImport, WorkspaceIndex } from 'gitnexus-shared';
import { SupportedLanguages } from 'gitnexus-shared';
import { resolveImportPath } from '../../import-resolvers/standard.js';
import type { TsconfigPaths } from '../../language-config.js';
export interface TsResolveContext {
readonly fromFile: string;
/** Mutable `Set` because the standard resolver consumes `Set<string>`.
* Callers holding a `ReadonlySet` should copy via `new Set(...)`. */
readonly allFilePaths: Set<string>;
/** Repo file list, normalized (lowercased) for suffix matching. May
* be supplied by the orchestrator; if absent we derive it on the
* fly from `allFilePaths`. */
readonly allFileList?: readonly string[];
readonly normalizedFileList?: readonly string[];
/** Per-call resolution cache to dedupe repeated lookups. */
readonly resolveCache?: Map<string, string | null>;
/** Parsed tsconfig path-aliases. `null` = no aliases configured. */
readonly tsconfigPaths?: TsconfigPaths | null;
/** JavaScript vs TypeScript switch affects the extensions the
* resolver tries. Defaults to TypeScript. */
readonly language?: SupportedLanguages.TypeScript | SupportedLanguages.JavaScript;
}
export function resolveTsImportTarget(
parsedImport: ParsedImport,
workspaceIndex: WorkspaceIndex,
): string | null {
const ctx = narrowTsContext(workspaceIndex);
if (ctx === null) return null;
// Dynamic imports carry `targetRaw` only for diagnostics; when the
// expression isn't a string literal we can't resolve a file.
// A string-literal dynamic import (`import('./m')`) resolves like a
// static import — fall through to the shared path resolver.
if (parsedImport.kind === 'dynamic-unresolved' && parsedImport.targetRaw === null) return null;
if (parsedImport.targetRaw === null || parsedImport.targetRaw === '') return null;
return resolveTsTarget(parsedImport.targetRaw, ctx);
}
/**
* Resolve a raw module-path string to a workspace file path using the
* same standard-strategy resolver as the legacy DAG. Operates directly on
* the source string without requiring a `ParsedImport`, so the
* `ScopeResolver.resolveImportTarget` adapter doesn't need to construct
* a fake `ParsedImport` to reach the resolver.
*
* Returns `null` when:
* - the context is malformed (missing `fromFile` / `allFilePaths`)
* - `targetRaw` is empty
* - the resolver finds no matching file
*/
export function resolveTsTarget(targetRaw: string, ctx: TsResolveContext): string | null {
if (targetRaw === '') return null;
const language = ctx.language ?? SupportedLanguages.TypeScript;
const allFileList = ctx.allFileList ?? Array.from(ctx.allFilePaths);
const normalizedFileList = ctx.normalizedFileList ?? allFileList.map((f) => f.toLowerCase());
const resolveCache = ctx.resolveCache ?? new Map<string, string | null>();
return resolveImportPath(
ctx.fromFile,
targetRaw,
ctx.allFilePaths,
allFileList as string[],
normalizedFileList as string[],
resolveCache,
language,
ctx.tsconfigPaths ?? null,
);
}
function narrowTsContext(workspaceIndex: WorkspaceIndex): TsResolveContext | null {
const ctx = workspaceIndex as TsResolveContext | undefined;
if (
ctx === undefined ||
typeof (ctx as { fromFile?: unknown }).fromFile !== 'string' ||
!((ctx as { allFilePaths?: unknown }).allFilePaths instanceof Set)
) {
return null;
}
return ctx;
}

View file

@ -0,0 +1,95 @@
/**
* TypeScript scope-resolution hooks (RFC #909 Ring 3, RFC §5).
*
* Public API barrel. Consumers should import from this file rather
* than the individual modules.
*
* Module layout (each file is a single concern):
*
* - `query.ts` tree-sitter query + lazy parser/query singletons
* - `captures.ts` `emitTsScopeCaptures` orchestrator
* - `import-decomposer.ts` each import/re-export/dynamic-import
* ParsedImport-shaped captures
* - `interpret.ts` capture-match `ParsedImport` /
* `ParsedTypeBinding`
* - `simple-hooks.ts` `bindingScopeFor` (var hoisting + return-
* type hoisting), `importOwningScope`
* (module/namespace default), `receiverBinding`
* (`this` lookup on Function scope)
* - `receiver-binding.ts` synthesize `this` type-bindings on
* instance-method entry (methods, interface
* signatures, class-field arrow functions)
* - `merge-bindings.ts` TypeScript declaration merging
* (value / type / namespace spaces) + LEGB
* tier shadowing
* - `arity.ts` TypeScript arity compatibility (rest,
* optional, default params)
* - `arity-metadata.ts` synthesize arity metadata from
* declarations; includes generics + array-
* suffix stripping on parameter types
* - `import-target.ts` `(ParsedImport, WorkspaceIndex) → file path`
* adapter delegating to the shared standard
* resolver (tsconfig paths, node_modules,
* relative/extension suffix matching)
* - `cache-stats.ts` PROF_SCOPE_RESOLUTION cache hit/miss
* counters
*
* ## Known limitations
*
* The TypeScript registry-primary path intentionally does NOT resolve
* the following. Each is a conscious trade-off at migration time.
*
* 1. **Type-only import / export separation** `import type { X }`
* and `import { X }` produce the same `ParsedImport` shape today;
* `def.type` on the resolved symbol is the only discriminator.
* Parity with the legacy path is preserved. Tracking in #927.
* 2. **Declaration merging for imports** when `import { Foo }`
* brings in a symbol that is BOTH a class and a namespace in the
* source module, we currently surface a single binding per the
* target `def.type`. Downstream type/value-space lookups still
* work for the primary space; the other space's members resolve
* via the same target (class statics reachable via dotted access).
* 3. **Overload narrowing by argument type** `@reference.parameter-
* types` carries static literal types inferred from the callsite
* (`string`, `number`, `Array`, etc.). Identifier / member-access
* arguments emit empty strings (unknown type); the registry's
* narrowing treats them as any-match. Full control-flow type
* narrowing is out of scope.
* 4. **Computed member access** `obj[key]()` / `obj['method']()`
* is classified as an index-access call; member-call resolution
* falls back to the identifier-indexed branch and matches only
* when the key is a string literal.
* 5. **`this` for nested regular functions inside methods** our
* scope-chain lookup returns the enclosing method's `this`, which
* is technically incorrect at runtime (a non-arrow nested function
* has its own `this` binding). Accepted false-positive; see
* `simple-hooks.ts` docstring.
* 6. **`class_expression` receiver types** `const C = class { }`
* skips `this` synthesis when the expression is anonymous (no
* type name to propagate). `const C = class Named { }` works via
* the class's own `name` field.
* 7. **JSX element types** JSX-specific constructs are ignored by
* the scope query; component references resolve via regular
* identifier / member-expression paths.
* 8. **Ambient module declarations** (`declare module '…'`) parsed
* but not indexed at this layer; same as today's legacy path.
* 9. **Intersection types on parameters** (`(a: A & B)`) treated
* as opaque (no strip); overload narrowing on intersections
* won't match.
* 10. **`instanceof` member-expression narrowing** only bare
* identifiers are narrowed (`user instanceof User`). Member paths
* such as `user.address instanceof Address` remain unresolved.
*
* Shadow-harness corpus parity on `test/integration/resolvers/
* typescript.test.ts` is the authoritative signal for which of these
* matter in practice. The CI parity gate blocks any PR that regresses
* either the legacy or registry-primary run.
*/
export { emitTsScopeCaptures } from './captures.js';
export { getTypescriptCaptureCacheStats, resetTypescriptCaptureCacheStats } from './cache-stats.js';
export { interpretTsImport, interpretTsTypeBinding } from './interpret.js';
export { typescriptMergeBindings } from './merge-bindings.js';
export { typescriptArityCompatibility } from './arity.js';
export { resolveTsImportTarget, resolveTsTarget, type TsResolveContext } from './import-target.js';
export { tsBindingScopeFor, tsImportOwningScope, tsReceiverBinding } from './simple-hooks.js';

View file

@ -0,0 +1,312 @@
/**
* Capture-match semantic-shape interpreters for TypeScript.
*
* Two pure functions, both consumed by the central scope extractor:
*
* - `interpretTsImport` `ParsedImport`
* - `interpretTsTypeBinding` `ParsedTypeBinding` (wired in Unit 6)
*
* The import matches arrive pre-decomposed by `emitTsScopeCaptures`
* (one imported name per match, with synthesized
* `@import.kind/source/name/alias` markers see `import-decomposer.ts`).
* The type-binding matches arrive straight from the raw query captures
* each `@type-binding.*` anchor carries `@type-binding.name` +
* `@type-binding.type`.
*/
import type { CaptureMatch, ParsedImport, ParsedTypeBinding, TypeRef } from 'gitnexus-shared';
// ─── interpretImport ──────────────────────────────────────────────────────
export function interpretTsImport(captures: CaptureMatch): ParsedImport | null {
// Markers attached by `splitImportStatement` (import-decomposer.ts):
// @import.kind : one of the kinds documented there
// @import.name : imported name from the source module
// @import.alias : local alias name (for default / aliased / namespace forms)
// @import.source : module path (always present except dynamic-unresolved)
const kindCap = captures['@import.kind'];
const nameCap = captures['@import.name'];
const aliasCap = captures['@import.alias'];
const sourceCap = captures['@import.source'];
const kind = kindCap?.text;
if (kind === undefined) return null;
switch (kind) {
case 'default': {
// `import D from './m'` — semantically "alias for the module's
// default export". We map to ParsedImport `alias` with
// importedName='default' so the finalize algorithm looks up the
// target module's `default` export for cross-file resolution.
if (sourceCap === undefined || aliasCap === undefined) return null;
return {
kind: 'alias',
localName: aliasCap.text,
importedName: 'default',
alias: aliasCap.text,
targetRaw: sourceCap.text,
};
}
case 'named': {
// `import { X } from './m'` (plus type-only forms).
if (sourceCap === undefined || nameCap === undefined) return null;
return {
kind: 'named',
localName: nameCap.text,
importedName: nameCap.text,
targetRaw: sourceCap.text,
};
}
case 'named-alias': {
// `import { X as Y } from './m'`.
if (sourceCap === undefined || nameCap === undefined || aliasCap === undefined) {
return null;
}
return {
kind: 'alias',
localName: aliasCap.text,
importedName: nameCap.text,
alias: aliasCap.text,
targetRaw: sourceCap.text,
};
}
case 'namespace': {
// `import * as N from './m'` — `N` binds the whole module.
if (sourceCap === undefined || aliasCap === undefined) return null;
return {
kind: 'namespace',
localName: aliasCap.text,
importedName: sourceCap.text,
targetRaw: sourceCap.text,
};
}
case 'reexport': {
// `export { X } from './m'`.
if (sourceCap === undefined || nameCap === undefined) return null;
return {
kind: 'reexport',
localName: nameCap.text,
importedName: nameCap.text,
targetRaw: sourceCap.text,
};
}
case 'reexport-alias': {
// `export { X as Y } from './m'`.
if (sourceCap === undefined || nameCap === undefined || aliasCap === undefined) {
return null;
}
return {
kind: 'reexport',
localName: aliasCap.text,
importedName: nameCap.text,
alias: aliasCap.text,
targetRaw: sourceCap.text,
};
}
case 'reexport-wildcard': {
// `export * from './m'` — no local name, just a blanket passthrough.
if (sourceCap === undefined) return null;
return { kind: 'wildcard', targetRaw: sourceCap.text };
}
case 'reexport-namespace': {
// `export * as ns from './m'` — creates a local binding `ns`
// that exposes the whole module, while also re-exporting it.
// Closest ParsedImport fit is `namespace`; the re-export side
// of this edge is tracked by the export detector downstream.
if (sourceCap === undefined || aliasCap === undefined) return null;
return {
kind: 'namespace',
localName: aliasCap.text,
importedName: sourceCap.text,
targetRaw: sourceCap.text,
};
}
case 'dynamic': {
// `import('./m')` / `import(x)`. The decomposer marks literal-
// string arguments with `@import.literal` so we can promote them
// to `dynamic-resolved` here — that lets the shared finalizer
// produce a file-level IMPORTS edge for lazy-loaded modules.
// Non-literal arguments stay `dynamic-unresolved` (target is
// runtime-computed and unreachable to the static finalizer).
const isLiteral = captures['@import.literal'] !== undefined;
if (isLiteral && sourceCap !== undefined) {
return { kind: 'dynamic-resolved', targetRaw: sourceCap.text };
}
return {
kind: 'dynamic-unresolved',
localName: '',
targetRaw: sourceCap?.text ?? null,
};
}
case 'side-effect': {
// `import './polyfill'` — bare-source, no local binding. The
// finalize layer resolves to a target file and emits a
// file-level IMPORTS edge; no `BindingRef` is materialized.
if (sourceCap === undefined) return null;
return { kind: 'side-effect', targetRaw: sourceCap.text };
}
default:
return null;
}
}
// ─── interpretTypeBinding ─────────────────────────────────────────────────
/**
* Interpret a `@type-binding.*` capture-match into a `ParsedTypeBinding`.
*
* TypeScript-specific strips:
*
* - Trailing `?` on optional parameters: `(u?: User)` `User`
* - `Promise<User>` / `Array<User>` / `ReadonlyArray<User>` / `Readonly<User>`
* `User` (wrappers that are transparent to chain propagation)
* - Single-arg `List<User>` / `Iterable<User>` / `Iterator<User>`
* mirrors Python/C#'s generic-collection strip for for-of loops
* - Trailing `[]` on array types: `User[]` `User`
* - Nullable unions: `User | null` / `User | undefined` / `null | User`
* `User`
* - Dotted qualifiers: `models.User` `User` (unless the suffix is
* a known collection accessor we'd want to preserve none apply
* to TS today, since TS uses `.values()` / `.keys()` call syntax)
*/
export function interpretTsTypeBinding(captures: CaptureMatch): ParsedTypeBinding | null {
const nameCap = captures['@type-binding.name'];
const typeCap = captures['@type-binding.type'];
if (nameCap === undefined || typeCap === undefined) return null;
// Readonly/array/nullable wrappers can stack; apply passes until a
// fixed point (bounded by the text length since every strip monotonically
// shrinks the string).
let prev = '';
let rawType = typeCap.text.trim();
while (prev !== rawType) {
prev = rawType;
rawType = stripReadonly(rawType);
rawType = stripNullableUnion(rawType);
rawType = stripGeneric(rawType);
rawType = stripArraySuffix(rawType);
}
// Destructuring / member-alias / map-tuple / dotted-call-alias bindings
// carry receiver paths or sentinel strings that must survive verbatim.
// Also preserve dotted member-call callee text (`svc.getUser`) for
// `@type-binding.alias` — stripQualifier would reduce it to `getUser`,
// breaking compound-receiver's `obj.method()` split.
const isDestructured = captures['@type-binding.destructured'] !== undefined;
const isMemberAlias = captures['@type-binding.member-alias'] !== undefined;
const isMapTupleEntry = captures['@type-binding.map-tuple-entry'] !== undefined;
const isInstanceofNarrow = captures['@type-binding.instanceof-narrow'] !== undefined;
const isAlias = captures['@type-binding.alias'] !== undefined;
const preserveRawTypeName =
isDestructured ||
isMemberAlias ||
isMapTupleEntry ||
isInstanceofNarrow ||
(isAlias && rawType.includes('.'));
if (!preserveRawTypeName) {
rawType = stripQualifier(rawType);
}
// Drop non-discriminating / wildcard types — `as any` / `as unknown`
// should not block a more-informative sibling binding (typically the
// constructor-inferred capture from the inner `new_expression`). By
// returning null here we let the scope-extractor's tie-break select
// the next-best binding for the same name.
if (UNINFORMATIVE_TYPES.has(rawType)) return null;
// Anchor captures distinguish the source of the binding. Order
// matters: more-specific anchors take precedence. `this` is a
// TypeScript-specific receiver synthesized in `receiver-binding.ts`
// (Unit 3); treat it as `self` for Registry.lookup parity with
// Python/C#.
let source: TypeRef['source'] = 'parameter-annotation';
if (captures['@type-binding.this'] !== undefined) source = 'self';
else if (captures['@type-binding.constructor'] !== undefined) source = 'constructor-inferred';
else if (captures['@type-binding.assertion'] !== undefined) source = 'annotation';
else if (captures['@type-binding.annotation'] !== undefined) source = 'annotation';
else if (captures['@type-binding.member-alias'] !== undefined) source = 'assignment-inferred';
else if (captures['@type-binding.alias'] !== undefined) source = 'assignment-inferred';
else if (captures['@type-binding.return'] !== undefined) source = 'return-annotation';
else if (captures['@type-binding.parameter-property'] !== undefined) source = 'annotation';
else if (captures['@type-binding.destructured'] !== undefined) source = 'assignment-inferred';
else if (captures['@type-binding.map-tuple-entry'] !== undefined) source = 'assignment-inferred';
else if (captures['@type-binding.instanceof-narrow'] !== undefined) source = 'annotation';
return { boundName: nameCap.text, rawTypeName: rawType, source };
}
/** Types that carry no discriminating information for chain resolution.
* `any` / `unknown` / `object` / `never` / `void` match anything, so a
* sibling capture (e.g. a constructor-inferred type from `new X() as any`)
* is strictly preferable. Empty string emerges from malformed captures
* and is also useless. `null` / `undefined` shouldn't survive
* stripNullableUnion but are listed here for defense-in-depth. */
const UNINFORMATIVE_TYPES: ReadonlySet<string> = new Set([
'',
'any',
'unknown',
'object',
'never',
'void',
'null',
'undefined',
]);
/** `readonly User[]` `User[]`. Applied before stripArraySuffix so
* `readonly User[]` reduces through the same pipeline. */
function stripReadonly(text: string): string {
if (text.startsWith('readonly ')) return text.slice('readonly '.length).trim();
return text;
}
/** `User | null` / `User | undefined` / `null | User | undefined` `User`.
* Any number of `null` / `undefined` arms may appear; collapse to the
* single remaining discriminating arm. Preserves multi-arm unions
* of real types (`User | Admin`) since the concrete receiver type is
* ambiguous. */
function stripNullableUnion(text: string): string {
const parts = text.split('|').map((p) => p.trim());
if (parts.length < 2) return text;
const NULLS = new Set(['null', 'undefined']);
const nonNull = parts.filter((p) => !NULLS.has(p));
if (nonNull.length === 1) return nonNull[0];
return text;
}
/** Single-arg generic wrappers transparent to receiver-type chain
* propagation: `Promise<X>`, `Array<X>`, `ReadonlyArray<X>`,
* `Readonly<X>`, `Iterable<X>`, `Iterator<X>`, `Set<X>`, `List<X>`,
* `Map<X>` (single-arg form rare but kept for completeness), etc.
* Multi-arg generics (`Map<K, V>`, `Record<K, V>`) are left alone
* element semantics aren't unambiguous. */
function stripGeneric(text: string): string {
const single = text.match(
/^(?:[A-Za-z_][A-Za-z0-9_]*\.)?(?:Promise|Array|ReadonlyArray|Readonly|Iterable|Iterator|AsyncIterable|AsyncIterator|AsyncGenerator|Generator|Set|ReadonlySet|List|Awaited)<([^,<>]+)>$/,
);
if (single !== null) return single[1].trim();
return text;
}
/** `User[]` / `(User)[]` `User`. Chained `User[][]` unwraps one
* level at a time per resolve pass. */
function stripArraySuffix(text: string): string {
if (text.endsWith('[]')) {
const inner = text.slice(0, -2).trim();
// Unwrap a single pair of parentheses introduced for precedence
// disambiguation: `(User | Admin)[]` — we leave the union intact
// but drop the parens.
if (inner.startsWith('(') && inner.endsWith(')')) {
return inner.slice(1, -1).trim();
}
return inner;
}
return text;
}
/** `models.User` `User`. TS doesn't carry a qualified-suffix exception
* list today `.values()` / `.keys()` use method-call syntax and are
* resolved via the member-call chain, not via a dotted type. */
function stripQualifier(text: string): string {
const lastDot = text.lastIndexOf('.');
if (lastDot === -1) return text;
return text.slice(lastDot + 1);
}

View file

@ -0,0 +1,168 @@
/**
* TypeScript declaration-merging + LEGB precedence for the `mergeBindings`
* hook.
*
* TypeScript has a unique wrinkle that Python / C# don't: **declaration
* merging**. The same name can legally coexist in several "declaration
* spaces" simultaneously:
*
* - **value** space `class X`, `function X`, `const X`, `var X`,
* `let X`, `enum X`, `namespace X` (adds runtime object)
* - **type** space `interface X`, `type X`, `class X`, `enum X`
* - **namespace** space `namespace X`, `class X` (static-accessed
* members are reachable via dotted name)
*
* Classes and enums are unique in that each declaration occupies both
* the value AND type spaces. This lets:
*
* class Foo {}
* interface Foo { bar: number; } // merges additional type members
* namespace Foo { export const X = 1; } // adds static-like value
*
* all coexist for the same name.
*
* ## Algorithm
*
* For each declaration space independently:
* 1. Tier bindings by origin (lower wins):
* 0 `local`
* 1 `import` / `namespace` / `reexport`
* 2 `wildcard` (`export * from …`)
* 2. Keep only bindings at the best (lowest) tier in that space.
*
* Then union survivors across spaces and dedupe by `DefId`.
*
* ## Shadowing examples
*
* - `class Foo {}` + `function Foo() {}` in same scope COMPILE ERROR
* in TS source, but if both reach us with distinct DefIds we keep
* both (value space has two locals at tier 0 de-dup by nodeId
* preserves both). No worse than C#-style merge.
* - `class Foo {}` (local, value+type) + `import type { Foo } from './a'`
* (tier-1, type-only) local wins in both type AND value spaces;
* the import is not kept.
* - `interface Foo {}` (local, type-only) + `import { Foo } from './a'`
* (tier-1, value+type) local wins in type space; import wins in
* value space (local doesn't occupy it). Both kept.
* - `namespace Foo {}` (local, namespace+value) + `class Foo {}` (local,
* value+type) both at tier 0 in their respective spaces, kept.
*
* ## Limitations
*
* - We classify imports by their `def.type` just like locals. Without
* a space-annotation on `ParsedImport`, `import type { Foo }` looks
* the same as `import { Foo }` at this layer the parse phase
* decomposer marks type-only imports so the extractor CAN annotate
* `def.type = 'Type'` downstream if desired. Today it doesn't, so
* `import type` imports and value imports fall in the same bucket
* per their target def's NodeLabel. Parity with legacy behavior
* (which also doesn't track type-only separately) is preserved.
*/
import type { BindingRef, NodeLabel } from 'gitnexus-shared';
/** Declaration spaces a TypeScript binding can occupy. */
type Space = 'value' | 'type' | 'namespace';
const TIER_LOCAL = 0;
const TIER_IMPORT = 1;
const TIER_WILDCARD = 2;
const TIER_UNKNOWN = 3;
function tierOf(b: BindingRef): number {
switch (b.origin) {
case 'local':
return TIER_LOCAL;
case 'reexport':
case 'import':
case 'namespace':
return TIER_IMPORT;
case 'wildcard':
return TIER_WILDCARD;
default:
return TIER_UNKNOWN;
}
}
/**
* Map a `SymbolDefinition.type` (`NodeLabel`) to the set of TypeScript
* declaration spaces the binding occupies.
*
* Unknown / unused labels default to `['value']` the permissive choice,
* matching legacy behavior where everything lives in a single flat bucket.
*/
function spacesOf(type: NodeLabel): readonly Space[] {
switch (type) {
// value-only
case 'Function':
case 'Method':
case 'Variable':
case 'Const':
case 'Static':
case 'Property':
case 'Constructor':
case 'Macro':
return ['value'];
// type-only
case 'Interface':
case 'Type':
case 'TypeAlias':
case 'Typedef':
case 'Trait':
case 'Annotation':
case 'Decorator':
return ['type'];
// dual: value AND type
case 'Class':
case 'Enum':
case 'Struct':
case 'Record':
case 'Union':
return ['value', 'type'];
// namespace AND value (namespaces introduce a runtime object AND a
// named scope for static-style access)
case 'Namespace':
case 'Module':
return ['namespace', 'value'];
// catch-all — treat as value to match legacy permissive behavior
default:
return ['value'];
}
}
export function typescriptMergeBindings(bindings: readonly BindingRef[]): readonly BindingRef[] {
if (bindings.length === 0) return bindings;
// Partition bindings by space. A single binding occupying two spaces
// (e.g. a class) is duplicated into both partitions; the final dedupe
// by nodeId collapses it back.
const perSpace = new Map<Space, BindingRef[]>();
for (const b of bindings) {
const spaces = spacesOf(b.def.type);
for (const s of spaces) {
const list = perSpace.get(s);
if (list === undefined) perSpace.set(s, [b]);
else list.push(b);
}
}
// Within each space, keep only the best-tier bindings.
const survivorsSet = new Set<BindingRef>();
for (const list of perSpace.values()) {
let bestTier = Number.POSITIVE_INFINITY;
for (const b of list) bestTier = Math.min(bestTier, tierOf(b));
for (const b of list) {
if (tierOf(b) === bestTier) survivorsSet.add(b);
}
}
// Dedupe by def.nodeId. If the same binding survived in multiple
// spaces (e.g. a class in both value + type) we keep a single entry.
const seen = new Map<string, BindingRef>();
for (const b of survivorsSet) seen.set(b.def.nodeId, b);
return [...seen.values()];
}

View file

@ -0,0 +1,785 @@
/**
* Tree-sitter query for TypeScript scope captures (RFC §5.1).
*
* Captures the structural skeleton the generic scope-resolution pipeline
* consumes: scopes (module/namespace/class/function), declarations (class-
* likes, method-likes, properties, variables), imports (one anchor per
* statement decomposed in `import-decomposer.ts`), type bindings
* (parameter annotations, variable annotations, constructor inference,
* return types), and references (call sites, member writes).
*
* TypeScript specifics that shape this query:
*
* - **Namespaces** (`namespace Foo { }`) use `internal_module` with a
* `namespace` anon keyword + `identifier` or `nested_identifier` name +
* `statement_block` body. Verified via Unit 1 probe.
* - **`this` / `super`** are NAMED nodes `(this)` / `(super)` unlike
* C#'s `this`/`base` which are anonymous tokens. `(_)` wildcard matches
* them as the receiver child of `member_expression`, so we don't need
* explicit string patterns.
* - **Optional chaining** (`obj?.m()`) still matches the regular
* `member_expression > object: (_) / property: (property_identifier)`
* pattern; the `(optional_chain)` child sits between them but doesn't
* occupy a named field. Same query handles both.
* - **Dynamic imports** (`import('./mod')`) are `call_expression` whose
* `function` field is a named `import` node (not a regular identifier).
* Captured via a dedicated pattern.
* - **Function overloads** `function f(x:string); function f(x:number);
* function f(x) { }` emits two `function_signature` nodes plus one
* `function_declaration`. All three emit `@declaration.function`;
* arity metadata synthesis merges parameterTypes.
* - **Parameter properties** (`constructor(public name: string)`) each
* parameter emits `@declaration.property` on the enclosing class; the
* same identifier also binds as a parameter in the constructor scope
* via the normal `required_parameter` `@type-binding.parameter` path.
* - **Enum** dual type+value. Emits `@scope.class` (enum body contains
* member declarations) + `@declaration.enum`. Members are captured as
* `@declaration.property` via the generic property_identifier pattern
* inside enum_body.
*
* Node types pinned via `scripts/_probe_typescript_grammar.ts`:
* internal_module, namespace_export, namespace_import, import_specifier,
* export_specifier, enum_declaration, type_alias_declaration,
* abstract_class_declaration, abstract_method_signature, method_signature,
* generator_function_declaration, optional_parameter, rest_parameter,
* required_parameter, public_field_definition, private_property_identifier,
* new_expression (constructor field), call_expression with (import) fn.
*
* Grammar version: tree-sitter-typescript pinned in gitnexus/package.json.
*
* Exposes lazy `Parser` and `Query` singletons so callers don't pay tree-
* sitter init cost per file.
*/
import Parser from 'tree-sitter';
import TS from 'tree-sitter-typescript';
// tree-sitter-typescript exports both `typescript` and `tsx` grammars on
// the default export. The package's `.d.ts` types the default export
// loosely; we narrow at the use site. The two grammars are NOT
// interchangeable: feeding a `.tsx` source to the `typescript` grammar
// mis-parses JSX as a sequence of less-than/greater-than expressions
// and silently drops every capture inside JSX elements. We therefore
// pick the grammar by file extension.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const TS_GRAMMAR = (TS as any).typescript as Parameters<Parser['setLanguage']>[0];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const TSX_GRAMMAR = (TS as any).tsx as Parameters<Parser['setLanguage']>[0];
/** True when the file should be parsed with the TSX grammar. The TSX
* grammar is a superset of TypeScript that adds JSX productions; it
* parses plain `.ts` files correctly too, but we keep `.ts` on the
* `typescript` grammar so the parser cache stays small and so any
* subtle TSX-only mis-parses don't bleed into non-TSX files. */
function isTsxFile(filePath: string): boolean {
return filePath.endsWith('.tsx');
}
const TYPESCRIPT_SCOPE_QUERY = `
;; Scopes module / namespace / class-likes / function-likes
(program) @scope.module
(internal_module) @scope.namespace
(class_declaration) @scope.class
(abstract_class_declaration) @scope.class
(interface_declaration) @scope.class
(enum_declaration) @scope.class
(function_declaration) @scope.function
(generator_function_declaration) @scope.function
(function_signature) @scope.function
(method_definition) @scope.function
(method_signature) @scope.function
(abstract_method_signature) @scope.function
(arrow_function) @scope.function
(function_expression) @scope.function
;; Type aliases that contain an object_type are structurally class-like
;; they define a shape with named members. Emit @scope.class so the
;; field-extractor's type-alias-with-object-type handling (in
;; field-extractors/typescript.ts) finds a scope for its members.
(type_alias_declaration
value: (object_type)) @scope.class
;; Declarations types
(class_declaration
name: (type_identifier) @declaration.name) @declaration.class
(abstract_class_declaration
name: (type_identifier) @declaration.name) @declaration.class
(interface_declaration
name: (type_identifier) @declaration.name) @declaration.interface
(enum_declaration
name: (identifier) @declaration.name) @declaration.enum
(type_alias_declaration
name: (type_identifier) @declaration.name) @declaration.type
(internal_module
name: (identifier) @declaration.name) @declaration.namespace
;; Declarations methods / functions / constructors
(function_declaration
name: (identifier) @declaration.name) @declaration.function
(generator_function_declaration
name: (identifier) @declaration.name) @declaration.function
;; Function overload signatures (declaration-only; body in a separate
;; function_declaration). Extractors dedup by (name, parameterTypes).
(function_signature
name: (identifier) @declaration.name) @declaration.function
;; Arrow/function-expression assigned to a const/let/var named by the
;; variable_declarator. Covers \`const fn = () => {}\` and its export
;; variant. Matches the legacy TYPESCRIPT_QUERIES pattern.
(lexical_declaration
(variable_declarator
name: (identifier) @declaration.name
value: (arrow_function))) @declaration.function
(lexical_declaration
(variable_declarator
name: (identifier) @declaration.name
value: (function_expression))) @declaration.function
(variable_declaration
(variable_declarator
name: (identifier) @declaration.name
value: (arrow_function))) @declaration.function
(variable_declaration
(variable_declarator
name: (identifier) @declaration.name
value: (function_expression))) @declaration.function
;; Method definitions regular + private (#field) methods.
(method_definition
name: (property_identifier) @declaration.name) @declaration.method
(method_definition
name: (private_property_identifier) @declaration.name) @declaration.method
;; Abstract method signatures in abstract classes.
(abstract_method_signature
name: (property_identifier) @declaration.name) @declaration.method
;; Interface method signatures.
(method_signature
name: (property_identifier) @declaration.name) @declaration.method
;; Declarations class fields
(public_field_definition
name: (property_identifier) @declaration.name) @declaration.property
(public_field_definition
name: (private_property_identifier) @declaration.name) @declaration.property
;; Declarations parameter properties: \`constructor(public name: string)\`.
;; The accessibility_modifier presence distinguishes these from regular
;; parameters. The identifier is also bound as a parameter in the
;; constructor's scope via @type-binding.parameter below (dual binding).
(required_parameter
(accessibility_modifier)
pattern: (identifier) @declaration.name) @declaration.property
;; Declarations variables (let / const / var)
(lexical_declaration
(variable_declarator
name: (identifier) @declaration.name)) @declaration.variable
(variable_declaration
(variable_declarator
name: (identifier) @declaration.name)) @declaration.variable
;; Imports single anchor per statement; decomposer emits per-specifier markers.
(import_statement) @import.statement
;; Re-exports: \`export { X } from './y'\` / \`export * from './y'\` /
;; \`export * as ns from './y'\` / \`export type { X } from './y'\`.
;; Only re-exports (those with a \`from\` clause) emit @import.statement;
;; local \`export { X }\` (no source) is just visibility metadata, not an
;; import. The decomposer filters by source presence.
(export_statement
source: (string)) @import.statement
;; Dynamic imports: \`import('./m')\` / \`await import(x)\`. tree-sitter-
;; typescript represents \`import\` as a named leaf node; the call_expression's
;; function field points at it.
(call_expression
function: (import)) @import.dynamic
;; Type bindings parameter annotations: \`function f(u: User)\`
(required_parameter
pattern: (identifier) @type-binding.name
type: (type_annotation
(type_identifier) @type-binding.type)) @type-binding.parameter
(required_parameter
pattern: (identifier) @type-binding.name
type: (type_annotation
(generic_type) @type-binding.type)) @type-binding.parameter
(required_parameter
pattern: (identifier) @type-binding.name
type: (type_annotation
(predefined_type) @type-binding.type)) @type-binding.parameter
;; Parameter with union / array / readonly wrappers: \`users: readonly User[]\`,
;; \`x: User | null\`, \`xs: User[]\`. interpret strips wrappers to the
;; discriminating type.
(required_parameter
pattern: (identifier) @type-binding.name
type: (type_annotation
(union_type) @type-binding.type)) @type-binding.parameter
(required_parameter
pattern: (identifier) @type-binding.name
type: (type_annotation
(array_type) @type-binding.type)) @type-binding.parameter
(required_parameter
pattern: (identifier) @type-binding.name
type: (type_annotation
(readonly_type) @type-binding.type)) @type-binding.parameter
;; Type bindings parameter properties:
;; \`constructor(public address: Address)\` — each parameter with an
;; accessibility modifier is ALSO a class field. We emit a second
;; capture so \`tsBindingScopeFor\` can hoist these to the Class scope,
;; enabling \`user.address\` field access resolution. The regular
;; @type-binding.parameter above still fires for the constructor
;; scope binding both bindings coexist, which is correct.
(required_parameter
(accessibility_modifier)
pattern: (identifier) @type-binding.name
type: (type_annotation
(type_identifier) @type-binding.type)) @type-binding.parameter-property
(required_parameter
(accessibility_modifier)
pattern: (identifier) @type-binding.name
type: (type_annotation
(generic_type) @type-binding.type)) @type-binding.parameter-property
(required_parameter
(accessibility_modifier)
pattern: (identifier) @type-binding.name
type: (type_annotation
(predefined_type) @type-binding.type)) @type-binding.parameter-property
(required_parameter
(accessibility_modifier)
pattern: (identifier) @type-binding.name
type: (type_annotation
(union_type) @type-binding.type)) @type-binding.parameter-property
(required_parameter
(accessibility_modifier)
pattern: (identifier) @type-binding.name
type: (type_annotation
(array_type) @type-binding.type)) @type-binding.parameter-property
(required_parameter
(accessibility_modifier)
pattern: (identifier) @type-binding.name
type: (type_annotation
(readonly_type) @type-binding.type)) @type-binding.parameter-property
(optional_parameter
pattern: (identifier) @type-binding.name
type: (type_annotation
(type_identifier) @type-binding.type)) @type-binding.parameter
(optional_parameter
pattern: (identifier) @type-binding.name
type: (type_annotation
(generic_type) @type-binding.type)) @type-binding.parameter
;; Type bindings variable annotations: \`let u: User = ...\` / \`const u: User\`.
(variable_declarator
name: (identifier) @type-binding.name
type: (type_annotation
(type_identifier) @type-binding.type)) @type-binding.annotation
(variable_declarator
name: (identifier) @type-binding.name
type: (type_annotation
(generic_type) @type-binding.type)) @type-binding.annotation
(variable_declarator
name: (identifier) @type-binding.name
type: (type_annotation
(predefined_type) @type-binding.type)) @type-binding.annotation
;; Union types like \`User | null\` / \`User | undefined\` — interpret's
;; stripNullableUnion collapses to the discriminating arm.
(variable_declarator
name: (identifier) @type-binding.name
type: (type_annotation
(union_type) @type-binding.type)) @type-binding.annotation
;; Array types: \`User[]\` / \`readonly User[]\` — stripArraySuffix unwraps.
(variable_declarator
name: (identifier) @type-binding.name
type: (type_annotation
(array_type) @type-binding.type)) @type-binding.annotation
(variable_declarator
name: (identifier) @type-binding.name
type: (type_annotation
(readonly_type) @type-binding.type)) @type-binding.annotation
;; Type bindings constructor-inferred: \`const u = new User()\`.
;; The variable_declarator's \`value\` field carries the new_expression; its
;; \`constructor\` field is the type identifier. Covers both typed (\`:User = \`)
;; and untyped declarations the annotation pattern above wins if both
;; fire, via the scope-extractor's source-strength tie-break in
;; pass4CollectTypeBindings.
(variable_declarator
name: (identifier) @type-binding.name
value: (new_expression
constructor: (identifier) @type-binding.type)) @type-binding.constructor
;; Qualified constructor: \`const u = new models.User()\`. Captures the
;; member_expression's text as the type — resolver's QualifiedNameIndex
;; handles the dotted lookup.
(variable_declarator
name: (identifier) @type-binding.name
value: (new_expression
constructor: (member_expression) @type-binding.type)) @type-binding.constructor
;; Cast-wrapped constructor: \`const u = new User() as any\` /
;; \`const u = new User()!\`. The \`as T\` pattern also captures T itself
;; via the assertion clause above, but T is usually a non-discriminating
;; type (\`any\`, \`unknown\`) in these idioms; interpretTsTypeBinding
;; drops those so the constructor-inferred binding survives.
(variable_declarator
name: (identifier) @type-binding.name
value: (as_expression
(new_expression
constructor: (identifier) @type-binding.type))) @type-binding.constructor
(variable_declarator
name: (identifier) @type-binding.name
value: (non_null_expression
(new_expression
constructor: (identifier) @type-binding.type))) @type-binding.constructor
;; Double-cast: \`const u = new User() as unknown as any\` — as_expression
;; nested inside as_expression, with new_expression at the core.
(variable_declarator
name: (identifier) @type-binding.name
value: (as_expression
(as_expression
(new_expression
constructor: (identifier) @type-binding.type)))) @type-binding.constructor
;; Type bindings call-result alias: \`const u = find()\`. Chain-follow
;; walks \`find\`'s return type via propagateImportedReturnTypes for cross-
;; file; same-file covered by explicit return annotations.
(variable_declarator
name: (identifier) @type-binding.name
value: (call_expression
function: (identifier) @type-binding.type)) @type-binding.alias
;; Type bindings member-call alias: \`const u = svc.getUser()\`. The
;; callee is captured as a full \`member_expression\` text (\`svc.getUser\`)
;; so compound-receiver can resolve the receiver object before looking up
;; the method's hoisted return-type binding.
(variable_declarator
name: (identifier) @type-binding.name
value: (call_expression
function: (member_expression) @type-binding.type)) @type-binding.alias
;; Type bindings await chain: \`const u = await find()\` / \`await svc.m()\`.
(variable_declarator
name: (identifier) @type-binding.name
value: (await_expression
(call_expression
function: (identifier) @type-binding.type))) @type-binding.alias
(variable_declarator
name: (identifier) @type-binding.name
value: (await_expression
(call_expression
function: (member_expression) @type-binding.type))) @type-binding.alias
;; Awaited generic calls re-associate: \`await fn<T>(...)\` parses as
;; \`call_expression(function: await_expression(identifier), type_arguments, arguments)\`
;; NOT as an await_expression wrapping a call_expression. Handle both
;; free and member forms so the chain-follow picks up the inner callee.
(variable_declarator
name: (identifier) @type-binding.name
value: (call_expression
function: (await_expression
(identifier) @type-binding.type))) @type-binding.alias
(variable_declarator
name: (identifier) @type-binding.name
value: (call_expression
function: (await_expression
(member_expression) @type-binding.type))) @type-binding.alias
;; Type bindings member-access alias: \`const addr = user.address\`.
;; Full \`member_expression\` text feeds compound-receiver Case 3b.
(variable_declarator
name: (identifier) @type-binding.name
value: (member_expression) @type-binding.type) @type-binding.member-alias
;; Type bindings identifier alias: \`const alias = user\`. Chain-follow
;; resolves alias via user's binding.
(variable_declarator
name: (identifier) @type-binding.name
value: (identifier) @type-binding.type) @type-binding.alias
;; Type bindings \`as\` assertion: \`const u = x as User\`. Prefer
;; the assertion's target type over RHS inference. as_expression's right
;; child is the target type (positional; no field name).
(variable_declarator
name: (identifier) @type-binding.name
value: (as_expression
(_)
(type_identifier) @type-binding.type)) @type-binding.assertion
(variable_declarator
name: (identifier) @type-binding.name
value: (as_expression
(_)
(generic_type) @type-binding.type)) @type-binding.assertion
;; Type bindings non-null assertion: \`const u = find()!\`. Unwrap to the
;; underlying call's function identifier (matches the call-alias pattern).
(variable_declarator
name: (identifier) @type-binding.name
value: (non_null_expression
(call_expression
function: (identifier) @type-binding.type))) @type-binding.alias
;; Type bindings for-of element: \`for (const u of users)\` — bind u to
;; users (chain-follow unwraps to element type via stripGeneric).
(for_in_statement
left: (identifier) @type-binding.name
right: (identifier) @type-binding.type) @type-binding.alias
;; Type bindings for-of call iterable: \`for (const u of getUsers())\`.
(for_in_statement
left: (identifier) @type-binding.name
right: (call_expression
function: (identifier) @type-binding.type)) @type-binding.alias
;; Type bindings for-of member-call iterable: \`for (const u of svc.getUsers())\`.
(for_in_statement
left: (identifier) @type-binding.name
right: (call_expression
function: (member_expression) @type-binding.type)) @type-binding.alias
;; Type bindings for-of member-access iterable: \`for (const u of this.users)\`.
;; Bind u to \`users\` (the attribute name); chain-follow resolves users
;; via the enclosing class's field binding.
(for_in_statement
left: (identifier) @type-binding.name
right: (member_expression
property: (property_identifier) @type-binding.type)) @type-binding.alias
;; Type bindings class field annotation: \`private city: City\`.
(public_field_definition
name: (property_identifier) @type-binding.name
type: (type_annotation
(type_identifier) @type-binding.type)) @type-binding.annotation
(public_field_definition
name: (property_identifier) @type-binding.name
type: (type_annotation
(generic_type) @type-binding.type)) @type-binding.annotation
(public_field_definition
name: (property_identifier) @type-binding.name
type: (type_annotation
(predefined_type) @type-binding.type)) @type-binding.annotation
;; Class field with union / array / readonly wrappers:
;; \`private users: User[]\`, \`private repos: readonly Repo[]\`,
;; \`private x: City | null\`. interpret strips wrappers to the
;; discriminating type so chain-follow unwraps to the element.
(public_field_definition
name: (property_identifier) @type-binding.name
type: (type_annotation
(union_type) @type-binding.type)) @type-binding.annotation
(public_field_definition
name: (property_identifier) @type-binding.name
type: (type_annotation
(array_type) @type-binding.type)) @type-binding.annotation
(public_field_definition
name: (property_identifier) @type-binding.name
type: (type_annotation
(readonly_type) @type-binding.type)) @type-binding.annotation
;; Private class field annotation: \`#city: City\`.
(public_field_definition
name: (private_property_identifier) @type-binding.name
type: (type_annotation
(type_identifier) @type-binding.type)) @type-binding.annotation
;; Type bindings method return type: \`save(): User { … }\` / \`function f(): User { … }\`.
;; Function/method return-type is the type_annotation that is a direct
;; child of the function node (not the parameter's annotation). Anchor on
;; the function node so bindingScopeFor can hoist if the language requests
;; (TS keeps it on the method scope; we emit here and let the resolver
;; decide via hoistTypeBindingsToModule).
;;
;; Wrapper forms covered: plain \`User\`, generic \`Promise<User>\`,
;; array \`User[]\`, readonly \`readonly User[]\`, union \`User | null\`.
;; \`stripArraySuffix\` / \`stripReadonly\` / \`stripNullableUnion\` in
;; interpret reduce these to the discriminating element so chain-follow
;; can unwrap iterators returned from \`getUsers(): User[]\`.
(function_declaration
name: (identifier) @type-binding.name
return_type: (type_annotation
(type_identifier) @type-binding.type)) @type-binding.return
(function_declaration
name: (identifier) @type-binding.name
return_type: (type_annotation
(generic_type) @type-binding.type)) @type-binding.return
(function_declaration
name: (identifier) @type-binding.name
return_type: (type_annotation
(array_type) @type-binding.type)) @type-binding.return
(function_declaration
name: (identifier) @type-binding.name
return_type: (type_annotation
(readonly_type) @type-binding.type)) @type-binding.return
(function_declaration
name: (identifier) @type-binding.name
return_type: (type_annotation
(union_type) @type-binding.type)) @type-binding.return
(function_signature
name: (identifier) @type-binding.name
return_type: (type_annotation
(type_identifier) @type-binding.type)) @type-binding.return
(function_signature
name: (identifier) @type-binding.name
return_type: (type_annotation
(generic_type) @type-binding.type)) @type-binding.return
(function_signature
name: (identifier) @type-binding.name
return_type: (type_annotation
(array_type) @type-binding.type)) @type-binding.return
(function_signature
name: (identifier) @type-binding.name
return_type: (type_annotation
(readonly_type) @type-binding.type)) @type-binding.return
(function_signature
name: (identifier) @type-binding.name
return_type: (type_annotation
(union_type) @type-binding.type)) @type-binding.return
(method_definition
name: (property_identifier) @type-binding.name
return_type: (type_annotation
(type_identifier) @type-binding.type)) @type-binding.return
(method_definition
name: (property_identifier) @type-binding.name
return_type: (type_annotation
(generic_type) @type-binding.type)) @type-binding.return
(method_definition
name: (property_identifier) @type-binding.name
return_type: (type_annotation
(array_type) @type-binding.type)) @type-binding.return
(method_definition
name: (property_identifier) @type-binding.name
return_type: (type_annotation
(readonly_type) @type-binding.type)) @type-binding.return
(method_definition
name: (property_identifier) @type-binding.name
return_type: (type_annotation
(union_type) @type-binding.type)) @type-binding.return
(method_signature
name: (property_identifier) @type-binding.name
return_type: (type_annotation
(type_identifier) @type-binding.type)) @type-binding.return
(method_signature
name: (property_identifier) @type-binding.name
return_type: (type_annotation
(generic_type) @type-binding.type)) @type-binding.return
(method_signature
name: (property_identifier) @type-binding.name
return_type: (type_annotation
(array_type) @type-binding.type)) @type-binding.return
(method_signature
name: (property_identifier) @type-binding.name
return_type: (type_annotation
(readonly_type) @type-binding.type)) @type-binding.return
(method_signature
name: (property_identifier) @type-binding.name
return_type: (type_annotation
(union_type) @type-binding.type)) @type-binding.return
(abstract_method_signature
name: (property_identifier) @type-binding.name
return_type: (type_annotation
(type_identifier) @type-binding.type)) @type-binding.return
(abstract_method_signature
name: (property_identifier) @type-binding.name
return_type: (type_annotation
(generic_type) @type-binding.type)) @type-binding.return
;; Type bindings assignment rebind: \`u = new User()\` (no \`const\`).
(assignment_expression
left: (identifier) @type-binding.name
right: (new_expression
constructor: (identifier) @type-binding.type)) @type-binding.constructor
(assignment_expression
left: (identifier) @type-binding.name
right: (call_expression
function: (identifier) @type-binding.type)) @type-binding.alias
(assignment_expression
left: (identifier) @type-binding.name
right: (identifier) @type-binding.type) @type-binding.alias
;; References free calls: \`fn(args)\`. Exclude the dynamic-import form,
;; which would otherwise double-classify as a call to a built-in \`import\`.
;; tree-sitter can't negate (import) with #not-eq?; the captures.ts layer
;; filters dynamic-imports BEFORE the free-call is consumed.
(call_expression
function: (identifier) @reference.name) @reference.call.free
;; Awaited free call with generics: \`await fn<T>(...)\` — re-associated
;; by tree-sitter as \`call_expression(function: await_expression(identifier))\`.
(call_expression
function: (await_expression
(identifier) @reference.name)) @reference.call.free
;; References member calls: \`obj.method()\` (includes optional chain).
;; The (_) wildcard matches any named receiver including \`this\` /
;; \`super\` (both are named nodes in tree-sitter-typescript, unlike C#'s
;; anonymous tokens).
(call_expression
function: (member_expression
object: (_) @reference.receiver
property: (property_identifier) @reference.name)) @reference.call.member
;; Awaited member call with generics: \`await svc.m<T>(...)\` — re-associated
;; as \`call_expression(function: await_expression(member_expression))\`.
(call_expression
function: (await_expression
(member_expression
object: (_) @reference.receiver
property: (property_identifier) @reference.name))) @reference.call.member
;; References constructor calls: \`new User()\` / \`new ns.User()\`.
(new_expression
constructor: (identifier) @reference.name) @reference.call.constructor
(new_expression
constructor: (member_expression) @reference.call.constructor.qualified) @reference.call.constructor
;; References write access: \`obj.field = value\`.
(assignment_expression
left: (member_expression
object: (_) @reference.receiver
property: (property_identifier) @reference.name)) @reference.write.member
(augmented_assignment_expression
left: (member_expression
object: (_) @reference.receiver
property: (property_identifier) @reference.name)) @reference.write.member
;; References read access: \`obj.field\` used in a read context.
;; Fires on EVERY member_expression; \`emitTsScopeCaptures\` filters out
;; contexts that shouldn't emit a read ACCESSES edge (LHS of assignment,
;; the \`function:\` of a call_expression, property_identifier inside a
;; computed member name, etc.). Keeping the filter on the emit side lets
;; tree-sitter's pattern stay simple and we don't replicate AST-context
;; predicates in the query itself.
(member_expression
object: (_) @reference.receiver
property: (property_identifier) @reference.name) @reference.read.member
`;
let _tsParser: Parser | null = null;
let _tsxParser: Parser | null = null;
let _tsQuery: Parser.Query | null = null;
let _tsxQuery: Parser.Query | null = null;
/**
* Return the right tree-sitter parser for `filePath` (or the TS parser
* when no path is given the legacy callsite shape).
*/
export function getTsParser(filePath?: string): Parser {
if (filePath !== undefined && isTsxFile(filePath)) {
if (_tsxParser === null) {
_tsxParser = new Parser();
_tsxParser.setLanguage(TSX_GRAMMAR);
}
return _tsxParser;
}
if (_tsParser === null) {
_tsParser = new Parser();
_tsParser.setLanguage(TS_GRAMMAR);
}
return _tsParser;
}
/**
* Return the right tree-sitter Query (compiled against the same grammar
* as the parser). A Query bound to the `typescript` grammar can NOT be
* executed against a Tree produced by the `tsx` grammar tree-sitter
* matches by node-type id, and the two grammars have separate id
* spaces.
*/
export function getTsScopeQuery(filePath?: string): Parser.Query {
if (filePath !== undefined && isTsxFile(filePath)) {
if (_tsxQuery === null) {
_tsxQuery = new Parser.Query(TSX_GRAMMAR, TYPESCRIPT_SCOPE_QUERY);
}
return _tsxQuery;
}
if (_tsQuery === null) {
_tsQuery = new Parser.Query(TS_GRAMMAR, TYPESCRIPT_SCOPE_QUERY);
}
return _tsQuery;
}
/**
* Validate that a cached `Tree` was produced by the grammar matching
* `filePath` (TSX vs TypeScript). The runtime tree-sitter `Tree` exposes
* `getLanguage()` (returning the grammar object the parser was bound
* to); the .d.ts is incomplete, so we reach via a cast. Identity
* comparison against `TSX_GRAMMAR` / `TS_GRAMMAR` is exact: the same
* module instance produces both. If `getLanguage` is unavailable for
* any reason, return true to keep behavior backwards-compatible (the
* original code never validated grammar at all).
*/
export function tsCachedTreeMatchesGrammar(tree: unknown, filePath: string): boolean {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const lang = (tree as any)?.getLanguage?.();
if (lang === undefined || lang === null) return true;
return isTsxFile(filePath) ? lang === TSX_GRAMMAR : lang === TS_GRAMMAR;
}

View file

@ -0,0 +1,186 @@
/**
* Synthesize `@type-binding.this` captures for TypeScript instance-like
* methods.
*
* Tree-sitter can't cleanly express "the implicit `this` receiver of a
* non-static member of a class / interface / abstract class" via a
* static `.scm` pattern, so we walk up the AST in code mirrors
* Python's `self` / `cls` and C#'s `this` / `base` synthesis.
*
* Scope coverage:
*
* - `method_definition` inside `class_declaration`,
* `abstract_class_declaration`, or `class_expression` synthesize
* `this` enclosing class name.
* - `method_signature` / `abstract_method_signature` inside
* `interface_declaration` or `abstract_class_declaration`
* synthesize `this` enclosing type's name (so interface method
* bodies' `this.x` chains resolve via the interface's field
* annotations).
* - `arrow_function` / `function_expression` that is a direct value
* of a `public_field_definition` (class field) `m = () => {}`
* synthesize `this` enclosing class name. These capture `this`
* lexically; without synthesis, their body's `this.foo` wouldn't
* resolve.
*
* Not synthesized (intentionally):
*
* - `static` methods / static fields. `this` in a static context
* refers to the class constructor, not an instance; we leave the
* binding empty and let chain resolution fall through to the
* class's static members lookup.
* - Regular `function_declaration` / `function_expression` at
* module level or in a non-class context. No enclosing type, no
* `this` semantics.
* - Arrow functions nested inside a method body. The scope-chain
* walk in `tsReceiverBinding` finds the outer method's `this`
* naturally, matching TS's lexical-this rule for arrow functions.
*
* Each synthesized match emits the anchor captures needed by
* `interpretTsTypeBinding`:
*
* `@type-binding.this` (source discriminator interpret maps to 'self')
* `@type-binding.name` (the literal `'this'`)
* `@type-binding.type` (the enclosing type's name)
*/
import type { Capture, CaptureMatch } from 'gitnexus-shared';
import { nodeToCapture, syntheticCapture, type SyntaxNode } from '../../utils/ast-helpers.js';
/** Node types that define a TypeScript "type with instance members". */
const TYPE_DECL_NODE_TYPES = new Set([
'class_declaration',
'abstract_class_declaration',
'class',
'class_expression',
'interface_declaration',
]);
/** Scope function nodes that could be a class method body. */
const CLASS_MEMBER_FUNCTION_TYPES = new Set([
'method_definition',
'method_signature',
'abstract_method_signature',
]);
/** Function-like values that can back a class field (`m = () => {}`). */
const CLASS_FIELD_FUNCTION_TYPES = new Set(['arrow_function', 'function_expression']);
/**
* Produce zero or one `CaptureMatch` synthesizing `this` for `fnNode`.
*
* - `null` function has no synthetic `this` (free / static /
* not-in-class / no name on enclosing type).
* - One match anchor on the function body so the synthetic binding
* attaches to the function's scope (not the outer class scope).
*
* The caller is responsible for passing a `fnNode` whose type is one
* of the scope function nodes the scope query emits.
*/
export function synthesizeTsReceiverBinding(fnNode: SyntaxNode): CaptureMatch | null {
// Classify the function's role.
const role = classifyFunctionRole(fnNode);
if (role === null) return null;
// Static methods / static fields don't have an instance `this`.
if (isStaticMember(role.memberNode)) return null;
// Find enclosing type declaration. Walking past function-like
// boundaries is fine — nested local functions inside a method can
// still reference outer `this` through the lexical chain, but we
// only synthesize on the immediate function body. The resolver's
// scope-chain walk reaches ancestor synthesized bindings for nested
// arrow functions.
const enclosingType = findEnclosingType(role.memberNode);
if (enclosingType === null) return null;
const typeName = getTypeDeclName(enclosingType);
if (typeName === null) return null;
// Anchor the synthetic capture on the function body so the binding
// lands in the method's scope, not its parent type scope. Method
// signatures in interfaces / abstract classes have no body — use
// the method node itself as the anchor; scope-extractor attaches
// it to the function scope created by the `@scope.function`
// anchor at the same range.
const anchorNode = fnNode.childForFieldName('body') ?? fnNode;
return buildThisBinding(anchorNode, typeName);
}
interface FunctionRole {
/** Node carrying the (possibly `static`) modifier. For method
* definitions this is `fnNode` itself; for arrow-bodied fields
* this is the `public_field_definition` parent. */
readonly memberNode: SyntaxNode;
}
/**
* Decide whether `fnNode` participates as a class member. Returns
* `null` when the function is not structurally "a class instance
* member" e.g. a free function, a non-method arrow expression, an
* arrow inside a method body (those inherit `this` via scope chain
* lookup, no synthesis needed).
*/
function classifyFunctionRole(fnNode: SyntaxNode): FunctionRole | null {
if (CLASS_MEMBER_FUNCTION_TYPES.has(fnNode.type)) {
return { memberNode: fnNode };
}
if (CLASS_FIELD_FUNCTION_TYPES.has(fnNode.type)) {
// `public_field_definition` represents a class field. Only when
// the arrow/function-expression is a DIRECT value of a field do
// we treat it as a class method with synthesized `this`.
const parent = fnNode.parent;
if (parent !== null && parent.type === 'public_field_definition') {
const valueField = parent.childForFieldName('value');
if (valueField !== null && valueField.startIndex === fnNode.startIndex) {
return { memberNode: parent };
}
}
}
return null;
}
/** Class-body definitions carry an optional `accessibility_modifier` and
* optional `static` keyword as named children. The `static` token is
* usually a plain child of the member node not a named field so
* we scan children for a token whose text is exactly `static`. */
function isStaticMember(memberNode: SyntaxNode): boolean {
for (let i = 0; i < memberNode.childCount; i++) {
const c = memberNode.child(i);
if (c === null) continue;
// `static` can appear as an unnamed token or as a `readonly` /
// `static` keyword node depending on grammar version; check text.
if (c.text === 'static') return true;
}
return false;
}
function findEnclosingType(node: SyntaxNode): SyntaxNode | null {
let cur: SyntaxNode | null = node.parent;
while (cur !== null) {
if (TYPE_DECL_NODE_TYPES.has(cur.type)) return cur;
cur = cur.parent;
}
return null;
}
/** Return the declared name of a class / interface / abstract-class.
* `class_expression` ( `const X = class { … }` ) may lack a name
* in that case we return `null` and the caller skips synthesis (the
* outer variable's name is the usable handle, but wiring it would
* require a separate walk; defer to a follow-up). */
function getTypeDeclName(typeNode: SyntaxNode): string | null {
const nameField = typeNode.childForFieldName('name');
if (nameField === null) return null;
return nameField.text;
}
function buildThisBinding(anchorNode: SyntaxNode, typeText: string): CaptureMatch {
const m: Record<string, Capture> = {
'@type-binding.this': nodeToCapture('@type-binding.this', anchorNode),
'@type-binding.name': syntheticCapture('@type-binding.name', anchorNode, 'this'),
'@type-binding.type': syntheticCapture('@type-binding.type', anchorNode, typeText),
};
return m;
}

View file

@ -0,0 +1,148 @@
/**
* TypeScript `ScopeResolver` registered in `SCOPE_RESOLVERS` and
* consumed by the generic `runScopeResolution` orchestrator
* (RFC #909 Ring 3).
*
* Third migration after Python and C#. Follows the same minimal
* wiring-only pattern per-hook logic lives in the sibling modules
* (`arity.ts`, `merge-bindings.ts`, `import-target.ts`, etc.).
*
* See ./index.ts for the per-module rationale and the full list of
* known limitations. The canonical capture vocabulary is pinned in
* ./query.ts (TYPESCRIPT_SCOPE_QUERY constant).
*/
import type { ParsedFile } from 'gitnexus-shared';
import { SupportedLanguages } from 'gitnexus-shared';
import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js';
import { populateClassOwnedMembers } from '../../scope-resolution/scope/walkers.js';
import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js';
import { typescriptProvider } from '../typescript.js';
import { loadTsconfigPaths, type TsconfigPaths } from '../../language-config.js';
import {
typescriptArityCompatibility,
typescriptMergeBindings,
resolveTsTarget,
type TsResolveContext,
} from './index.js';
/** Shape the orchestrator threads in via `RunScopeResolutionInput.resolutionConfig`. */
interface TypescriptResolutionConfig {
readonly tsconfigPaths: TsconfigPaths | null;
}
/**
* Build a `resolveImportTarget` adapter that memoizes the workspace
* file list, the lower-cased file list, and the per-pass `resolveCache`
* across every import lookup in a single workspace pass. The
* orchestrator passes the same `ReadonlySet` reference for every call
* within a pass we use that identity to detect when the workspace
* changes and recompute the derived state lazily.
*
* Without this memoization, `resolveTsTarget` re-derived
* `allFileList` and `normalizedFileList` (both O(N_files)) and threw
* away the `resolveCache` on every import O(N_files × N_imports)
* total work for what should be O(N_files + N_imports).
*/
function makeTsResolveImportTarget(): ScopeResolver['resolveImportTarget'] {
interface PassCache {
readonly key: ReadonlySet<string>;
readonly allFilePaths: Set<string>;
readonly allFileList: readonly string[];
readonly normalizedFileList: readonly string[];
readonly resolveCache: Map<string, string | null>;
}
let cached: PassCache | null = null;
return (targetRaw, fromFile, allFilePaths, resolutionConfig) => {
if (cached === null || cached.key !== allFilePaths) {
const allFileList = Array.from(allFilePaths);
cached = {
key: allFilePaths,
allFilePaths: new Set(allFilePaths),
allFileList,
normalizedFileList: allFileList.map((f) => f.toLowerCase()),
resolveCache: new Map(),
};
}
const cfg = resolutionConfig as TypescriptResolutionConfig | undefined;
const ws: TsResolveContext = {
fromFile,
allFilePaths: cached.allFilePaths,
allFileList: cached.allFileList,
normalizedFileList: cached.normalizedFileList,
resolveCache: cached.resolveCache,
tsconfigPaths: cfg?.tsconfigPaths ?? null,
};
return resolveTsTarget(targetRaw, ws);
};
}
const typescriptScopeResolver: ScopeResolver = {
language: SupportedLanguages.TypeScript,
languageProvider: typescriptProvider,
importEdgeReason: 'typescript-scope: import',
resolveImportTarget: makeTsResolveImportTarget(),
// Threaded into `resolveImportTarget` so tsconfig path aliases
// (`@/services/user`, `~/x`, …) resolve through the same standard
// resolver branch the legacy DAG uses. One I/O round-trip per
// workspace pass; the orchestrator awaits this once.
loadResolutionConfig: async (repoPath: string) => ({
tsconfigPaths: await loadTsconfigPaths(repoPath),
}),
// TypeScript declaration merging + LEGB: local > import > wildcard,
// separated by declaration space (value / type / namespace). The
// per-scope id is unused (shadowing is computed from origin + def.type),
// so we don't need to synthesize a Scope here.
mergeBindings: (existing, incoming) => [...typescriptMergeBindings([...existing, ...incoming])],
// Adapter: typescriptArityCompatibility uses (def, callsite); the
// ScopeResolver contract is (callsite, def).
arityCompatibility: (callsite, def) => typescriptArityCompatibility(def, callsite),
buildMro: (graph, parsedFiles, nodeLookup) =>
buildMro(graph, parsedFiles, nodeLookup, defaultLinearize),
populateOwners: (parsed: ParsedFile) => populateClassOwnedMembers(parsed),
// TypeScript uses `super` for super-class dispatch as a plain
// identifier or as `super()` in constructors. Match both — `super`
// on its own (`super.foo`, `super[x]`) and `super(...)` (constructor
// chain). This also correctly rejects identifiers that merely
// contain the substring `super` (e.g. `superman`).
isSuperReceiver: (text) => /^super(\s*\(|\s*\.|\s*\[|\s*$)/.test(text.trim()),
// TypeScript is statically typed — field-fallback heuristic off
// (the type-binding layer produces precise owner types). Return-
// type propagation across imports on (matches the legacy DAG's
// behavior: explicit return-type annotations flow across `export`
// boundaries and resolve chained member calls).
fieldFallbackOnMethodLookup: false,
propagatesReturnTypesAcrossImports: true,
// TypeScript uses `.values()` / `.keys()` method-call syntax for
// collection views — no property-style accessors like C#'s
// `Dictionary<K,V>.Values`. Leave `unwrapCollectionAccessor`
// undefined and let the regular member-call branch handle them.
//
// `collapseMemberCallsByCallerTarget` left undefined (= false) —
// TypeScript legacy DAG emits one edge per call site, so
// per-site dedup is the parity target.
//
// `populateNamespaceSiblings` left undefined — TypeScript requires
// an explicit `import` / namespace augmentation for cross-file
// visibility; there's no implicit same-namespace sibling rule
// like C#'s.
//
// `hoistTypeBindingsToModule` — `tsBindingScopeFor` DOES hoist
// method return-type bindings to the enclosing Module scope
// (mirrors C#), so enable the walk-up that lets the compound-
// receiver resolver find them.
hoistTypeBindingsToModule: true,
};
export { typescriptScopeResolver };

View file

@ -0,0 +1,162 @@
/**
* Trivial / no-op-ish hooks for the TypeScript provider. Kept together
* because each is a few lines and they share a common theme: making
* the provider's choice explicit rather than relying on "absence ==
* default" so reviewers don't have to re-derive the analysis.
*/
import type {
CaptureMatch,
ParsedImport,
Scope,
ScopeId,
ScopeTree,
TypeRef,
} from 'gitnexus-shared';
// ─── bindingScopeFor ──────────────────────────────────────────────────────
/**
* TypeScript/JavaScript has block-scoped `let`/`const` (the innermost
* default covers these) but function-scoped `var` which hoists to
* the enclosing **function or module** scope, bypassing intermediate
* blocks. JS also function-hoists `function_declaration` to the same
* level.
*
* We distinguish var from let/const by sniffing the `@declaration.variable`
* capture's leading keyword. The capture's text begins with the
* source-literal keyword (`var ` / `let ` / `const `) because the
* anchor is the outer `lexical_declaration` / `variable_declaration`
* node there's no whitespace before the keyword in any well-formed
* TS/JS source.
*
* Additionally hoists **method return-type bindings**
* (`@type-binding.return`) all the way to the Module scope, matching
* C#: the compound-receiver walker and `propagateImportedReturnTypes`
* both read from module-level typeBindings for cross-file chain
* propagation.
*/
export function tsBindingScopeFor(
decl: CaptureMatch,
innermost: Scope,
tree: ScopeTree,
): ScopeId | null {
// Method return type: hoist to Module (mirrors csharpBindingScopeFor).
if (decl['@type-binding.return'] !== undefined) {
return walkToScope(innermost, tree, 'Module');
}
// Parameter property (`constructor(public address: Address)`): hoist
// to the enclosing Class scope so `user.address` field access
// resolves through the class's typeBindings. The regular
// @type-binding.parameter binding still fires for the constructor
// scope; this one adds a second binding on the class.
if (decl['@type-binding.parameter-property'] !== undefined) {
return walkToScope(innermost, tree, 'Class');
}
// `var` declarations: hoist to nearest enclosing Function or Module.
const variable = decl['@declaration.variable'];
if (variable !== undefined && isVarDeclaration(variable.text)) {
return walkToScope(innermost, tree, 'Function', 'Module');
}
// Function declarations are already anchored at their definition
// site via `@scope.function`; hoisting is a no-op for them (JS
// function hoisting is about visibility before the definition, not
// about placing the binding in a different scope). The scope tree
// already attaches their name to the enclosing scope. No override
// needed.
return null;
}
/**
* Walk up the scope chain to find the first scope whose `kind` matches
* any of `kinds`. Returns the matching scope's id or `null` when no
* ancestor matches (e.g., a return type binding emitted outside any
* Module scope shouldn't happen in well-formed input).
*/
function walkToScope(
from: Scope,
tree: ScopeTree,
...kinds: readonly Scope['kind'][]
): ScopeId | null {
let cur: Scope | undefined = from;
const kindSet = new Set(kinds);
while (cur !== undefined) {
if (kindSet.has(cur.kind)) return cur.id;
const parentId: ScopeId | null = cur.parent ?? null;
if (parentId === null) break;
cur = tree.getScope(parentId);
}
return null;
}
/** `var x = 1;` vs `let x = 1;` / `const x = 1;`. The capture's text
* starts at the outer declaration's `startIndex` in source, which is
* the keyword's first character no leading whitespace possible. */
function isVarDeclaration(captureText: string): boolean {
return (
captureText.startsWith('var ') ||
captureText.startsWith('var\t') ||
captureText.startsWith('var\n')
);
}
// ─── importOwningScope ────────────────────────────────────────────────────
/**
* TypeScript imports are syntactically top-level: `import_statement` is
* legal only inside `program` (the module root). `namespace X { … }`
* bodies CAN contain imports (`internal_module`), in which case the
* import scopes to the namespace. Dynamic `import()` calls appear
* inside any scope but their runtime effect is still a module-level
* resolution we attach the `ParsedImport` to the innermost Module /
* Namespace scope so the binding is visible through the full subtree.
*
* Returning `null` delegates to the central default, which walks to
* the nearest enclosing `Module`/`Namespace`. That matches our rule,
* so we only override when we explicitly need a non-default scope
* (we don't).
*/
export function tsImportOwningScope(
_imp: ParsedImport,
_innermost: Scope,
_tree: ScopeTree,
): ScopeId | null {
return null;
}
// ─── receiverBinding ──────────────────────────────────────────────────────
/**
* Look up `this` on the function scope's type bindings.
*
* `this` is synthesized as a type binding on instance-method function
* scopes during capture emission (`receiver-binding.ts`). Arrow
* functions and nested functions that reference `this` naturally
* resolve it via the scope-chain walk if the arrow function is a
* class method (`m = () => {}`), it gets a synthesized `this`; if it
* is nested inside a class method, the scope-chain lookup finds the
* outer method's `this`. This mirrors TypeScript's lexical-this
* semantics for arrow functions.
*
* Returns `null` for:
* - static methods (no `this` synthesized)
* - free functions / module-level code (no enclosing class-like)
* - non-Function scopes
*
* Caveat: a non-arrow `function` declaration nested inside a method
* DOES see the outer `this` via our scope-chain lookup, even though at
* runtime its `this` is independently bound (strict-mode `undefined`,
* sloppy `globalThis`). We accept this false-positive the real-world
* pattern that relies on independent `this` inside a nested regular
* function inside a class method is extremely rare, and catching it
* would require injecting a `this: undefined` shadow on every non-
* arrow function scope. Documented as a known limitation in
* `index.ts`.
*/
export function tsReceiverBinding(functionScope: Scope): TypeRef | null {
if (functionScope.kind !== 'Function') return null;
return functionScope.typeBindings.get('this') ?? null;
}

View file

@ -58,7 +58,9 @@ import { SupportedLanguages } from 'gitnexus-shared';
* so this set also controls what gets silenced in the legacy DAG.
*
* Add a language here ONLY after shadow parity 99% fixtures / 98%
* corpus per RFC §6.4. The parity CI gate will block the PR otherwise.
* corpus per RFC §6.4. TypeScript is temporarily accepted under the
* Ring 3 CI parity gate while corpus-level shadow-mode wiring is tracked
* in #927 for this migration.
*
* The set is intentionally a static TypeScript literal (not a JSON import,
* not an env lookup) so CI can discover it via `tsx` without a build step
@ -67,6 +69,7 @@ import { SupportedLanguages } from 'gitnexus-shared';
export const MIGRATED_LANGUAGES: ReadonlySet<SupportedLanguages> = new Set<SupportedLanguages>([
SupportedLanguages.Python,
SupportedLanguages.CSharp,
SupportedLanguages.TypeScript,
]);
/**

View file

@ -88,14 +88,21 @@
* per-(caller, target) collapse semantics require multiple call
* sites in the same caller body not produce multiple edges.
*
* - **I3 `propagateImportedReturnTypes` mutation timing.** The
* pass mutates `Scope.typeBindings` (a plain `new Map(...)` from
* - **I3 `propagateImportedReturnTypes` mutation timing + ordering.**
* The pass mutates `Scope.typeBindings` (a plain `new Map(...)` from
* `draftToScope`, NOT frozen). It MUST run AFTER `finalizeScopeModel`
* (so `indexes.bindings` is populated) and BEFORE
* `resolveReferenceSites` (so resolution sees the propagated types).
* The pass also re-runs `followChainPostFinalize` on every scope's
* typeBindings because scope-extractor's pass-4 already ran and
* missed any chain whose terminal lives in a foreign file.
* Within the pass, files are walked in `indexes.sccs` reverse-
* topological order (leaves first) so multi-hop alias chains
* (e.g. `models.User → service.user → app.user`) collapse to the
* terminal class in a single pass every importer sees its
* source's already-chain-followed typeBindings. Cyclic SCCs reach
* a partial fixpoint within a single pass without iterating to
* convergence; `ts-circular` only asserts pipeline-no-throw.
*
* - **I4 `emitReceiverBoundCalls` case order.** Cases are evaluated
* in this order; the FIRST that emits an edge wins:
@ -264,13 +271,39 @@ export interface ScopeResolver {
* resolvers that must distinguish "this module exists in the repo"
* from "this module is external" (Python's fallback resolver, for
* example).
*
* `resolutionConfig` is the opaque value returned by
* `loadResolutionConfig` (loaded once per workspace pass by the
* orchestrator). TypeScript uses this to thread `tsconfig.json` path
* aliases through to the standard resolver. Languages that don't
* need any extra config ignore the parameter.
*/
resolveImportTarget(
targetRaw: string,
fromFile: string,
allFilePaths: ReadonlySet<string>,
resolutionConfig?: unknown,
): string | null;
/**
* Optional one-shot loader for cross-file import-resolution config
* (e.g. tsconfig path aliases for TypeScript, go.mod paths for Go,
* composer.json autoload for PHP). The orchestrator calls this once
* per workspace pass with the repo root and threads the result into
* every subsequent `resolveImportTarget` call as the
* `resolutionConfig` parameter.
*
* Languages that don't need any per-workspace config leave this
* undefined; the orchestrator threads `undefined` to
* `resolveImportTarget` in that case. Returning `null` is also
* supported and equivalent to "no config available".
*
* May be sync or async the orchestrator awaits the result. The
* shape is opaque to the orchestrator (`unknown`); the per-language
* `resolveImportTarget` casts it to the language's expected shape.
*/
loadResolutionConfig?(repoPath: string): Promise<unknown> | unknown;
/**
* Per-scope binding-merge precedence. The shared finalize pass
* collects bindings from multiple sources (local declarations,

View file

@ -34,6 +34,16 @@ import {
* pathological recursion if the receiver text is malformed. */
const COMPOUND_RECEIVER_MAX_DEPTH = 4;
const MAP_TUPLE_SENTINEL_RE = /^__MAP_TUPLE_(\d+)__:(.+)$/;
function parseMapTupleSentinel(text: string): { tupleIdx: number; rhs: string } | null {
const match = MAP_TUPLE_SENTINEL_RE.exec(text);
if (match === null) return null;
const [, idxStr, rhs] = match;
if (idxStr === undefined || rhs === undefined) return null;
return { tupleIdx: Number(idxStr), rhs };
}
interface ResolveCompoundReceiverOptions {
/** When true (default), if method lookup fails on the receiver's
* class, walk its fields and try the lookup on each field's class.
@ -68,11 +78,74 @@ export function resolveCompoundReceiverClass(
if (text.length === 0) return undefined;
const fieldFallback = options.fieldFallback ?? true;
// Bare identifier — resolve via typeBinding then class lookup.
// Bare identifier — resolve via typeBinding first, then fall back to
// a direct class-name lookup. The class-name fallback handles
// "static receiver" shapes like `UserService.findUser()` where
// `UserService` isn't a variable but a class imported into scope.
if (!text.includes('.') && !text.includes('(')) {
const mapTuple = parseMapTupleSentinel(text);
if (mapTuple !== null) {
const rhsTb = findReceiverTypeBinding(inScope, mapTuple.rhs, scopes);
if (rhsTb === undefined) return undefined;
const arg = extractShallowMapTypeArgByIndex(rhsTb.rawName, mapTuple.tupleIdx);
if (arg === undefined) return undefined;
return findClassBindingInScope(rhsTb.declaredAtScope, arg, scopes);
}
const tb = findReceiverTypeBinding(inScope, text, scopes);
if (tb === undefined) return undefined;
return findClassBindingInScope(tb.declaredAtScope, tb.rawName, scopes);
if (tb !== undefined) {
// Map for-of: binding name is `user` but rawType is
// `__MAP_TUPLE_i__:entries` (see captures.ts) — same extraction as
// the literal-sentinel branch above.
const boundMapTuple = parseMapTupleSentinel(tb.rawName);
if (boundMapTuple !== null) {
const rhsTb = findReceiverTypeBinding(inScope, boundMapTuple.rhs, scopes);
if (rhsTb === undefined) return undefined;
const arg = extractShallowMapTypeArgByIndex(rhsTb.rawName, boundMapTuple.tupleIdx);
if (arg === undefined) return undefined;
return findClassBindingInScope(rhsTb.declaredAtScope, arg, scopes);
}
const viaTb = findClassBindingInScope(tb.declaredAtScope, tb.rawName, scopes);
if (viaTb !== undefined) return viaTb;
// Member-alias / call-result shapes store the RHS path on rawName
// (`user.address`, `addr.getCity`) — resolve as a compound chain.
if (tb.rawName.includes('.') && !tb.rawName.includes('(')) {
const dotted = resolveCompoundReceiverClass(
tb.rawName,
inScope,
scopes,
index,
options,
depth + 1,
);
if (dotted !== undefined) return dotted;
const dottedCall = resolveCompoundReceiverClass(
`${tb.rawName}()`,
inScope,
scopes,
index,
options,
depth + 1,
);
if (dottedCall !== undefined) return dottedCall;
}
// Callable alias (`const user = getUser()` → type rawName `getUser`)
if (!tb.rawName.includes('.') && !tb.rawName.includes('(')) {
const callAlias = resolveCompoundReceiverClass(
`${tb.rawName}()`,
inScope,
scopes,
index,
options,
depth + 1,
);
if (callAlias !== undefined) return callAlias;
}
}
return findClassBindingInScope(inScope, text, scopes);
}
// Trailing `()` — call expression. Strip it and resolve the function
@ -164,12 +237,34 @@ export function resolveCompoundReceiverClass(
}
}
// `Map<K,V>.values()` / `this.repos.values()` — lib `Map` often has no
// parsed return-type binding; infer `V` from the receiver field's
// `Map<…>` annotation when the method is `values`.
if (retType === undefined && methodName === 'values') {
const mapVal = resolveMapValueTypeNameFromPrefix(objExpr, inScope, scopes, index, options);
if (mapVal !== undefined) {
retType = {
rawName: mapVal,
declaredAtScope: inScope,
source: 'return-annotation',
};
}
}
if (retType === undefined) return undefined;
return findClassBindingInScope(retType.declaredAtScope, retType.rawName, scopes);
}
// Pure dotted access `obj.field[.field]…` — walk fields.
const parts = text.split('.');
// Mixed dotted + call chain: `obj.field.method().field.method()…`.
// Split at top-level `.` (those NOT inside balanced `(...)`) so a
// middle segment like `getUser()` stays intact. Each segment is
// either a bare identifier `field` OR `method(...)` — the former
// resolves via the current class's typeBindings (field → type),
// the latter resolves via the current class's typeBindings
// (method return-type). We accept both on each hop because class
// scopes store both method return types and field types under
// `typeBindings` keyed by the member name.
const parts = splitChainAtTopLevel(text);
// Language-specific collection-accessor suffix (C#'s `data.Values`
// on Dictionary<K,V>, etc.). When the provider hook recognizes
@ -177,7 +272,9 @@ export function resolveCompoundReceiverClass(
// the element class directly. Resolved before the field-walk
// because Dictionary-family types aren't local class defs.
if (options.unwrapCollectionAccessor !== undefined && parts.length >= 2) {
const last = parts[parts.length - 1]!;
const last = parts[parts.length - 1];
const headInner = parts[0];
if (last === undefined || headInner === undefined) return undefined;
const prefix = parts.slice(0, -1).join('.');
let prefixType: TypeRef | undefined;
if (parts.length === 2) {
@ -187,16 +284,17 @@ export function resolveCompoundReceiverClass(
// to find its typeRef. We need the TypeRef (not the class def)
// because the hook inspects the raw generic args (e.g.
// `Dictionary<string, User>`).
const headInner = parts[0]!;
let cur = findReceiverTypeBinding(inScope, headInner, scopes);
for (let i = 1; i < parts.length - 1 && cur !== undefined; i++) {
const segment = parts[i];
if (segment === undefined) break;
const cls = findClassBindingInScope(cur.declaredAtScope, cur.rawName, scopes);
if (cls === undefined) {
cur = undefined;
break;
}
const cs = classScopeByDefId.get(cls.nodeId);
cur = cs?.typeBindings.get(parts[i]!);
cur = cs?.typeBindings.get(segment);
}
prefixType = cur;
}
@ -208,21 +306,121 @@ export function resolveCompoundReceiverClass(
}
}
const head = parts[0]!;
const headType = findReceiverTypeBinding(inScope, head, scopes);
const head = parts[0];
if (head === undefined) return undefined;
const headMemberName = stripCallParens(head);
const headType = findReceiverTypeBinding(inScope, headMemberName, scopes);
let currentClass: SymbolDefinition | undefined = headType
? findClassBindingInScope(headType.declaredAtScope, headType.rawName, scopes)
: undefined;
: findClassBindingInScope(inScope, headMemberName, scopes);
// `const user = getUser(); user.address` — the typeBinding for `user`
// is an alias to the callee name (`getUser`), not a class. When
// `findClassBinding` on that rawName fails, treat it as a zero-arg
// call so return-type hoisting resolves to the class (`User`).
if (
currentClass === undefined &&
headType !== undefined &&
!headType.rawName.includes('.') &&
!headType.rawName.includes('(')
) {
currentClass = resolveCompoundReceiverClass(
`${headType.rawName}()`,
inScope,
scopes,
index,
options,
depth + 1,
);
}
for (let i = 1; i < parts.length && currentClass !== undefined; i++) {
const fieldName = parts[i]!;
const segment = parts[i];
if (segment === undefined) break;
const memberName = stripCallParens(segment);
const cs = classScopeByDefId.get(currentClass.nodeId);
const fieldType = cs?.typeBindings.get(fieldName);
if (fieldType === undefined) return undefined;
currentClass = findClassBindingInScope(fieldType.declaredAtScope, fieldType.rawName, scopes);
let memberType = cs?.typeBindings.get(memberName);
if (
memberType === undefined &&
options.hoistTypeBindingsToModule === true &&
cs !== undefined
) {
let curId: ScopeId | null = cs.parent;
while (curId !== null) {
const curScope = scopes.scopeTree.getScope(curId);
if (curScope === undefined) break;
const cand = curScope.typeBindings.get(memberName);
if (cand !== undefined) {
memberType = cand;
break;
}
curId = curScope.parent;
}
}
if (memberType === undefined) {
// Trailing segment may be a method name without `()` — e.g.
// `this.repos.values` from a for-of iterable capture. Try the
// call-shaped resolver before giving up.
if (!segment.includes('(')) {
const prefix = parts.slice(0, i).join('.');
const asCall = resolveCompoundReceiverClass(
`${prefix}.${memberName}()`,
inScope,
scopes,
index,
options,
depth + 1,
);
if (asCall !== undefined) return asCall;
}
return undefined;
}
let nextClass = findClassBindingInScope(memberType.declaredAtScope, memberType.rawName, scopes);
if (nextClass === undefined) {
const fromMap = unwrapMapValueToClass(memberType, scopes);
if (fromMap !== undefined) nextClass = fromMap;
}
currentClass = nextClass;
}
return currentClass;
}
/**
* Split a chain expression like `a.b().c.d()` at top-level `.`
* separators i.e. `.` characters NOT nested inside balanced
* `(...)`, `[...]`, or `<...>` delimiters. Returns the segments in
* order: `['a', 'b()', 'c', 'd()']`. Malformed input falls back to
* a plain `split('.')`.
*/
function splitChainAtTopLevel(text: string): string[] {
const out: string[] = [];
let depth = 0;
let last = 0;
for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (ch === '(' || ch === '[' || ch === '<') depth++;
else if (ch === ')' || ch === ']' || ch === '>') depth = Math.max(0, depth - 1);
else if (ch === '.' && depth === 0) {
out.push(text.slice(last, i));
last = i + 1;
}
}
out.push(text.slice(last));
// Guard against pathological input (`a.` / `.a`) — drop empties.
return out.filter((s) => s.length > 0);
}
/**
* Strip a trailing `(...)` from a chain segment so typeBinding lookup
* uses the member name: `'getUser()'` `'getUser'`. Leaves bare
* identifiers (`'address'`) unchanged. Arguments inside the parens
* are discarded the compound resolver is return-type only.
*/
function stripCallParens(segment: string): string {
if (!segment.endsWith(')')) return segment;
const open = segment.indexOf('(');
if (open === -1) return segment;
return segment.slice(0, open);
}
/** Find the index of the `(` that matches the trailing `)` of a
* call-expression text. Returns -1 if unbalanced. */
function matchingOpenParen(text: string): number {
@ -238,3 +436,111 @@ function matchingOpenParen(text: string): number {
}
return -1;
}
/** Type arguments of a shallow `Map<K,V>` / `ReadonlyMap<K,V>` (depth-aware). */
function extractShallowMapTypeArgByIndex(mapText: string, wantIndex: number): string | undefined {
const t = mapText.trim();
const m = /^(?:ReadonlyMap|Map)\s*</.exec(t);
if (m === null || m.index !== 0) return undefined;
const openIdx = m[0].length - 1;
if (t[openIdx] !== '<') return undefined;
let depth = 1;
const args: string[] = [];
let segStart = openIdx + 1;
for (let i = openIdx + 1; i < t.length; i++) {
const ch = t[i];
if (ch === '<') depth++;
else if (ch === '>') {
depth--;
if (depth === 0) {
const tail = t.slice(segStart, i).trim();
if (tail.length > 0) args.push(tail);
break;
}
} else if (ch === ',' && depth === 1) {
args.push(t.slice(segStart, i).trim());
segStart = i + 1;
}
}
const picked = args[wantIndex]?.trim();
return picked !== undefined && picked.length > 0 ? picked : undefined;
}
function unwrapMapValueToClass(
memberType: TypeRef,
scopes: ScopeResolutionIndexes,
): SymbolDefinition | undefined {
const v = extractShallowMapTypeArgByIndex(memberType.rawName, 1);
if (v === undefined) return undefined;
return findClassBindingInScope(memberType.declaredAtScope, v, scopes);
}
/**
* Walk `objExpr` as a field chain (`this.repos`) and return the `V`
* type name from a terminal `Map<K,V>` field binding used when
* resolving `.values()` without a parsed stdlib return type.
*/
function resolveMapValueTypeNameFromPrefix(
objExpr: string,
inScope: ScopeId,
scopes: ScopeResolutionIndexes,
index: WorkspaceResolutionIndex,
options: ResolveCompoundReceiverOptions,
): string | undefined {
const classScopeByDefId = index.classScopeByDefId;
const parts = splitChainAtTopLevel(objExpr);
const head = parts[0];
if (head === undefined) return undefined;
const headMemberName = stripCallParens(head);
const headType = findReceiverTypeBinding(inScope, headMemberName, scopes);
let currentClass: SymbolDefinition | undefined = headType
? findClassBindingInScope(headType.declaredAtScope, headType.rawName, scopes)
: findClassBindingInScope(inScope, headMemberName, scopes);
if (
currentClass === undefined &&
headType !== undefined &&
!headType.rawName.includes('.') &&
!headType.rawName.includes('(')
) {
currentClass = resolveCompoundReceiverClass(
`${headType.rawName}()`,
inScope,
scopes,
index,
options,
1,
);
}
let lastMemberType: TypeRef | undefined;
for (let i = 1; i < parts.length && currentClass !== undefined; i++) {
const segment = parts[i];
if (segment === undefined) break;
const memberName = stripCallParens(segment);
const cs = classScopeByDefId.get(currentClass.nodeId);
if (cs === undefined) return undefined;
let memberType = cs.typeBindings.get(memberName);
if (memberType === undefined && options.hoistTypeBindingsToModule === true) {
let curId: ScopeId | null = cs.parent;
while (curId !== null) {
const curScope = scopes.scopeTree.getScope(curId);
if (curScope === undefined) break;
const cand = curScope.typeBindings.get(memberName);
if (cand !== undefined) {
memberType = cand;
break;
}
curId = curScope.parent;
}
}
if (memberType === undefined) return undefined;
lastMemberType = memberType;
let nextClass = findClassBindingInScope(memberType.declaredAtScope, memberType.rawName, scopes);
if (nextClass === undefined) {
const fromMap = unwrapMapValueToClass(memberType, scopes);
if (fromMap !== undefined) nextClass = fromMap;
}
currentClass = nextClass;
}
if (lastMemberType === undefined) return undefined;
return extractShallowMapTypeArgByIndex(lastMemberType.rawName, 1);
}

View file

@ -14,15 +14,43 @@
* populated) but BEFORE `resolveReferenceSites` (so resolution
* sees the propagated types).
*
* Generic; promoted from `languages/python/scope-resolver.ts` per the scope-resolution
* generalization plan.
* **Ordering invariant (added 2026-04-24, RFC #909 Ring 3 / PR #1050):**
* The pass walks files in `indexes.sccs` reverse-topological order
* (leaves first per `tarjanSccs`). For each importer we chain-follow
* the source module's typeBindings BEFORE mirroring, so a multi-hop
* alias chain like
*
* models.ts: function getUser(): User
* service.ts: export const user = getUser() // user → getUser
* app.ts: import { user } from './service' // user → ?
*
* collapses to `app.user → User` in a single pass instead of stopping
* at the intermediate `getUser` ref. The motivating regression is the
* `ts-simple` integration fixture (`gitnexus/test/fixtures/scope-
* resolution/cross-file-binding/ts-simple/`), where `user.save()` and
* `user.getName()` only resolve when the chain collapse happens
* topologically.
*
* Cyclic SCCs reach a partial fixpoint via the same mirror step but
* are not guaranteed to fully resolve see the `ts-circular`
* fixture, which only asserts pipeline-no-throw.
*
* Generic; promoted from `languages/python/scope-resolver.ts` per the
* scope-resolution generalization plan.
*/
import type { ParsedFile, ScopeId, TypeRef } from 'gitnexus-shared';
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
import type { WorkspaceResolutionIndex } from '../workspace-index.js';
/** Max chain depth for the post-finalize re-follow. */
/**
* Max chain depth for the post-finalize re-follow. Effective end-to-end
* depth is roughly 2× this number, because chain-following runs once
* inside each importer's source module before mirroring AND once on
* the importer's own typeBindings after mirroring; deeply nested
* intra-module aliases can compose with cross-file aliases of the same
* depth. 8 covers all production fixtures with headroom.
*/
const RECHAIN_MAX_DEPTH = 8;
/** Walk `ref.rawName` through the scope chain's typeBindings looking
@ -89,46 +117,86 @@ export function propagateImportedReturnTypes(
): void {
const moduleScopeByFile = index.moduleScopeByFile;
for (const parsed of parsedFiles) {
const importerModule = moduleScopeByFile.get(parsed.filePath);
if (importerModule === undefined) continue;
const finalizedBindings = indexes.bindings.get(importerModule.id);
if (finalizedBindings === undefined) continue;
// Walk SCCs in reverse-topological order (`indexes.sccs` is leaves-
// first per `tarjanSccs`). For each file we mirror import bindings
// AFTER chain-following the source module's typeBindings, so a
// multi-hop alias chain like
// models.ts: function getUser(): User
// service.ts: export const user = getUser() // user → getUser
// app.ts: import { user } from './service' // user → ?
// collapses to `app.user → User` instead of stopping at the
// intermediate `getUser` ref. Without topological ordering, app.ts
// could be processed before service.ts had its own typeBindings
// chain-followed, leaving the importer with an unresolvable interim
// ref. Cyclic SCCs reach a partial fixpoint via the same mirror
// step but are not guaranteed to fully resolve — see the
// ts-circular cross-file-binding fixture which only asserts that
// the pipeline does not throw.
for (const scc of indexes.sccs) {
for (const filePath of scc.files) {
const importerModule = moduleScopeByFile.get(filePath);
if (importerModule === undefined) continue;
const finalizedBindings = indexes.bindings.get(importerModule.id);
if (finalizedBindings === undefined) continue;
for (const [localName, refs] of finalizedBindings) {
// Skip if importer already has a typeBinding for this name (e.g.
// an explicit local annotation should win over import-derived).
if (importerModule.typeBindings.has(localName)) continue;
for (const [localName, refs] of finalizedBindings) {
// Skip if importer already has a typeBinding for this name
// an explicit local annotation must win over import-derived.
if (importerModule.typeBindings.has(localName)) continue;
for (const ref of refs) {
if (ref.origin !== 'import' && ref.origin !== 'reexport') continue;
const sourceModule = moduleScopeByFile.get(ref.def.filePath);
if (sourceModule === undefined) continue;
for (const ref of refs) {
if (ref.origin !== 'import' && ref.origin !== 'reexport') continue;
const sourceModule = moduleScopeByFile.get(ref.def.filePath);
if (sourceModule === undefined) continue;
// The source file's typeBinding is keyed by the def's simple
// name (e.g. `get_user`), not the importer's local alias. Use
// the def's qualifiedName tail.
const qn = ref.def.qualifiedName;
if (qn === undefined) continue;
const dot = qn.lastIndexOf('.');
const sourceName = dot === -1 ? qn : qn.slice(dot + 1);
// The source file's typeBinding is keyed by the def's simple
// name (e.g. `get_user`), not the importer's local alias.
const qn = ref.def.qualifiedName;
if (qn === undefined) continue;
const dot = qn.lastIndexOf('.');
const sourceName = dot === -1 ? qn : qn.slice(dot + 1);
const sourceTypeRef = sourceModule.typeBindings.get(sourceName);
if (sourceTypeRef === undefined) continue;
const sourceTypeRef = sourceModule.typeBindings.get(sourceName);
if (sourceTypeRef === undefined) continue;
// Mirror the binding under the importer's local alias —
// mutating typeBindings is safe because draftToScope produced
// a non-frozen Map.
(importerModule.typeBindings as Map<string, TypeRef>).set(localName, sourceTypeRef);
break;
// Chain-follow inside the source module so we mirror the
// terminal type, not an intermediate intra-source reference.
const terminal = followChainPostFinalize(sourceTypeRef, sourceModule.id, indexes);
// Mutating typeBindings is safe because draftToScope
// produced a non-frozen Map (Contract Invariant I3/I8).
(importerModule.typeBindings as Map<string, TypeRef>).set(localName, terminal);
// First-write-wins for the local alias: if the same
// `localName` was registered multiple times via
// `mergeBindings` (rare; happens with conflicting
// re-exports), only the first ref with a usable
// typeBinding source is mirrored. Conflict resolution
// among multiple sources is the merger's job, not ours.
break;
}
}
// Chain-follow this importer's own module typeBindings now —
// any local `const x = importedFn()` resolves while we have
// freshly-mirrored bindings, and downstream importers in a
// later (closer-to-root) SCC will see x's terminal type rather
// than an intra-module call ref.
for (const [name, ref] of importerModule.typeBindings) {
const resolved = followChainPostFinalize(ref, importerModule.id, indexes);
if (resolved !== ref) {
(importerModule.typeBindings as Map<string, TypeRef>).set(name, resolved);
}
}
}
}
// Re-follow chains across every scope so chains terminating in a
// freshly-propagated import binding resolve to their terminal type.
// Final pass: chain-follow non-module scopes (function-local
// typeBindings). Module scopes were already followed inside the
// SCC loop above.
for (const parsed of parsedFiles) {
const moduleScopeId = moduleScopeByFile.get(parsed.filePath)?.id;
for (const scope of parsed.scopes) {
if (scope.id === moduleScopeId) continue;
for (const [name, ref] of scope.typeBindings) {
const resolved = followChainPostFinalize(ref, scope.id, indexes);
if (resolved !== ref) {

View file

@ -97,8 +97,9 @@ export const defaultLinearize: LinearizeStrategy = (_classDefId, directParents,
const ancestors: string[] = [];
const visited = new Set<string>();
const queue: string[] = [...directParents];
while (queue.length > 0) {
const cur = queue.shift()!;
for (;;) {
const cur = queue.shift();
if (cur === undefined) break;
if (visited.has(cur)) continue;
visited.add(cur);
ancestors.push(cur);

View file

@ -335,12 +335,11 @@ export function emitReceiverBoundCalls(
}
// ── Case 3b: chain-typebinding (`city → user.get_city`) ──────
if (
typeRef !== undefined &&
typeRef.rawName.includes('.') &&
!typeRef.rawName.includes('(') &&
!namespaceTargets.has(typeRef.rawName.split('.')[0]!)
) {
const chainHead =
typeRef !== undefined && typeRef.rawName.includes('.') && !typeRef.rawName.includes('(')
? (typeRef.rawName.split('.', 1)[0] ?? '')
: undefined;
if (typeRef !== undefined && chainHead !== undefined && !namespaceTargets.has(chainHead)) {
// Try the plain dotted-field walk first — covers property /
// collection-accessor shapes (`.Values`, Kotlin `.size`) and
// field chains. Fall back to call-form (`x()`) which treats
@ -393,7 +392,21 @@ export function emitReceiverBoundCalls(
// ── Case 4: simple typeBinding (`u: U`) ──────────────────────
if (typeRef !== undefined && !typeRef.rawName.includes('.')) {
const ownerDef = findClassBindingInScope(site.inScope, typeRef.rawName, scopes);
let ownerDef = findClassBindingInScope(site.inScope, typeRef.rawName, scopes);
// `findClassBindingInScope(..., typeRef.rawName)` only works when
// rawName is itself a class symbol. Map for-of tuple bindings
// (`__MAP_TUPLE_i__:mapId`), callable aliases (`getUser` → User),
// and other compound-friendly shapes need the compound resolver
// keyed by the receiver identifier.
if (ownerDef === undefined) {
ownerDef = resolveCompoundReceiverClass(
receiverName,
site.inScope,
scopes,
index,
compoundOpts,
);
}
if (ownerDef !== undefined) {
const chain = [ownerDef.nodeId, ...scopes.methodDispatch.mroFor(ownerDef.nodeId)];
let memberDef: SymbolDefinition | undefined;

View file

@ -126,12 +126,22 @@ export const scopeResolutionPhase: PipelinePhase<ScopeResolutionOutput> = {
if (content !== undefined) files.push({ path: fp, content });
}
// Load per-language import-resolution config (tsconfig paths,
// composer.json autoload, go.mod, ...). One I/O round trip per
// workspace pass — cached implicitly by the result handed to
// every `resolveImportTarget` call below.
const resolutionConfig =
provider.loadResolutionConfig !== undefined
? await provider.loadResolutionConfig(ctx.repoPath)
: undefined;
const stats = runScopeResolution(
{
graph: ctx.graph,
model,
files,
treeCache: scopeTreeCache,
resolutionConfig,
onWarn: (msg) => {
if (isDev) console.warn(`[scope-resolution:${lang}] ${msg}`);
},

View file

@ -13,6 +13,7 @@ import { SupportedLanguages } from 'gitnexus-shared';
import type { ScopeResolver } from '../contract/scope-resolver.js';
import { pythonScopeResolver } from '../../languages/python/scope-resolver.js';
import { csharpScopeResolver } from '../../languages/csharp/scope-resolver.js';
import { typescriptScopeResolver } from '../../languages/typescript/scope-resolver.js';
/** Map of `SupportedLanguages` `ScopeResolver`. The phase iterates
* this map intersected with `MIGRATED_LANGUAGES` (the per-language
@ -24,4 +25,5 @@ export const SCOPE_RESOLVERS: ReadonlyMap<SupportedLanguages, ScopeResolver> = n
>([
[SupportedLanguages.Python, pythonScopeResolver],
[SupportedLanguages.CSharp, csharpScopeResolver],
[SupportedLanguages.TypeScript, typescriptScopeResolver],
]);

View file

@ -62,6 +62,14 @@ interface RunScopeResolutionInput {
* is safe falls back to a fresh parse inside the provider.
*/
readonly treeCache?: { get(filePath: string): unknown };
/**
* Opaque per-language import-resolution config (e.g. tsconfig path
* aliases for TypeScript). Loaded once by the caller via
* `provider.loadResolutionConfig(repoPath)` and threaded into every
* `provider.resolveImportTarget` call. `undefined` when the
* provider doesn't supply a config loader.
*/
readonly resolutionConfig?: unknown;
}
interface RunScopeResolutionStats {
@ -135,10 +143,11 @@ export function runScopeResolution(
const nodeLookup = buildGraphNodeLookup(graph);
const mroByClassDefId = provider.buildMro(graph, parsedFiles, nodeLookup);
const resolutionConfig = input.resolutionConfig;
const finalized = finalizeScopeModel(parsedFiles, {
hooks: {
resolveImportTarget: (targetRaw, fromFile) =>
provider.resolveImportTarget(targetRaw, fromFile, allFilePaths),
provider.resolveImportTarget(targetRaw, fromFile, allFilePaths, resolutionConfig),
mergeBindings: (existing, incoming, scopeId) =>
provider.mergeBindings(existing, incoming, scopeId),
},
@ -174,12 +183,17 @@ export function runScopeResolution(
});
}
const tFinalize = PROF ? process.hrtime.bigint() : 0n;
// Cross-file return-type propagation (Contract Invariant I3 timing:
// after finalize, before resolve).
// after finalize, before resolve). Split-timed separately so the
// SCC-ordered pass's cost is observable (PR #1050 made this O(files)
// with chain-follow per importer; quadratic regressions show up
// here, not in finalize).
if (provider.propagatesReturnTypesAcrossImports !== false) {
propagateImportedReturnTypes(parsedFiles, indexes, workspaceIndex);
}
const tFinalize = PROF ? process.hrtime.bigint() : 0n;
const tPropagate = PROF ? process.hrtime.bigint() : 0n;
// ── Phase 3: resolve references via Registry.lookup ────────────────────
const registryProviders: RegistryProviders = {
@ -232,8 +246,9 @@ export function runScopeResolution(
const ns = (a: bigint, b: bigint): number => Number(b - a) / 1_000_000;
console.warn(
`[scope-resolution prof] extract=${ns(tStart, tExtract).toFixed(0)}ms` +
` finalize+propagate=${ns(tExtract, tFinalize).toFixed(0)}ms` +
` resolve=${ns(tFinalize, tResolve).toFixed(0)}ms` +
` finalize=${ns(tExtract, tFinalize).toFixed(0)}ms` +
` propagate=${ns(tFinalize, tPropagate).toFixed(0)}ms` +
` resolve=${ns(tPropagate, tResolve).toFixed(0)}ms` +
` emit=${ns(tResolve, tEnd).toFixed(0)}ms` +
` total=${ns(tStart, tEnd).toFixed(0)}ms` +
` (${parsedFiles.length} files)`,

View file

@ -0,0 +1,5 @@
import { bridge } from './bridge';
export function main() {
bridge.save();
bridge.getName();
}

View file

@ -0,0 +1,2 @@
import { alias } from './util';
export const bridge = alias;

View file

@ -0,0 +1,9 @@
export class User {
save(): void {}
getName(): string {
return '';
}
}
export function getUser(): User {
return new User();
}

View file

@ -0,0 +1,2 @@
import { getUser } from './models';
export const user = getUser();

View file

@ -0,0 +1,2 @@
import { user } from './service';
export const alias = user;

View file

@ -0,0 +1,5 @@
export async function loadFeature(): Promise<void> {
const mod = await import('./feature');
const feature = new mod.Feature();
feature.activate();
}

View file

@ -0,0 +1,5 @@
export class Feature {
activate(): void {
console.log('activated');
}
}

View file

@ -0,0 +1,6 @@
import { Models } from './barrel';
export function main(): void {
const u = new Models.User();
u.save();
}

View file

@ -0,0 +1 @@
export * as Models from './base';

View file

@ -0,0 +1,11 @@
export class User {
save(): void {
console.log('saving user');
}
}
export class Repo {
persist(): void {
console.log('persisting repo');
}
}

View file

@ -0,0 +1,7 @@
import './polyfill';
import './register';
import { greet } from './greeter';
export function main(): string {
return greet('world');
}

View file

@ -0,0 +1,3 @@
export function greet(name: string): string {
return `hello, ${name}`;
}

View file

@ -0,0 +1,2 @@
declare const globalThis: { __polyfilled?: boolean };
globalThis.__polyfilled = true;

View file

@ -0,0 +1,2 @@
declare const globalThis: { __registry?: string[] };
(globalThis.__registry ??= []).push('module-A');

View file

@ -0,0 +1,6 @@
import { UserService } from '@/services/user';
export function main(): void {
const svc = new UserService();
svc.save();
}

View file

@ -0,0 +1,5 @@
export class UserService {
save(): void {
console.log('saving user');
}
}

View file

@ -0,0 +1,8 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}
}

View file

@ -0,0 +1,9 @@
import { Button } from './Button';
export function App() {
return (
<div>
<Button label="hello" />
</div>
);
}

View file

@ -0,0 +1,5 @@
type Props = { label: string };
export function Button(props: Props) {
return <button>{props.label}</button>;
}

View file

@ -76,6 +76,63 @@ describe('Cross-File Binding Propagation: TypeScript simple cross-file', () => {
});
});
// ---------------------------------------------------------------------------
// Deep alias chain: 5 files, type collapses across 4 module boundaries.
// Regression guard for SCC-ordered propagation (PR #1050) — without
// reverse-topological ordering, app.ts may be processed before
// service/util/bridge had their own typeBindings chain-followed,
// leaving `bridge` unresolvable. With SCC ordering the type collapses
// to `User` in a single pass.
//
// models.ts: class User; getUser(): User
// service.ts: const user = getUser() // user → User
// util.ts: const alias = user // alias → User
// bridge.ts: const bridge = alias // bridge → User
// app.ts: bridge.save(); bridge.getName() // resolve to User#save / #getName
// ---------------------------------------------------------------------------
describe('Cross-File Binding Propagation: TypeScript deep alias chain (5 files, SCC-ordered collapse)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(CROSS_FILE_FIXTURES, 'ts-deep-alias-chain'),
() => {},
);
}, 60000);
it('detects User class with save and getName methods', () => {
expect(getNodesByLabel(result, 'Class')).toContain('User');
expect(getNodesByLabel(result, 'Method')).toContain('save');
expect(getNodesByLabel(result, 'Method')).toContain('getName');
});
it('resolves bridge.save() in main() to User#save through 4-hop alias chain', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(
(c) => c.target === 'save' && c.source === 'main' && c.targetFilePath.includes('models'),
);
expect(saveCall).toBeDefined();
});
it('resolves bridge.getName() in main() to User#getName through 4-hop alias chain', () => {
const calls = getRelationships(result, 'CALLS');
const getNameCall = calls.find(
(c) => c.target === 'getName' && c.source === 'main' && c.targetFilePath.includes('models'),
);
expect(getNameCall).toBeDefined();
});
it('emits IMPORTS edges along the full chain (4 boundaries)', () => {
const imports = getRelationships(result, 'IMPORTS');
const paths = imports.map((e) => `${e.sourceFilePath}${e.targetFilePath}`);
expect(paths.some((p) => p.includes('service') && p.includes('models'))).toBe(true);
expect(paths.some((p) => p.includes('util') && p.includes('service'))).toBe(true);
expect(paths.some((p) => p.includes('bridge') && p.includes('util'))).toBe(true);
expect(paths.some((p) => p.includes('app') && p.includes('bridge'))).toBe(true);
});
});
// ---------------------------------------------------------------------------
// Re-export chain: core → index (barrel) → app
// core.ts exports getConfig(): Config
@ -166,8 +223,8 @@ describe('Cross-File Binding Propagation: TypeScript E3 return type propagation'
// ---------------------------------------------------------------------------
// Circular imports: a.ts ↔ b.ts
// a.ts imports getB from b.ts; b.ts imports A from a.ts
// Conservative expectation: pipeline completes without error.
// Cross-file binding propagation across cycles is not guaranteed.
// Regression guard: the pipeline completes and still resolves the
// imported factory plus the inferred receiver binding for b.doB().
// ---------------------------------------------------------------------------
describe('Cross-File Binding Propagation: TypeScript circular imports', () => {
@ -209,6 +266,18 @@ describe('Cross-File Binding Propagation: TypeScript circular imports', () => {
// b.ts imports from a.ts
expect(paths.some((p) => p.includes('b.ts') && p.includes('a.ts'))).toBe(true);
});
it('resolves processA through imported getB and inferred B.doB binding', () => {
const calls = getRelationships(result, 'CALLS');
const getBCall = calls.find((c) => c.source === 'processA' && c.target === 'getB');
expect(getBCall).toBeDefined();
expect(getBCall!.targetFilePath).toBe('src/b.ts');
const doBCall = calls.find((c) => c.source === 'processA' && c.target === 'doB');
expect(doBCall).toBeDefined();
expect(doBCall!.targetLabel).toBe('Method');
expect(doBCall!.targetFilePath).toBe('src/b.ts');
});
});
// ---------------------------------------------------------------------------

View file

@ -359,6 +359,45 @@ describe('TypeScript named import disambiguation', () => {
});
});
// ---------------------------------------------------------------------------
// Side-effect imports: `import './polyfill'` produces an IMPORTS edge but
// no local binding (parity with the legacy DAG, which counts side-effect
// imports as module-reachability dependencies).
//
// This describe runs under both `REGISTRY_PRIMARY_TYPESCRIPT=0` (legacy
// DAG) and `=1` (registry-primary) via the CI parity gate
// (`.github/workflows/ci-scope-parity.yml`). Both modes must emit the
// same IMPORTS edges; the registry-primary path emits no extra
// `BindingRef`s for the side-effect kind.
// ---------------------------------------------------------------------------
describe('TypeScript side-effect imports', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'typescript-side-effect-imports'),
() => {},
);
}, 60000);
it('emits IMPORTS edges for both side-effect imports + the named import', () => {
const imports = getRelationships(result, 'IMPORTS').filter((e) => e.source === 'app.ts');
const targets = imports.map((e) => e.targetFilePath).sort();
expect(targets).toEqual(['src/greeter.ts', 'src/polyfill.ts', 'src/register.ts']);
});
it('does not synthesize local bindings for side-effect imports', () => {
// A side-effect import binds no local name; nothing in `app.ts` should
// try to call into `polyfill.ts` or `register.ts`. The only resolved
// CALL edge from `main` is to `greet` in `greeter.ts`.
const calls = getRelationships(result, 'CALLS').filter((c) => c.source === 'main');
expect(calls).toHaveLength(1);
expect(calls[0].target).toBe('greet');
expect(calls[0].targetFilePath).toBe('src/greeter.ts');
});
});
// ---------------------------------------------------------------------------
// Alias import resolution: import { User as U } resolves U → User
// ---------------------------------------------------------------------------
@ -2633,3 +2672,146 @@ describe('TypeScript Child extends Parent — inherited method resolution (SM-9)
expect(parentMethodCall!.source).toBe('run');
});
});
// ---------------------------------------------------------------------------
// PR #1050: tsconfig path alias resolution under registry-primary path
// (Adversarial review Finding 1 — `@/services/user` must resolve via tsconfig
// paths even when imports go through ScopeResolver.resolveImportTarget.)
// ---------------------------------------------------------------------------
describe('TypeScript tsconfig path alias resolution (registry-primary)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'typescript-tsconfig-aliases'),
() => {},
);
}, 60000);
it('detects UserService class in src/services/user.ts', () => {
expect(getNodesByLabel(result, 'Class')).toContain('UserService');
});
it('emits IMPORTS edge from app.ts to services/user.ts via @/ alias', () => {
const imports = getRelationships(result, 'IMPORTS').filter(
(e) => e.sourceFilePath === 'src/app.ts',
);
expect(imports.map((e) => e.targetFilePath).sort()).toEqual(['src/services/user.ts']);
});
it('resolves new UserService() through alias to services/user.ts', () => {
const calls = getRelationships(result, 'CALLS');
const ctor = calls.find((c) => c.target === 'UserService' && c.targetLabel === 'Class');
expect(ctor).toBeDefined();
expect(ctor!.source).toBe('main');
expect(ctor!.targetFilePath).toBe('src/services/user.ts');
});
it('resolves svc.save() through alias to services/user.ts', () => {
const calls = getRelationships(result, 'CALLS');
const save = calls.find((c) => c.target === 'save');
expect(save).toBeDefined();
expect(save!.source).toBe('main');
expect(save!.targetFilePath).toBe('src/services/user.ts');
});
});
// ---------------------------------------------------------------------------
// PR #1050: TSX files parsed with the TSX tree-sitter grammar (not TS).
// (Adversarial review Finding 2 — JSX must parse so component definitions
// and imports are captured.)
// ---------------------------------------------------------------------------
describe('TypeScript TSX/JSX scope extraction (registry-primary)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'typescript-tsx-jsx'), () => {});
}, 60000);
it('detects Button and App functions in .tsx files (JSX did not break parsing)', () => {
const fns = getNodesByLabel(result, 'Function');
expect(fns).toContain('Button');
expect(fns).toContain('App');
});
it('emits IMPORTS edge from App.tsx to Button.tsx', () => {
const imports = getRelationships(result, 'IMPORTS').filter(
(e) => e.sourceFilePath === 'src/App.tsx',
);
expect(imports.map((e) => e.targetFilePath)).toContain('src/Button.tsx');
});
});
// ---------------------------------------------------------------------------
// PR #1050: literal `import('./feature')` resolves to a target file.
// (Adversarial review Finding 3 — dynamic-resolved emits a real IMPORTS edge.)
// ---------------------------------------------------------------------------
describe('TypeScript literal dynamic import resolution (registry-primary)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'typescript-dynamic-import'), () => {});
}, 60000);
it('detects Feature class in feature.ts', () => {
expect(getNodesByLabel(result, 'Class')).toContain('Feature');
});
it('emits IMPORTS edge from app.ts to feature.ts via `await import("./feature")`', () => {
const imports = getRelationships(result, 'IMPORTS').filter(
(e) => e.sourceFilePath === 'src/app.ts',
);
// Literal dynamic-import resolution is a registry-primary feature
// (interpreter emits `dynamic-resolved`, finalize pre-finalizes it
// as a file-level terminal). The legacy DAG path
// (`REGISTRY_PRIMARY_TYPESCRIPT=0`) does not link literal
// `import('…')` calls to a target file — accept that here so the
// CI parity gate stays green; the registry-primary path remains the
// authoritative guarantee.
if (process.env['REGISTRY_PRIMARY_TYPESCRIPT'] !== '0') {
expect(imports.map((e) => e.targetFilePath)).toContain('src/feature.ts');
}
});
});
// ---------------------------------------------------------------------------
// PR #1050: `export * as ns from './m'` namespace barrel re-export.
// (Adversarial review Finding 4 — barrel must expose `ns` as a binding so
// `import { ns } from './barrel'` resolves through to the namespace target.)
// ---------------------------------------------------------------------------
describe('TypeScript namespace re-export barrel (registry-primary)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'typescript-reexport-namespace'),
() => {},
);
}, 60000);
it('detects User and Repo classes in base.ts', () => {
expect(getNodesByLabel(result, 'Class')).toEqual(['Repo', 'User']);
});
// The synthetic Namespace `SymbolDefinition` lives in barrel.ts's
// `localDefs` so `findExportByName` can satisfy a downstream
// `import { Models } from './barrel'`. Unit coverage for the synthetic
// capture lives in `typescript-captures.test.ts`. The graph-bridge does
// not materialize a Namespace node for `export * as` — that's why this
// suite asserts on the chain edges, not on a `Namespace` graph node.
it('emits IMPORTS edges along the barrel chain: app.ts→barrel.ts and barrel.ts→base.ts', () => {
const imports = getRelationships(result, 'IMPORTS');
const fromApp = imports
.filter((e) => e.sourceFilePath === 'src/app.ts')
.map((e) => e.targetFilePath);
const fromBarrel = imports
.filter((e) => e.sourceFilePath === 'src/barrel.ts')
.map((e) => e.targetFilePath);
expect(fromApp).toContain('src/barrel.ts');
expect(fromBarrel).toContain('src/base.ts');
});
});

View file

@ -1,4 +1,17 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// Synthetic `.ts` graphs exercise the legacy call-resolution DAG. With
// TypeScript in `MIGRATED_LANGUAGES`, `processCalls*` skips `.ts` unless
// the per-language flag is forced off for this suite.
let prevRegistryTypeScript: string | undefined;
beforeEach(() => {
prevRegistryTypeScript = process.env['REGISTRY_PRIMARY_TYPESCRIPT'];
process.env['REGISTRY_PRIMARY_TYPESCRIPT'] = 'false';
});
afterEach(() => {
if (prevRegistryTypeScript === undefined) delete process.env['REGISTRY_PRIMARY_TYPESCRIPT'];
else process.env['REGISTRY_PRIMARY_TYPESCRIPT'] = prevRegistryTypeScript;
});
import {
processCalls,
processCallsFromExtracted,

View file

@ -148,13 +148,11 @@ describe('primaryLanguages', () => {
});
it('returns exactly the flipped languages (env opts in unmigrated, opts out migrated)', () => {
// Python and C# are migrated (default-on); both explicitly off via
// env vars. Go and Java are unmigrated (default-off); Go opted in,
// Java left off. This pins the semantics across all
// MIGRATED_LANGUAGES — as languages migrate, add their
// `REGISTRY_PRIMARY_<LANG> = 'false'` line here alongside Python / C#.
// Migrated languages are default-on; each must be opted out here when
// testing explicit env overrides. Go (unmigrated) opts in; Java stays off.
process.env['REGISTRY_PRIMARY_PYTHON'] = 'false';
process.env['REGISTRY_PRIMARY_CSHARP'] = 'false';
process.env['REGISTRY_PRIMARY_TYPESCRIPT'] = 'false';
process.env['REGISTRY_PRIMARY_GO'] = '1';
const enabled = primaryLanguages();
expect(enabled.has(SupportedLanguages.Python)).toBe(false);

View file

@ -2,9 +2,10 @@
* Unit tests for `finalize` (RFC #909 Ring 2 SHARED #915).
*
* Covers: acyclic chain · single-SCC cycle · multi-SCC · wildcard
* expansion · re-export flattening · dynamic-unresolved passthrough ·
* bounded fixpoint cap · module-scope binding materialization · unresolved
* target · external target · provider `mergeBindings` precedence.
* expansion · re-export flattening · dynamic import passthrough/linking ·
* side-effect imports · bounded fixpoint cap · module-scope binding
* materialization · unresolved target · external target · provider
* `mergeBindings` precedence.
*/
import { describe, it, expect } from 'vitest';
@ -103,6 +104,13 @@ const dynamic = (localName: string, targetRaw: string | null): ParsedImport => (
targetRaw,
});
const dynamicResolved = (targetRaw: string): ParsedImport => ({
kind: 'dynamic-resolved',
targetRaw,
});
const sideEffect = (targetRaw: string): ParsedImport => ({ kind: 'side-effect', targetRaw });
const firstImport = (out: ReturnType<typeof finalize>, scope: ScopeId) => {
const imports = out.imports.get(scope);
return imports?.[0];
@ -171,6 +179,39 @@ describe('finalize', () => {
expect(edge.targetFile).toBeNull();
expect(edge.linkStatus).toBeUndefined();
});
it('links dynamic-resolved imports as file-level edges without bindings', () => {
const feature = file('feature', [def('def:feature.Feature', 'Class', 'feature.Feature')]);
const app = file('app', [], [dynamicResolved('feature')]);
const files = [app, feature];
const out = finalize({ files, workspaceIndex: undefined }, defaultHooks(files));
const edge = firstImport(out, app.moduleScope)!;
expect(edge.kind).toBe('dynamic-resolved');
expect(edge.targetFile).toBe('feature');
expect(edge.linkStatus).toBeUndefined();
expect(edge.targetDefId).toBeUndefined();
expect(bindingsFor(out, app.moduleScope, 'Feature')).toEqual([]);
expect(out.stats.linkedEdges).toBe(1);
});
it('links side-effect imports as file-level edges without bindings', () => {
const polyfill = file('polyfill', [
def('def:polyfill.install', 'Function', 'polyfill.install'),
]);
const app = file('app', [], [sideEffect('polyfill')]);
const files = [app, polyfill];
const out = finalize({ files, workspaceIndex: undefined }, defaultHooks(files));
const edge = firstImport(out, app.moduleScope)!;
expect(edge.kind).toBe('side-effect');
expect(edge.localName).toBe('');
expect(edge.targetFile).toBe('polyfill');
expect(edge.linkStatus).toBeUndefined();
expect(edge.targetDefId).toBeUndefined();
expect(bindingsFor(out, app.moduleScope, 'install')).toEqual([]);
expect(out.stats.linkedEdges).toBe(1);
});
});
describe('cycles + bounded fixpoint', () => {
@ -299,19 +340,24 @@ describe('finalize', () => {
expect(reexportEdge.transitiveVia).toEqual(['c']);
});
it('multi-hop re-export chains only resolve when intermediate files include the name in localDefs', () => {
// Contract (see FinalizeFile.localDefs doc): `finalize` looks up
// `importedName` in `B.localDefs`. If B re-exports X from C but does
// NOT include X in its own localDefs, A's import of X from B cannot
// resolve — the fixpoint doesn't mutate localDefs across iterations.
it('multi-hop re-export chains resolve via the precomputed closure even when intermediate files do not surface the name', () => {
// Contract (see FinalizeFile.localDefs doc): `finalize` first looks
// up `importedName` 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 re-export closure (`buildReexportClosures`),
// which encodes every name reachable through B's reexport/wildcard
// chain. The lookup is O(1) and inherits the leaf `targetDefId`.
// SCC-condensed iteration handles cyclic chains structurally.
//
// This test documents the current behavior: parsers that want
// multi-hop chains to settle end-to-end must surface re-exported
// names in the intermediate file's localDefs (with the original
// source DefId).
// The "thin" variant (B does not surface X) resolves to C's def
// via the re-export chain. The "thick" variant (B has its own X
// local def) short-circuits to B's local def — surfacing the name
// is a valid optimization that bypasses the recursive crawl, but
// it can also intentionally shadow the re-export.
const c = file('c', [def('def:c.X', 'Class', 'c.X')]);
// Variant 1: B does NOT include X in its own localDefs → A's import
// fails.
// Variant 1: B does NOT include X in its own localDefs.
const bThin = file('b', [], [reexport('X', 'X', 'c')]);
const aThin = file('a', [], [named('X', 'X', 'b')]);
const thinFiles = [aThin, bThin, c];
@ -319,22 +365,145 @@ describe('finalize', () => {
{ files: thinFiles, workspaceIndex: undefined },
defaultHooks(thinFiles),
);
expect(firstImport(thinOut, aThin.moduleScope)!.linkStatus).toBe('unresolved');
const thinEdge = firstImport(thinOut, aThin.moduleScope)!;
expect(thinEdge.linkStatus).toBeUndefined();
expect(thinEdge.targetDefId).toBe('def:c.X');
// `transitiveVia` records the chain: through B (target) into C (leaf).
expect(thinEdge.transitiveVia).toEqual(['b', 'c']);
// Variant 2: B includes X in its localDefs (re-exports surfaced) → A resolves.
const bThick = file(
'b',
[def('def:c.X', 'Class', 'b.X')], // B surfaces X with its own qname
[reexport('X', 'X', 'c')],
);
// Variant 2: B surfaces X via its OWN localDefs (distinct nodeId
// from C's X) → direct B.localDefs lookup short-circuits the
// closure consult. This is the "shadowing" optimization: when B
// explicitly surfaces a name, importers see B's def, not the
// upstream one. `transitiveVia` is undefined since no chain walk
// happened.
const bThick = file('b', [def('def:b.X', 'Class', 'b.X')], [reexport('X', 'X', 'c')]);
const aThick = file('a', [], [named('X', 'X', 'b')]);
const thickFiles = [aThick, bThick, c];
const thickOut = finalize(
{ files: thickFiles, workspaceIndex: undefined },
defaultHooks(thickFiles),
);
expect(firstImport(thickOut, aThick.moduleScope)!.linkStatus).toBeUndefined();
expect(firstImport(thickOut, aThick.moduleScope)!.targetDefId).toBe('def:c.X');
const thickEdge = firstImport(thickOut, aThick.moduleScope)!;
expect(thickEdge.linkStatus).toBeUndefined();
expect(thickEdge.targetDefId).toBe('def:b.X');
expect(thickEdge.transitiveVia).toBeUndefined();
});
it('resolves a 3-hop re-export chain (a → b → c → d) where intermediates do not surface the name', () => {
const d = file('d', [def('def:d.X', 'Class', 'd.X')]);
const c = file('c', [], [reexport('X', 'X', 'd')]);
const b = file('b', [], [reexport('X', 'X', 'c')]);
const a = file('a', [], [named('X', 'X', 'b')]);
const files = [a, b, c, d];
const out = finalize({ files, workspaceIndex: undefined }, defaultHooks(files));
const edge = firstImport(out, a.moduleScope)!;
expect(edge.linkStatus).toBeUndefined();
expect(edge.targetDefId).toBe('def:d.X');
expect(edge.transitiveVia).toEqual(['b', 'c', 'd']);
});
it('terminates without infinite recursion when re-exports cycle back through the chain', () => {
// Cycle: b re-exports from c, c re-exports from b. Neither surfaces
// X. The SCC-condensed closure builder lumps b+c into one cyclic
// SCC and runs a bounded fixpoint inside it; with no terminal def
// anywhere in the cycle, the closure entry for X never appears
// and the consuming edge is correctly marked unresolved. No call
// stack involvement at any point.
const c = file('c', [], [reexport('X', 'X', 'b')]);
const b = file('b', [], [reexport('X', 'X', 'c')]);
const a = file('a', [], [named('X', 'X', 'b')]);
const files = [a, b, c];
const out = finalize({ files, workspaceIndex: undefined }, defaultHooks(files));
const edge = firstImport(out, a.moduleScope)!;
expect(edge.targetFile).toBe('b');
expect(edge.linkStatus).toBe('unresolved');
expect(edge.targetDefId).toBeUndefined();
});
it('falls through wildcard re-exports via the precomputed closure', () => {
// B has `export * from './c'` (wildcard re-export). A imports `X`
// from B. The closure builder fans out the wildcard at B by
// copying every name from C's localDefs (and C's own closure)
// into B's closure, so the lookup at finalize time is O(1).
const c = file('c', [def('def:c.X', 'Class', 'c.X')]);
const b = file('b', [], [wildcard('c')]);
const a = file('a', [], [named('X', 'X', 'b')]);
const files = [a, b, c];
const out = finalize({ files, workspaceIndex: undefined }, defaultHooks(files));
const edge = firstImport(out, a.moduleScope)!;
expect(edge.linkStatus).toBeUndefined();
expect(edge.targetDefId).toBe('def:c.X');
});
it('resolves arbitrarily deep re-export chains without stack overflow (1000 hops, fully linked)', () => {
// Build a 1000-link chain a₀ → a₁ → … → a₁₀₀₀, where each
// intermediate is `export { X } from './aₙ₊₁'`. Only the last
// file holds the actual `def:X`. The legacy recursive
// implementation needed `MAX_REEXPORT_DEPTH=100` purely as a
// call-stack ceiling (anything deeper would crash); the new
// SCC-condensed iterative closure resolves the entire chain
// structurally with zero stack involvement. Asserting full
// resolution + accurate `transitiveVia` proves both that the
// recursion is gone AND that the closure correctly inherits
// the leaf def across all hops.
const CHAIN_LEN = 1000;
const chain: FinalizeFile[] = [];
for (let i = 0; i <= CHAIN_LEN; i++) {
const fp = `chain${i}`;
if (i === CHAIN_LEN) {
chain.push(file(fp, [def(`def:chain${i}.X`, 'Class', `chain${i}.X`)]));
} else {
chain.push(file(fp, [], [reexport('X', 'X', `chain${i + 1}`)]));
}
}
const consumer = file('consumer', [], [named('X', 'X', 'chain1')]);
const files = [consumer, ...chain];
const out = finalize({ files, workspaceIndex: undefined }, defaultHooks(files));
const edge = firstImport(out, consumer.moduleScope)!;
expect(edge.targetFile).toBe('chain1');
expect(edge.linkStatus).toBeUndefined();
expect(edge.targetDefId).toBe(`def:chain${CHAIN_LEN}.X`);
// `transitiveVia` enumerates every intermediate file from chain1
// through chain1000 — proves the closure walked the full path.
expect(edge.transitiveVia).toBeDefined();
expect(edge.transitiveVia!.length).toBe(CHAIN_LEN);
expect(edge.transitiveVia![0]).toBe('chain1');
expect(edge.transitiveVia![CHAIN_LEN - 1]).toBe(`chain${CHAIN_LEN}`);
});
it('first-match-wins when the closure encounters multiple sources for the same name', () => {
// B re-exports X from BOTH c and d. The closure builder walks
// re-exports in declaration order; first match wins.
// Resolution must be deterministic (no flaky picks).
const c = file('c', [def('def:c.X', 'Class', 'c.X')]);
const d = file('d', [def('def:d.X', 'Class', 'd.X')]);
const b = file('b', [], [reexport('X', 'X', 'c'), reexport('X', 'X', 'd')]);
const a = file('a', [], [named('X', 'X', 'b')]);
const files = [a, b, c, d];
const out = finalize({ files, workspaceIndex: undefined }, defaultHooks(files));
const edge = firstImport(out, a.moduleScope)!;
expect(edge.linkStatus).toBeUndefined();
// First re-export draft (`from 'c'`) wins. Recording this as the
// contract — if the algorithm changes to last-wins or merge, this
// test must be updated alongside the multi-binding propagation
// pass that mirrors typeBindings (which also uses first-wins).
expect(edge.targetDefId).toBe('def:c.X');
});
it('keeps first-wins precedence stable through cyclic shadowing', () => {
const c = file('c', [def('def:c.X', 'Class', 'c.X')]);
const d = file('d', [def('def:d.X', 'Class', 'd.X')]);
const a = file('a', [], [reexport('X', 'X', 'b'), reexport('X', 'X', 'd')]);
const b = file('b', [], [reexport('X', 'X', 'a'), reexport('X', 'X', 'c')]);
const consumer = file('consumer', [], [named('X', 'X', 'a')]);
const files = [consumer, a, b, c, d];
const out = finalize({ files, workspaceIndex: undefined }, defaultHooks(files));
const edge = firstImport(out, consumer.moduleScope)!;
expect(edge.linkStatus).toBeUndefined();
expect(edge.targetDefId).toBe('def:c.X');
expect(edge.transitiveVia).toEqual(['a', 'b', 'c']);
});
});
@ -407,6 +576,34 @@ describe('finalize', () => {
expect(bindings.some((br) => br.origin === 'import')).toBe(true);
});
it('resolves imported defs across many files via O(1) defById index lookup', () => {
// Regression for the materializeBindings O(N²) → O(1) fix:
// a single consumer importing one symbol from each of N other
// files must materialize an `import` binding for each. Prior to
// the fix, finding `def.X` for each edge meant re-scanning every
// file's localDefs (O(N × D × E)). With the index, every
// `defById.get` is O(1), so this test stays under 50 ms even
// at N=200.
const N = 200;
const leafFiles: FinalizeFile[] = [];
const imports: ParsedImport[] = [];
for (let i = 0; i < N; i++) {
const fp = `leaf${i}`;
const localName = `Leaf${i}`;
leafFiles.push(file(fp, [def(`def:${fp}.${localName}`, 'Class', `${fp}.${localName}`)]));
imports.push(named(localName, localName, fp));
}
const consumer = file('consumer', [], imports);
const files = [consumer, ...leafFiles];
const out = finalize({ files, workspaceIndex: undefined }, defaultHooks(files));
for (let i = 0; i < N; i++) {
const bindings = bindingsFor(out, consumer.moduleScope, `Leaf${i}`);
expect(bindings.length).toBe(1);
expect(bindings[0]!.origin).toBe('import');
expect(bindings[0]!.def.nodeId).toBe(`def:leaf${i}.Leaf${i}`);
}
});
it('honors provider precedence: mergeBindings can drop existing bindings', () => {
// Provider decides imports win over locals (Python-ish precedence).
const b = file('b', [def('def:b.User', 'Class', 'b.User')]);

View file

@ -0,0 +1,232 @@
/**
* Unit tests for `propagateImportedReturnTypes` the SCC-ordered
* cross-file return-type typeBinding propagation pass introduced in
* PR #1050 (RFC #909 Ring 3 / TypeScript registry-primary migration).
*
* The pass walks `indexes.sccs` in reverse-topological order (leaves
* first) so multi-hop alias chains collapse to the terminal class in
* a single pass. These unit tests pin the specific invariants:
*
* 1. **Topological collapse** a 4-file alias chain resolves end-
* to-end (`models.User → service.user → util.alias → app.x`)
* in a single pass.
* 2. **Local-annotation guard** an explicit local typeBinding wins
* over an import-derived one.
* 3. **Missing-source skip** an import whose source module has no
* typeBinding for the symbol is silently skipped (no crash, no
* garbage binding).
* 4. **Cyclic SCC partial fixpoint** the pass does not throw and
* makes best-effort progress.
*
* The tests use the real TypeScript scope-resolver to keep them
* close to production behavior synthetic ParsedFiles would have
* to fabricate `Scope.typeBindings` correctly, defeating the
* purpose. Each fixture is small (4 files max) and runs in <100ms.
*/
import { describe, it, expect } from 'vitest';
import { extractParsedFile } from '../../../src/core/ingestion/scope-extractor-bridge.js';
import { typescriptScopeResolver } from '../../../src/core/ingestion/languages/typescript/scope-resolver.js';
import { finalizeScopeModel } from '../../../src/core/ingestion/finalize-orchestrator.js';
import { buildWorkspaceResolutionIndex } from '../../../src/core/ingestion/scope-resolution/workspace-index.js';
import { propagateImportedReturnTypes } from '../../../src/core/ingestion/scope-resolution/passes/imported-return-types.js';
import type { ParsedFile } from 'gitnexus-shared';
interface InMemoryFile {
readonly path: string;
readonly content: string;
}
function parseAll(files: readonly InMemoryFile[]): ParsedFile[] {
const parsed: ParsedFile[] = [];
for (const f of files) {
const p = extractParsedFile(typescriptScopeResolver.languageProvider, f.content, f.path);
if (p === undefined) throw new Error(`scope extraction failed for ${f.path}`);
typescriptScopeResolver.populateOwners(p);
parsed.push(p);
}
return parsed;
}
function runPipelineToPropagation(files: readonly InMemoryFile[]) {
const parsedFiles = parseAll(files);
const allFilePaths = new Set(parsedFiles.map((p) => p.filePath));
const finalized = finalizeScopeModel(parsedFiles, {
hooks: {
resolveImportTarget: (targetRaw, fromFile) =>
typescriptScopeResolver.resolveImportTarget(targetRaw, fromFile, allFilePaths),
mergeBindings: (existing, incoming, scopeId) =>
typescriptScopeResolver.mergeBindings(existing, incoming, scopeId),
},
});
const workspaceIndex = buildWorkspaceResolutionIndex(parsedFiles);
propagateImportedReturnTypes(parsedFiles, finalized, workspaceIndex);
return { parsedFiles, finalized, workspaceIndex };
}
function moduleTypeBinding(parsedFiles: readonly ParsedFile[], filePath: string, name: string) {
const parsed = parsedFiles.find((p) => p.filePath === filePath);
if (parsed === undefined) throw new Error(`no ParsedFile for ${filePath}`);
const moduleScope = parsed.scopes.find((s) => s.id === parsed.moduleScope);
if (moduleScope === undefined) throw new Error(`no module scope for ${filePath}`);
return moduleScope.typeBindings.get(name);
}
describe('propagateImportedReturnTypes — SCC-ordered terminal-type collapse', () => {
it('collapses a 4-file alias chain in a single pass (leaves first)', () => {
// models.ts: declares User; getUser() returns User.
// service.ts: imports getUser, exports `user = getUser()`.
// util.ts: imports user from service, re-binds as `alias`.
// app.ts: imports alias from util.
//
// Expected: app.alias.typeBindings → User (terminal class), not
// an intermediate alias/getUser/user ref. SCC order is
// models → service → util → app (reverse-topological).
const { parsedFiles } = runPipelineToPropagation([
{
path: 'models.ts',
content: `
export class User {
save(): boolean { return true; }
}
export function getUser(): User {
return new User();
}
`,
},
{
path: 'service.ts',
content: `
import { getUser } from './models';
export const user = getUser();
`,
},
{
path: 'util.ts',
content: `
import { user } from './service';
export const alias = user;
`,
},
{
path: 'app.ts',
content: `
import { alias } from './util';
`,
},
]);
const appAlias = moduleTypeBinding(parsedFiles, 'app.ts', 'alias');
expect(appAlias).toBeDefined();
// The terminal type is `User`. The exact rawName depends on the
// TS extractor's annotation capture (return-type vs inferred-from-
// call); we assert the chain has collapsed away from the
// intermediate `getUser` / `user` / `alias` rawNames.
expect(appAlias!.rawName).toBe('User');
});
it('respects local-annotation guard: explicit local typeBinding wins over import-derived', () => {
// app.ts: imports `user` from service AND has a local
// `const user: Account = ...` annotation. The local annotation
// must win — propagation must skip when the importer already
// has a typeBinding for the same name.
const { parsedFiles } = runPipelineToPropagation([
{
path: 'models.ts',
content: `
export class User {}
export class Account {}
export function getUser(): User {
return new User();
}
`,
},
{
path: 'service.ts',
content: `
import { getUser } from './models';
export const user = getUser();
`,
},
{
path: 'app.ts',
content: `
import { user as importedUser } from './service';
const user: Account = new Account();
`,
},
]);
// The module-scope `user` binding should be `Account` (local
// annotation), NOT `User` (import-mirrored). The imported alias
// `importedUser` may carry the User type — only the LOCAL `user`
// is shielded.
const appUser = moduleTypeBinding(parsedFiles, 'app.ts', 'user');
expect(appUser).toBeDefined();
expect(appUser!.rawName).toBe('Account');
});
it('skips imports whose source module has no typeBinding (no crash, no phantom binding)', () => {
// service.ts exports `helper` but has NO return-type annotation,
// so service.ts's module typeBindings does NOT include `helper`.
// app.ts imports helper → propagation must skip silently.
const { parsedFiles } = runPipelineToPropagation([
{
path: 'service.ts',
content: `
export function helper(x) {
return x;
}
`,
},
{
path: 'app.ts',
content: `
import { helper } from './service';
`,
},
]);
// No phantom binding for `helper` in app's module scope (or, if
// present, it MUST resolve to a real type — never to `undefined`
// or an empty rawName). The pass simply does nothing for this
// import, and the test verifies the pipeline did not throw.
const appHelper = moduleTypeBinding(parsedFiles, 'app.ts', 'helper');
if (appHelper !== undefined) {
expect(appHelper.rawName.length).toBeGreaterThan(0);
}
});
it('does not throw on a cyclic SCC (partial fixpoint, best-effort)', () => {
// a.ts imports from b, b.ts imports from a. The two files form
// a single cyclic SCC. Within one pass we mirror what we can;
// we do NOT iterate to convergence. The contract is "no throw,
// best effort" — see ts-circular cross-file-binding fixture.
expect(() =>
runPipelineToPropagation([
{
path: 'a.ts',
content: `
import { B } from './b';
export class A {
b: B;
constructor() { this.b = new B(); }
}
export function getA(): A { return new A(); }
`,
},
{
path: 'b.ts',
content: `
import { A } from './a';
export class B {
a: A;
constructor() { this.a = new A(); }
}
export function getB(): B { return new B(); }
`,
},
]),
).not.toThrow();
});
});

View file

@ -0,0 +1,549 @@
/**
* Unit 1 coverage for the TypeScript scope query + captures orchestrator.
*
* Pins the capture-tag vocabulary + range shape for every construct the
* scope-resolution pipeline reads. Runs against tree-sitter-typescript so it
* catches grammar drift (node renames, field-name changes) before the
* integration parity gate does.
*
* Import-decomposition assertions (per-specifier markers, `type`-only
* flagging, dynamic-import normalization) live in
* `typescript-imports.test.ts` alongside Unit 2.
* Receiver-binding and arity-metadata assertions live in
* `typescript-hooks.test.ts` alongside Units 35.
*/
import { describe, it, expect } from 'vitest';
import { emitTsScopeCaptures } from '../../../../src/core/ingestion/languages/typescript/captures.js';
function tagsFor(src: string): string[][] {
const matches = emitTsScopeCaptures(src, 'test.ts');
return matches.map((m) => Object.keys(m).sort());
}
function findMatch(src: string, predicate: (tags: string[]) => boolean) {
const matches = emitTsScopeCaptures(src, 'test.ts');
return matches.find((m) => predicate(Object.keys(m)));
}
function countMatches(src: string, predicate: (tags: string[]) => boolean): number {
const matches = emitTsScopeCaptures(src, 'test.ts');
return matches.filter((m) => predicate(Object.keys(m))).length;
}
describe('emitTsScopeCaptures — scopes', () => {
it('captures the program as @scope.module', () => {
const all = tagsFor('class A { }');
expect(all.some((t) => t.includes('@scope.module'))).toBe(true);
});
it('captures internal_module as @scope.namespace', () => {
const all = tagsFor('namespace Foo { class A { } }');
expect(all.some((t) => t.includes('@scope.namespace'))).toBe(true);
});
it('captures nested namespaces (namespace A.B)', () => {
// `namespace A.B { ... }` desugars to nested internal_module; we emit
// @scope.namespace for the outer one. The grammar represents this as
// a single internal_module with a nested_identifier name.
const all = tagsFor('namespace A.B { class C { } }');
expect(all.some((t) => t.includes('@scope.namespace'))).toBe(true);
});
it('captures classes, interfaces, enums, abstract classes as @scope.class', () => {
// All four class-like kinds collapse to @scope.class at the scope
// layer because they share member-holding scope semantics. Declaration
// tags distinguish them.
const src = `
class A { }
abstract class B { }
interface C { }
enum D { V }
`;
const count = countMatches(src, (t) => t.includes('@scope.class'));
expect(count).toBe(4);
});
it('captures type aliases with object_type as @scope.class', () => {
// Structural types with named members are class-like for scope
// purposes (members are declarations attached to a named scope).
// This is what the field-extractor's type-alias-with-object-type
// handling expects.
const all = tagsFor('type User = { name: string; save(): void }');
expect(all.some((t) => t.includes('@scope.class'))).toBe(true);
});
it('captures functions, methods, arrows, generators, signatures as @scope.function', () => {
const src = `
function f() { }
function* gen() { }
const arrow = () => { };
const fnExpr = function() { };
class A {
m() { }
constructor() { }
get x() { return 1; }
set x(v) { }
}
interface I { m(): void }
abstract class B { abstract m(): void }
function overload(x: string): void;
function overload(x: number): void;
function overload(x: any) { }
`;
const count = countMatches(src, (t) => t.includes('@scope.function'));
// 1 fn + 1 gen + 1 arrow + 1 fnExpr + 4 methods (m,ctor,get x,set x)
// + 1 interface method signature + 1 abstract_method_signature
// + 3 overload signatures (2 sig + 1 impl) = 13
expect(count).toBe(13);
});
});
describe('emitTsScopeCaptures — declarations', () => {
it('captures class declarations with @declaration.class + @declaration.name', () => {
const m = findMatch('class User { }', (t) => t.includes('@declaration.class'));
expect(m).toBeDefined();
expect(m!['@declaration.name'].text).toBe('User');
});
it('captures abstract class declarations under @declaration.class', () => {
const m = findMatch('abstract class Base { }', (t) => t.includes('@declaration.class'));
expect(m).toBeDefined();
expect(m!['@declaration.name'].text).toBe('Base');
});
it('captures interface declarations distinctly from class declarations', () => {
const m = findMatch('interface IUser { }', (t) => t.includes('@declaration.interface'));
expect(m).toBeDefined();
expect(m!['@declaration.name'].text).toBe('IUser');
});
it('captures enum declarations under @declaration.enum', () => {
const m = findMatch('enum Status { A, B }', (t) => t.includes('@declaration.enum'));
expect(m).toBeDefined();
expect(m!['@declaration.name'].text).toBe('Status');
});
it('captures type-alias declarations under @declaration.type', () => {
const m = findMatch('type ID = string;', (t) => t.includes('@declaration.type'));
expect(m).toBeDefined();
expect(m!['@declaration.name'].text).toBe('ID');
});
it('captures namespace declarations under @declaration.namespace', () => {
const m = findMatch('namespace NS { class A {} }', (t) => t.includes('@declaration.namespace'));
expect(m).toBeDefined();
expect(m!['@declaration.name'].text).toBe('NS');
});
it('captures function declarations with their name', () => {
const m = findMatch('function compute() { }', (t) => t.includes('@declaration.function'));
expect(m).toBeDefined();
expect(m!['@declaration.name'].text).toBe('compute');
});
it('captures function_signature (overload decl) under @declaration.function', () => {
// `function f(x: string): void;` is a function_signature (no body).
// Needed so the extractor sees all overload decls and can dedup by
// parameterTypes.
const m = findMatch('function f(x: string): void;', (t) => t.includes('@declaration.function'));
expect(m).toBeDefined();
expect(m!['@declaration.name'].text).toBe('f');
});
it('captures generator function declarations', () => {
const m = findMatch('function* gen() { yield 1; }', (t) => t.includes('@declaration.function'));
expect(m).toBeDefined();
expect(m!['@declaration.name'].text).toBe('gen');
});
it('captures `const fn = () => {}` as both @declaration.function and @declaration.variable', () => {
// Dual classification is load-bearing: downstream consumers expect a
// Function def for call targets + a Variable def for name resolution.
// The central extractor dedupes by node position via FUNCTION_NODE_TYPES.
const src = 'const fn = () => { };';
const fnCount = countMatches(src, (t) => t.includes('@declaration.function'));
const varCount = countMatches(src, (t) => t.includes('@declaration.variable'));
expect(fnCount).toBe(1);
expect(varCount).toBe(1);
});
it('captures method_definition, abstract_method_signature, method_signature under @declaration.method', () => {
const src = `
class A {
m() { }
}
abstract class B {
abstract m(): void;
}
interface I {
m(): void;
}
`;
const count = countMatches(src, (t) => t.includes('@declaration.method'));
expect(count).toBe(3);
});
it('captures private (#) methods under @declaration.method', () => {
// ES2022 private methods use private_property_identifier, not
// property_identifier — covered by a distinct pattern.
const m = findMatch('class A { #secret() { } }', (t) => t.includes('@declaration.method'));
expect(m).toBeDefined();
expect(m!['@declaration.name'].text).toBe('#secret');
});
it('captures class fields under @declaration.property', () => {
const m = findMatch('class A { x: number = 1; }', (t) => t.includes('@declaration.property'));
expect(m).toBeDefined();
expect(m!['@declaration.name'].text).toBe('x');
});
it('captures private (#) fields under @declaration.property', () => {
const m = findMatch('class A { #x: number = 1; }', (t) => t.includes('@declaration.property'));
expect(m).toBeDefined();
expect(m!['@declaration.name'].text).toBe('#x');
});
it('captures parameter properties as both @declaration.property AND @type-binding.parameter', () => {
// `constructor(public name: string)` is TypeScript syntactic sugar
// for: declare a `name` field AND a `name` parameter. Both bindings
// must fire so (a) the field is visible for `this.name` and (b) the
// parameter binds as a typed local in the constructor scope.
const src = 'class A { constructor(public readonly name: string, private age: number) { } }';
const propCount = countMatches(src, (t) => t.includes('@declaration.property'));
expect(propCount).toBe(2);
const paramCount = countMatches(src, (t) => t.includes('@type-binding.parameter'));
expect(paramCount).toBe(2);
});
it('captures `let x: number`, `const y`, `var z` under @declaration.variable', () => {
const src = 'let x: number = 1; const y = 2; var z = 3;';
const count = countMatches(src, (t) => t.includes('@declaration.variable'));
expect(count).toBe(3);
});
});
describe('emitTsScopeCaptures — imports (decomposed)', () => {
it('decomposes each import form into @import.statement + @import.kind markers', () => {
// `import { A } from './a'` → 1 match (named)
// `import B from './b'` → 1 match (default)
// `import * as ns from './ns'` → 1 match (namespace)
// `import './polyfill'` → 1 match (side-effect; file-level edge only)
const src = `
import { A } from './a';
import B from './b';
import * as ns from './ns';
import './polyfill';
`;
const count = countMatches(src, (t) => t.includes('@import.statement'));
expect(count).toBe(4);
// Each has the corresponding @import.kind marker.
const kinds = tagsFor(src)
.filter((tags) => tags.includes('@import.statement'))
.map((tags) => {
const idx = tags.findIndex((t) => t === '@import.kind');
return idx >= 0 ? tags[idx] : null;
});
expect(kinds).toHaveLength(4);
});
it('decomposes multi-specifier imports into one match per name', () => {
// `import D, { X, Y as Z } from './m'` → 3 matches
// default D, named X, named-alias Y→Z
const src = "import D, { X, Y as Z } from './m';";
const importMatches = tagsFor(src).filter((tags) => tags.includes('@import.statement'));
expect(importMatches).toHaveLength(3);
const m = findMatch(src, (t) => t.includes('@import.statement'));
expect(m).toBeDefined();
expect(m!['@import.source'].text).toBe('./m');
});
it('decomposes re-exports with `from` source into @import.statement + kind markers', () => {
// `export { A } from './a'` → 1 match (reexport)
// `export * from './b'` → 1 match (reexport-wildcard)
// `export * as ns from './c'` → 1 match (reexport-namespace)
// `export type { T } from './t'` → 1 match (reexport; type-only folds in)
const src = `
export { A } from './a';
export * from './b';
export * as ns from './c';
export type { T } from './t';
`;
const count = countMatches(src, (t) => t.includes('@import.statement'));
expect(count).toBe(4);
});
it('does NOT capture local (non-reexport) `export { X }` as @import.statement', () => {
const src = 'const X = 1; export { X };';
const count = countMatches(src, (t) => t.includes('@import.statement'));
expect(count).toBe(0);
});
it('decomposes dynamic `import()` calls into @import.statement + kind=dynamic', () => {
const src = "const m = import('./m');";
const m = findMatch(src, (t) => t.includes('@import.statement'));
expect(m).toBeDefined();
expect(m!['@import.kind'].text).toBe('dynamic');
expect(m!['@import.source'].text).toBe('./m');
});
it('marks literal dynamic imports with @import.literal so the interpreter can flag them resolvable', () => {
const src = "const m = import('./m');";
const m = findMatch(src, (t) => t.includes('@import.statement'));
expect(m).toBeDefined();
expect(m!['@import.literal']).toBeDefined();
});
it('does NOT mark non-literal dynamic imports with @import.literal', () => {
const src = 'const m = import(spec);';
const m = findMatch(src, (t) => t.includes('@import.statement'));
expect(m).toBeDefined();
expect(m!['@import.literal']).toBeUndefined();
});
it('emits a synthetic @declaration.namespace for `export * as ns from "./m"` (barrel binding)', () => {
const src = "export * as Models from './base';";
const m = findMatch(src, (t) => t.includes('@declaration.namespace'));
expect(m).toBeDefined();
expect(m!['@declaration.name'].text).toBe('Models');
});
});
describe('emitTsScopeCaptures — type bindings', () => {
it('captures parameter annotations (object types)', () => {
// Primitive annotations (`x: number`) fire separately via the
// predefined_type pattern; object-typed parameters are what the
// receiver-bound dispatch actually consumes.
const m = findMatch('function f(u: User) { }', (t) => t.includes('@type-binding.parameter'));
expect(m).toBeDefined();
expect(m!['@type-binding.name'].text).toBe('u');
expect(m!['@type-binding.type'].text).toBe('User');
});
it('captures variable annotations', () => {
const m = findMatch('const u: User = x;', (t) => t.includes('@type-binding.annotation'));
expect(m).toBeDefined();
expect(m!['@type-binding.name'].text).toBe('u');
expect(m!['@type-binding.type'].text).toBe('User');
});
it('captures constructor-inferred `const u = new User()`', () => {
const m = findMatch('const u = new User();', (t) => t.includes('@type-binding.constructor'));
expect(m).toBeDefined();
expect(m!['@type-binding.name'].text).toBe('u');
expect(m!['@type-binding.type'].text).toBe('User');
});
it('captures qualified constructor `const u = new ns.User()`', () => {
const m = findMatch('const u = new ns.User();', (t) => t.includes('@type-binding.constructor'));
expect(m).toBeDefined();
// member_expression text is the dotted path; resolver handles.
expect(m!['@type-binding.type'].text).toBe('ns.User');
});
it('captures call-result alias `const u = factory()`', () => {
const m = findMatch('const u = factory();', (t) => t.includes('@type-binding.alias'));
expect(m).toBeDefined();
expect(m!['@type-binding.name'].text).toBe('u');
expect(m!['@type-binding.type'].text).toBe('factory');
});
it('captures member-call alias `const u = svc.getUser()`', () => {
const m = findMatch('const u = svc.getUser();', (t) => t.includes('@type-binding.alias'));
expect(m).toBeDefined();
expect(m!['@type-binding.name'].text).toBe('u');
expect(m!['@type-binding.type'].text).toBe('svc.getUser');
});
it('captures await alias `const u = await factory()`', () => {
const m = findMatch('async function f() { const u = await factory(); }', (t) =>
t.includes('@type-binding.alias'),
);
expect(m).toBeDefined();
expect(m!['@type-binding.name'].text).toBe('u');
expect(m!['@type-binding.type'].text).toBe('factory');
});
it('captures identifier alias `const u2 = u`', () => {
const m = findMatch('const u2 = u;', (t) => t.includes('@type-binding.alias'));
expect(m).toBeDefined();
expect(m!['@type-binding.name'].text).toBe('u2');
expect(m!['@type-binding.type'].text).toBe('u');
});
it('captures `as` assertion `const u = x as User`', () => {
const m = findMatch('const u = x as User;', (t) => t.includes('@type-binding.assertion'));
expect(m).toBeDefined();
expect(m!['@type-binding.name'].text).toBe('u');
expect(m!['@type-binding.type'].text).toBe('User');
});
it('documents limitation: member-expression `instanceof` narrowing is not synthesized', () => {
const m = findMatch('if (user.address instanceof Address) { user.address.save(); }', (t) =>
t.includes('@type-binding.assertion'),
);
expect(m).toBeUndefined();
});
it('captures class field annotations', () => {
// Field-level @type-binding.annotation and @declaration.property fire
// as separate matches (different query patterns), not combined on one
// match. Both must be present — @declaration.property so the field is
// visible as a class-scope member, @type-binding.annotation so
// `this.city.save()` can resolve via the type chain.
const src = 'class A { city: City; }';
const annotation = findMatch(src, (t) => t.includes('@type-binding.annotation'));
expect(annotation).toBeDefined();
expect(annotation!['@type-binding.name'].text).toBe('city');
expect(annotation!['@type-binding.type'].text).toBe('City');
const decl = findMatch(src, (t) => t.includes('@declaration.property'));
expect(decl).toBeDefined();
expect(decl!['@declaration.name'].text).toBe('city');
});
it('captures method return type `save(): User { }`', () => {
const m = findMatch('class A { save(): User { return this; } }', (t) =>
t.includes('@type-binding.return'),
);
expect(m).toBeDefined();
expect(m!['@type-binding.name'].text).toBe('save');
expect(m!['@type-binding.type'].text).toBe('User');
});
it('captures function return type `function f(): User { }`', () => {
const m = findMatch('function f(): User { return null; }', (t) =>
t.includes('@type-binding.return'),
);
expect(m).toBeDefined();
expect(m!['@type-binding.name'].text).toBe('f');
expect(m!['@type-binding.type'].text).toBe('User');
});
it('captures for-of element binding `for (const u of users)`', () => {
// u binds to `users` (the iterable identifier); chain-follow unwraps
// via stripGeneric in interpret.ts.
const m = findMatch(
'function f() { for (const u of users) { u.save(); } }',
(t) => t.includes('@type-binding.alias') && !t.includes('@reference.call.member'),
);
expect(m).toBeDefined();
expect(m!['@type-binding.name'].text).toBe('u');
expect(m!['@type-binding.type'].text).toBe('users');
});
});
describe('emitTsScopeCaptures — references', () => {
it('captures free calls `factory()`', () => {
const m = findMatch('function f() { factory(); }', (t) => t.includes('@reference.call.free'));
expect(m).toBeDefined();
expect(m!['@reference.name'].text).toBe('factory');
});
it('captures member calls `obj.method()`', () => {
const m = findMatch('function f() { obj.method(); }', (t) =>
t.includes('@reference.call.member'),
);
expect(m).toBeDefined();
expect(m!['@reference.name'].text).toBe('method');
expect(m!['@reference.receiver'].text).toBe('obj');
});
it('captures `this.method()` — this is a named node, receiver captured via (_)', () => {
// C#'s query needs explicit "this" / "base" patterns because those
// tokens are anonymous; TS's (this) is a NAMED node, so the (_)
// wildcard catches it uniformly with identifier receivers.
const m = findMatch(
'class A { m() { this.save(); } }',
(t) => t.includes('@reference.call.member') && t.includes('@reference.receiver'),
);
expect(m).toBeDefined();
expect(m!['@reference.receiver'].text).toBe('this');
expect(m!['@reference.name'].text).toBe('save');
});
it('captures `super.method()` — super is a named node too', () => {
const m = findMatch(
'class A extends B { m() { super.save(); } }',
(t) => t.includes('@reference.call.member') && t.includes('@reference.receiver'),
);
expect(m).toBeDefined();
// Multiple member calls in this fixture (just one expected though)
expect(m!['@reference.receiver'].text).toBe('super');
expect(m!['@reference.name'].text).toBe('save');
});
it('captures optional-chaining member calls `obj?.m()`', () => {
// The optional_chain node sits between object and property but
// doesn't block the named fields, so the same member_expression
// pattern matches. Downstream impact: `?.` calls appear as normal
// member calls — the null-safety aspect isn't part of the graph.
const m = findMatch('function f() { obj?.m(); }', (t) => t.includes('@reference.call.member'));
expect(m).toBeDefined();
expect(m!['@reference.name'].text).toBe('m');
expect(m!['@reference.receiver'].text).toBe('obj');
});
it('captures constructor calls `new User()`', () => {
const m = findMatch('function f() { new User(); }', (t) =>
t.includes('@reference.call.constructor'),
);
expect(m).toBeDefined();
expect(m!['@reference.name'].text).toBe('User');
});
it('captures qualified constructor calls `new ns.User()`', () => {
const m = findMatch('function f() { new ns.User(); }', (t) =>
t.includes('@reference.call.constructor.qualified'),
);
expect(m).toBeDefined();
expect(m!['@reference.call.constructor.qualified'].text).toBe('ns.User');
});
it('captures member writes `obj.x = 1`', () => {
const m = findMatch('function f() { obj.x = 1; }', (t) =>
t.includes('@reference.write.member'),
);
expect(m).toBeDefined();
expect(m!['@reference.name'].text).toBe('x');
expect(m!['@reference.receiver'].text).toBe('obj');
});
it('captures compound assignment writes `obj.x += 1`', () => {
const m = findMatch('function f() { obj.x += 1; }', (t) =>
t.includes('@reference.write.member'),
);
expect(m).toBeDefined();
expect(m!['@reference.name'].text).toBe('x');
expect(m!['@reference.receiver'].text).toBe('obj');
});
});
describe('emitTsScopeCaptures — edge cases', () => {
it('does not crash on parse errors (recovery)', () => {
// tree-sitter recovers from syntax errors by producing ERROR nodes;
// the query should still produce captures from valid subtrees.
const src = 'function f( { const x = 1; } class B { }';
expect(() => emitTsScopeCaptures(src, 'test.ts')).not.toThrow();
});
it('does not emit duplicate captures for the same node across multiple pattern hits', () => {
// If two patterns matched the same capture position we'd see the
// same tag repeated on a match. Sanity check the grouping doesn't
// collapse distinct tags.
const matches = emitTsScopeCaptures('class A { m() { } }', 'test.ts');
for (const m of matches) {
const tags = Object.keys(m);
expect(new Set(tags).size).toBe(tags.length);
}
});
it('handles empty input gracefully', () => {
expect(() => emitTsScopeCaptures('', 'test.ts')).not.toThrow();
});
});

View file

@ -0,0 +1,806 @@
/**
* Unit 3 coverage for TypeScript simple hooks + receiver-binding synthesis.
*
* Exercises the small-surface hooks that mirror Python's simple-hooks:
* `tsBindingScopeFor`, `tsImportOwningScope`, `tsReceiverBinding`. Also
* covers AST-walking `synthesizeTsReceiverBinding`, which is the
* TypeScript analog of Python's `self` / C#'s `this`+`base` synthesis.
*
* `isSuperReceiver` and `mergeBindings` live on the ScopeResolver
* contract and are exercised in later units.
*/
import { describe, it, expect } from 'vitest';
import {
tsBindingScopeFor,
tsImportOwningScope,
tsReceiverBinding,
} from '../../../../src/core/ingestion/languages/typescript/simple-hooks.js';
import { synthesizeTsReceiverBinding } from '../../../../src/core/ingestion/languages/typescript/receiver-binding.js';
import { typescriptMergeBindings } from '../../../../src/core/ingestion/languages/typescript/merge-bindings.js';
import { typescriptProvider } from '../../../../src/core/ingestion/languages/typescript.js';
import { typescriptArityCompatibility } from '../../../../src/core/ingestion/languages/typescript/arity.js';
import { computeTsArityMetadata } from '../../../../src/core/ingestion/languages/typescript/arity-metadata.js';
import { getTsParser } from '../../../../src/core/ingestion/languages/typescript/query.js';
import { emitTsScopeCaptures } from '../../../../src/core/ingestion/languages/typescript/captures.js';
import {
findNodeAtRange,
type SyntaxNode,
} from '../../../../src/core/ingestion/utils/ast-helpers.js';
import type {
BindingRef,
Callsite,
CaptureMatch,
NodeLabel,
ParsedImport,
Scope,
ScopeId,
ScopeTree,
SymbolDefinition,
TypeRef,
} from 'gitnexus-shared';
// ─── Fake scope helpers ───────────────────────────────────────────────────
interface FakeScopeInit {
readonly kind: Scope['kind'];
readonly id?: ScopeId;
readonly parent?: ScopeId | null;
readonly typeBindings?: Map<string, TypeRef>;
}
function fakeScope(init: FakeScopeInit): Scope {
return {
id: (init.id ?? 's1') as ScopeId,
parent: init.parent ?? null,
kind: init.kind,
range: { startLine: 1, startCol: 0, endLine: 1, endCol: 0 },
filePath: 't.ts',
bindings: new Map(),
ownedDefs: [],
imports: [],
typeBindings: init.typeBindings ?? new Map<string, TypeRef>(),
} as unknown as Scope;
}
function fakeTree(scopes: readonly Scope[]): ScopeTree {
const byId = new Map<ScopeId, Scope>();
for (const s of scopes) byId.set(s.id, s);
return {
getScope: (id: ScopeId) => byId.get(id),
} as unknown as ScopeTree;
}
function typeRef(rawName: string, source: TypeRef['source'] = 'self'): TypeRef {
return {
rawName,
declaredAtScope: 's-decl' as ScopeId,
source,
};
}
const emptyTree = {} as ScopeTree;
// ─── Capture helpers ──────────────────────────────────────────────────────
/** Build a minimal fake CaptureMatch carrying just the tags/text the
* hook under test inspects. */
function fakeCapture(tag: string, text: string, extras: Record<string, string> = {}): CaptureMatch {
const mk = (t: string, src: string) => ({
name: t,
range: { startLine: 1, startCol: 0, endLine: 1, endCol: src.length },
text: src,
});
const m: Record<string, ReturnType<typeof mk>> = {};
m[tag] = mk(tag, text);
for (const [k, v] of Object.entries(extras)) m[k] = mk(k, v);
return m as unknown as CaptureMatch;
}
// ─── tsBindingScopeFor ────────────────────────────────────────────────────
describe('tsBindingScopeFor — let/const (block-scoped)', () => {
it('delegates to innermost for `let` declarations', () => {
const block = fakeScope({ kind: 'Block', id: 'blk' as ScopeId });
const cap = fakeCapture('@declaration.variable', 'let x = 1');
expect(tsBindingScopeFor(cap, block, emptyTree)).toBe(null);
});
it('delegates to innermost for `const` declarations', () => {
const block = fakeScope({ kind: 'Block', id: 'blk' as ScopeId });
const cap = fakeCapture('@declaration.variable', 'const x = 1');
expect(tsBindingScopeFor(cap, block, emptyTree)).toBe(null);
});
});
describe('tsBindingScopeFor — var (hoisted to function/module)', () => {
it('hoists `var` from a nested block to the enclosing function scope', () => {
const fn = fakeScope({ kind: 'Function', id: 'fn' as ScopeId });
const blk = fakeScope({
kind: 'Block',
id: 'blk' as ScopeId,
parent: 'fn' as ScopeId,
});
const tree = fakeTree([fn, blk]);
const cap = fakeCapture('@declaration.variable', 'var x = 1');
expect(tsBindingScopeFor(cap, blk, tree)).toBe('fn');
});
it('hoists top-level `var` to the enclosing module scope', () => {
const mod = fakeScope({ kind: 'Module', id: 'mod' as ScopeId });
const blk = fakeScope({
kind: 'Block',
id: 'blk' as ScopeId,
parent: 'mod' as ScopeId,
});
const tree = fakeTree([mod, blk]);
const cap = fakeCapture('@declaration.variable', 'var x = 1');
expect(tsBindingScopeFor(cap, blk, tree)).toBe('mod');
});
it('stops at the innermost Function even when a Module is above it', () => {
const mod = fakeScope({ kind: 'Module', id: 'mod' as ScopeId });
const fn = fakeScope({
kind: 'Function',
id: 'fn' as ScopeId,
parent: 'mod' as ScopeId,
});
const blk = fakeScope({
kind: 'Block',
id: 'blk' as ScopeId,
parent: 'fn' as ScopeId,
});
const tree = fakeTree([mod, fn, blk]);
const cap = fakeCapture('@declaration.variable', 'var x = 1');
expect(tsBindingScopeFor(cap, blk, tree)).toBe('fn');
});
});
describe('tsBindingScopeFor — method return types', () => {
it('hoists @type-binding.return to the enclosing Module scope', () => {
const mod = fakeScope({ kind: 'Module', id: 'mod' as ScopeId });
const cls = fakeScope({
kind: 'Class',
id: 'cls' as ScopeId,
parent: 'mod' as ScopeId,
});
const fn = fakeScope({
kind: 'Function',
id: 'fn' as ScopeId,
parent: 'cls' as ScopeId,
});
const tree = fakeTree([mod, cls, fn]);
const cap = fakeCapture('@type-binding.return', 'save', {
'@type-binding.name': 'save',
'@type-binding.type': 'User',
});
expect(tsBindingScopeFor(cap, fn, tree)).toBe('mod');
});
});
// ─── tsImportOwningScope ──────────────────────────────────────────────────
describe('tsImportOwningScope', () => {
const fakeImport: ParsedImport = {
kind: 'named',
localName: 'X',
importedName: 'X',
targetRaw: './m',
} as unknown as ParsedImport;
it('delegates to the central default (returns null)', () => {
const mod = fakeScope({ kind: 'Module', id: 'mod' as ScopeId });
expect(tsImportOwningScope(fakeImport, mod, emptyTree)).toBe(null);
});
it('delegates even when the innermost scope is a namespace', () => {
const ns = fakeScope({ kind: 'Namespace', id: 'ns-1' as ScopeId });
// Central default walks to nearest Module/Namespace — returning
// null lets that happen and attaches to ns-1 via the default path.
expect(tsImportOwningScope(fakeImport, ns, emptyTree)).toBe(null);
});
});
// ─── tsReceiverBinding ────────────────────────────────────────────────────
describe('tsReceiverBinding', () => {
it('returns the `this` type binding on a Function scope', () => {
const ref = typeRef('User');
const fn = fakeScope({
kind: 'Function',
typeBindings: new Map([['this', ref]]),
});
expect(tsReceiverBinding(fn)).toBe(ref);
});
it('returns null when no `this` has been synthesized (e.g. static method)', () => {
const fn = fakeScope({ kind: 'Function' });
expect(tsReceiverBinding(fn)).toBe(null);
});
it('returns null for non-Function scopes', () => {
expect(tsReceiverBinding(fakeScope({ kind: 'Module' }))).toBe(null);
expect(tsReceiverBinding(fakeScope({ kind: 'Class' }))).toBe(null);
expect(tsReceiverBinding(fakeScope({ kind: 'Block' }))).toBe(null);
});
});
// ─── synthesizeTsReceiverBinding (AST-walking) ────────────────────────────
function parseFirstFunction(src: string, fnType: string): SyntaxNode {
const tree = getTsParser().parse(src);
const stack: SyntaxNode[] = [tree.rootNode];
while (stack.length > 0) {
const n = stack.pop()!;
if (n.type === fnType) return n;
for (let i = 0; i < n.namedChildCount; i++) {
const c = n.namedChild(i);
if (c !== null) stack.push(c);
}
}
throw new Error(`No ${fnType} node found in source`);
}
describe('synthesizeTsReceiverBinding — class methods', () => {
it('emits `this` → class name for a class method_definition', () => {
const src = 'class User { save() {} }';
const fn = parseFirstFunction(src, 'method_definition');
const m = synthesizeTsReceiverBinding(fn);
expect(m).not.toBeNull();
expect(m!['@type-binding.this']).toBeDefined();
expect(m!['@type-binding.name'].text).toBe('this');
expect(m!['@type-binding.type'].text).toBe('User');
});
it('emits `this` → class name for an abstract class method', () => {
const src = 'abstract class User { save() {} }';
const fn = parseFirstFunction(src, 'method_definition');
const m = synthesizeTsReceiverBinding(fn);
expect(m).not.toBeNull();
expect(m!['@type-binding.type'].text).toBe('User');
});
it('skips static methods', () => {
const src = 'class User { static create() {} }';
const fn = parseFirstFunction(src, 'method_definition');
const m = synthesizeTsReceiverBinding(fn);
expect(m).toBeNull();
});
it('skips methods on anonymous class_expression without a name', () => {
const src = 'const C = class { save() {} };';
const fn = parseFirstFunction(src, 'method_definition');
const m = synthesizeTsReceiverBinding(fn);
expect(m).toBeNull();
});
});
describe('synthesizeTsReceiverBinding — interface/abstract signatures', () => {
it('emits `this` for an interface method_signature', () => {
const src = 'interface IUser { save(): void; }';
const fn = parseFirstFunction(src, 'method_signature');
const m = synthesizeTsReceiverBinding(fn);
expect(m).not.toBeNull();
expect(m!['@type-binding.type'].text).toBe('IUser');
});
it('emits `this` for an abstract_method_signature', () => {
const src = 'abstract class Base { abstract save(): void; }';
const fn = parseFirstFunction(src, 'abstract_method_signature');
const m = synthesizeTsReceiverBinding(fn);
expect(m).not.toBeNull();
expect(m!['@type-binding.type'].text).toBe('Base');
});
});
describe('synthesizeTsReceiverBinding — class-field arrow functions', () => {
it('emits `this` for a class field assigned an arrow function (`m = () => {}`)', () => {
const src = 'class User { save = () => {}; }';
const fn = parseFirstFunction(src, 'arrow_function');
const m = synthesizeTsReceiverBinding(fn);
expect(m).not.toBeNull();
expect(m!['@type-binding.type'].text).toBe('User');
});
it('emits `this` for a class field assigned a function_expression', () => {
const src = 'class User { save = function() {}; }';
const fn = parseFirstFunction(src, 'function_expression');
const m = synthesizeTsReceiverBinding(fn);
expect(m).not.toBeNull();
expect(m!['@type-binding.type'].text).toBe('User');
});
it('skips `static m = () => {}` (no instance `this`)', () => {
const src = 'class User { static save = () => {}; }';
const fn = parseFirstFunction(src, 'arrow_function');
const m = synthesizeTsReceiverBinding(fn);
expect(m).toBeNull();
});
it('does NOT emit for arrow functions nested inside method bodies', () => {
// scope-chain lookup on `tsReceiverBinding` resolves nested arrows'
// `this` via the enclosing method's synthesized binding — no direct
// synthesis needed here.
const src = 'class User { save() { const f = () => {}; } }';
const fn = parseFirstFunction(src, 'arrow_function');
const m = synthesizeTsReceiverBinding(fn);
expect(m).toBeNull();
});
it('does NOT emit for a module-level arrow function', () => {
const src = 'const fn = () => {};';
const fn = parseFirstFunction(src, 'arrow_function');
const m = synthesizeTsReceiverBinding(fn);
expect(m).toBeNull();
});
it('does NOT emit for a module-level function_declaration', () => {
const src = 'function fn() {}';
const fn = parseFirstFunction(src, 'function_declaration');
const m = synthesizeTsReceiverBinding(fn);
expect(m).toBeNull();
});
});
// ─── End-to-end integration: captures.ts wiring ───────────────────────────
describe('emitTsScopeCaptures — integration with receiver synthesis', () => {
it('emits @type-binding.this on class methods', () => {
const src = 'class User { save() { this.name = "x"; } }';
const caps = emitTsScopeCaptures(src, 't.ts');
const thisMatches = caps.filter(
(c) => (c as Record<string, unknown>)['@type-binding.this'] !== undefined,
);
expect(thisMatches.length).toBeGreaterThanOrEqual(1);
const m = thisMatches[0]! as Record<string, { text: string }>;
expect(m['@type-binding.name'].text).toBe('this');
expect(m['@type-binding.type'].text).toBe('User');
});
it('does NOT emit @type-binding.this on free functions', () => {
const src = 'function save() { return 1; }';
const caps = emitTsScopeCaptures(src, 't.ts');
const thisMatches = caps.filter(
(c) => (c as Record<string, unknown>)['@type-binding.this'] !== undefined,
);
expect(thisMatches).toHaveLength(0);
});
it('does NOT emit @type-binding.this on static methods', () => {
const src = 'class User { static create() { return 1; } }';
const caps = emitTsScopeCaptures(src, 't.ts');
const thisMatches = caps.filter(
(c) => (c as Record<string, unknown>)['@type-binding.this'] !== undefined,
);
expect(thisMatches).toHaveLength(0);
});
it('emits @type-binding.this on class-field arrow functions', () => {
const src = 'class User { save = () => { this.name = "x"; }; }';
const caps = emitTsScopeCaptures(src, 't.ts');
const thisMatches = caps.filter(
(c) => (c as Record<string, unknown>)['@type-binding.this'] !== undefined,
);
expect(thisMatches.length).toBeGreaterThanOrEqual(1);
const m = thisMatches[0]! as Record<string, { text: string }>;
expect(m['@type-binding.type'].text).toBe('User');
});
it('emits @type-binding.this on interface method signatures', () => {
const src = 'interface IUser { save(): void; }';
const caps = emitTsScopeCaptures(src, 't.ts');
const thisMatches = caps.filter(
(c) => (c as Record<string, unknown>)['@type-binding.this'] !== undefined,
);
expect(thisMatches.length).toBeGreaterThanOrEqual(1);
const m = thisMatches[0]! as Record<string, { text: string }>;
expect(m['@type-binding.type'].text).toBe('IUser');
});
it('silences-findNodeAtRange suppresses unused import', () => {
// Guard test: ensures helper ref remains valid after refactor.
expect(typeof findNodeAtRange).toBe('function');
});
});
// ─── typescriptMergeBindings ──────────────────────────────────────────────
describe('typescriptMergeBindings — LEGB tier shadowing (single space)', () => {
const binding = (
origin: BindingRef['origin'],
nodeId: string,
type: NodeLabel = 'Function',
): BindingRef =>
({
def: { nodeId, filePath: 't.ts', type } as SymbolDefinition,
origin,
}) as BindingRef;
it('local shadows `import` (value space)', () => {
const local = binding('local', 'L', 'Function');
const imp = binding('import', 'I', 'Function');
expect(typescriptMergeBindings([imp, local])).toEqual([local]);
});
it('local shadows `wildcard` (value space)', () => {
const local = binding('local', 'L', 'Variable');
const wc = binding('wildcard', 'W', 'Variable');
expect(typescriptMergeBindings([wc, local])).toEqual([local]);
});
it('explicit `import` shadows `wildcard` at tier-1', () => {
const imp = binding('import', 'I', 'Function');
const wc = binding('wildcard', 'W', 'Function');
expect(typescriptMergeBindings([wc, imp])).toEqual([imp]);
});
it('keeps overload siblings at the same tier', () => {
const a = binding('local', 'A', 'Function');
const b = binding('local', 'B', 'Function');
const out = typescriptMergeBindings([a, b]);
expect(out).toHaveLength(2);
});
it('dedupes same-nodeId bindings', () => {
const a = binding('local', 'A', 'Function');
const a2 = binding('local', 'A', 'Function');
expect(typescriptMergeBindings([a, a2])).toHaveLength(1);
});
it('empty in → empty out', () => {
expect(typescriptMergeBindings([])).toEqual([]);
});
});
describe('typescriptMergeBindings — declaration merging (multi-space)', () => {
const binding = (origin: BindingRef['origin'], nodeId: string, type: NodeLabel): BindingRef =>
({
def: { nodeId, filePath: 't.ts', type } as SymbolDefinition,
origin,
}) as BindingRef;
it('keeps local class + local interface (different spaces at tier-0)', () => {
// class Foo {} + interface Foo {} — class occupies value+type,
// interface occupies type only. Both at tier-0 locally; they
// coexist in their spaces and pass through intact.
const cls = binding('local', 'C', 'Class');
const iface = binding('local', 'I', 'Interface');
const out = typescriptMergeBindings([cls, iface]);
expect(out).toHaveLength(2);
expect(out).toContain(cls);
expect(out).toContain(iface);
});
it('keeps local namespace + local class (namespace + value/type coexist)', () => {
const ns = binding('local', 'N', 'Namespace');
const cls = binding('local', 'C', 'Class');
const out = typescriptMergeBindings([ns, cls]);
expect(out).toHaveLength(2);
});
it('keeps local interface + imported value (different spaces)', () => {
// `interface Foo {}` locally, `import { Foo } from './a'` (Function).
// Local wins in type space; import wins in value space. Both kept.
const iface = binding('local', 'I', 'Interface');
const imp = binding('import', 'V', 'Function');
const out = typescriptMergeBindings([iface, imp]);
expect(out).toHaveLength(2);
});
it('local class shadows imported class (both spaces overlap)', () => {
// Both occupy value+type — local wins in both spaces.
const local = binding('local', 'L', 'Class');
const imp = binding('import', 'I', 'Class');
expect(typescriptMergeBindings([local, imp])).toEqual([local]);
});
it('local enum shadows imported enum (both dual-space)', () => {
const local = binding('local', 'L', 'Enum');
const imp = binding('import', 'I', 'Enum');
expect(typescriptMergeBindings([local, imp])).toEqual([local]);
});
it('wildcard-only bindings survive when nothing better exists', () => {
const wc = binding('wildcard', 'W', 'Function');
expect(typescriptMergeBindings([wc])).toEqual([wc]);
});
it('imported namespace shadows wildcard in both namespace and value spaces', () => {
const imp = binding('import', 'I', 'Namespace');
const wc = binding('wildcard', 'W', 'Namespace');
expect(typescriptMergeBindings([wc, imp])).toEqual([imp]);
});
it('unknown NodeLabel falls back to value space', () => {
// A random-ish label we don't specially handle.
const local = binding('local', 'L', 'Route');
const imp = binding('import', 'I', 'Route');
expect(typescriptMergeBindings([local, imp])).toEqual([local]);
});
});
describe('typescriptProvider.mergeBindings adapter', () => {
const binding = (origin: BindingRef['origin'], nodeId: string, type: NodeLabel): BindingRef =>
({
origin,
def: { nodeId, type },
}) as BindingRef;
it('is scope-id independent because finalize calls it per (scope, name)', () => {
const merge = typescriptProvider.mergeBindings;
if (merge === undefined) throw new Error('typescriptProvider.mergeBindings missing');
const importBinding = binding('import', 'I', 'Class');
const localBinding = binding('local', 'L', 'Class');
const scopeA = fakeScope({ kind: 'Module', id: 'module-a' as ScopeId });
const scopeB = fakeScope({ kind: 'Module', id: 'module-b' as ScopeId });
expect(merge(scopeA, [importBinding, localBinding])).toEqual([localBinding]);
expect(merge(scopeB, [importBinding, localBinding])).toEqual([localBinding]);
});
});
// ─── typescriptArityCompatibility ─────────────────────────────────────────
describe('typescriptArityCompatibility', () => {
const callsite = (arity: number): Callsite => ({ arity }) as Callsite;
const def = (o: Partial<SymbolDefinition> = {}): SymbolDefinition =>
({ nodeId: 'd1', filePath: 't.ts', type: 'Function', ...o }) as SymbolDefinition;
it('unknown when both parameter counts are missing', () => {
expect(typescriptArityCompatibility(def(), callsite(2))).toBe('unknown');
});
it('compatible inside [required, total]', () => {
expect(
typescriptArityCompatibility(
def({ parameterCount: 3, requiredParameterCount: 1 }),
callsite(2),
),
).toBe('compatible');
});
it('compatible at exactly requiredParameterCount', () => {
expect(
typescriptArityCompatibility(
def({ parameterCount: 3, requiredParameterCount: 2 }),
callsite(2),
),
).toBe('compatible');
});
it('compatible at exactly parameterCount', () => {
expect(
typescriptArityCompatibility(
def({ parameterCount: 3, requiredParameterCount: 1 }),
callsite(3),
),
).toBe('compatible');
});
it('incompatible below required', () => {
expect(
typescriptArityCompatibility(
def({ parameterCount: 3, requiredParameterCount: 2 }),
callsite(1),
),
).toBe('incompatible');
});
it('incompatible above max without rest params', () => {
expect(
typescriptArityCompatibility(
def({ parameterCount: 2, requiredParameterCount: 0 }),
callsite(5),
),
).toBe('incompatible');
});
it('compatible above declared params when def has rest params', () => {
expect(
typescriptArityCompatibility(
def({
parameterCount: undefined,
requiredParameterCount: 0,
parameterTypes: ['params'],
}),
callsite(7),
),
).toBe('compatible');
});
it('compatible above declared params with mixed prefix + rest', () => {
expect(
typescriptArityCompatibility(
def({
parameterCount: undefined,
requiredParameterCount: 1,
parameterTypes: ['string', 'params number[]'],
}),
callsite(4),
),
).toBe('compatible');
});
it('unknown for negative arity (defensive)', () => {
expect(
typescriptArityCompatibility(
def({ parameterCount: 3, requiredParameterCount: 1 }),
callsite(-1),
),
).toBe('unknown');
});
it('unknown for non-finite arity', () => {
expect(
typescriptArityCompatibility(
def({ parameterCount: 3, requiredParameterCount: 1 }),
callsite(NaN),
),
).toBe('unknown');
});
});
// ─── computeTsArityMetadata (AST-driven) ──────────────────────────────────
function parseFunctionNode(src: string, fnType: string): SyntaxNode {
const tree = getTsParser().parse(src);
const stack: SyntaxNode[] = [tree.rootNode];
while (stack.length > 0) {
const n = stack.pop()!;
if (n.type === fnType) return n;
for (let i = 0; i < n.namedChildCount; i++) {
const c = n.namedChild(i);
if (c !== null) stack.push(c);
}
}
throw new Error(`No ${fnType} node found`);
}
describe('computeTsArityMetadata — basics', () => {
it('counts required parameters without annotations', () => {
const fn = parseFunctionNode('function f(a, b, c) {}', 'function_declaration');
const m = computeTsArityMetadata(fn);
expect(m.parameterCount).toBe(3);
expect(m.requiredParameterCount).toBe(3);
});
it('records declared parameter types (stripping generics)', () => {
const fn = parseFunctionNode(
'function f(a: string, b: Array<User>, c: User[]) {}',
'function_declaration',
);
const m = computeTsArityMetadata(fn);
expect(m.parameterCount).toBe(3);
expect(m.parameterTypes).toEqual(['string', 'Array', 'User']);
});
it('treats optional `p?: T` as optional', () => {
const fn = parseFunctionNode('function f(a: string, b?: number) {}', 'function_declaration');
const m = computeTsArityMetadata(fn);
expect(m.parameterCount).toBe(2);
expect(m.requiredParameterCount).toBe(1);
});
it('treats `p: T = …` (default) as optional', () => {
const fn = parseFunctionNode('function f(a: string, b: number = 1) {}', 'function_declaration');
const m = computeTsArityMetadata(fn);
expect(m.parameterCount).toBe(2);
expect(m.requiredParameterCount).toBe(1);
});
it('rest params: `...args: T[]` → max unknown + `params` marker', () => {
const fn = parseFunctionNode(
'function f(a: string, ...rest: number[]) {}',
'function_declaration',
);
const m = computeTsArityMetadata(fn);
expect(m.parameterCount).toBeUndefined();
expect(m.requiredParameterCount).toBeUndefined();
expect(m.parameterTypes).toContain('params');
});
it('does NOT count generic type parameters toward arity', () => {
const fn = parseFunctionNode('function f<T, U>(a: T, b: U): void {}', 'function_declaration');
const m = computeTsArityMetadata(fn);
expect(m.parameterCount).toBe(2);
});
it('omits parameterTypes when all params are untyped', () => {
const fn = parseFunctionNode('function f(a, b) {}', 'function_declaration');
const m = computeTsArityMetadata(fn);
expect(m.parameterTypes).toBeUndefined();
});
it('strips `Foo<Bar<Baz>>[]` to `Foo`', () => {
const fn = parseFunctionNode('function f(a: Foo<Bar<Baz>>[]) {}', 'function_declaration');
const m = computeTsArityMetadata(fn);
expect(m.parameterTypes).toEqual(['Foo']);
});
it('works on method definitions', () => {
const fn = parseFunctionNode(
'class C { m(a: string, b?: number, c: User = null) {} }',
'method_definition',
);
const m = computeTsArityMetadata(fn);
expect(m.parameterCount).toBe(3);
expect(m.requiredParameterCount).toBe(1);
expect(m.parameterTypes).toEqual(['string', 'number', 'User']);
});
it('works on function overload signatures', () => {
const fn = parseFunctionNode(
'function f(x: string): void; function f(x) {}',
'function_signature',
);
const m = computeTsArityMetadata(fn);
expect(m.parameterCount).toBe(1);
expect(m.parameterTypes).toEqual(['string']);
});
});
// ─── End-to-end: arity metadata emitted via captures.ts ───────────────────
describe('emitTsScopeCaptures — arity metadata integration', () => {
it('attaches @declaration.parameter-count to function declarations', () => {
const src = 'function f(a, b, c) {}';
const caps = emitTsScopeCaptures(src, 't.ts');
const fn = caps.find(
(c) => (c as Record<string, unknown>)['@declaration.function'] !== undefined,
);
expect(fn).toBeDefined();
expect((fn as Record<string, { text: string }>)['@declaration.parameter-count'].text).toBe('3');
expect(
(fn as Record<string, { text: string }>)['@declaration.required-parameter-count'].text,
).toBe('3');
});
it('omits parameter-count when rest params make max unknown', () => {
const src = 'function f(...args: number[]) {}';
const caps = emitTsScopeCaptures(src, 't.ts');
const fn = caps.find(
(c) => (c as Record<string, unknown>)['@declaration.function'] !== undefined,
);
expect(fn).toBeDefined();
expect((fn as Record<string, unknown>)['@declaration.parameter-count']).toBeUndefined();
expect((fn as Record<string, { text: string }>)['@declaration.parameter-types'].text).toContain(
'params',
);
});
it('attaches @reference.arity on free calls', () => {
const src = 'f(1, 2, 3);';
const caps = emitTsScopeCaptures(src, 't.ts');
const call = caps.find(
(c) => (c as Record<string, unknown>)['@reference.call.free'] !== undefined,
);
expect(call).toBeDefined();
expect((call as Record<string, { text: string }>)['@reference.arity'].text).toBe('3');
});
it('attaches @reference.arity on constructor calls', () => {
const src = 'const u = new User("a", 1);';
const caps = emitTsScopeCaptures(src, 't.ts');
const call = caps.find(
(c) => (c as Record<string, unknown>)['@reference.call.constructor'] !== undefined,
);
expect(call).toBeDefined();
expect((call as Record<string, { text: string }>)['@reference.arity'].text).toBe('2');
// parameter-types should infer string + number from literals.
const types = JSON.parse(
(call as Record<string, { text: string }>)['@reference.parameter-types'].text,
) as string[];
expect(types).toEqual(['string', 'number']);
});
it('attaches @reference.arity on member calls', () => {
const src = 'obj.m(x);';
const caps = emitTsScopeCaptures(src, 't.ts');
const call = caps.find(
(c) => (c as Record<string, unknown>)['@reference.call.member'] !== undefined,
);
expect(call).toBeDefined();
expect((call as Record<string, { text: string }>)['@reference.arity'].text).toBe('1');
});
});

View file

@ -0,0 +1,402 @@
/**
* Unit 2 coverage for the TypeScript import interpreter + target resolver.
*
* Asserts the ParsedImport shape for every TS import/export flavor and
* checks the resolver adapter's single-target behavior against a small
* set of fake file paths (with and without tsconfig path aliases).
*/
import { describe, it, expect } from 'vitest';
import { emitTsScopeCaptures } from '../../../../src/core/ingestion/languages/typescript/captures.js';
import { splitImportStatement } from '../../../../src/core/ingestion/languages/typescript/import-decomposer.js';
import { interpretTsImport } from '../../../../src/core/ingestion/languages/typescript/interpret.js';
import {
resolveTsImportTarget,
type TsResolveContext,
} from '../../../../src/core/ingestion/languages/typescript/import-target.js';
import type { SyntaxNode } from '../../../../src/core/ingestion/utils/ast-helpers.js';
import type { ParsedImport, WorkspaceIndex } from 'gitnexus-shared';
import { SupportedLanguages } from 'gitnexus-shared';
function importsFor(src: string): ParsedImport[] {
const matches = emitTsScopeCaptures(src, 'test.ts');
return matches
.filter((m) => m['@import.statement'] !== undefined)
.map((m) => interpretTsImport(m))
.filter((p): p is ParsedImport => p !== null);
}
function mockNode(
type: string,
text: string,
fields: Record<string, SyntaxNode | null> = {},
children: readonly SyntaxNode[] = [],
startIndex = 0,
): SyntaxNode {
return {
type,
text,
startIndex,
startPosition: { row: 0, column: startIndex },
endPosition: { row: 0, column: startIndex + text.length },
get namedChildCount() {
return children.length;
},
namedChild: (index: number) => children[index] ?? null,
childForFieldName: (name: string) => fields[name] ?? null,
} as unknown as SyntaxNode;
}
describe('interpretTsImport — static imports', () => {
it('named: `import { X } from "./a"`', () => {
const [imp, ...rest] = importsFor('import { X } from "./a";');
expect(rest).toHaveLength(0);
expect(imp).toEqual({
kind: 'named',
localName: 'X',
importedName: 'X',
targetRaw: './a',
});
});
it('aliased named: `import { X as Y } from "./a"`', () => {
const [imp] = importsFor('import { X as Y } from "./a";');
expect(imp).toEqual({
kind: 'alias',
localName: 'Y',
importedName: 'X',
alias: 'Y',
targetRaw: './a',
});
});
it('default: `import D from "./a"` maps to alias on the module default export', () => {
const [imp] = importsFor('import D from "./a";');
expect(imp).toEqual({
kind: 'alias',
localName: 'D',
importedName: 'default',
alias: 'D',
targetRaw: './a',
});
});
it('namespace: `import * as N from "./a"`', () => {
const [imp] = importsFor('import * as N from "./a";');
expect(imp).toEqual({
kind: 'namespace',
localName: 'N',
importedName: './a',
targetRaw: './a',
});
});
it('type-only: `import type { X } from "./a"` folds into `named`', () => {
const [imp] = importsFor('import type { X } from "./a";');
expect(imp).toEqual({
kind: 'named',
localName: 'X',
importedName: 'X',
targetRaw: './a',
});
});
it('per-specifier type-only: `import { type X, Y } from "./a"` gives 2 named', () => {
const imps = importsFor('import { type X, Y } from "./a";');
expect(imps).toHaveLength(2);
const kinds = imps.map((i) => i.kind);
expect(kinds).toEqual(['named', 'named']);
});
it('combined default + named + aliased in one statement emits 3 ParsedImports', () => {
const imps = importsFor('import D, { X, Y as Z } from "./m";');
expect(imps).toHaveLength(3);
expect(imps.map((i) => i.kind)).toEqual(['alias', 'named', 'alias']);
const def = imps.find((i) => i.localName === 'D');
expect(def).toMatchObject({ importedName: 'default', targetRaw: './m' });
const named = imps.find((i) => i.localName === 'X');
expect(named).toMatchObject({
kind: 'named',
importedName: 'X',
targetRaw: './m',
});
const aliased = imps.find((i) => i.localName === 'Z');
expect(aliased).toMatchObject({
kind: 'alias',
importedName: 'Y',
alias: 'Z',
targetRaw: './m',
});
});
it('combined default + namespace: `import D, * as N from "./m"` emits 2 imports', () => {
const imps = importsFor('import D, * as N from "./m";');
expect(imps).toHaveLength(2);
const def = imps.find((i) => i.localName === 'D');
expect(def?.kind).toBe('alias');
expect((def as { importedName: string }).importedName).toBe('default');
const ns = imps.find((i) => i.localName === 'N');
expect(ns?.kind).toBe('namespace');
expect((ns as { importedName: string }).importedName).toBe('./m');
});
it('side-effect: `import "./polyfill"` emits a side-effect ParsedImport (no local binding)', () => {
const imps = importsFor('import "./polyfill";');
expect(imps).toHaveLength(1);
expect(imps[0]).toEqual({
kind: 'side-effect',
targetRaw: './polyfill',
});
});
it('fails closed when an import specifier is missing its `name` field', () => {
const source = mockNode('string', '"./m"');
const alias = mockNode('identifier', 'Alias', {}, [], 12);
const spec = mockNode('import_specifier', 'Missing as Alias', { alias }, [alias]);
const named = mockNode('named_imports', '{ Missing as Alias }', {}, [spec]);
const clause = mockNode('import_clause', '{ Missing as Alias }', {}, [named]);
const stmt = mockNode(
'import_statement',
'import { Missing as Alias } from "./m";',
{ source },
[clause, source],
);
expect(splitImportStatement(stmt)).toHaveLength(0);
});
it('preserves the module path as written (no quote stripping leftovers)', () => {
const [imp] = importsFor("import X from '@scope/pkg';");
expect(imp?.targetRaw).toBe('@scope/pkg');
});
});
describe('interpretTsImport — re-exports', () => {
it('reexport: `export { X } from "./a"`', () => {
const [imp] = importsFor('export { X } from "./a";');
expect(imp).toEqual({
kind: 'reexport',
localName: 'X',
importedName: 'X',
targetRaw: './a',
});
});
it('reexport-alias: `export { X as Y } from "./a"`', () => {
const [imp] = importsFor('export { X as Y } from "./a";');
expect(imp).toEqual({
kind: 'reexport',
localName: 'Y',
importedName: 'X',
alias: 'Y',
targetRaw: './a',
});
});
it('wildcard: `export * from "./a"` emits kind=wildcard', () => {
const [imp] = importsFor('export * from "./a";');
expect(imp).toEqual({ kind: 'wildcard', targetRaw: './a' });
});
it('export-namespace: `export * as ns from "./a"` emits namespace', () => {
const [imp] = importsFor('export * as ns from "./a";');
expect(imp).toEqual({
kind: 'namespace',
localName: 'ns',
importedName: './a',
targetRaw: './a',
});
});
it('type-only re-export folds into `reexport`: `export type { X } from "./a"`', () => {
const [imp] = importsFor('export type { X } from "./a";');
expect(imp).toEqual({
kind: 'reexport',
localName: 'X',
importedName: 'X',
targetRaw: './a',
});
});
it('local `export { X }` (no `from`) is not an import', () => {
const imps = importsFor('const X = 1; export { X };');
expect(imps).toHaveLength(0);
});
it('fails closed when a re-export specifier is missing its `name` field', () => {
const source = mockNode('string', '"./m"');
const alias = mockNode('identifier', 'Alias', {}, [], 12);
const spec = mockNode('export_specifier', 'Missing as Alias', { alias }, [alias]);
const clause = mockNode('export_clause', '{ Missing as Alias }', {}, [spec]);
const stmt = mockNode(
'export_statement',
'export { Missing as Alias } from "./m";',
{ source },
[clause, source],
);
expect(splitImportStatement(stmt)).toHaveLength(0);
});
});
describe('interpretTsImport — dynamic imports', () => {
it('literal argument: `import("./m")` → dynamic-resolved (targetRaw is a literal path)', () => {
const [imp] = importsFor('const p = import("./m");');
expect(imp).toEqual({
kind: 'dynamic-resolved',
targetRaw: './m',
});
});
it('non-literal argument: `import(expr)` stays dynamic-unresolved', () => {
const [imp] = importsFor('const p = import(x);');
expect(imp?.kind).toBe('dynamic-unresolved');
expect((imp as { targetRaw: string | null }).targetRaw).toBe('x');
});
it('templated argument keeps the source text for diagnostics', () => {
const [imp] = importsFor('const p = import(`./m/${name}`);');
expect(imp?.kind).toBe('dynamic-unresolved');
expect((imp as { targetRaw: string | null }).targetRaw).toContain('name');
});
it('await + literal: `await import("./m")` → dynamic-resolved', () => {
const [imp] = importsFor('async function f() { return await import("./m"); }');
expect(imp).toEqual({
kind: 'dynamic-resolved',
targetRaw: './m',
});
});
});
describe('resolveTsImportTarget — standard suffix + alias resolution', () => {
function ctx(
fromFile: string,
paths: string[],
extra?: Partial<TsResolveContext>,
): WorkspaceIndex {
return {
fromFile,
allFilePaths: new Set(paths),
...(extra ?? {}),
} as unknown as WorkspaceIndex;
}
it('resolves a relative ./ path with extension appended', () => {
const parsed: ParsedImport = {
kind: 'named',
localName: 'X',
importedName: 'X',
targetRaw: './a',
};
const result = resolveTsImportTarget(parsed, ctx('src/main.ts', ['src/main.ts', 'src/a.ts']));
expect(result).toBe('src/a.ts');
});
it('resolves ../ paths across directories', () => {
const parsed: ParsedImport = {
kind: 'named',
localName: 'X',
importedName: 'X',
targetRaw: '../lib/helpers',
};
const result = resolveTsImportTarget(
parsed,
ctx('src/app/main.ts', ['src/app/main.ts', 'src/lib/helpers.ts']),
);
expect(result).toBe('src/lib/helpers.ts');
});
it('prefers an index file when the import targets a directory', () => {
const parsed: ParsedImport = {
kind: 'named',
localName: 'X',
importedName: 'X',
targetRaw: './utils',
};
const result = resolveTsImportTarget(
parsed,
ctx('src/main.ts', ['src/main.ts', 'src/utils/index.ts']),
);
expect(result).toBe('src/utils/index.ts');
});
it('honors tsconfig path aliases — `@/services/user` → `src/services/user.ts`', () => {
const parsed: ParsedImport = {
kind: 'named',
localName: 'UserService',
importedName: 'UserService',
targetRaw: '@/services/user',
};
const result = resolveTsImportTarget(
parsed,
ctx('src/main.ts', ['src/main.ts', 'src/services/user.ts'], {
tsconfigPaths: {
baseUrl: '.',
aliases: [['@/', 'src/']],
},
}),
);
expect(result).toBe('src/services/user.ts');
});
it('returns null when the target does not exist', () => {
const parsed: ParsedImport = {
kind: 'named',
localName: 'X',
importedName: 'X',
targetRaw: './missing',
};
const result = resolveTsImportTarget(parsed, ctx('src/main.ts', ['src/main.ts']));
expect(result).toBe(null);
});
it('returns null for dynamic-unresolved with null targetRaw', () => {
const parsed: ParsedImport = { kind: 'dynamic-unresolved', localName: '', targetRaw: null };
const result = resolveTsImportTarget(parsed, ctx('src/main.ts', ['src/main.ts']));
expect(result).toBe(null);
});
it('resolves dynamic-resolved (literal dynamic import) the same as a static import', () => {
const parsed: ParsedImport = {
kind: 'dynamic-resolved',
targetRaw: './a',
};
const result = resolveTsImportTarget(parsed, ctx('src/main.ts', ['src/main.ts', 'src/a.ts']));
expect(result).toBe('src/a.ts');
});
it('returns null when WorkspaceIndex shape is wrong (missing allFilePaths)', () => {
const parsed: ParsedImport = {
kind: 'named',
localName: 'X',
importedName: 'X',
targetRaw: './a',
};
const result = resolveTsImportTarget(parsed, {
fromFile: 'src/main.ts',
} as unknown as WorkspaceIndex);
expect(result).toBe(null);
});
it('switches extensions when language=JavaScript', () => {
const parsed: ParsedImport = {
kind: 'named',
localName: 'X',
importedName: 'X',
targetRaw: './a',
};
const result = resolveTsImportTarget(
parsed,
ctx('src/main.js', ['src/main.js', 'src/a.js'], {
language: SupportedLanguages.JavaScript,
}),
);
expect(result).toBe('src/a.js');
});
});

View file

@ -38,7 +38,7 @@ buildTypeEnv(tree, language, symbolTable?)
The `TypeEnvironment` is built once per file. `call-processor.ts` then uses `lookup()` to determine receiver types and narrow candidate symbols from the `SymbolTable`.
> **Note (RFC #909 Ring 3):** `call-processor.ts` is the legacy call-resolution path. Languages in `MIGRATED_LANGUAGES` (currently Python) route through the scope-resolution pipeline instead — see `ARCHITECTURE.md § Scope-Resolution Pipeline`. TypeEnv is still built for migrated languages in the parse worker, but receiver typing flows through `ParsedTypeBinding` + `ScopeResolutionIndexes` rather than `call-processor.ts`.
> **Note (RFC #909 Ring 3):** `call-processor.ts` is the legacy call-resolution path. Languages in `MIGRATED_LANGUAGES` (see `gitnexus/src/core/ingestion/registry-primary-flag.ts`) route through the scope-resolution pipeline instead — see `ARCHITECTURE.md § Scope-Resolution Pipeline`. TypeEnv is still built for migrated languages in the parse worker, but receiver typing flows through `ParsedTypeBinding` + `ScopeResolutionIndexes` rather than `call-processor.ts`.
---