GitNexus/ARCHITECTURE.md
Gergő Magyar 77360e1043
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
fix(scope-resolution): make interface dispatch generic-instantiation aware (#2912) (#2939)
* fix(scope-resolution): make interface dispatch generic-instantiation aware (#2912)

Interface-dispatch fan-out walked the subtype closure with generic arguments
erased, so `IValidator<string>` and `IValidator<int>` — one declaration, one
subtype list — were indistinguishable and a call through the first reached
`IntValidator.Check(int)`, a target no runtime dispatch can produce.

The arguments were already in the capture, unread: every language anchors
`@reference.inherits` on the whole base node while `@reference.name` keeps the
erased base. `ReferenceSite.typeArguments` is therefore derived generically in
`scope-extractor.ts` from the anchor's own spelling — no per-language query
changed — covering C#, Java, TypeScript, Kotlin, Go (`Base[int]` embedding),
Python (`Base[User]`) and Swift; Rust and Dart anchor on the bare name and get
nothing, which reads as "unknown". `preEmitInheritanceEdges` is the only code
that pairs a heritage site with a resolved (subtype, supertype), so it records
the instantiation there and hands it to the dispatch pass.

The closure is then walked carrying a substitution, as a type checker would:
`Wrapper<T> : IValidator<T>` binds T to the receiver's argument and stays
reachable from every instantiation, while its own subtypes are matched against
that binding. An incompatible hop is skipped without being marked seen, so a
type reachable by a second, compatible path still gets its edge, and without
descending, since its subtypes inherit the mismatch.

Pruning happens only on positive evidence that two instantiations differ.
Unknown arguments on either side, an arity that does not line up, an unresolved
qualified spelling of the same simple name, or an argument that might be a type
variable the language never captured all keep the target. Telling an uncaptured
type VARIABLE from a concrete type is the crux: `typeParameters` is absent both
for a non-generic declaration and for every declaration in a language whose
query omits `@declaration.type-parameters`, so the pass reads the evidence in
front of it — one run resolves one language, so a single generic declaration
anywhere in it proves the captures record parameters. A language recording
neither arguments nor parameters keeps exactly its pre-#2912 fan-out.

Type arguments are compared as resolved declarations rather than spellings, so
`Models.User` and an imported `User` are one type; the new optional
`ScopeResolver.normalizeTypeArgument` hook canonicalizes a language's predefined
aliases, implemented for C# (`string` ≡ `String`) where mixing the spellings
would otherwise delete a real implementor.

Fan-out cap, skipped-target reporting, overload selection and non-generic
closure behaviour are unchanged. SCHEMA_BUMP 60 -> 64: the heritage arguments
are a parse-time capture, so a warm cache would replay pre-fix sites and leave
the filter silently inert on unchanged files (61/62/63 are claimed by open PRs).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01StNKYi7Qxv5DnSURZuFBef

* fix(scope-resolution): close the two generic-dispatch gaps (#2912)

The first commit left two shapes on the pre-#2912 fan-out. Both are now
covered, and the second one turned out to need a route the pipeline did not
have at all.

**Folded receivers (Cases 0 and 3b).** `this._validator.Check(x)` is typed by
the compound fold, and the fold answers with a CLASS — which is exactly what
loses the instantiation, since `IValidator<string>` and `IValidator<int>` fold
to one declaration. The fold now reports the SPELLING it typed each receiver
position from, through a pure side channel (`recordReceiverType`) added to the
one helper every declared-type route already shares plus the two return-type
routes; resolution is unchanged whether or not a caller passes it. The reader
keeps the last report and uses it only when it names the class the fold
returned, so an intermediate position cannot lend its arguments to another
class. This covers the dependency-injection shape the issue is really about —
a field-held generic interface — and multi-hop chains, where it is the last
hop's spelling that types the receiver.

**Rust and Dart heritage.** Neither recorded arguments, for two different
reasons, so both routes exist now:

  - Rust's `@reference.inherits` anchor is the trait identifier INSIDE a
    `generic_type`. Widening the anchor would move the site's range, and that
    range is part of every inheritance edge's id, so the arguments arrive
    through a new `@reference.type-arguments` sub-tag instead.
  - Dart's `implements` / `with` never become reference sites at all: they
    travel as heritage MARKERS and their edges are emitted by the language
    hook. The arguments ride the marker payload as an optional fourth field
    (dropped, not encoded, when the spelling contains the marker delimiter),
    and `ScopeResolver.emitHeritageEdges` now receives the same sink
    `preEmitInheritanceEdges` writes to, so whichever pass emits an edge
    records that edge's instantiation. Dart also gained the
    `@declaration.type-parameters` capture, without which its own type
    VARIABLES are indistinguishable from concrete arguments and
    `class Box<T> implements Validator<T>` would be pruned from every
    instantiation.

Note this makes Rust and Dart record their instantiations; it does not make
them fan out. Interface dispatch still fires only for a receiver whose folded
type is an `Interface` symbol, so a Rust `Trait` or a Dart abstract `Class`
receiver has no secondary targets to filter. Widening that gate emits new
edges for several languages and belongs to its own issue.

**Two matcher rules the wider coverage exposed.** A WILDCARD names a set of
types rather than one — `Repo<? extends User>` holds a `Repo<User>`, and
Kotlin's `Repo<*>` / `Repo<out User>` say the same — so a position with one on
either side is unknown; nullable spellings trip the same test, which costs a
little precision in the safe direction. And insignificant whitespace inside a
nested spelling (`Map<string, User>` vs `Map<string,User>`) is no longer a
difference.

One expectation changed in the #2833 field-receiver matrix: a
`Repo<Repo<User>>` receiver no longer reaches `UserRepo implements Repo<User>`.
That edge is precisely the false positive this issue is about, and the primary
edge to the interface's own declaration — which is what the matrix row exists
to prove — is untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01StNKYi7Qxv5DnSURZuFBef

* refactor(scope-resolution): apply the quality pass to the #2912 change

Three cleanups, no behaviour change.

**One balanced-list scanner, not two.** `erasedTypeApplication` and
`typeApplicationArguments` each carried a copy of the same fiddly scan — one
bracket list, balanced, closing on the last character, non-empty — differing
only in what they did with the result. Both now call `balancedTailList`; the
rule that rejects `User[][]` and `Repo<User>?` lives in one place instead of
being free to drift between two.

**The receiver's arguments are parsed after the gates, not before them.**
`emitInterfaceDispatchFor` takes the receiver's declared SPELLING and parses it
itself, once the owner is known to be an Interface with subtypes. Every one of
the five cases calls it unconditionally and the overwhelming majority of
receivers are concrete classes that return at the first line, so the parse was
running per resolved receiver site to be discarded immediately. Case 4 and Case
6 now hand over the string they already hold, and the folded-receiver helper
returns the recorded spelling rather than parsing it.

**One question gates the whole instantiation apparatus.** Inside the closure
walk, the graph-id lookups now hang off "is the supertype's instantiation
known?" — false for every non-generic receiver and for every language that
captures no heritage arguments, which is what makes those walks cost exactly
what they cost before #2912.

Also lifted the argument-route choice in `pass5CollectReferences` out of a
nested ternary into a named `heritageTypeArguments`, where the reason the
explicit sub-tag wins over the anchor text can be stated once.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01StNKYi7Qxv5DnSURZuFBef

* test(scope-resolution): cover generic interface dispatch in Kotlin and Go (#2912)

Extends the #2912 dispatch coverage past C#/Java/TypeScript. No production
code changes — the derivation is language-agnostic by construction
(`heritageTypeArguments` reads the heritage anchor's own spelling), so the
question was only which languages actually reach the filter.

Kotlin rides the shared heritage pre-pass; Go reaches the same filter from
the other side, matching implementors structurally while the receiver's
`Validator[string]` spelling carries the instantiation. Both are confirmed
to prune the mismatched implementor.

Each language gets a NON-GENERIC control asserting the fan-out still reaches
every implementor. Without it the `not.toContain` assertion passes just as
well when a language emits no dispatch edge at all — which is what Dart,
Python and Rust were measured doing for this receiver shape, generic or not.
They are deliberately not asserted on here: a "filtered correctly" test over
a path that never fans out measures nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Px638Zyqa9CJMUU7DsJoB

* test(bench): re-baseline the Rust and Dart capture fingerprints for #2912

The Rust trait-impl and Dart heritage capture changes this branch makes are
additive TEXT on existing matches — each carries the instantiation the clause
was written with — so they drift the scope-capture digest without adding or
removing a match. The baselines were never re-measured when those captures
landed, which left `measure.mjs --check` red on this branch independently of
the merge.

Re-measured rather than hand-edited. Rust's capture_groups_fp (3556) and
fixture_count (202) are unchanged across the move, which is the evidence that
this is digest drift and not a capture-set regression. The other 13 languages
are byte-identical; 15/15 pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Px638Zyqa9CJMUU7DsJoB

* refactor(scope-resolution): quality pass over the #2912 change

Cleanup only — no behavior change. Findings from a four-angle review (reuse,
simplification, efficiency, altitude), applied where they were verified.

Reuse / duplication:
* `stripTrailingCallSuffix` was a second copy of `matchingOpenParen`'s backward
  balanced-paren scan. Both now live in `template-arguments.ts` beside
  `balancedTailList`, for the reason that helper was shared in the first place:
  two copies of a scan this fiddly are free to disagree.
* The two call-return arms of the compound fold repeated the same four-part
  expression character for character; they share `classOfReturnType` now, the
  return-type twin of `classOfDeclaredType`, which keeps the "look up by
  rawName, report the erased application" pairing in one place.
* `pipeline/run.ts` implemented first-writer-wins twice — once in the pre-pass
  and once in the provider sink. One store, one sink, one rule; the pass keeps
  its `Set<string>` return and the callable-flow-only arm stops building an
  empty map to satisfy a widened return shape.

Simplification:
* `subtypeParametersComplete` dropped a disjunct that could never decide: every
  `subDef` reaching it comes out of the same loop that sets
  `languageCapturesTypeParameters`, from exactly those defs.
* The heritage-argument lookup asked "is the supertype's instantiation known?"
  three times; `superGraphId` now gates the block once.
* `TypeArgumentResolver` and `HeritageInstantiationResult` un-exported — no
  consumer outside their module.

Efficiency (all on the per-site dispatch walk):
* `resolveSupertypeArgument` captures only the site, so it is built once per
  site instead of once per subtype visited; the subtype's scope id is looked up
  once per subtype instead of once per argument position.
* `erasedTypeApplication` no longer runs on every fold hop through a call — the
  spelling is built only once the lookup has found a class, since it is
  discarded otherwise.
* `normalize`+`compact` computed once per side rather than twice.
* Regex literals and the identity `normalize` fallback hoisted to module scope.
* C# `System.` prefix stripped with `startsWith`/`slice` instead of a regex.

Verified: tsc clean, build clean, 1994 scope-resolution unit tests, 171
generic-dispatch + generic-field-receiver integration tests, 15/15 capture
bench fingerprints unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Px638Zyqa9CJMUU7DsJoB

* style: apply Prettier to the two files the quality pass reformatted

Whitespace only — `quality / format` (npx prettier --check .) was red.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Px638Zyqa9CJMUU7DsJoB

* fix(scope-resolution): close the generic-dispatch review findings (#2912)

Addresses the gitnexus-check review on #2939.

A repeated type variable was rebound rather than unified: `class C<T> :
Pair<T, T>` accepted a `Pair<string, int>` receiver, with `T = int` silently
replacing `T = string` and the bogus substitution carried to the next hop.
It now unifies, and prunes only on the same positive evidence the concrete
path demands — an undecidable repeat keeps the target with no binding.

A type PARAMETER of the declaration enclosing either side is now recognised
and never compared. `subtypeParametersComplete` is evidence about the
SUBTYPE's parameter list and says nothing about a `T` written at the call
site, so `void Run<T>(IValidator<T> v) { v.Check(x); }` pruned every
implementor: unbounded, `T` grounds to nothing; bounded, it grounds to its
BOUND. Both read as a difference of type. That is the missing-edge failure
this filter is built to avoid, and it is the common dependency-injection
shape in C#, Java and Kotlin.

Making that recognition reliable is why generic METHODS now capture
`@declaration.type-parameters` in C#, Java and Kotlin — TypeScript already
did, which is why its generic functions never had the defect. The capture
feeds the existing `bindsTypeParameter` guard, so a method-level `T` also
stops resolving to a same-named class in every other lookup.

C# alias normalization additionally strips the `global::` qualifier, which
`import-decomposer` already unwraps elsewhere: `global::System.String` read
as unequal to `string` and pruned a live implementor.

The C# captures golden fixture is regenerated for the new capture; the
extractor reads `@declaration.type-parameters` generically, so no reader
changed. SCHEMA_BUMP 64 already covers these capture changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Px638Zyqa9CJMUU7DsJoB

* fix(scope-resolution): close the two remaining gitnexus-check findings (#2912)

`balancedTailList` counted ONE bracket family, so a crossed pair slipped
through: scanning `Foo<Bar]>` it never sees the `]`, reaches the final `>` at
depth zero, and reports `Bar]` as a balanced argument list — which
`typeApplicationArguments` then splits and `erasedTypeApplication` rebuilds a
spelling from. It now tracks a stack of expected closers, so every closer must
match the opener it actually closes and a crossed pair declines to `undefined`,
the "unknown" both callers already fail open on. Well-formed mixed nesting
(`List<Dict[a, b]>`) is unaffected.

C# `normalizeTypeArgument` stripped `System.` from every qualified spelling, so
`System.Custom` answered `Custom` and compared equal to an unrelated `Custom`
elsewhere in the workspace. The strip is now earned: a keyword answers from the
alias table first, and the qualifier is dropped only when what remains IS a
predefined type. `System.Custom` is returned as written and goes to the identity
comparison instead — the step that can actually tell two declarations apart.
`global::System.String` still meets `string`.

Both are pinned by unit tests, including the well-formed mixed nesting and the
`global::`-qualified ordinary type that must keep its qualifier.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Px638Zyqa9CJMUU7DsJoB

* docs(csharp): record why a shadowed `String` keeps its implementor (#2912)

Answers a review finding rather than changing behavior.

A workspace may declare its own type named `String`, shadowing the BCL simple
name, and the alias table then reads `IValidator<String>` as the `string`
instantiation and keeps that implementor. That is the SAFE direction, not an
oversight: pruning instead would rest on the belief that two spellings differ,
which is the missing-edge failure `generic-instantiation.ts` exists to avoid.

Resolving rather than normalizing cannot settle it either — the identity
comparison needs a `definitionId` from both sides, and a built-in name carries
none, so "built-in versus workspace-declared implies different" would be a new
prune with no positive evidence behind it. The cost is one surplus edge for that
pair, which is exactly the pre-#2912 fan-out and no worse.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Px638Zyqa9CJMUU7DsJoB

* refactor(scope-resolution): pair the receiver spelling with the class structurally (#2912)

The fan-out needs the spelling a receiver position was typed from, because the
class the fold returns has lost the generic arguments. That was carried by a
PASS-LEVEL mutable holder, written by every declared-type lookup anywhere in the
fold and read back through a def-id coincidence check, with the holder cleared
by hand before each call site. Three things were load-bearing and none were
enforced:

* the reset had to be remembered at every call site. It was not: the Case 3b
  retry (`rawName` then `rawName + '()'`) reset once, BEFORE the first attempt,
  so a spelling reported by the attempt that failed could be attributed to the
  one that succeeded.
* the holder outlived every resolution, so a site that resolved through a route
  reporting nothing could read the previous site's spelling if the def ids
  happened to line up.
* the pairing itself was inferred from "whichever lookup reported last", not
  from the fold's own bookkeeping — losing branches (an MRO walk that moved on,
  a step later folded past) report too.

`foldReceiverChain` already had the answer and threw it away: its final
`FoldState` holds `def` and `declaredType` produced by the SAME step. It now
reports that pairing last, so the structural route is the one that stands.

`resolveCompoundReceiverTyped` returns `{def, declaredSpelling}` and owns a sink
created and read within the single call, which is what removes the reset
discipline — a local cannot be forgotten, and each of the two retry attempts
carries its own. The def-id guard stays as the check that a report names the
class actually returned.

Behavior is unchanged: 1975 scope-resolution unit tests, 177 generic-dispatch
and generic-field-receiver integration tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Px638Zyqa9CJMUU7DsJoB

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 14:50:53 +01:00

63 KiB
Raw Permalink Blame History

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. Ingestionanalyze.tsrunFullAnalysis (run-analyze.ts) → runPipelineFromRepo (pipeline.ts). The default DAG of 19 phases builds a KnowledgeGraph in memory, then loads into LadybugDB under .gitnexus/. Repo registered in ~/.gitnexus/registry.json for MCP discovery.

  2. Persistencerepo-manager.ts (paths, registry, LadybugDB cleanup). lbug-adapter.ts (graph load, queries, embedding batches).

  3. Query layer — three interfaces to the same backend:

    • MCP (stdio): mcp.tsLocalBackend → 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. Stalenessstaleness.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
trace Shortest directed path between two symbols (call + class-member edges); group-aware (repo: "@<group>") for cross-repo traces
route_map API route → handler → consumer mappings
tool_map MCP/RPC tool definitions and handlers
shape_check Response shape vs consumer property access mismatches
explain Persisted taint findings (source→sink data flows) — needs analyze --pdg
pdg_query Control/data dependence — CDG (mode: controls) / REACHING_DEF (mode: flows) — needs analyze --pdg
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). trace is also group-aware via repo: "@<groupName>" — but, unlike the others, it resolves from/to across all members (a @<groupName>/<memberPath> suffix is advisory for trace, not a scope); pass from_uid/to_uid to disambiguate a symbol name that occurs in more than one member.

Group-mode trace (gitnexus/src/core/group/cross-trace.ts) stitches a path that crosses repositories: it resolves from/to across all members, and when they live in different repos it joins the home-repo segment to the target-repo segment over a single ContractLink boundary (an HTTP consumer→provider link, joined on Contract.symbolUid), reported as a CONTRACT_LINK hop in crossings[]. The crossing is clamped to one boundary (MAX_SUPPORTED_CROSS_DEPTH, shared with cross-impact); deeper crossDepth is reported via notes[]. With pdg: true (experimental, opt-in), each boundary-adjacent segment is enriched with its intra-procedural REACHING_DEF data-flow when that repo was indexed with --pdg (reusing the same anchored flows query as pdg_query); data flow never crosses the repo boundary, and a missing PDG layer degrades to call-level hops with a note. Two stores meet only at the symbolUid grain — the per-repo PDG/call graph and the group bridge — so this is the documented join; full cross-program (SDG-like) data flow across the boundary remains deferred (see docs/plans/2026-06-18-002-feat-unified-pdg-impact-evaluation-plan.md). 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/inheritance/MRO src/core/ingestion/scope-resolution/ (pipeline, passes, graph-bridge)
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

19 default phases are defined in gitnexus/src/core/ingestion/pipeline-phases/, each with explicit deps and typed output. --pdg adds taintSummaries and callSummaries (21 total).

scan → structure → [springConfig, markdown, cobol] → parse → [routes, tools, orm]
  → crossFile → scopeResolution → [springAutoConfiguration, springAop]
  → pruneLocalSymbols → mro → springAopInheritance → di → communities → processes
Phase File Deps Output
scan scan.ts (root) File paths + sizes
structure structure.ts scan File/Folder nodes, CONTAINS edges, allPathSet
springConfig spring-config.ts structure Spring configuration-property nodes and metadata
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, and JS/TS dispatch guards — see below)
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
scopeResolution scope-resolution/pipeline/phase.ts parse, crossFile, structure Binding/reference + inheritance edges; disposes BindingAccumulator
springAutoConfiguration spring-auto-configuration.ts structure, scopeResolution DECLARES and CONDITIONAL_ON metadata for Spring configuration candidates
springAop spring-aop.ts scopeResolution Direct declarative/advice ADVISED_BY edges and pointcut evidence
pruneLocalSymbols prune-local-symbols.ts scopeResolution Drops inert block-local Const/Variable/Static nodes (only a File→DEFINES edge) post-resolution
mro mro.ts crossFile, scopeResolution, pruneLocalSymbols, structure METHOD_OVERRIDES + METHOD_IMPLEMENTS edges
springAopInheritance spring-aop.ts springAop, mro Propagates declarative behavior through class/interface inheritance decisions
di di.ts mro INJECTS edges from consumer Classes or factory Methods to provider Classes/declaration CodeElements (framework-neutral DI resolution; per-language matchers registered in di-extractors/)
communities communities.ts mro, pruneLocalSymbols, structure Community nodes + MEMBER_OF edges (Leiden algorithm)
processes processes.ts communities, routes, tools, pruneLocalSymbols, 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), 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 accessgetPhaseOutput<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 phasesskipGraphPhases omits MRO/di/communities/processes (faster tests); pruneLocalSymbols still runs (it is graph cleanup, not analysis). skipWorkers is no longer a sequential escape hatch — it (like --workers 0 / GITNEXUS_WORKER_POOL_SIZE=0) is rejected with an actionable error, since the worker pool is the sole parse path (§ Chunked parse-and-resolve).
  • Local-symbol pruningpruneLocalSymbols removes inert block-local value symbols after scope resolution has consumed them. Opt out per-call with PipelineOptions.keepLocalValueSymbols or globally with the GITNEXUS_KEEP_LOCAL_VALUE_SYMBOLS env var.

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
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 */
    };
  },
};

Where routes come from

route-extractors/ holds four independent ways a route can be discovered, all converging on the routes phase's (method, url) registry:

Source Shape Examples
Filesystem convention path → URL, no parsing Next.js app/, Expo, PHP
Single-file framework route isRouteFile + worker extraction Laravel routes/*.php
Cross-file framework route discoverRootRouteFiles + extractRoutes Django urlpatterns
AST-level route in a normal file extractDecoratorRoutes Spring, FastAPI, NestJS, JS/TS dispatch guards

The last row is the one whose name undersells it. A route is DECLARED by a decorator, but it can also be inferred from a raw node:http server's own dispatch — if (req.method === 'GET' && pathname === '/api/x') is a route with a path, a verb and a handler, and nothing else in the pipeline could see it. route-extractors/dispatch-guard.ts reads that shape; the transport, dedup and handler resolution are shared with decorator routes, and ExtractedDecoratorRoute.source carries the provenance difference through to the HANDLES_ROUTE edge.

That extractor is deliberately precision-weighted: route_map presents its output as fact, so a startsWith namespace test, a bare pathname === '/' without a verb, and any regex it cannot translate exactly are all dropped rather than guessed at. A missing route is a coverage limit; an invented one is a lie.

Two rules there need more than one comparison to decide, and are worth knowing about before changing either:

  • Same-file constant folding. pathname === `${basePath}/rules` is common enough that refusing it loses whole route modules — and loses them invisibly, since a module with unfoldable paths and a module with no routes produce the same empty answer. Folding is same-file, string literals only, one alias hop, and refuses on ambiguity (a name declared twice with different values is dropped, never guessed).
  • Whole-repo reconciliation (reconcileDispatchGuardRoutes, applied in the routes phase). A split route table — one module listing every path it recognises so the dispatcher can 404 early, handlers in others — otherwise lists every route twice, once verb-less with the table as its "handler". It applies to dispatch-guard routes only: a framework route with no verb is method-agnostic by declaration, which is a fact, not a weaker observation.

Semantic model

SemanticModel (gitnexus/src/core/ingestion/model/semantic-model.ts) is the authoritative store for every symbol-indexed lookup (by nodeId, simpleName, qualifiedName, or filePath). The scope-resolution pipeline reads from here: findOwnedMember, pickOverload, and findExportedDefByName all consult model.methods / model.fields / model.symbols.

ParsedFile (gitnexus-shared/src/scope-resolution/parsed-file.ts) is the single per-file artifact the scope-resolution pipeline consumes. 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.

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: 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 MutableSemanticModelSemanticModel at the phase boundary so downstream passes physically cannot mutate the model even accidentally.

Reconciliation pass. reconcileOwnership (scope-resolution/pipeline/reconcile-ownership.ts) is a shim for languages whose parse-time 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 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 scope-resolution resolver. This is the resolution path for every language — it owns CALLS/ACCESSES/USES emission and inheritance edges. Adding a language is one interface implementation (ScopeResolver) plus one registration in the SCOPE_RESOLVERS map — no changes to shared code, no new pipeline phase. (RING4-1 #942 removed the legacy call-resolution DAG and the per-language MIGRATED_LANGUAGES flag, so SCOPE_RESOLVERS registration is all that's needed.)

Pipeline stages

 ParsedFile[]  (extractParsedFile per file)
    │  finalizeScopeModel (+ provider hooks)
    ▼
 ScopeResolutionIndexes
    │  resolveReferenceSites  (via MethodRegistry.lookup)
    ▼
 ReferenceIndex
    │  emitReceiverBoundCalls  ── FIRST
    │  emitFreeCallFallback    ── THEN
    │  emitReferencesViaLookup ── uses handledSites + deferred-site skip set
    │  emitPropertyDispatchCalls ── registration USES + conservative CALLS
    │  emitCallableValueFlow   ── assigned/passed callable invocation CALLS
    │  emitImportedValueReferences ── cross-file value reads via finalized imports
    │  emitUniqueNamePropertyAccesses ── LAST-RESORT property reads by name,
    │       narrowed same-file → direct-import, refusing to choose otherwise
    │  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 the registered SCOPE_RESOLVERS over the worker-serialized ParsedFiles. (Per-language emitScopeCaptures hooks may reuse a cached Tree via the orchestrator's treeCache, but in worker-pool runs that cache is empty — Trees can't cross MessageChannels — so they consume the pre-extracted ParsedFile instead; § Performance notes.)

Callable-value flow

First-class callable values use a language-neutral inclusion analysis in passes/callable-value-flow.ts. Providers recognize their own syntax and emit JSON-safe CallableFlowSite facts (seed, copy, alias, address, load, store, formal, argument, and invoke) into ParsedFile; shared ingestion never branches on a language name. These always-on facts cross workers and the durable parse store, whose schema is bumped whenever their semantic shape changes.

The emit stage defers only invocation sites proven to reference a flow cell. Ordinary receiver/free/reference passes still resolve direct callees first and record exact callee IDs by file/line/column. Property dispatch then runs before callable flow because a property-dispatched wrapper call can seed actual-to-formal propagation. The callable solver consumes those direct targets, propagates callable sets through lexical cells and formals, and emits CALLS at the real indirect invocation site with reason callable-value-flow (confidence 0.8 for a singleton, 0.7 for a bounded multi-target set).

The solver is flow-insensitive but bounded: dependency-indexed work items rerun only when a cell they read changes; target/address sets cap at 32; a hostile fact graph has a finite work budget; overflow or budget exhaustion emits no partial CALLS and produces a structured warning. Lexical shadowing is function/block aware, invocation/constructor results are not reinterpreted as callable designators, and overload selection uses provider-supplied signature metadata. C/C++ additionally associate visible prototypes with unique definitions so actual-to-formal flow crosses translation units; the provider-owned hasFileLocalCallableLinkage hook prevents static declarations or definitions from leaking across files. C++ member-function pointers preserve parameter/cv shape, keep non-virtual targets exact, and expand virtual targets through MethodDispatchIndex/MRO.

Property-key dispatch remains a separate conservative fallback. Its per-key fan-out cap is 32; capped keys synthesize no partial calls and are reported at warning level with language, skipped-key count, dropped key names (bounded), and cap; the count also travels in RunScopeResolutionStats.propertyDispatchSkippedKeys.

Interface-dispatch fan-out walks the subtype closure of the receiver's interface and is generic-instantiation aware (#2912): a call through IValidator<string> must not reach an implementor of IValidator<int>, which shares its declaration and therefore its subtype list. Each heritage clause's arguments reach resolution by one of three routes — read off the @reference.inherits anchor's own spelling where that anchor spans the whole base (most languages, no query change), through the @reference.type-arguments sub-tag where the anchor is the bare name and moving it would renumber inheritance edge ids (Rust impl T<A> for S, Dart extends), or on a heritage MARKER payload for clauses that never become reference sites (Dart implements/with). Whichever pass emits the edge records the pair through one sink: preEmitInheritanceEdges for heritage clauses, ScopeResolver.emitHeritageEdges for the rest.

The walk then carries a substitution: a subtype's own type parameters bind to the receiver's arguments, so class Wrapper<T> : IValidator<T> stays reachable from every instantiation while class IntValidator : IValidator<int> is pruned from the string one. Receiver arguments come from the declared type (Case 4), a class-level field's declared type (Case 6), or — for a compound receiver such as this._repo — the spelling the compound fold typed that position from, reported back through recordReceiverType and accepted only when it names the class the fold returned.

The filter prunes only on positive evidence: an unknown instantiation on either side, an argument list whose arity does not line up, a name that may be a type variable the language's captures never recorded, or an unresolved spelling whose simple name matches all keep the target. A type parameter of the declaration ENCLOSING either side is recognised as such and never compared — void Run<T>(IValidator<T> v) writes a receiver with no known instantiation, so it keeps the unfiltered fan-out. That recognition is what generic METHODS now carry @declaration.type-parameters for in C#, Java and Kotlin (TypeScript already did): without it an unbounded T grounds to nothing and a bounded one grounds to its BOUND, and both compare unequal to an implementor's concrete argument. Languages that capture neither type arguments nor type parameters therefore emit exactly the pre-#2912 fan-out. The fan-out cap (32, GITNEXUS_MAX_INTERFACE_DISPATCH_FANOUT) and its skipped-target reporting are unchanged and apply after filtering. Note the fan-out itself still fires only for a receiver whose folded type is an Interface symbol, so a Rust Trait or a Dart abstract Class receiver emits no secondary targets to filter in the first place.

Standalone (regex-based) providers such as COBOL participate via ScopeResolver.scopeResolutionEdgeMode: 'callable-flow-only': runScopeResolution runs for them, but every ordinary emission path — heritage, interface implementations, receiver-bound, free-call fallback, reference/import edges, post-resolution hooks — is gated off, so their legacy phase (e.g. cobolPhase) remains the sole owner of structural edges and the callable solver's CALLS are purely additive. A callable-flow-only provider whose files emitted no callable facts exits early, before finalize, keeping the opt-in proportional to source scanning.

Receiver chains and the drop census (#2766)

A compound receiver (svc.getUser().address.save()) is captured as a compact string on ReferenceSite.receiverChain. utils/receiver-chain-codec.ts is the ONE encoder/decoder — capture emitters, the scope-resolution fold, and the durable ParsedFile store all import it rather than hand-rolling the format.

Wire format is v2: 2|<base>|<step>|<step>…, one-character version prefix, then base-first steps, each a one-character kind sigil plus the member name (c = call, f = field). a (await) and i (index) are name-free and encode as a bare sigil — an awaited call's name already lives on its c step, and a subscript key is a value, not a lookup-able identifier. The version went 1 → 2 when those two kinds were added, and a decoder REFUSES a foreign version rather than decoding the prefix it understands: a chain missing its await/index hop decodes cleanly as a different, shorter chain and would type the receiver against the wrong member. The format is unescaped (| and ~ cannot occur in an identifier), so an unencodable name is refused rather than escaped, and the payload is capped at MAX_RECEIVER_CHAIN_BYTES / MAX_CHAIN_DEPTH steps. Because these strings live in the incremental parse cache and the durable ParsedFile store, a format change requires a PARSE_CACHE_VERSION schema bump — a stale cache would otherwise replay v1 chains this build discards.

Receivers the resolver could not type are not silently dropped. Each records a ResolutionOutcome (scope-resolution/resolution-outcome.ts) carrying the receiver's shape (classifyReceiverShape: chain-call / chain-field / chain-mixed / chain-unwrap / no-chain — the bench censuses these) and its origin (in-program / external / unknown). scope-resolution/unresolved-receivers.ts aggregates them per member name into the index-persisted unresolvedReceiverMembers summary, keeping in-program and external counts under separate keys. Only in-program drops make a count short: an external-rooted call (System.out.println, fetch(...)) has no in-graph node an edge could have reached, so it is reported but does not hedge. impact / context read that summary and publish epistemic: 'exact' | 'lower-bound', prose boundaries, and the machine-readable causes split (EpistemicCauses in mcp/local/local-backend.ts).

Optional CFG/PDG emission (--pdg, #2081#2086)

On a --pdg run the parse worker builds a per-function control-flow graph from the tree-sitter AST (LanguageProvider.cfgVisitor; TypeScript/JavaScript today) and serializes it onto ParsedFile.cfgSideChannel as plain data. Scope-resolution then emits the program-dependence layers from that side-channel inside Phase 4 of runScopeResolution, while the disk-backed ParsedFile store is still live — the only window where the worker-built CFGs are loaded (the store is cleared right after the phase returns). A standalone post-mro phase would read an empty store, so the emit deliberately lives in-phase, mirroring the applyCaptureSideChannel pattern. The opt-in is off by default (graph byte-identical), folded into the parse-cache key (a pdg-off warm cache is never reused on a --pdg run), and each layer is bounded by a per-function edge cap that logs any dropped edges. All layers are BasicBlock → BasicBlock edges in the single CodeRelation table, keyed by type; there is no Function → BasicBlock edge — the symbol↔block join is reconstructed from the BasicBlock id prefix + line span. The layers build on each other:

  • M1 — CFG (#2081): BasicBlock nodes + CFG edges. Edge kind (seq/cond-true/loop-back/…) rides the reason column (CFG is one CodeRelation type, not one per kind).
  • M2 — REACHING_DEF (#2082): GEN/KILL def→use data dependence from a pure fixpoint solver; the variable name rides reason.
  • M3/M4 — TAINTED / SANITIZES / TAINT_PATH (#2083#2084): intra- and inter-procedural taint (source→sink) — the explain tool's data.
  • M5 — CDG (#2085): Ferrante control dependence over a CooperHarveyKennedy post-dominator tree (the EXIT-rooted reverse CFG); branch sense ('T'/'F') rides reason. A CFG whose EXIT is unreachable from some block is skipped for CDG (post-dominance would be unsound) while its CFG/REACHING_DEF layers are kept.
  • M6 — read surface (#2086): the pdg_query MCP tool answers "what gates X?" (CDG, mode: controls) and "where does Y flow?" (REACHING_DEF, mode: flows); explain is the taint consumer. Both are always anchored + LIMIT-bounded (LadybugDB has no rel-property index) and share one resolveBlockAnchor helper. These PDG edge types are deliberately kept out of the default VALID_RELATION_TYPES / web schema.
  • Cross-repo trace enrichment: group-mode trace (pdg: true) reuses the same anchored REACHING_DEF flows query to annotate a boundary-adjacent segment with how a value reaches the cross-repo call — strictly intra-procedural (data flow never crosses the repo boundary). See the group-aware tools note above.

See core/ingestion/cfg/ (emit + the pure CFG / post-dominator / control-dependence / reaching-defs / taint passes) and mcp/local/local-backend.ts (_pdgQueryImpl, _explainImpl, the shared resolveBlockAnchor).

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.)
isNamespaceImport(parsedImport, targetFile, fromFile) Optionally reclassify a resolved named import as a namespace handle when the imported symbol is itself a module
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)
elementTypeOf? (containerType, via: {kind:'index'} | {kind:'accessor',name}) → elementType | undefined — element type of a container, reached by subscript (repos[0]) or by a property-style collection view (data.Values). ONE hook for both routes (it replaced the split unwrapCollectionAccessor / unwrapCollectionElement, where implementing one silently answered nothing for the other). Consulted only where the source actually performed the access — never as a general type-name normalizer
stripTypePreservingDecoration? (typeName) → strippedName | undefined — strip ONE layer of TYPE-PRESERVING decoration (pointer, reference, const, nullable, borrow, sigil) so a receiver declared *Host still finds the Host binding (#2766). Never a container: unwrapping Repo[] here would fold repos.find(x) to Repo.find — that is elementTypeOf's job, and only after a real subscript. Consulted only after every undecorated lookup fails, and only by receiver-chain base/step resolution — 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
hasFileLocalCallableLinkage? Precise internal-linkage predicate used only when joining callable declarations/prototypes to cross-file definitions; C/C++ use it for static free functions
constructorCallTargetsClass? A constructor-form call Type(...) links to the Class def rather than its explicit Constructor def — default off; Swift and Dart opt in
constructionSyntax? How the language spells construction, so an INLINE constructor receiver (Service(db).m(), new Service(db).m(), Service.new.m()) can be typed — bare / keyword / selector; default off, opt in per language only where measured to be needed (#2708)

Per-language registration

  1. Implement ScopeResolver in languages/<lang>/scope-resolver.ts.
  2. Add entry to SCOPE_RESOLVERS in scope-resolution/pipeline/registry.ts.

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
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: the orchestrator's treeCache (RunScopeResolutionInput.treeCache) lets a scope-resolution per-language hook (emitScopeCaptures) reuse a tree instead of re-parsing. Workers leave it empty — Trees can't cross MessageChannels — so in normal (worker-pool) runs scope-resolution does NOT rely on it: workers serialize each file's ParsedFile (+ capture side-channel) and stream them in, so scope-resolution consumes the pre-extracted artifact rather than re-parsing on the main thread (§ Chunked parse-and-resolve). 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.
  • Callable-value worklist: dependency-indexed inclusion propagation is linear in a reverse-ordered copy-chain fixture; target/address sets cap at 32 and the whole worklist has a finite budget with no partial edge emission on exhaustion.
  • 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)
           ↑
 Scope-Resolution Pipeline (registry lookup + 3-tier import resolution + MRO)
           ↑
 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
descriptionExtractor Optional hook returning a symbol's doc-comment text as its description; feeds the embedding metadata header so doc-only terms are semantically searchable (issue #2270). Most languages register createLeadingDocDescriptionExtractor (shared, language-neutral; per-language comment/wrapper config passed at the call site)

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, @reference.inherits. 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 (the sole parse path — there is no sequential fallback; skipWorkers, --workers 0, and GITNEXUS_WORKER_POOL_SIZE=0 are rejected with an actionable error)
  2. Each worker: detect language → load grammar → run queries → return unified ParseWorkerResult
  3. Synthesize wildcard bindings (wildcard-synthesis.ts)
  4. Resolve imports
  5. Collect BindingAccumulator entries for cross-file propagation

Inheritance edges are emitted later, by the scope-resolution phase (preEmitInheritanceEdges + emitHeritageEdges), not during parse.

Workers: workers/worker-pool.ts, workers/parse-worker.ts.

Worker-serialized ParsedFiles (#2038). To index very large repos (e.g. the Linux kernel) without OOM, the worker pool is the sole parse path and workers serialize each file's ParsedFile (plus its capture side-channel) in parallel, streaming them to scope-resolution through a disk-backed store. Scope-resolution consumes the pre-extracted artifact instead of re-parsing every file on the main thread — tree-sitter's native input buffers are not GC-reclaimable, so the former main-thread re-parse leaked native memory until the process died. Pool creation is lazy / cache-miss-gated, so a warm all-cache-hit run replays cached worker output without spawning a worker (hence usedWorkerPool can be false even when the repo has parseable files).

Inheritance and MRO

Inheritance is captured by the @reference.inherits tag and emitted by the scope-resolution phase: preEmitInheritanceEdges resolves each base in scope, then emitHeritageEdges writes the EXTENDS/IMPLEMENTS edges. The phase then computes method resolution order via each ScopeResolver's buildMro hook, feeding a MethodDispatchIndex used for owner-scoped lookups. Per-language strategy:

  • first-wins — Java, C#, C++, TS, Ruby, Go
  • c3 — Python (C3 linearization)
  • ruby-mixin — Ruby (mixin-aware linearization)
  • none — single-inheritance languages

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.shadow    # Shadow sidecar (checkpoint staging)
  ├── lbug.lock      # Single-writer lock
  ├── lbug.{wal,shadow}.dirty-recovery  # parked sidecars from a crashed run; safe to delete
  ├── gitnexus.json  # lastCommit, indexedAt, stats (primary metadata file)
  └── meta.json      # legacy mirror of gitnexus.json, kept in sync (see MIGRATION.md)

~/.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, INHERITS, EXTENDS, IMPLEMENTS, USES, DECORATES, HAS_METHOD, HAS_PROPERTY, ACCESSES, METHOD_OVERRIDES, METHOD_IMPLEMENTS, MEMBER_OF, STEP_IN_PROCESS, HANDLES_ROUTE, FETCHES, HANDLES_TOOL, ENTRY_POINT_OF, WRAPS, QUERIES, INJECTS, CONDITIONAL_ON, DECLARES, ADVISED_BY, BINDS_EVENT_HANDLER, EMITS_EVENT.

Optional --pdg additions (off by default, opt-in via gitnexus analyze --pdg; see Optional CFG/PDG emission above): a BasicBlock node table, plus the PDG relation types CFG, REACHING_DEF, CDG, TAINTED, SANITIZES, and TAINT_PATH on the same CodeRelation table. These are deliberately kept out of the default VALID_RELATION_TYPES / web graph schema — query them via cypher, explain, or pdg_query.

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
  • MIGRATION.md — breaking changes and migration guidance
  • RUNBOOK.md — operational commands and recovery
  • GUARDRAILS.md — safety boundaries for humans and agents
  • TESTING.md — how to run tests
  • AGENTS.md / CLAUDE.md — agent workflows and tool usage