GitNexus/ARCHITECTURE.md
Gergő Magyar ab077b4c29
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
2026-04-26 08:23:08 +01:00

502 lines
32 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Architecture — GitNexus
Monorepo: **CLI/MCP** (`gitnexus/`) + **browser UI** (`gitnexus-web/`).
## Repository layout
| Path | Role |
|------|------|
| `gitnexus/` | npm package `gitnexus`: CLI, MCP server (stdio), HTTP API, ingestion pipeline, LadybugDB graph, embeddings. |
| `gitnexus-web/` | Vite + React thin client: graph explorer + AI chat. All queries via `gitnexus serve` HTTP API. |
| `gitnexus-shared/` | Shared TypeScript types and constants (consumed by CLI and Web). |
| `.claude/`, `gitnexus-claude-plugin/`, `gitnexus-cursor-integration/` | Agent skills and plugin metadata. |
| `eval/` | Evaluation harnesses for benchmarking tool usage. |
| `.github/` | CI workflows + composite actions (`setup-gitnexus/`, `setup-gitnexus-web/`). |
## End-to-end flow: index → graph → tools
1. **Ingestion**`analyze.ts``runFullAnalysis` (`run-analyze.ts`) → `runPipelineFromRepo` (`pipeline.ts`). DAG of 12 phases builds a `KnowledgeGraph` in memory, then loads into LadybugDB under `.gitnexus/`. Repo registered in `~/.gitnexus/registry.json` for MCP discovery.
2. **Persistence**`repo-manager.ts` (paths, registry, KuzuDB cleanup). `lbug-adapter.ts` (graph load, queries, embedding batches).
3. **Query layer** — three interfaces to the same backend:
- **MCP (stdio):** `mcp.ts``LocalBackend` → tools (`tools.ts`) + resources (`resources.ts`)
- **HTTP bridge:** `serve.ts` → Express (`api.ts`, `mcp-http.ts`) for web UI
- **CLI direct:** `gitnexus query|context|impact|cypher` in `tool.ts`
4. **Staleness**`staleness.ts` compares indexed `lastCommit` to `HEAD`, surfaces hints.
## MCP tools
| Tool | Purpose |
|------|---------|
| `list_repos` | Discover indexed repos |
| `query` | Hybrid BM25 + vector search over the graph |
| `cypher` | Ad hoc Cypher against the schema |
| `context` | Callers, callees, processes for one symbol |
| `impact` | Blast radius (upstream/downstream) with risk summary |
| `detect_changes` | Map git diffs to affected symbols and processes |
| `rename` | Graph-assisted multi-file rename with `dry_run` preview |
| `api_impact` | Pre-change impact report for an API route handler |
| `route_map` | API route → handler → consumer mappings |
| `tool_map` | MCP/RPC tool definitions and handlers |
| `shape_check` | Response shape vs consumer property access mismatches |
| `group_list` | List repo groups or details for one group |
| `group_sync` | Rebuild group Contract Registry (`contracts.json`) and bridge graph |
`query`, `context`, and `impact` are group-aware: pass `repo: "@<groupName>"` (or `"@<groupName>/<memberPath>"` to scope to one member) plus optional `service: "<monorepo/path>"`. Group-mode `query` merges per-repo results via Reciprocal Rank Fusion; group-mode `impact` runs the local walk in the chosen member and fans out across boundaries via the Contract Bridge (`gitnexus/src/core/group/cross-impact.ts`). The previously-planned `group_query`, `group_context`, `group_impact`, `group_contracts`, `group_status` MCP tools are intentionally not introduced — group-level state is exposed via resources instead:
| Resource URI | Purpose |
|--------------|---------|
| `gitnexus://group/{name}/contracts` | Contract Registry (provider/consumer rows + cross-links) |
| `gitnexus://group/{name}/status` | Per-member index + Contract Registry staleness |
## Where to change what
| Concern | Start in |
|---------|----------|
| CLI commands/flags | `src/cli/` (`index.ts`, per-command modules) |
| Parsing/graph construction | `src/core/ingestion/pipeline-phases/` + `pipeline.ts` |
| Graph schema/DB | `src/core/lbug/` (`schema.ts`, `lbug-adapter.ts`) |
| MCP tools/resources | `src/mcp/server.ts`, `tools.ts`, `resources.ts` |
| Cross-repo groups (sync, contracts, `@<group>` routing) | `src/core/group/` (`service.ts`, `cross-impact.ts`, `sync.ts`, `bridge-db.ts`) |
| Search ranking | `src/core/search/` (BM25, hybrid fusion) |
| Embeddings | `src/core/embeddings/` + `src/core/run-analyze.ts` |
| Wiki generation | `src/core/wiki/` |
| Language support | `src/core/ingestion/languages/` + `tree-sitter-queries.ts` + `gitnexus-shared/src/languages.ts` |
| Import resolution | `src/core/ingestion/import-processor.ts` + `import-resolvers/configs/` + `model/resolution-context.ts` |
| Call resolution/MRO | `src/core/ingestion/call-processor.ts` + `model/resolve.ts` |
| Type extraction | `src/core/ingestion/type-extractors/` |
| Worker pool | `src/core/ingestion/workers/` |
| Web UI | `gitnexus-web/src/` |
| CI | `.github/workflows/*.yml`, `.github/actions/` |
> Paths above are relative to `gitnexus/` unless they start with `gitnexus-web/` or `.github/`.
---
## Pipeline Phase DAG
12 phases defined in `gitnexus/src/core/ingestion/pipeline-phases/`, each with explicit `deps` and typed output.
```
scan → structure → [markdown, cobol] → parse → [routes, tools, orm]
→ crossFile → mro → communities → processes
```
| Phase | File | Deps | Output |
|-------|------|------|--------|
| `scan` | `scan.ts` | (root) | File paths + sizes |
| `structure` | `structure.ts` | `scan` | File/Folder nodes, CONTAINS edges, `allPathSet` |
| `markdown` | `markdown.ts` | `structure` | Section nodes, cross-link edges from .md/.mdx |
| `cobol` | `cobol.ts` | `structure` | COBOL program/paragraph/section nodes (regex, no tree-sitter) |
| `parse` | `parse.ts` + `parse-impl.ts` | `structure`, `markdown`, `cobol` | Symbol nodes, IMPORTS/CALLS/EXTENDS edges, extracted routes/tools/ORM queries |
| `routes` | `routes.ts` | `parse` | Route nodes + HANDLES_ROUTE edges (Next.js, Expo, PHP, decorators) |
| `tools` | `tools.ts` | `parse` | Tool nodes + HANDLES_TOOL edges |
| `orm` | `orm.ts` | `parse` | QUERIES edges (Prisma, Supabase) |
| `crossFile` | `cross-file.ts` + `cross-file-impl.ts` | `parse`, `routes`, `tools`, `orm` | Cross-file type propagation in topological import order |
| `mro` | `mro.ts` | `crossFile`, `structure` | METHOD_OVERRIDES + METHOD_IMPLEMENTS edges |
| `communities` | `communities.ts` | `mro`, `structure` | Community nodes + MEMBER_OF edges (Leiden algorithm) |
| `processes` | `processes.ts` | `communities`, `routes`, `tools`, `structure` | Process nodes + STEP_IN_PROCESS edges |
**Non-phase files in the same directory:** `parse-impl.ts`, `cross-file-impl.ts` (implementation), `wildcard-synthesis.ts` (whole-module import expansion), `orm-extraction.ts` (sequential ORM fallback), `types.ts`, `runner.ts`, `index.ts`.
### DAG runner
`runner.ts` — static phase graph, no plugins, compile-time type safety.
1. **Validation** — Kahn's topological sort. Rejects on: duplicate names, missing deps, cycles (DFS traces the concrete cycle path, e.g., `A -> B -> C -> A`, plus count of transitively blocked dependents).
2. **Execution** — sequential in topological order. Each phase receives:
- `ctx: PipelineContext` — shared mutable `KnowledgeGraph`, `repoPath`, progress callback, options
- `deps: ReadonlyMap<string, PhaseResult>`**declared deps only** (runner filters the results map to prevent hidden coupling)
3. **Error handling** — wraps phase errors with the phase name, emits terminal `error` progress event, swallows progress handler errors to preserve the original cause.
4. **Timing** — per-phase `durationMs` in `PhaseResult`, dev-mode console logging.
**Design patterns:**
- **Single graph accumulator** — all phases mutate the same `KnowledgeGraph` in `ctx`; the graph is the primary output.
- **Typed phase access** — `getPhaseOutput<T>(deps, 'name')` for type-safe upstream results.
- **Binding accumulator lifecycle** — created in `parse`, disposed by `crossFile` (in `finally`). No other phase should take ownership.
- **Skippable phases** — `skipGraphPhases` omits MRO/communities/processes (faster tests). `skipWorkers` forces sequential parsing.
### How to add a new phase
1. Create `pipeline-phases/my-phase.ts` with a `PipelinePhase<MyOutput>` (name, deps, execute)
2. Export from `pipeline-phases/index.ts`
3. Add to `buildPhaseList()` in `pipeline.ts`
```typescript
import type { PipelinePhase, PhaseResult } from './types.js';
import { getPhaseOutput } from './types.js';
import type { ParseOutput } from './parse.js';
export interface MyPhaseOutput { /* ... */ }
export const myPhase: PipelinePhase<MyPhaseOutput> = {
name: 'myPhase',
deps: ['parse'],
async execute(ctx, deps) {
const { allPaths } = getPhaseOutput<ParseOutput>(deps, 'parse');
// ... write to ctx.graph ...
return { /* typed output */ };
},
};
```
---
## Call-Resolution DAG
Typed 6-stage pipeline in `call-processor.ts` (inside the `parse` phase) that resolves method/function calls and emits CALLS edges. Language behavior plugs in at two `LanguageProvider` hook points (stages 34); shared code names no languages. Scope: call resolution only — import resolution, type extraction, heritage, and symbol-table population live in other phases.
### Stages
```
extract-call ──▶ classify-form ──▶ infer-receiver ──▶ select-dispatch ──▶ resolve-target ──▶ emit-edge
(1) (2) (3) [hook] (4) [hook] (5) (6)
```
| Stage | Produces | Location |
|-------|----------|----------|
| **extract-call** | `ExtractedCallSite` (name, form, receiver, argCount) | `call-extractors/` (per-language); runs in worker |
| **classify-form** | callForm (`free`/`member`/`constructor`) + arity | `call-analysis.ts``inferCallForm`; shared, runs in worker |
| **infer-receiver** | `ReceiverEnriched` (receiver type finalized) | `call-processor.ts`; shared default chain, then `inferImplicitReceiver` hook |
| **select-dispatch** | `DispatchDecision` (primary, fallback, ancestryView) | `selectDispatch` hook, falls back to shared default |
| **resolve-target** | `TieredCandidates` | `model/resolve.ts``lookupMethodByOwnerWithMRO` (MRO walk) |
| **emit-edge** | CALLS edge in graph | `call-processor.ts`; writes edge with confidence tier |
### Provider hooks
Both hooks are optional on `LanguageProvider`. Ruby is the only current implementer.
**`inferImplicitReceiver`** — called after shared infer-receiver defaults. Returns `ImplicitReceiverOverride | null`.
| | |
|---|---|
| Inputs | `calledName`, `callForm`, `receiverName`, `receiverTypeName`, `callNode` (AST), `filePath` |
| Non-null fields | `callForm`, `receiverName`, `receiverTypeName` (required); `receiverSource: 'implicit-self'` (fixed); `hint?` (opaque, passed to `selectDispatch`) |
| Null | Keep existing `ReceiverEnriched` state |
**`selectDispatch`** — called after infer-receiver (including hook). Returns `DispatchDecision | null`; null uses shared default (constructor → `primary:'constructor'`; typed receiver → `primary:'owner-scoped'`; else → `primary:'free'`).
| | |
|---|---|
| Inputs | `calledName`, `callForm`, `receiverName`, `receiverTypeName`, `receiverSource`, `hint` |
| Non-null fields | `primary: 'owner-scoped' \| 'free' \| 'constructor'`; `fallback?: 'free-arity-narrowed'`; `ancestryView?: 'instance' \| 'singleton'`; `hint?` |
**`DispatchDecision` field semantics:**
- `primary: 'owner-scoped'` — MRO walk from receiver's type; used when receiver type is known.
- `fallback: 'free-arity-narrowed'` — after owner-scoped miss, search free-call candidates by arity only (Ruby uses this for implicit-self calls that miss their owner's MRO).
- `ancestryView: 'singleton'` — walk singleton/class ancestry instead of instance ancestry (Ruby `def self.foo` bodies, so `extend`-ed methods are found).
### Adding language behavior
1. **Implicit receivers** — implement `inferImplicitReceiver`: return null if call already has a receiver; otherwise use `findEnclosingClassInfo` (`ast-helpers.ts`) to find the enclosing context, return `ImplicitReceiverOverride` with `receiverSource: 'implicit-self'`, and optionally set `hint` for `selectDispatch`.
2. **Custom dispatch** — implement `selectDispatch`: inspect `receiverSource` and `hint`, return `DispatchDecision` with `primary`, optional `fallback`, optional `ancestryView`; return null to keep shared defaults.
3. **MRO strategy** — confirm `mroStrategy` is `'first-wins'`, `'c3'`, `'ruby-mixin'`, or `'none'`; consumed by `lookupMethodByOwnerWithMRO`.
**Ruby example** (`languages/ruby.ts` + `utils/ruby-self-call.ts`): `inferImplicitReceiver` rewrites bare-identifier calls to `self.method` and sets `hint` to `'instance'`/`'singleton'`; `selectDispatch` uses hint for `ancestryView` and adds `fallback: 'free-arity-narrowed'` for implicit-self calls.
### Code references
| Module | Purpose |
|--------|---------|
| `core/ingestion/call-types.ts` | DAG types: `ReceiverEnriched`, `DispatchDecision`, `ImplicitReceiverOverride` |
| `core/ingestion/language-provider.ts` | Hook signatures: `inferImplicitReceiver`, `selectDispatch` |
| `core/ingestion/call-processor.ts` | `processCalls`: stages 36 |
| `core/ingestion/model/resolve.ts` | `lookupMethodByOwnerWithMRO`: stage 5 MRO walk |
| `core/ingestion/languages/ruby.ts` | Both hooks + `mroStrategy: 'ruby-mixin'` |
| `core/ingestion/utils/ruby-self-call.ts` | Bare-call rewrite for `inferImplicitReceiver` |
### Coexistence with the scope-resolution pipeline
The Call-Resolution DAG is the **legacy path**. RFC #909 Ring 3 introduces a parallel **scope-resolution pipeline** (next section) that replaces stages 16 with a scope-indexed registry lookup. Both paths ship side-by-side and are gated per-language via `MIGRATED_LANGUAGES` + the `REGISTRY_PRIMARY_<LANG>` env var.
- **Unmigrated language** → Call-Resolution DAG runs; scope-resolution phase is a no-op.
- **Migrated language** (currently: Python, C#) → scope-resolution owns CALLS/ACCESSES/USES emission; the legacy DAG gates off for that language via `isRegistryPrimary(lang)` checks in `call-processor.ts` and `import-processor.ts`.
- `import-processor` still populates `importMap` for migrated languages — heritage's `ctx.resolve` reads it to disambiguate parent classes. Only edge emission is gated.
- CI runs BOTH paths for every migrated language on every PR (`.github/workflows/ci-scope-parity.yml`); both must pass.
#### Same-graph guarantee
Edges emitted by the scope-resolution pipeline and edges emitted by the legacy DAG are indistinguishable to downstream consumers (MCP tools, HTTP API, embeddings, group bridge):
- **Node identity** — both paths use `generateId(...)` from `lib/utils.ts`, the same qualified-name keyspace, and the same node labels (`File`, `Folder`, `Class`, `Method`, `Function`, …). Overload disambiguation suffixes `parameterTypes` into the id consistently — see `scope-resolution/graph-bridge/ids.ts` and the legacy emitter in `call-processor.ts`.
- **Edge vocabulary** — both paths emit the same reasons: `'import-resolved' | 'global' | 'local-call' | 'same-file' | 'interface-dispatch' | 'read' | 'write'`. Migrating a language must not change which reasons consumers see for previously-resolved edges.
- **Confidence tier** — both paths attach a numeric `confidence` to each edge using the same scale.
The CI parity workflow (`.github/workflows/ci-scope-parity.yml`) runs both paths against every migrated language's fixture corpus and fails on any divergence.
#### Semantic-model source of truth
Two independent invariants.
**ParsedFile = the AST-level truth.** `ParsedFile` (`gitnexus-shared/src/scope-resolution/parsed-file.ts`) is the single per-file artifact both resolution paths consume. Scope-resolution passes MUST NOT build a parallel parse representation. If a per-language hook needs AST-level facts that `ParsedFile` doesn't expose, it should reuse the orchestrator's `treeCache` (`RunScopeResolutionInput.treeCache`) rather than re-invoking `parser.parse(...)` on its own — the C# `populateNamespaceSiblings` hook is the reference implementation of this pattern.
**SemanticModel = the symbol-level truth.** `SemanticModel` (`gitnexus/src/core/ingestion/model/semantic-model.ts`) is the authoritative store for every symbol-indexed lookup (by `nodeId`, `simpleName`, `qualifiedName`, or `filePath`). Both paths read from here:
- Legacy Call-Resolution DAG → `call-processor` Tier 1/2/3 via `model.symbols.lookupExactAll`, `model.methods.lookupMethodByName`, `model.types.lookupClassByName`, `lookupMethodByOwnerWithMRO`.
- Scope-resolution pipeline → `findOwnedMember`, `pickOverload`, `findExportedDefByName` all consult `model.methods` / `model.fields` / `model.symbols`.
The scope-resolution pipeline additionally carries `WorkspaceResolutionIndex` for `Scope`-valued lookups (`classScopeByDefId`, `moduleScopeByFile`) that `SemanticModel` structurally cannot hold. No symbol-indexed duplicates exist outside `SemanticModel`.
**Write / read phase contract.** The model is mutable during three ordered phases and read-only afterward:
```
Phase 1: legacy parse ──► symbolTable.add fans into types/methods/fields
Phase 2: scope-resolution ──► reconcileOwnership() registers corrected ownerIds
Phase 3: finalize ──► model.attachScopeIndexes(bundle) — one-shot freeze
─────────────────────────── phase boundary ───────────────────────────
Read phase: all resolution passes + MCP + HTTP + embeddings see
SemanticModel (read-only handle); writes are type-errors.
```
`runScopeResolution` narrows `MutableSemanticModel``SemanticModel` at the phase boundary so downstream passes physically cannot mutate the model even accidentally.
**Transitional: reconciliation pass.** `reconcileOwnership` (`scope-resolution/pipeline/reconcile-ownership.ts`) is a shim for languages whose legacy extractor doesn't resolve `enclosingClassId` at parse time (Python class-body methods are the canonical case). It walks `parsed.localDefs[i].ownerId` after `populateOwners` and registers any missed methods/fields into the model. Idempotent — safe to re-run, safe alongside languages whose legacy extractor already carries `ownerId` (C#).
The architectural end state is for every language's parse-time extractor to emit the correct `ownerId` directly, making reconciliation a no-op (tracked as a follow-up refactor). The dev-mode validator `validateOwnershipParity` surfaces any drift via `onWarn` under `NODE_ENV !== 'production' && VALIDATE_SEMANTIC_MODEL !== '0'`.
References: `semantic-model.ts` file-head (full write/read contract); `contract/scope-resolver.ts` Contract Invariant I9 (scope-resolution-side rule).
---
## Scope-Resolution Pipeline (RFC #909 Ring 3)
Language-agnostic registry-primary resolver. Replaces the Call-Resolution DAG for migrated languages. Adding a language is one interface implementation (`ScopeResolver`) plus two registrations — no changes to shared code, no new pipeline phase.
### Pipeline stages
```
ParsedFile[] (extractParsedFile per file)
│ finalizeScopeModel (+ provider hooks)
ScopeResolutionIndexes
│ resolveReferenceSites (via MethodRegistry.lookup)
ReferenceIndex
│ emitReceiverBoundCalls ── FIRST
│ emitFreeCallFallback ── THEN
│ emitReferencesViaLookup ── LAST (uses handledSites)
│ emitImportEdges
KnowledgeGraph (IMPORTS / CALLS / ACCESSES / INHERITS / USES)
```
Orchestrator: `runScopeResolution(input, provider)` in `scope-resolution/pipeline/run.ts`.
Pipeline phase: `scopeResolutionPhase` in `scope-resolution/pipeline/phase.ts` — iterates `SCOPE_RESOLVERS ∩ MIGRATED_LANGUAGES`, reads per-file Trees from the parse phase's `scopeTreeCache`, disposes the cache at the end.
### `ScopeResolver` contract
Single interface a language implements to plug into the pipeline. Contract fully documented in `scope-resolution/contract/scope-resolver.ts`.
| Hook | Purpose |
|------|---------|
| `languageProvider` | Base `LanguageProvider` (tree-sitter query, `emitScopeCaptures`, import/binding interpreters, hooks) |
| `populateOwners(parsed)` | Fill deferred `ownerId` fields on method defs (captures can't always know the owning class at parse time) |
| `buildMro(graph, parsed, nodeLookup)` | Produce `mroByClassDefId: Map<DefId, DefId[]>` — C3, Ruby-mixin, or first-wins per language |
| `resolveImportTarget(target, fromFile, allFiles)` | `(rawImportPath, sourceFile) → targetFilePath` (PEP-328 for Python, etc.) |
| `mergeBindings(existing, incoming, scopeId)` | Shadowing / LEGB precedence |
| `arityCompatibility` | Provider consumed by registry during `MethodRegistry.lookup` Step 2 |
| `importEdgeReason` | Confidence-tier string for IMPORTS edge reason field |
| `propagatesReturnTypesAcrossImports?` | Opt out of cross-file return-type propagation (default on) |
| `fieldFallbackOnMethodLookup?` | Statically-typed languages turn this OFF — the heuristic over-connects (default on) |
| `unwrapCollectionAccessor?` | Property-style collection views (`data.Values` on Dictionary-like receivers) — default off |
| `collapseMemberCallsByCallerTarget?` | One CALLS edge per (caller, target) instead of per-site — default off |
| `populateNamespaceSiblings?` | Cross-file implicit visibility (compiler-implicit namespace sharing) — default off; ctx carries `treeCache` |
| `hoistTypeBindingsToModule?` | Walk up to Module scope when looking up a method's return-type typeBinding — default off; enable only when bindings are stored at module level |
### Per-language registration
1. Implement `ScopeResolver` in `languages/<lang>/scope-resolver.ts`.
2. Add entry to `SCOPE_RESOLVERS` in `scope-resolution/pipeline/registry.ts`.
3. Add the language to `MIGRATED_LANGUAGES` in `registry-primary-flag.ts` when the shadow-harness corpus parity ≥ 99% fixtures / ≥ 98% corpus.
CI auto-discovers the set via `tsx`. No workflow edit required.
### Code references
| Module | Purpose |
|--------|---------|
| `scope-resolution/contract/scope-resolver.ts` | `ScopeResolver` interface + shared types |
| `scope-resolution/pipeline/run.ts` | Generic orchestrator |
| `scope-resolution/pipeline/phase.ts` | Pipeline-phase wrapper (deps: `parse`, `structure`) |
| `scope-resolution/pipeline/registry.ts` | `SCOPE_RESOLVERS` map |
| `scope-resolution/passes/*.ts` | Reference-resolution passes (receiver-bound, free-call fallback, compound-receiver, MRO, cross-file return-type propagation) |
| `scope-resolution/graph-bridge/*.ts` | CLI-local translation from resolved references → `KnowledgeGraph` edges |
| `scope-resolution/scope/*.ts` | Generic scope-chain walkers + namespace targets |
| `scope-resolution/workspace-index.ts` | Build-once O(1) lookup index |
| `registry-primary-flag.ts` | `MIGRATED_LANGUAGES` set + `isRegistryPrimary(lang)` |
| `languages/python/index.ts` | Python `ScopeResolver` hooks + known-limitation docs |
| `languages/python/captures.ts` | `emitPythonScopeCaptures` (honors cross-phase Tree cache) |
| `languages/csharp/index.ts` | C# `ScopeResolver` hooks + known-limitation docs |
| `languages/csharp/captures.ts` | `emitCsharpScopeCaptures` (honors cross-phase Tree cache) |
| `languages/csharp/namespace-siblings.ts` | Cross-file implicit-namespace visibility hook (reads `treeCache`) |
### Performance notes
- **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.
---
## Language-agnostic graph feeding
16 languages → single unified graph. Four abstraction layers:
```
Unified Graph Schema (44 node types, 21 relationship types)
Unified Resolution (3-tier name lookup + MRO walk)
Language Providers (import semantics, type config, export checker, MRO strategy)
Tree-Sitter Queries (per-language S-expressions, unified capture tags)
```
### Language providers
Each language implements `LanguageProvider` (`language-provider.ts`). Key fields:
| Field | Purpose |
|-------|---------|
| `id`, `extensions` | Language identity and file matching |
| `treeSitterQueries` | S-expression queries for AST extraction |
| `importSemantics` | `named` / `wildcard-leaf` / `wildcard-transitive` / `namespace` |
| `importResolver` | Language-specific path → file resolution |
| `exportChecker` | Public/exported symbol detection |
| `typeConfig` | Type annotation extraction rules |
| `mroStrategy` | `first-wins` / `c3` / `none` |
16 providers in `languages/index.ts` via `satisfies Record<SupportedLanguages, LanguageProvider>` — missing a language is a compile error.
### Unified capture tags
Per-language tree-sitter queries use different AST node names but produce the **same semantic capture tags**: `@definition.class`, `@definition.function`, `@call.name`, `@import.source`, `@heritage.extends`. Downstream extraction needs no language branching. Defined in `tree-sitter-queries.ts`.
### Import resolution
Per-language import resolution uses the **configs + factory** pattern (like call/method/class extractors). Each language declares an `ImportResolutionConfig` in `import-resolvers/configs/`, listing an ordered chain of `ImportResolverStrategy` functions. `createImportResolver()` (in `resolver-factory.ts`) composes them: first non-null result wins. Low-level helpers shared across strategies live alongside the configs in `import-resolvers/` (e.g. `go.ts`, `rust.ts`, `python.ts`).
Unified 3-tier algorithm (`model/resolution-context.ts`), per-language `importSemantics` controls which tier activates:
| Tier | Confidence | Mechanism |
|------|-----------|-----------|
| 1 — same-file | 0.95 | Symbol table for caller's file |
| 2 — import-scoped | 0.9 | `NamedImportMap` chains (named) or all files in `importMap` (wildcard) |
| 3 — global | 0.5 | O(1) index lookups: class, impl, callable. Fallback only |
| Import strategy | Languages | Behavior |
|----------------|-----------|----------|
| `named` | TS, JS, Java, C#, Rust, PHP, Kotlin | Only explicitly imported names visible |
| `wildcard-leaf` | Go, Ruby, Swift, Dart | Whole-package import, no transitive re-exports |
| `wildcard-transitive` | C, C++ | `#include` closure chains through re-exports |
| `namespace` | Python | Module aliases resolved at call site |
### Chunked parse-and-resolve
`parse` processes files in ~20 MB byte-budget chunks to bound memory. Per chunk:
1. Worker pool dispatches files (or sequential fallback via `skipWorkers`)
2. Each worker: detect language → load grammar → run queries → return unified `ParseWorkerResult`
3. Synthesize wildcard bindings (`wildcard-synthesis.ts`)
4. Resolve imports and heritage
5. Collect `BindingAccumulator` entries for cross-file propagation
Workers: `workers/worker-pool.ts`, `workers/parse-worker.ts`.
### Heritage and MRO
All languages emit unified `ExtractedHeritage` (child, parent, `EXTENDS`/`IMPLEMENTS`). MRO phase walks the heritage graph using per-language strategy:
- **`first-wins`** — Java, C#, C++, TS, Ruby, Go
- **`c3`** — Python (C3 linearization)
- **`none`** — single-inheritance languages
Unified walk: `lookupMethodByOwnerWithMRO()` in `model/resolve.ts`.
---
## Full analysis flow
`runFullAnalysis` in `run-analyze.ts` orchestrates everything around the pipeline:
```
CLI (analyze.ts) → runFullAnalysis(repoPath, options, callbacks)
1. Early exit if lastCommit == HEAD (unless --force) [0%]
2. Cache existing embeddings from prior index [0%]
3. runPipelineFromRepo() → KnowledgeGraph [0-60%]
4. Clean up legacy KuzuDB files [60%]
5. initLbug() → loadGraphToLbug() via CSV streaming [60-85%]
6. Create FTS indexes (File, Function, Class, Method...) [85-90%]
7. Restore cached embeddings (batch insert) [88%]
8. Generate new embeddings if --embeddings [90-98%]
9. Save metadata + register repo + update .gitignore [98-100%]
10. Generate AI context files (AGENTS.md, CLAUDE.md) [100%]
```
**Options:** `--force` (rebuild regardless), `--embeddings` (opt-in, skipped if >50k nodes), `--skipGit`, `--noStats`.
## Storage
```
<repo>/.gitnexus/
├── lbug # LadybugDB database
├── lbug.wal # Write-ahead log
├── lbug.lock # Single-writer lock
└── meta.json # lastCommit, indexedAt, stats
~/.gitnexus/
└── registry.json # Global repo registry (MCP discovery)
```
Managed by `repo-manager.ts`.
## LadybugDB schema
Defined in `lbug/schema.ts`. Separate node tables per type, single `CodeRelation` table.
**Node tables:** File, Folder, Function, Class, Interface, Method, Constructor, CodeElement, Struct, Enum, Macro, Typedef, Union, Namespace, Trait, Impl, TypeAlias, Const, Static, Property, Record, Delegate, Annotation, Template, Module, Community, Process, Route, Tool, Section, Embedding.
**Relation types** (`CodeRelation.type`): CONTAINS, DEFINES, CALLS, IMPORTS, EXTENDS, IMPLEMENTS, HAS_METHOD, HAS_PROPERTY, ACCESSES, METHOD_OVERRIDES, METHOD_IMPLEMENTS, MEMBER_OF, STEP_IN_PROCESS, HANDLES_ROUTE, FETCHES, HANDLES_TOOL, ENTRY_POINT_OF.
## Embeddings and search
**Embeddings** (`src/core/embeddings/`): Snowflake arctic-embed-xs (384D). Embeddable: File, Function, Class, Method, Interface. Incremental via SHA1 content hash. Separate `Embedding` table.
**Search** (`src/core/search/`): Hybrid BM25 + semantic vector, merged via Reciprocal Rank Fusion (K=60).
## Known limitations
### Overloaded method resolution
Node IDs use arity suffix (`#<paramCount>`): `Method:file:Class.method#1` vs `#2`.
**Same-arity disambiguation:** type-hash suffix `~type1,type2` when collision detected and type annotations present. Languages without types (Python, Ruby, JS) use arity-only. TS/JS overload signatures excluded (collapse to implementation body). See #651.
**C++ const-qualified:** `$const` suffix after type-hash when non-const collision exists: `Method:file:Container.begin#0$const`.
**Generic/template types:** type-hash uses `rawType` (full AST text including generics): `~vector<int>` vs `~vector<std::string>`.
**ID stability:** collision-only tags mean IDs change when overloads are added. `save#1` becomes `save#1~int` when `save(String)` is added.
**Variadic matching:** confidence 0.7 when one side is variadic and the other has fixed count.
**METHOD_IMPLEMENTS confidence tiering:**
| Match quality | Confidence |
|---|---|
| Exact parameter types match | 1.0 |
| Arity match, types unavailable | 1.0 |
| Variadic vs fixed | 0.7 |
| Insufficient info | 0.7 |
## Related docs
- [MIGRATION.md](MIGRATION.md) — breaking changes and migration guidance
- [RUNBOOK.md](RUNBOOK.md) — operational commands and recovery
- [GUARDRAILS.md](GUARDRAILS.md) — safety boundaries for humans and agents
- [TESTING.md](TESTING.md) — how to run tests
- `AGENTS.md` / `CLAUDE.md` — agent workflows and tool usage