mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
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:
parent
13abf192e0
commit
ab077b4c29
56 changed files with 6626 additions and 155 deletions
|
|
@ -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. |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 file→file
|
||||
* `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. */
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
61
gitnexus/src/core/ingestion/languages/typescript/arity.ts
Normal file
61
gitnexus/src/core/ingestion/languages/typescript/arity.ts
Normal 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';
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
501
gitnexus/src/core/ingestion/languages/typescript/captures.ts
Normal file
501
gitnexus/src/core/ingestion/languages/typescript/captures.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
95
gitnexus/src/core/ingestion/languages/typescript/index.ts
Normal file
95
gitnexus/src/core/ingestion/languages/typescript/index.ts
Normal 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';
|
||||
312
gitnexus/src/core/ingestion/languages/typescript/interpret.ts
Normal file
312
gitnexus/src/core/ingestion/languages/typescript/interpret.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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()];
|
||||
}
|
||||
785
gitnexus/src/core/ingestion/languages/typescript/query.ts
Normal file
785
gitnexus/src/core/ingestion/languages/typescript/query.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 };
|
||||
162
gitnexus/src/core/ingestion/languages/typescript/simple-hooks.ts
Normal file
162
gitnexus/src/core/ingestion/languages/typescript/simple-hooks.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
]);
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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)`,
|
||||
|
|
|
|||
5
gitnexus/test/fixtures/cross-file-binding/ts-deep-alias-chain/src/app.ts
vendored
Normal file
5
gitnexus/test/fixtures/cross-file-binding/ts-deep-alias-chain/src/app.ts
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { bridge } from './bridge';
|
||||
export function main() {
|
||||
bridge.save();
|
||||
bridge.getName();
|
||||
}
|
||||
2
gitnexus/test/fixtures/cross-file-binding/ts-deep-alias-chain/src/bridge.ts
vendored
Normal file
2
gitnexus/test/fixtures/cross-file-binding/ts-deep-alias-chain/src/bridge.ts
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
import { alias } from './util';
|
||||
export const bridge = alias;
|
||||
9
gitnexus/test/fixtures/cross-file-binding/ts-deep-alias-chain/src/models.ts
vendored
Normal file
9
gitnexus/test/fixtures/cross-file-binding/ts-deep-alias-chain/src/models.ts
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
export class User {
|
||||
save(): void {}
|
||||
getName(): string {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
export function getUser(): User {
|
||||
return new User();
|
||||
}
|
||||
2
gitnexus/test/fixtures/cross-file-binding/ts-deep-alias-chain/src/service.ts
vendored
Normal file
2
gitnexus/test/fixtures/cross-file-binding/ts-deep-alias-chain/src/service.ts
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
import { getUser } from './models';
|
||||
export const user = getUser();
|
||||
2
gitnexus/test/fixtures/cross-file-binding/ts-deep-alias-chain/src/util.ts
vendored
Normal file
2
gitnexus/test/fixtures/cross-file-binding/ts-deep-alias-chain/src/util.ts
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
import { user } from './service';
|
||||
export const alias = user;
|
||||
5
gitnexus/test/fixtures/lang-resolution/typescript-dynamic-import/src/app.ts
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/typescript-dynamic-import/src/app.ts
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export async function loadFeature(): Promise<void> {
|
||||
const mod = await import('./feature');
|
||||
const feature = new mod.Feature();
|
||||
feature.activate();
|
||||
}
|
||||
5
gitnexus/test/fixtures/lang-resolution/typescript-dynamic-import/src/feature.ts
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/typescript-dynamic-import/src/feature.ts
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export class Feature {
|
||||
activate(): void {
|
||||
console.log('activated');
|
||||
}
|
||||
}
|
||||
6
gitnexus/test/fixtures/lang-resolution/typescript-reexport-namespace/src/app.ts
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/typescript-reexport-namespace/src/app.ts
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { Models } from './barrel';
|
||||
|
||||
export function main(): void {
|
||||
const u = new Models.User();
|
||||
u.save();
|
||||
}
|
||||
1
gitnexus/test/fixtures/lang-resolution/typescript-reexport-namespace/src/barrel.ts
vendored
Normal file
1
gitnexus/test/fixtures/lang-resolution/typescript-reexport-namespace/src/barrel.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * as Models from './base';
|
||||
11
gitnexus/test/fixtures/lang-resolution/typescript-reexport-namespace/src/base.ts
vendored
Normal file
11
gitnexus/test/fixtures/lang-resolution/typescript-reexport-namespace/src/base.ts
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
export class User {
|
||||
save(): void {
|
||||
console.log('saving user');
|
||||
}
|
||||
}
|
||||
|
||||
export class Repo {
|
||||
persist(): void {
|
||||
console.log('persisting repo');
|
||||
}
|
||||
}
|
||||
7
gitnexus/test/fixtures/lang-resolution/typescript-side-effect-imports/src/app.ts
vendored
Normal file
7
gitnexus/test/fixtures/lang-resolution/typescript-side-effect-imports/src/app.ts
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import './polyfill';
|
||||
import './register';
|
||||
import { greet } from './greeter';
|
||||
|
||||
export function main(): string {
|
||||
return greet('world');
|
||||
}
|
||||
3
gitnexus/test/fixtures/lang-resolution/typescript-side-effect-imports/src/greeter.ts
vendored
Normal file
3
gitnexus/test/fixtures/lang-resolution/typescript-side-effect-imports/src/greeter.ts
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export function greet(name: string): string {
|
||||
return `hello, ${name}`;
|
||||
}
|
||||
2
gitnexus/test/fixtures/lang-resolution/typescript-side-effect-imports/src/polyfill.ts
vendored
Normal file
2
gitnexus/test/fixtures/lang-resolution/typescript-side-effect-imports/src/polyfill.ts
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
declare const globalThis: { __polyfilled?: boolean };
|
||||
globalThis.__polyfilled = true;
|
||||
2
gitnexus/test/fixtures/lang-resolution/typescript-side-effect-imports/src/register.ts
vendored
Normal file
2
gitnexus/test/fixtures/lang-resolution/typescript-side-effect-imports/src/register.ts
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
declare const globalThis: { __registry?: string[] };
|
||||
(globalThis.__registry ??= []).push('module-A');
|
||||
6
gitnexus/test/fixtures/lang-resolution/typescript-tsconfig-aliases/src/app.ts
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/typescript-tsconfig-aliases/src/app.ts
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { UserService } from '@/services/user';
|
||||
|
||||
export function main(): void {
|
||||
const svc = new UserService();
|
||||
svc.save();
|
||||
}
|
||||
5
gitnexus/test/fixtures/lang-resolution/typescript-tsconfig-aliases/src/services/user.ts
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/typescript-tsconfig-aliases/src/services/user.ts
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export class UserService {
|
||||
save(): void {
|
||||
console.log('saving user');
|
||||
}
|
||||
}
|
||||
8
gitnexus/test/fixtures/lang-resolution/typescript-tsconfig-aliases/tsconfig.json
vendored
Normal file
8
gitnexus/test/fixtures/lang-resolution/typescript-tsconfig-aliases/tsconfig.json
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
9
gitnexus/test/fixtures/lang-resolution/typescript-tsx-jsx/src/App.tsx
vendored
Normal file
9
gitnexus/test/fixtures/lang-resolution/typescript-tsx-jsx/src/App.tsx
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { Button } from './Button';
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<div>
|
||||
<Button label="hello" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
5
gitnexus/test/fixtures/lang-resolution/typescript-tsx-jsx/src/Button.tsx
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/typescript-tsx-jsx/src/Button.tsx
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
type Props = { label: string };
|
||||
|
||||
export function Button(props: Props) {
|
||||
return <button>{props.label}</button>;
|
||||
}
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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')]);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 3–5.
|
||||
*/
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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`.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue