Merge branch 'abhigyanpatwari:main' into feat/desktop-release

This commit is contained in:
Sparsh 2026-04-23 21:11:30 +05:30 committed by GitHub
commit f893dfa16c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
196 changed files with 16215 additions and 503 deletions

View file

@ -14,6 +14,8 @@ coverage
.env.local
.env.*.local
**/*.tsbuildinfo
.gitnexus
gitnexus-web/playwright-report
gitnexus-web/test-results

View file

@ -9,7 +9,7 @@ jobs:
timeout-minutes: 5
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 20
cache: npm
@ -22,7 +22,7 @@ jobs:
timeout-minutes: 10
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 20
cache: npm

109
.github/workflows/ci-scope-parity.yml vendored Normal file
View file

@ -0,0 +1,109 @@
name: Scope Resolution Parity
# Reusable workflow — called from ci.yml. Does NOT declare concurrency;
# it inherits the caller's concurrency group per the convention documented
# in CONTRIBUTING.md → "GitHub Actions — Concurrency Convention".
#
# ── Purpose (RFC #909 Ring 3, §6.4 "Observability gates") ──────────────
# For every language in `MIGRATED_LANGUAGES` (exported from
# `gitnexus/src/core/ingestion/registry-primary-flag.ts`), run the
# resolver integration test at `test/integration/resolvers/<slug>.test.ts`
# TWICE on every PR:
#
# 1. `REGISTRY_PRIMARY_<LANG>=0` — legacy DAG path (guarantees we haven't
# broken the old path while migrating).
# 2. `REGISTRY_PRIMARY_<LANG>=1` — registry-primary path (guarantees the
# new path carries the same behavior — the parity gate).
#
# BOTH must pass. The source of truth is the TypeScript constant — adding
# a language to that `Set` is the ONLY contributor action; CI auto-
# discovers it, runs parity, and the language's default production path
# flips to registry-primary in the same change.
#
# When the set is empty (e.g. mid-Ring-3 for every language), the parity
# matrix is skipped and the workflow reports success — no-op until a
# language is explicitly claimed migrated.
on:
workflow_call:
jobs:
discover:
name: Discover migrated languages
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
languages: ${{ steps.read.outputs.languages }}
has-any: ${{ steps.read.outputs.has-any }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: ./.github/actions/setup-gitnexus
- name: Extract MIGRATED_LANGUAGES from registry-primary-flag.ts
id: read
shell: bash
working-directory: gitnexus
run: |
set -euo pipefail
# `tsx` evaluates the TS source directly (no build step), imports
# the exported `Set`, and emits a GH-Actions-friendly JSON matrix.
LANGS=$(npx tsx scripts/ci-list-migrated-languages.ts)
COUNT=$(printf '%s' "$LANGS" | jq 'length')
HAS_ANY="false"
if [[ "$COUNT" -gt 0 ]]; then HAS_ANY="true"; fi
echo "languages=$LANGS" >> "$GITHUB_OUTPUT"
echo "has-any=$HAS_ANY" >> "$GITHUB_OUTPUT"
echo "Discovered $COUNT migrated language(s): $LANGS"
echo "Parity matrix will run: $HAS_ANY"
parity:
name: ${{ matrix.lang.slug }} parity
needs: discover
if: needs.discover.outputs.has-any == 'true'
runs-on: ubuntu-latest
timeout-minutes: 20
strategy:
# One language failing must not abort the others — we want the full
# parity matrix result on a single CI run so a reviewer sees every
# regression at once rather than one-at-a-time.
fail-fast: false
matrix:
lang: ${{ fromJSON(needs.discover.outputs.languages) }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: ./.github/actions/setup-gitnexus
with:
build: 'true'
- name: Verify resolver test file exists
shell: bash
working-directory: gitnexus
run: |
set -euo pipefail
TEST_FILE="test/integration/resolvers/${{ matrix.lang.slug }}.test.ts"
if [[ ! -f "$TEST_FILE" ]]; then
echo "::error title=Missing resolver test::\
Expected $TEST_FILE for '${{ matrix.lang.slug }}' (listed in \
MIGRATED_LANGUAGES). Either fix the slug or add the test file \
before listing this language as migrated."
exit 1
fi
- name: Resolver tests — legacy DAG (REGISTRY_PRIMARY_${{ matrix.lang.envvar }}=0)
shell: bash
working-directory: gitnexus
env:
FLAG_NAME: REGISTRY_PRIMARY_${{ matrix.lang.envvar }}
# Explicitly force the flag to `0` even though it also defaults to
# `MIGRATED_LANGUAGES.has(lang)` — once a language is in the set,
# the default flips to registry-primary, so an unset env var would
# silently re-run the same path as step #2. `env FOO=0 cmd` spawns
# `cmd` with the override scoped to just this invocation.
run: env "$FLAG_NAME=0" npx vitest run "test/integration/resolvers/${{ matrix.lang.slug }}.test.ts"
- name: Resolver tests — registry-primary (REGISTRY_PRIMARY_${{ matrix.lang.envvar }}=1)
shell: bash
working-directory: gitnexus
env:
FLAG_NAME: REGISTRY_PRIMARY_${{ matrix.lang.envvar }}
run: env "$FLAG_NAME=1" npx vitest run "test/integration/resolvers/${{ matrix.lang.slug }}.test.ts"

View file

@ -29,6 +29,8 @@ concurrency:
# ci-quality.yml — typecheck (tsc --noEmit)
# ci-tests.yml — unit + integration tests with coverage + cross-platform
# ci-e2e.yml — E2E tests (only when gitnexus-web/ changes)
# ci-scope-parity.yml — RFC #909 Ring 3 parity gate: legacy DAG + registry-primary
# both pass, per migrated language in the JSON registry
#
# Shared setup is DRY via .github/actions/setup-gitnexus composite action.
@ -48,6 +50,11 @@ jobs:
permissions:
contents: read
scope-parity:
uses: ./.github/workflows/ci-scope-parity.yml
permissions:
contents: read
# ── Save PR metadata for the reporting workflow ─────────────────
# The ci-report.yml workflow (triggered by workflow_run) needs the
# PR number and job results to post a comment. We save them as an
@ -56,7 +63,7 @@ jobs:
save-pr-meta:
name: Save PR Metadata
if: always() && github.event_name == 'pull_request'
needs: [quality, tests, e2e]
needs: [quality, tests, e2e, scope-parity]
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
@ -67,12 +74,14 @@ jobs:
QUALITY: ${{ needs.quality.result }}
TESTS: ${{ needs.tests.result }}
E2E: ${{ needs.e2e.result }}
SCOPE_PARITY: ${{ needs.scope-parity.result }}
run: |
mkdir -p pr-meta
echo "$PR_NUMBER" > pr-meta/pr_number
echo "$QUALITY" > pr-meta/quality_result
echo "$TESTS" > pr-meta/tests_result
echo "$E2E" > pr-meta/e2e_result
echo "$PR_NUMBER" > pr-meta/pr_number
echo "$QUALITY" > pr-meta/quality_result
echo "$TESTS" > pr-meta/tests_result
echo "$E2E" > pr-meta/e2e_result
echo "$SCOPE_PARITY" > pr-meta/scope_parity_result
# TODO(post-merge): remove backward-compat copies once ci-report.yml
# on main reads underscore names.
# Backward-compat: ci-report.yml on main still reads hyphenated
@ -95,7 +104,7 @@ jobs:
# Single required check for branch protection.
ci-status:
name: CI Gate
needs: [quality, tests, e2e]
needs: [quality, tests, e2e, scope-parity]
if: always()
runs-on: ubuntu-latest
timeout-minutes: 5
@ -106,10 +115,12 @@ jobs:
QUALITY: ${{ needs.quality.result }}
TESTS: ${{ needs.tests.result }}
E2E: ${{ needs.e2e.result }}
SCOPE_PARITY: ${{ needs.scope-parity.result }}
run: |
echo "Quality: $QUALITY"
echo "Tests: $TESTS"
echo "E2E: $E2E"
echo "Scope parity: $SCOPE_PARITY"
if [[ "$QUALITY" != "success" ]] ||
[[ "$TESTS" != "success" ]]; then
echo "::error::Quality or test jobs failed"
@ -119,3 +130,14 @@ jobs:
echo "::error::E2E job failed"
exit 1
fi
# scope-parity is a reusable workflow. With an empty migrated-
# languages list, its parity matrix is skipped and the outer
# workflow still reports `success`. If any entry's legacy-DAG or
# registry-primary run fails, the workflow reports `failure`.
# Accept only `success`; `skipped` would mean the entire
# discover job was skipped too (upstream failure), which should
# still block.
if [[ "$SCOPE_PARITY" != "success" ]]; then
echo "::error::Scope-resolution parity gate failed (RFC #909 Ring 3)"
exit 1
fi

View file

@ -33,7 +33,7 @@ jobs:
id-token: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 20
registry-url: https://registry.npmjs.org

View file

@ -133,7 +133,7 @@ jobs:
fetch-depth: 0
fetch-tags: true
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 20
registry-url: https://registry.npmjs.org

1
.gitignore vendored
View file

@ -108,3 +108,4 @@ _bmad/
# Local agent scratch / review prompts (never commit)
.tmp/
.agents/
.context/

View file

@ -1,7 +1,7 @@
<!-- version: 1.4.0 -->
<!-- Last updated: 2026-04-16 -->
<!-- version: 1.6.0 -->
<!-- Last updated: 2026-04-20 -->
Last reviewed: 2026-04-16
Last reviewed: 2026-04-20
**Project:** GitNexus · **Environment:** dev · **Maintainer:** repository maintainers (see GitHub)
@ -39,7 +39,8 @@ Commands and gotchas live under **Repo reference** below and in **[CONTRIBUTING.
## Reference docs
- **[ARCHITECTURE.md](ARCHITECTURE.md)**, **[CONTRIBUTING.md](CONTRIBUTING.md)**, **[GUARDRAILS.md](GUARDRAILS.md)**
- **Call-resolution DAG:** See ARCHITECTURE.md § Call-Resolution DAG. Typed 6-stage DAG inside the `parse` phase; language-specific behavior behind `inferImplicitReceiver` / `selectDispatch` hooks on `LanguageProvider`. Shared code in `gitnexus/src/core/ingestion/` must not name languages. Types: `gitnexus/src/core/ingestion/call-types.ts`.
- **Call-resolution DAG (legacy path):** See ARCHITECTURE.md § Call-Resolution DAG. Typed 6-stage DAG inside the `parse` phase; language-specific behavior behind `inferImplicitReceiver` / `selectDispatch` hooks on `LanguageProvider`. Shared code in `gitnexus/src/core/ingestion/` must not name languages. Types: `gitnexus/src/core/ingestion/call-types.ts`.
- **Scope-resolution pipeline (RFC #909 Ring 3):** See ARCHITECTURE.md § Scope-Resolution Pipeline. Replaces the legacy DAG for languages in `MIGRATED_LANGUAGES` (currently Python). A language plugs in by implementing `ScopeResolver` (`scope-resolution/contract/scope-resolver.ts`) and registering it in `SCOPE_RESOLVERS`. CI parity gate runs BOTH paths per migrated language on every PR.
- **Cursor:** `.cursor/index.mdc` (always-on); `.cursor/rules/*.mdc` (glob-scoped). Legacy `.cursorrules` deprecated.
- **GitNexus:** skills in `.claude/skills/gitnexus/`; MCP rules in `gitnexus:start` block below.
@ -47,6 +48,7 @@ Commands and gotchas live under **Repo reference** below and in **[CONTRIBUTING.
| Date | Version | Change |
|------|---------|--------|
| 2026-04-20 | 1.6.0 | Added scope-resolution pipeline pointer (RFC #909 Ring 3); Python migrated to registry-primary. |
| 2026-04-19 | 1.5.0 | Cross-repo impact (#794): `impact`/`query`/`context` accept `repo: "@<group>"` + `service`. Removed `group_query`/`group_contracts`/`group_status` MCP tools; added `gitnexus://group/{name}/contracts` and `gitnexus://group/{name}/status` resources. |
| 2026-04-16 | 1.4.0 | Fixed: web UI description, pre-commit behavior, MCP tools (7->16), added gitnexus-shared, removed stale vite-plugin-wasm gotcha. |
| 2026-04-13 | 1.3.0 | Updated GitNexus index stats after DAG refactor. |
@ -112,6 +114,8 @@ This project is indexed by GitNexus as **gitnexus** (18208 symbols, 25369 relati
| `impact` (group mode) | Cross-repo blast radius via Contract Bridge | `gitnexus_impact({repo: "@myGroup", target: "X", direction: "upstream"})` |
> Group mode: pass `repo: "@<groupName>"` to fan out across all member repos, or `repo: "@<groupName>/<memberPath>"` to target a single member (path keys from `group.yaml`). Optional `service: "<monorepo/path>"` filters by service root. Group-level state (contracts, staleness) lives in the resources table below — there are **no** `group_query` / `group_context` / `group_impact` / `group_contracts` / `group_status` MCP tools.
>
> For a full walkthrough of setting up a group across multiple repos that communicate over gRPC, see [docs/guides/microservices-grpc.md](docs/guides/microservices-grpc.md).
## Impact Risk Levels

View file

@ -210,6 +210,137 @@ Both hooks are optional on `LanguageProvider`. Ruby is the only current implemen
| `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.
---
## Language-agnostic graph feeding

209
DoD.md Normal file
View file

@ -0,0 +1,209 @@
# Definition of Done — GitNexus
Last reviewed: 2026-04-23 · Version: 2.0.0
This document defines the repo-wide completion bar for production-ready changes in GitNexus. It is the stable baseline. Implementation prompts, agent behavior, and review workflows may add task-specific checks, but they must never weaken this bar.
Use it together with:
- `AGENTS.md` — agent-facing rules of engagement
- `GUARDRAILS.md` — hard safety constraints
- `CONTRIBUTING.md` — contributor workflow
- `TESTING.md` — test strategy and coverage expectations
- `ARCHITECTURE.md` — pipeline boundaries, Call-Resolution DAG, LanguageProvider contract
## 1. Scope and Intent
A change is **Done** when it is correct, safely integrated, appropriately tested, operationally sound, and a net improvement to the codebase — not merely "the code compiles and a test passes."
This DoD applies to:
- CLI, MCP, and HTTP-bridge behavior in `gitnexus/`
- Browser UI in `gitnexus-web/`
- Shared contracts in `gitnexus-shared/`
- CI workflows, release pipelines, and repo-level docs
Out of scope: full agent personas, step-by-step implementation prompts, verbose review formatting rules, repo walkthroughs already covered elsewhere, temporary task-specific acceptance criteria. Those belong in prompts, PR templates, or other repo docs.
## 2. Core Definition of Done
Every change must satisfy **every relevant item** below. If an item does not apply, say so explicitly in the PR description.
### 2.1 Correctness and Completeness
- [ ] The requested behavior is implemented end-to-end in the **real runtime path** for the affected surface — no dead code, partial wiring, test-only shims, or "works in isolation but not in production" seams.
- [ ] Edge cases relevant to the changed surface are handled or explicitly documented as out of scope.
- [ ] Error handling is proportionate: inputs at system boundaries (user input, external APIs, filesystem, process spawn) are validated; internal, framework-guaranteed paths are trusted.
- [ ] The change produces the same result on re-run (idempotent where expected) and does not rely on accidental ordering.
### 2.2 Architecture and Placement
- [ ] The change is placed in the correct package and layer:
- `gitnexus/` for CLI, MCP, HTTP bridge, ingestion, graph, and runtime logic
- `gitnexus-web/` for browser UI (thin client — no WASM workers, all queries via HTTP API)
- `gitnexus-shared/` for shared contracts, types, and constants
- [ ] Pipeline and architecture boundaries remain explicit. Shared ingestion code in `gitnexus/src/core/ingestion/` must not name languages — use `LanguageProvider` hooks (see `AGENTS.md` and `ARCHITECTURE.md` § Call-Resolution DAG).
- [ ] No hidden cross-phase coupling; no leaking of language-specific logic into shared infrastructure without a documented architectural reason.
- [ ] Runtime and graph behavior are consistent — the real source of truth is fixed at the source, not symptom-patched in a downstream layer.
- [ ] Direct imports from `gitnexus-shared` are used. No barrel re-exports introduced to paper over drift between packages.
### 2.3 Design and Readability
- [ ] The implementation is the **smallest correct solution** for the requirement. No speculative abstraction, unnecessary indirection, clever but hard-to-follow control flow, or unrelated cleanup.
- [ ] Naming, control flow, ownership, and extension points are clear enough that the next contributor can extend the code without archaeology.
- [ ] Comments are minimal and useful — they explain intent, invariants, contracts, or non-obvious constraints. No stale comments, placeholder comments, narrated code, commented-out code, or "what" comments where a good name would do.
- [ ] No copy-paste duplication created for convenience; no premature deduplication of three similar lines.
### 2.4 Contracts and Compatibility
- [ ] Existing contracts (types in `gitnexus-shared/`, CLI flags, MCP tools/resources, HTTP routes, graph node/edge shapes, persisted IDs) are preserved unless the task explicitly requires a contract change.
- [ ] Any contract change is intentional, explicit, and reflected in **every direct consumer** in the same change, with types aligned end-to-end.
- [ ] Persisted data changes (graph schema, IDs, embeddings) are backward-compatible or accompanied by a documented migration / reindex path.
- [ ] If user-visible behavior, public usage, CLI help, or README examples change, the relevant docs, examples, help text, or migration notes are updated in the same change.
### 2.5 Security
- [ ] No new injection surfaces (command, path, SQL/Cypher-style, prompt) introduced on paths that consume untrusted input.
- [ ] No secrets, tokens, or credentials committed to the repo, to logs, or to error messages.
- [ ] Filesystem access honors the repo-scope and indexed-repo boundaries documented in `AGENTS.md` and `GUARDRAILS.md`.
- [ ] Third-party dependencies added or bumped are justified, from reputable sources, and do not regress the supply-chain posture.
### 2.6 Performance and Resource Use
- [ ] No repeated avoidable work, unnecessary scans, unnecessary round-trips, unbounded caches, or obvious hot-path regressions.
- [ ] Tree-sitter buffer sizing follows the adaptive 512KB32MB convention (`getTreeSitterBufferSize`) — do not hard-code new buffer sizes.
- [ ] Memory and handle lifecycles are explicit: database handles (LadybugDB) close cleanly, no dangling process watchers, no leaked tree-sitter parsers.
- [ ] Long-running or large-graph paths remain bounded or are measurably streamed; degradation on large real repos is considered, not assumed benign.
### 2.7 Tests
- [ ] Tests cover the **real changed path** — they would fail if behavior, wiring, or contracts were broken, not only if a mock were misconfigured.
- [ ] Integration tests hit a real database where the production path does; do not introduce mocks that hide migration or schema drift.
- [ ] Assertions are meaningful. Use `toBe` / `toEqual` for exact expectations; avoid `toBeGreaterThanOrEqual` and other bounds-only assertions that mask regressions.
- [ ] Fixtures are realistic enough for the risk of the change — a one-file fixture is not sufficient for a pipeline-wide behavior change.
- [ ] New tests are deterministic and do not depend on network, clock, or host-specific paths without explicit isolation.
### 2.8 Observability and Operability
- [ ] Errors surfaced to users or callers are actionable: they name what failed, what input was involved (without leaking secrets), and how to recover where possible.
- [ ] Logging is proportionate — no noisy debug logs left in hot paths, no silent catches that swallow diagnostics.
- [ ] CLI exit codes and MCP tool responses are correct for each outcome (success, user error, internal error).
- [ ] Progress reporting (`PipelineProgress` and similar shared contracts) remains accurate after the change.
### 2.9 Reversibility and Risk
- [ ] The change has a clear rollback story: revert is safe, or migration is accompanied by a documented rollback / reindex procedure.
- [ ] Residual risks, compatibility impacts, and operational concerns are either resolved or **clearly stated** in the PR description.
- [ ] Destructive or hard-to-reverse operations (graph rebuild, schema change, `git` state manipulation) are opt-in or guarded.
## 3. Agent-Assisted Workflow Guardrails
When the change is produced with or reviewed by an AI agent, the following additional gates apply:
- [ ] **Scope match.** The final diff matches the intended symbols, files, and processes — no speculative refactors, unrelated formatting churn, or collateral edits outside the task scope.
- [ ] **Evidence-based edits.** Claims about repo state are verified against the current code, not trusted from memory or stale documentation.
- [ ] **Impact analysis.** Where GitNexus graph tooling is available and relevant, impact of non-trivial symbol, contract, or runtime-path changes is checked **before** editing.
- [ ] **Embeddings preserved.** If an indexed repo already has embeddings and re-analysis is required, embeddings are preserved — not accidentally dropped by a destructive reindex.
- [ ] **No false-done.** "Done" is claimed only after the Validation Baseline below has been run or any gap is explicitly named. Green tests on an unrelated path do not constitute validation.
- [ ] **Five-axis self-review** before handing off: correctness, readability, architecture, security, performance.
## 4. Validation Baseline
Run the commands relevant to the touched area. If something cannot be run in the current environment, state it explicitly in the handoff.
### 4.1 Build ordering
- [ ] `gitnexus-shared/` dist is built before consuming packages are typechecked or tested (CI uses the `setup-gitnexus` action for this — local runs must match).
### 4.2 If `gitnexus/` changed
- [ ] `cd gitnexus && npx tsc --noEmit`
- [ ] `cd gitnexus && npm test`
- [ ] `cd gitnexus && npx prettier --check .` for files in the diff (pre-commit runs the affected-tests subset; do not expand scope)
### 4.3 If `gitnexus-web/` changed
- [ ] `cd gitnexus-web && npx tsc -b --noEmit`
- [ ] `cd gitnexus-web && npm test`
- [ ] `cd gitnexus-web && npm run test:e2e` when browser flows or user-facing UI behavior changed
### 4.4 If `gitnexus-shared/` changed
- [ ] Shared package builds cleanly (`npm run build` in `gitnexus-shared/`)
- [ ] Dependent packages still typecheck and test after the shared change — verify both CLI and web consumers together
### 4.5 If CI workflows or release pipelines changed
- [ ] The workflow passes a dry-run or triggered run before merge; concurrency (`cancel-in-progress`) and the `setup-gitnexus` action remain wired correctly.
- [ ] `CHANGELOG.md` is **not** edited here — it is owned by the release process.
## 5. Review Gates
A reviewer (human or agent) should be able to answer **yes** to each of the following before approving:
1. **Correctness** — Does the change do what it claims on the real runtime path?
2. **Readability** — Will the next contributor understand this in six months without asking?
3. **Architecture** — Is it in the right package, layer, and phase? Are boundaries respected?
4. **Security** — No new injection, leak, or trust-boundary violation?
5. **Performance** — No obvious regression on realistic inputs?
6. **Tests** — Would a regression in the changed behavior fail loudly?
7. **Scope** — Does the diff match the intended change, with no unrelated churn?
## 6. "Not Done" Signals
A change is **not** Done if any of the following is true, even if CI is green:
- The runtime path is not actually exercised by the tests.
- A contract drifted between `gitnexus/`, `gitnexus-web/`, and `gitnexus-shared/` and only one side was updated.
- A language-specific concern leaked into shared ingestion code.
- The diff contains unrelated reformatting, refactors, or cleanup beyond the stated task.
- Logs, comments, or TODOs were added as placeholders for work not done.
- The change depends on a manual step that is not documented.
- `CHANGELOG.md` was edited during PR work.
- Pre-commit, prettier, or typecheck was bypassed without explicit justification.
## 7. Task-Specific DoD Template
Use this in implementation and review prompts. Keep it short and tailor it to the actual change:
```md
# Definition of Done for this implementation
- [ ] Runtime wiring is complete for the affected path.
- [ ] Requested behavior is correct and relevant contracts are preserved or explicitly updated.
- [ ] The design stays scoped, readable, and proportionate to the task.
- [ ] Tests prove the changed behavior and catch broken wiring.
- [ ] Required validation for touched packages has been run, or any gap is explicitly noted.
- [ ] Repo boundaries, security, performance, and operational safety are respected.
- [ ] The diff contains only the intended change — no unrelated churn.
```
## 8. How to Use This File in Claude Review
Reference this file as the repo-wide completion bar. Add a task-specific review instruction such as:
```md
Review this change against `DoD.md` and the repo docs (`AGENTS.md`, `GUARDRAILS.md`,
`CONTRIBUTING.md`, `TESTING.md`, `ARCHITECTURE.md`). Treat `DoD.md` as the minimum
bar for production readiness. Flag anything that is partially wired, contract-unsafe,
under-tested, architecturally misplaced, scope-creeping, or harder to maintain than
necessary. Apply the five-axis review gate: correctness, readability, architecture,
security, performance.
```
## 9. Evolution
This DoD is living. Revisit it when:
- A class of incident slips past it (add a gate).
- A gate becomes consistently ceremonial without catching issues (remove or merge it).
- The architecture evolves in a way that changes what "done" means (update placement, validation, or contracts sections).
Track material updates in the changelog below. Keep the file tight — if it grows past a single read-in-one-sitting, something has drifted into the wrong place.
## Changelog
| Date | Version | Change |
| ---------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 2026-04-23 | 2.0.0 | Restructured into numbered sections; added Security, Observability, Reversibility, Agent-Assisted Guardrails, Review Gates, Not-Done Signals; expanded validation baseline (shared-first build, prettier, CI workflow checks). |
| 2026-04-13 | 1.0.0 | Initial repo-wide Definition of Done. |

View file

@ -4,17 +4,18 @@ ARG TARGETPLATFORM
# ── Builder ────────────────────────────────────────────────────────────
# Native modules (tree-sitter-*, onnxruntime-node, node-gyp builds for
# tree-sitter-proto / tree-sitter-swift) require python3 + a C/C++ toolchain.
FROM node:22-alpine AS builder
FROM node:22-trixie-slim AS builder
WORKDIR /app
# Toolchain for node-gyp / native builds.
RUN apk add --no-cache python3 make g++ git
RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++ git && rm -rf /var/lib/apt/lists/*
# Build gitnexus-shared first — gitnexus depends on it as a workspace.
COPY gitnexus-shared/package.json gitnexus-shared/package-lock.json ./gitnexus-shared/
RUN npm ci --prefix gitnexus-shared
COPY gitnexus-shared ./gitnexus-shared
RUN rm -f gitnexus-shared/tsconfig.tsbuildinfo
RUN npm run build --prefix gitnexus-shared
# Copy the full gitnexus package before installing — `npm ci` triggers
@ -28,10 +29,10 @@ RUN npm ci --prefix gitnexus
RUN npm prune --omit=dev --prefix gitnexus
# ── Runtime ────────────────────────────────────────────────────────────
FROM node:22-alpine AS runtime
FROM node:22-trixie-slim AS runtime
# curl for the healthcheck; git so `gitnexus` can clone repos at runtime.
RUN apk add --no-cache curl git
RUN apt-get update && apt-get install -y --no-install-recommends curl git && rm -rf /var/lib/apt/lists/*
WORKDIR /app

View file

@ -208,11 +208,11 @@ gitnexus wiki --model <model> # Wiki with custom LLM model (default: gpt-4o-m
gitnexus wiki --base-url <url> # Wiki with custom LLM API base URL
# Repository groups (multi-repo / monorepo service tracking)
gitnexus group create <name> # Create a repository group
gitnexus group add <name> <repo> # Add a repo to a group
gitnexus group remove <name> <repo> # Remove a repo from a group
gitnexus group list [name] # List groups, or show one group's config
gitnexus group sync <name> # Extract contracts and match across repos/services
gitnexus group create <name> # Create a repository group
gitnexus group add <group> <groupPath> <registryName> # Add a repo to a group. <groupPath> is a hierarchy path (e.g. hr/hiring/backend); <registryName> is the repo's name from the registry (see `gitnexus list`)
gitnexus group remove <group> <groupPath> # Remove a repo from a group by its hierarchy path
gitnexus group list [name] # List groups, or show one group's config
gitnexus group sync <name> # Extract contracts and match across repos/services
gitnexus group contracts <name> # Inspect extracted contracts and cross-links
gitnexus group query <name> <q> # Search execution flows across all repos in a group
gitnexus group status <name> # Check staleness of repos in a group

View file

@ -0,0 +1,295 @@
# Using GitNexus across gRPC microservices
## When to use this guide
This guide is for teams whose product lives in **several separate Git repositories** — one per service — and whose services talk to each other over **gRPC** (possibly alongside HTTP and message topics). GitNexus indexes each repo independently, then a _group_ stitches the per-repo indexes into a single cross-repo view that the `impact`, `query`, and `context` tools can traverse. If your services live in one monorepo, much of this still applies — set each service as a member of a group and use the `service` prefix to scope queries — but the walkthrough assumes the harder multi-repo case.
## Mental model
- Each repository has its own `.gitnexus/` index (a LadybugDB graph of symbols, relationships, processes). `gitnexus analyze` in each repo produces that index completely independently.
- A **group** is a higher-level construct stored at `~/.gitnexus/groups/<group>/` that references the per-repo indexes by their registry name.
- Sync-time extractors walk each member repo and emit **contracts** — provider or consumer records keyed by a canonical `contractId` (`grpc::auth.AuthService/Login`, `http::GET::/orders`, etc.).
- The sync step matches providers and consumers that share a `contractId` and writes **cross-links** to `<groupDir>/contracts.json`. Those cross-links are what lets `impact({repo: "@<group>", target: "X"})` hop from one repo into another.
- Contracts come from three places: automatic contract extractors (`grpc-extractor`, `http-route-extractor`, `topic-extractor`), a manifest escape hatch (`config.links` in `group.yaml`), and — for same-name symbol matches where no contract is declared — the exact-match matching cascade in [`matching.ts`](../../gitnexus/src/core/group/matching.ts).
- Each repo stays editable and re-indexable on its own. Re-run `gitnexus analyze` in a repo when it changes, then `gitnexus group sync <group>` to refresh `contracts.json`. `gitnexus group status` reports which members are stale.
## Prerequisites
- GitNexus installed and runnable as `gitnexus` or `npx gitnexus` (see the root [README.md](../../README.md)).
- Each service repository checked out locally. No requirement that they share a parent directory — the group references them by registry name.
- Write access to `~/.gitnexus/` (the default gitnexus home; see `getDefaultGitnexusDir` in [`storage.ts`](../../gitnexus/src/core/group/storage.ts)).
## Step-by-step walkthrough
The example uses three services — a TypeScript API gateway, a Go orders service, and a Python inventory service — with gRPC between them. The gateway is an `orders` consumer; the orders service is both an `orders` provider and an `inventory` consumer; the inventory service is an `inventory` provider.
### 1. Index each repository
Run `analyze` from inside each service repo (or pass the path). The CLI surface lives in [`gitnexus/src/cli/analyze.ts`](../../gitnexus/src/cli/analyze.ts) and is wired in [`gitnexus/src/cli/index.ts`](../../gitnexus/src/cli/index.ts).
```bash
cd ~/code/gateway && npx gitnexus analyze
cd ~/code/orders && npx gitnexus analyze
cd ~/code/inventory && npx gitnexus analyze
```
Useful flags:
- `--force` — reindex even if up to date.
- `--embeddings` — generate embedding vectors (needed only if you want semantic search; the exact-match cross-repo cascade does **not** need them).
- `--name <alias>` — register the repo under a specific alias when two repos share a basename (e.g. two `api/` folders).
- `--skip-git` — index a checkout that isn't a git repo.
Each run writes a `.gitnexus/` folder in the repo and registers the repo in `~/.gitnexus/registry.json`. Confirm with `npx gitnexus list`.
### 2. Author `group.yaml`
Create the group directory and edit the config. Either use the CLI scaffolder or write the file directly — both produce the same shape consumed by [`config-parser.ts`](../../gitnexus/src/core/group/config-parser.ts).
```bash
npx gitnexus group create payments-platform
# or manually:
mkdir -p ~/.gitnexus/groups/payments-platform
$EDITOR ~/.gitnexus/groups/payments-platform/group.yaml
```
Minimal working `group.yaml`:
```yaml
version: 1
name: payments-platform
description: Gateway + orders + inventory (gRPC)
repos:
gateway: gateway
orders: orders
inventory: inventory
# Only add explicit links when the automatic extractors miss something —
# see "When automatic extraction isn't enough" below.
links: []
packages: {}
detect:
http: true
grpc: true
topics: true
shared_libs: true
embedding_fallback: false
matching:
bm25_threshold: 0.7
embedding_threshold: 0.65
max_candidates_per_step: 3
```
Field notes (schema in [`types.ts`](../../gitnexus/src/core/group/types.ts)):
- `version` — must be `1`. The parser rejects anything else.
- `name` — required; used for the group directory name and all CLI / MCP calls.
- `repos` — a mapping from **group path** (a logical name you choose; can be a hierarchy like `backend/orders`) to **registry name** (the name shown by `npx gitnexus list`). Both sides appear throughout the tooling: contract rows use the group path; `@<group>/<groupPath>` routes tools to a single member.
- `links` — optional manifest escape hatch, one entry per explicit cross-repo contract. Validated by the parser: `from` and `to` must be known repo paths, `type` must be one of `http | grpc | topic | lib | custom`, and `role` must be `provider | consumer`.
- `detect` — toggles per extractor family. Defaults (set in `config-parser.ts`) turn `http`, `grpc`, `topics`, and `shared_libs` on; disable the ones you don't use to speed up sync.
- `matching` — thresholds for the matching cascade. The exact match is always run; other strategies depend on indexer state.
### 3. Sync the group
```bash
npx gitnexus group sync payments-platform --verbose
```
What this does (see [`sync.ts`](../../gitnexus/src/core/group/sync.ts)):
1. Opens each member's per-repo LadybugDB.
2. Runs the HTTP, gRPC, and topic extractors against the source files.
3. Applies manifest `links` through [`manifest-extractor.ts`](../../gitnexus/src/core/group/extractors/manifest-extractor.ts).
4. Runs the exact-match cascade, joining providers and consumers that share a normalized `contractId`.
5. Writes `contracts.json` in the group directory.
Flags:
- `--exact-only` — stop after the exact cascade; skip BM25 and embedding fallback.
- `--skip-embeddings` — run exact plus BM25 but not embedding-based matching.
- `--allow-stale` — don't warn if a member's index is stale.
- `--json` — machine-readable output.
The same operation is available over MCP as `group_sync({ name: "payments-platform" })` — see [`tools.ts`](../../gitnexus/src/mcp/tools.ts).
### 4. Inspect the registry
Use `gitnexus group contracts` for the CLI view or read the `gitnexus://group/<name>/contracts` MCP resource for the same data.
```bash
npx gitnexus group contracts payments-platform --type grpc --json
```
A shortened response:
```json
{
"contracts": [
{
"contractId": "grpc::orders.OrderService/PlaceOrder",
"type": "grpc",
"role": "provider",
"repo": "orders",
"symbolRef": { "filePath": "internal/grpc/order_server.go", "name": "RegisterOrderServiceServer" },
"confidence": 0.8,
"meta": { "service": "OrderService", "method": "PlaceOrder", "source": "go_register" }
},
{
"contractId": "grpc::orders.OrderService/PlaceOrder",
"type": "grpc",
"role": "consumer",
"repo": "gateway",
"symbolRef": { "filePath": "src/clients/orders.ts", "name": "OrderServiceClient" },
"confidence": 0.75,
"meta": { "service": "OrderService", "source": "ts_generated_client" }
}
],
"crossLinks": [
{
"from": { "repo": "gateway", "symbolUid": "…", "symbolRef": { "filePath": "src/clients/orders.ts", "name": "OrderServiceClient" } },
"to": { "repo": "orders", "symbolUid": "…", "symbolRef": { "filePath": "internal/grpc/order_server.go", "name": "RegisterOrderServiceServer" } },
"type": "grpc",
"contractId": "grpc::orders.OrderService/PlaceOrder",
"matchType": "exact",
"confidence": 1.0
}
]
}
```
Staleness of the underlying indexes shows up in `npx gitnexus group status payments-platform` or the `gitnexus://group/<name>/status` resource.
### 5. Run cross-repo impact with `@<group>` routing
From any shell (you do **not** have to `cd` into a member repo), the normal `impact` / `query` / `context` tools accept `repo: "@<group>"` to fan out across all members, or `repo: "@<group>/<memberPath>"` to target one member. Routing is implemented in [`resolve-at-member.ts`](../../gitnexus/src/core/group/resolve-at-member.ts) and described in [`tools.ts`](../../gitnexus/src/mcp/tools.ts).
Example MCP calls:
```json
{"tool": "impact", "arguments": {
"repo": "@payments-platform/orders",
"target": "PlaceOrder",
"direction": "upstream",
"crossDepth": 2
}}
```
```json
{"tool": "query", "arguments": {
"repo": "@payments-platform",
"query": "retry logic around PlaceOrder"
}}
```
The CLI equivalents still exist for scripting:
```bash
npx gitnexus group impact payments-platform \
--repo orders --target PlaceOrder --direction upstream --cross-depth 2
```
Phase 1 walks within the anchor member; Phase 2 hops across the Contract Bridge wherever a cross-link endpoint matches an impacted symbol. See [`cross-impact.ts`](../../gitnexus/src/core/group/cross-impact.ts) for the bridge query.
## How gRPC extraction works
`GrpcExtractor` ([`grpc-extractor.ts`](../../gitnexus/src/core/group/extractors/grpc-extractor.ts)) runs two passes per member repo:
1. **Proto map.** Every `**/*.proto` file is parsed to enumerate `service Foo { rpc Bar(...) }` blocks and (transitively) resolve the package name. Each RPC method becomes a provider contract with `contractId = grpc::<package>.<Service>/<Method>` and `confidence = 0.85`. Parsing uses the vendored `tree-sitter-proto` grammar when available and falls back to a length-preserving manual parser (`extractServiceBlocks`) otherwise, so `.proto` extraction works on platforms where the grammar fails to build.
2. **Source scan.** Every source file whose extension matches [`GRPC_SCAN_GLOB`](../../gitnexus/src/core/group/extractors/grpc-patterns/index.ts) is parsed by its language plugin:
| Language | Provider signal | Consumer signal |
|----------|-----------------|-----------------|
| Go ([`go.ts`](../../gitnexus/src/core/group/extractors/grpc-patterns/go.ts)) | `pb.RegisterXxxServer(...)`, `pb.UnimplementedXxxServer` embedded in struct | `pb.NewXxxClient(conn)` |
| Java ([`java.ts`](../../gitnexus/src/core/group/extractors/grpc-patterns/java.ts)) | `extends XxxServiceGrpc.XxxServiceImplBase` (with or without `@GrpcService`) | `XxxServiceGrpc.newBlockingStub(...)`, `newStub(...)` |
| Python ([`python.ts`](../../gitnexus/src/core/group/extractors/grpc-patterns/python.ts)) | `add_XxxServicer_to_server(...)` (bare or `_pb2_grpc.` attribute form) | `XxxStub(channel)` (ignores `Mock`/`Test`/`Fake`/`Stub`) |
| Node / TS ([`node.ts`](../../gitnexus/src/core/group/extractors/grpc-patterns/node.ts)) | NestJS `@GrpcMethod('Service','Method')` | `@GrpcClient` field typed `XxxServiceClient`, `client.getService<X>('Service')`, `new XxxServiceClient(...)`, `new foo.bar.XxxService(...)` in files that call `loadPackageDefinition` |
For each source-scan detection the extractor looks up the short service name in the proto map and picks:
- `grpc::<package>.<Service>/<Method>` when a method is named and the service resolves against the proto map,
- `grpc::<package>.<Service>/*` (wildcard) when only the service is known, or
- `grpc::<ServiceName>/*` when no `.proto` is available at all.
Provider detections land at confidence 0.8 (with proto) or 0.65 (without); consumers at 0.75 or 0.55. NestJS `@GrpcMethod` is fixed at 0.8 because the decorator is self-describing.
### Matching
`matching.ts` lowercases the package/service segment before comparing contract ids, so bindings that capitalize names differently (`auth.AuthService` vs `auth.authservice`) still match. Method names are compared case-sensitively because gRPC's wire path is case-sensitive. Service-only wildcards (`grpc::pkg.Svc/*`) match any method on the same service during cross-linking.
### Known limitations
- **Ambiguous proto resolution.** If a short service name exists in more than one `.proto` file and the source-scan hit can't be narrowed down by shared directory segments (`resolveProtoConflict` refuses to guess), the extractor skips contract emission and logs a warning.
- **Proto packages must be resolvable locally.** Transitive imports that point outside the repo produce an empty package segment, which means the contract id collapses to `grpc::<Service>/<Method>`. Cross-repo matches still work as long as both sides agree on the empty package.
- **Rewrite rules are not implemented.** If the provider repo writes `grpc::orders.OrderService/PlaceOrder` and the consumer repo writes `grpc::orderspb.OrderService/PlaceOrder`, they won't cross-link automatically. Use `config.links` to declare the correspondence (see below).
- **One sync = one snapshot.** Contracts are extracted against the indexed snapshot of each repo. Re-index first, then re-sync; the `status` command and resource surface staleness.
## When automatic extraction isn't enough
The escape hatch is the `links` list in `group.yaml`, handled by [`ManifestExtractor`](../../gitnexus/src/core/group/extractors/manifest-extractor.ts). Each entry is a **one-directional** provider/consumer declaration:
```yaml
version: 1
name: payments-platform
repos:
gateway: gateway
orders: orders
inventory: inventory
links:
# Explicit gRPC method: use when naming mismatches stop the
# automatic matcher from cross-linking.
- from: gateway
to: orders
type: grpc
contract: OrderService/PlaceOrder
role: consumer
# Service-level link when you don't want to enumerate methods.
- from: orders
to: inventory
type: grpc
contract: InventoryService
role: consumer
# Works for HTTP too — use `METHOD::/path` form for the exact
# handler, or just `/path` for a method-agnostic wildcard.
- from: gateway
to: orders
type: http
contract: POST::/orders
role: consumer
```
What the manifest extractor does (see [`manifest-extractor.ts`](../../gitnexus/src/core/group/extractors/manifest-extractor.ts)):
1. Builds a canonical `contractId` with `buildContractId` — the same canonicalization used by the automatic extractors, so manifest links cross-match automatic contracts on the other side.
2. Tries to resolve each side to a real graph symbol (the `Route` node for HTTP, a `Function|Method` / `Class|Interface` for gRPC, a `Package|Module` for `lib`).
3. If resolution fails, falls back to a deterministic synthetic uid (`manifest::<repo>::<contractId>`) so both sides still line up in cross-impact — name-only links still work when the symbol isn't in the graph.
4. Emits both a provider and a consumer `StoredContract` (confidence `1.0`, `source: "manifest"`) and a `CrossLink` with `matchType: "manifest"`.
Use `links` for exactly the cases the extractor can't infer: different package names across repos (see #701), hand-rolled transports, cases where the provider repo isn't checked out locally but you still want a record, or any contract whose provider and consumer simply don't share a surface the extractors know how to pattern-match.
History: the manifest extractor used to be silently skipped by the sync pipeline; that was fixed in [#827](https://github.com/abhigyanpatwari/GitNexus/pull/827) (tracking issue #826). If you ever see `config.links` with zero cross-links in `contracts.json`, make sure you're on a build that includes that fix, then re-run `group sync`.
## Troubleshooting
1. **`contracts.json` is empty after a sync.** Either no member repo contained a recognizable gRPC pattern, or the extractors are disabled in `detect`. Confirm `detect.grpc: true` and re-run with `--verbose`.
2. **A known provider/consumer pair doesn't cross-link.** Most common cause: the package segment differs. Check the raw contract ids with `gitnexus group contracts <name> --unmatched` — if you see two same-method contracts with different package prefixes, add a manifest `links:` entry to bridge them (no automatic rewrite rules yet).
3. **`matchType: "manifest"` is missing entirely.** The extractor needs `config.links` to be non-empty and the sync pipeline to actually call it — verify you're on a post-#827 build. Empty contract rows for manifest links usually mean `resolveSymbol` couldn't find a graph match; the synthetic uid still lets cross-impact work, it just won't carry a file path.
4. **Ambiguous proto warnings.** Look for `[grpc-extractor] Ambiguous proto resolution` in the sync logs; that means a service name exists in multiple `.proto` files under the same repo and the path-distance heuristic couldn't pick a winner. Resolve by renaming the service or declaring the intended pairing in `config.links`.
5. **Cross-impact says "stale".** Both sides need a fresh per-repo index _and_ a fresh group sync. Order matters: `gitnexus analyze` in each changed repo, then `gitnexus group sync <name>`. Use `gitnexus group status <name>` to see which side is behind.
## Related docs and references
- [AGENTS.md](../../AGENTS.md) — authoritative list of MCP tools and resources, including group-mode routing and the `gitnexus://group/…` resources.
- [ARCHITECTURE.md](../../ARCHITECTURE.md) — overall data flow and the call-resolution DAG that the per-repo indexer uses.
- [`gitnexus/src/core/group/`](../../gitnexus/src/core/group/) — `service.ts`, `sync.ts`, `config-parser.ts`, `matching.ts`.
- [`gitnexus/src/core/group/extractors/grpc-extractor.ts`](../../gitnexus/src/core/group/extractors/grpc-extractor.ts) and [`grpc-patterns/`](../../gitnexus/src/core/group/extractors/grpc-patterns/) — gRPC detection.
- [`gitnexus/src/core/group/extractors/manifest-extractor.ts`](../../gitnexus/src/core/group/extractors/manifest-extractor.ts) — the `config.links` escape hatch.
- [`gitnexus/src/mcp/tools.ts`](../../gitnexus/src/mcp/tools.ts) — MCP tool schemas (`group_list`, `group_sync`, plus `@<group>` routing on `impact` / `query` / `context`).
- [`gitnexus/src/cli/group.ts`](../../gitnexus/src/cli/group.ts) — CLI command definitions and flags.
- Upstream issues: [#701](https://github.com/abhigyanpatwari/GitNexus/issues/701), [#826](https://github.com/abhigyanpatwari/GitNexus/issues/826), [#906](https://github.com/abhigyanpatwari/GitNexus/issues/906).

View file

@ -8,13 +8,13 @@
"name": "gitnexus-shared",
"version": "1.0.0",
"devDependencies": {
"typescript": "^6.0.2"
"typescript": "^6.0.3"
}
},
"node_modules/typescript": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz",
"integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==",
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true,
"license": "Apache-2.0",
"bin": {

View file

@ -20,6 +20,6 @@
"src"
],
"devDependencies": {
"typescript": "^6.0.2"
"typescript": "^6.0.3"
}
}

View file

@ -37,6 +37,18 @@
* `localDefs`. A `ParsedFile` is trivially convertible to a `FinalizeFile`
* by picking those four fields, so the finalize orchestrator threads
* ParsedFile through to the shared algorithm without shape-shifting.
*
* ## Source-of-truth invariant
*
* `ParsedFile` is the single semantic model consumed by both the legacy
* DAG (`gitnexus/src/core/ingestion/` outside `scope-resolution/`) and
* the scope-resolution pipeline (`gitnexus/src/core/ingestion/scope-resolution/`).
* Downstream passes MUST NOT build a parallel parse representation; if
* a pass needs AST-level facts that `ParsedFile` doesn't expose, it
* should reuse the orchestrator's `treeCache` rather than re-invoke
* `parser.parse(...)` on its own. See the
* `ScopeResolver` contract (`gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts`)
* for the full list of invariants downstream consumers rely on.
*/
import type { Scope, ScopeId } from './types.js';

View file

@ -71,4 +71,12 @@ export interface ReferenceSite {
readonly explicitReceiver?: { readonly name: string };
/** Argument count at the call site; used by `provider.arityCompatibility`. */
readonly arity?: number;
/**
* Inferred argument types at the call site, one per argument. An
* empty-string entry means "unknown" consumers narrowing overload
* candidates treat unknown as any-match. Populated by languages
* that can derive types from literals / constructor expressions
* (C#: `42` `'int'`, `"alice"` `'string'`).
*/
readonly argumentTypes?: readonly string[];
}

View file

@ -44,7 +44,7 @@
"zod": "^3.25.76"
},
"devDependencies": {
"@babel/types": "^7.28.5",
"@babel/types": "^7.29.0",
"@playwright/test": "^1.58.2",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
@ -494,9 +494,9 @@
}
},
"node_modules/@babel/types": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz",
"integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==",
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
"dev": true,
"license": "MIT",
"dependencies": {

View file

@ -54,7 +54,7 @@
"zod": "^3.25.76"
},
"devDependencies": {
"@babel/types": "^7.28.5",
"@babel/types": "^7.29.0",
"@playwright/test": "^1.58.2",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",

View file

@ -4,6 +4,10 @@ All notable changes to GitNexus will be documented in this file.
## [Unreleased]
### Added
- **Configurable large-file skip threshold** — the walker's 512 KB default is now overridable via `GITNEXUS_MAX_FILE_SIZE` (KB) or `gitnexus analyze --max-file-size <kb>`. Values are clamped to the 32 MB tree-sitter ceiling, invalid inputs fall back to the default with a one-time warning, and the CLI banner reports the effective post-clamp threshold when an override is active (#991, #1044).
### Performance
- **`analyze` ~33% faster** — moved FTS index creation from the analyze pipeline to first-use lazy initialisation. The 5 `CREATE_FTS_INDEX` calls cost ~440 ms each in LadybugDB regardless of table size (≈2 s fixed overhead) and dominated runtime on small repos and slow CI runners. The cost now amortises across the first `query`/`context` call in a session via a new `ensureFTSIndex` helper. Mini-repo `analyze` measured locally on Windows: 6.4 s → 4.0 s warm; on CI Windows runners (≈3× slower) restores comfortable headroom against the 30 s e2e test budget.

View file

@ -155,6 +155,7 @@ gitnexus analyze --force # Force full re-index
gitnexus analyze --embeddings # Enable embedding generation (slower, better search)
gitnexus analyze --skip-agents-md # Preserve custom AGENTS.md/CLAUDE.md gitnexus section edits
gitnexus analyze --verbose # Log skipped files when parsers are unavailable
gitnexus analyze --max-file-size 1024 # Skip files larger than N KB (default: 512, cap: 32768)
gitnexus mcp # Start MCP server (stdio) — serves all indexed repos
gitnexus serve # Start local HTTP server (multi-repo) for web UI
gitnexus index # Register an existing .gitnexus/ folder into the global registry
@ -166,11 +167,11 @@ gitnexus wiki [path] # Generate LLM-powered docs from knowledge grap
gitnexus wiki --model <model> # Wiki with custom LLM model (default: gpt-4o-mini)
# Repository groups (multi-repo / monorepo service tracking)
gitnexus group create <name> # Create a repository group
gitnexus group add <name> <repo> # Add a repo to a group
gitnexus group remove <name> <repo> # Remove a repo from a group
gitnexus group list [name] # List groups, or show one group's config
gitnexus group sync <name> # Extract contracts and match across repos/services
gitnexus group create <name> # Create a repository group
gitnexus group add <group> <groupPath> <registryName> # Add a repo to a group. <groupPath> is a hierarchy path (e.g. hr/hiring/backend); <registryName> is the repo's name from the registry (see `gitnexus list`)
gitnexus group remove <group> <groupPath> # Remove a repo from a group by its hierarchy path
gitnexus group list [name] # List groups, or show one group's config
gitnexus group sync <name> # Extract contracts and match across repos/services
gitnexus group contracts <name> # Inspect extracted contracts and cross-links
gitnexus group query <name> <q> # Search execution flows across all repos in a group
gitnexus group status <name> # Check staleness of repos in a group
@ -307,6 +308,21 @@ echo "vendor/" >> .gitnexusignore
echo "dist/" >> .gitnexusignore
```
### Large files are being skipped
By default the walker skips files larger than **512 KB** (see log line `Skipped N large files (>512KB)`). Raise the threshold via either the CLI flag or the environment variable — both accept a value in **KB**:
```bash
# CLI flag (takes precedence over the env var)
npx gitnexus analyze --max-file-size 2048 # skip only files > 2 MB
# Environment variable (persists across commands)
export GITNEXUS_MAX_FILE_SIZE=2048
npx gitnexus analyze
```
Values above **32768 KB (32 MB)** are clamped to the tree-sitter parser ceiling; invalid values fall back to the 512 KB default with a one-time warning. When an override is active, `analyze` prints the effective threshold in its startup banner (e.g. `GITNEXUS_MAX_FILE_SIZE: effective threshold 2048KB (default 512KB)`).
## Privacy
- All processing happens locally on your machine

View file

@ -19,11 +19,12 @@
"cors": "^2.8.5",
"express": "^4.19.2",
"glob": "^13.0.6",
"graphology": "^0.25.4",
"graphology": "^0.26.0",
"graphology-indices": "^0.17.0",
"graphology-utils": "^2.3.0",
"ignore": "^7.0.5",
"js-yaml": "^4.1.1",
"jsonc-parser": "^3.3.1",
"lru-cache": "^11.0.0",
"mnemonist": "^0.40.3",
"onnxruntime-node": "^1.24.0",
@ -40,7 +41,7 @@
"tree-sitter-ruby": "^0.23.1",
"tree-sitter-rust": "0.23.1",
"tree-sitter-typescript": "^0.23.2",
"uuid": "^13.0.0"
"uuid": "^14.0.0"
},
"bin": {
"gitnexus": "dist/cli/index.js"
@ -50,8 +51,8 @@
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/js-yaml": "^4.0.9",
"@types/node": "^20.0.0",
"@types/uuid": "^10.0.0",
"@types/node": "^25.6.0",
"@types/uuid": "^11.0.0",
"@vitest/coverage-v8": "^4.0.18",
"gitnexus-shared": "file:../gitnexus-shared",
"tsx": "^4.0.0",
@ -640,15 +641,15 @@
"license": "Apache-2.0"
},
"node_modules/@huggingface/transformers": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@huggingface/transformers/-/transformers-4.1.0.tgz",
"integrity": "sha512-WiMf9eyvF6V2pj4gs12A7GQV3svyFIBtB/W+Hn5lT5E5DyqWUno1ZrWoAfJv69X1RNv/0GoOo6DFmL6NOYd+rg==",
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@huggingface/transformers/-/transformers-4.2.0.tgz",
"integrity": "sha512-8BRCoBMH0XsWaEIamuR0LrJGAfftgHAfb2Vrffy0VKlSAE/MnUJ5/h/zTfEP3fDIft+nk7TqB8xXEyABGitBjQ==",
"license": "Apache-2.0",
"dependencies": {
"@huggingface/jinja": "^0.5.6",
"@huggingface/tokenizers": "^0.1.3",
"onnxruntime-node": "1.24.3",
"onnxruntime-web": "1.26.0-dev.20260410-5e55544225",
"onnxruntime-web": "1.26.0-dev.20260416-b7804b056c",
"sharp": "^0.34.5"
}
},
@ -1554,9 +1555,9 @@
}
},
"node_modules/@oxc-project/types": {
"version": "0.124.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.124.0.tgz",
"integrity": "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==",
"version": "0.126.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.126.0.tgz",
"integrity": "sha512-oGfVtjAgwQVVpfBrbtk4e1XDyWHRFta6BS3GWVzrF8xYBT2VGQAk39yJS/wFSMrZqoiCU4oghT3Ch0HaHGIHcQ==",
"dev": true,
"license": "MIT",
"funding": {
@ -1628,9 +1629,9 @@
"license": "BSD-3-Clause"
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.15.tgz",
"integrity": "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==",
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.16.tgz",
"integrity": "sha512-rhY3k7Bsae9qQfOtph2Pm2jZEA+s8Gmjoz4hhmx70K9iMQ/ddeae+xhRQcM5IuVx5ry1+bGfkvMn7D6MJggVSA==",
"cpu": [
"arm64"
],
@ -1645,9 +1646,9 @@
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.15.tgz",
"integrity": "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==",
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.16.tgz",
"integrity": "sha512-rNz0yK078yrNn3DrdgN+PKiMOW8HfQ92jQiXxwX8yW899ayV00MLVdaCNeVBhG/TbH3ouYVObo8/yrkiectkcQ==",
"cpu": [
"arm64"
],
@ -1662,9 +1663,9 @@
}
},
"node_modules/@rolldown/binding-darwin-x64": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.15.tgz",
"integrity": "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==",
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.16.tgz",
"integrity": "sha512-r/OmdR00HmD4i79Z//xO06uEPOq5hRXdhw7nzkxQxwSavs3PSHa1ijntdpOiZ2mzOQ3fVVu8C1M19FoNM+dMUQ==",
"cpu": [
"x64"
],
@ -1679,9 +1680,9 @@
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.15.tgz",
"integrity": "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==",
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.16.tgz",
"integrity": "sha512-KcRE5w8h0OnjUatG8pldyD14/CQ5Phs1oxfR+3pKDjboHRo9+MkqQaiIZlZRpsxC15paeXme/I127tUa9TXJ6g==",
"cpu": [
"x64"
],
@ -1696,9 +1697,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.15.tgz",
"integrity": "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==",
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.16.tgz",
"integrity": "sha512-bT0guA1bpxEJ/ZhTRniQf7rNF8ybvXOuWbNIeLABaV5NGjx4EtOWBTSRGWFU9ZWVkPOZ+HNFP8RMcBokBiZ0Kg==",
"cpu": [
"arm"
],
@ -1713,9 +1714,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.15.tgz",
"integrity": "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==",
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.16.tgz",
"integrity": "sha512-+tHktCHWV8BDQSjemUqm/Jl/TPk3QObCTIjmdDy/nlupcujZghmKK2962LYrqFpWu+ai01AN/REOH3NEpqvYQg==",
"cpu": [
"arm64"
],
@ -1730,9 +1731,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.15.tgz",
"integrity": "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==",
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.16.tgz",
"integrity": "sha512-3fPzdREH806oRLxpTWW1Gt4tQHs0TitZFOECB2xzCFLPKnSOy90gwA7P29cksYilFO6XVRY1kzga0cL2nRjKPg==",
"cpu": [
"arm64"
],
@ -1747,9 +1748,9 @@
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.15.tgz",
"integrity": "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==",
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.16.tgz",
"integrity": "sha512-EKwI1tSrLs7YVw+JPJT/G2dJQ1jl9qlTTTEG0V2Ok/RdOenRfBw2PQdLPyjhIu58ocdBfP7vIRN/pvMsPxs/AQ==",
"cpu": [
"ppc64"
],
@ -1764,9 +1765,9 @@
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.15.tgz",
"integrity": "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==",
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.16.tgz",
"integrity": "sha512-Uknladnb3Sxqu6SEcqBldQyJUpk8NleooZEc0MbRBJ4inEhRYWZX0NJu12vNf2mqAq7gsofAxHrGghiUYjhaLQ==",
"cpu": [
"s390x"
],
@ -1781,9 +1782,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.15.tgz",
"integrity": "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==",
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.16.tgz",
"integrity": "sha512-FIb8+uG49sZBtLTn+zt1AJ20TqVcqWeSIyoVt0or7uAWesgKaHbiBh6OpA/k9v0LTt+PTrb1Lao133kP4uVxkg==",
"cpu": [
"x64"
],
@ -1798,9 +1799,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.15.tgz",
"integrity": "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==",
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.16.tgz",
"integrity": "sha512-RuERhF9/EgWxZEXYWCOaViUWHIboceK4/ivdtQ3R0T44NjLkIIlGIAVAuCddFxsZ7vnRHtNQUrt2vR2n2slB2w==",
"cpu": [
"x64"
],
@ -1815,9 +1816,9 @@
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.15.tgz",
"integrity": "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==",
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.16.tgz",
"integrity": "sha512-mXcXnvd9GpazCxeUCCnZ2+YF7nut+ZOEbE4GtaiPtyY6AkhZWbK70y1KK3j+RDhjVq5+U8FySkKRb/+w0EeUwA==",
"cpu": [
"arm64"
],
@ -1832,9 +1833,9 @@
}
},
"node_modules/@rolldown/binding-wasm32-wasi": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.15.tgz",
"integrity": "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==",
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.16.tgz",
"integrity": "sha512-3Q2KQxnC8IJOLqXmUMoYwyIPZU9hzRbnHaoV3Euz+VVnjZKcY8ktnNP8T9R4/GGQtb27C/UYKABxesKWb8lsvQ==",
"cpu": [
"wasm32"
],
@ -1844,10 +1845,10 @@
"dependencies": {
"@emnapi/core": "1.9.2",
"@emnapi/runtime": "1.9.2",
"@napi-rs/wasm-runtime": "^1.1.3"
"@napi-rs/wasm-runtime": "^1.1.4"
},
"engines": {
"node": ">=14.0.0"
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": {
@ -1862,9 +1863,9 @@
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz",
"integrity": "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==",
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.16.tgz",
"integrity": "sha512-tj7XRemQcOcFwv7qhpUxMTBbI5mWMlE4c1Omhg5+h8GuLXzyj8HviYgR+bB2DMDgRqUE+jiDleqSCRjx4aYk/Q==",
"cpu": [
"arm64"
],
@ -1879,9 +1880,9 @@
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.15.tgz",
"integrity": "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==",
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.16.tgz",
"integrity": "sha512-PH5DRZT+F4f2PTXRXR8uJxnBq2po/xFtddyabTJVJs/ZYVHqXPEgNIr35IHTEa6bpa0Q8Awg+ymkTaGnKITw4g==",
"cpu": [
"x64"
],
@ -1896,9 +1897,9 @@
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz",
"integrity": "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==",
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.16.tgz",
"integrity": "sha512-45+YtqxLYKDWQouLKCrpIZhke+nXxhsw+qAHVzHDVwttyBlHNBVs2K25rDXrZzhpTp9w1FlAlvweV1H++fdZoA==",
"dev": true,
"license": "MIT"
},
@ -2041,12 +2042,12 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "20.19.37",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz",
"integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==",
"version": "25.6.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
"undici-types": "~7.19.0"
}
},
"node_modules/@types/qs": {
@ -2097,21 +2098,25 @@
}
},
"node_modules/@types/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-11.0.0.tgz",
"integrity": "sha512-HVyk8nj2m+jcFRNazzqyVKiZezyhDKrGUA3jlEcg/nZ6Ms+qHwocba1Y/AaVaznJTAM9xpdFSh+ptbNrhOGvZA==",
"deprecated": "This is a stub types definition. uuid provides its own type definitions, so you do not need this installed.",
"dev": true,
"license": "MIT"
"license": "MIT",
"dependencies": {
"uuid": "*"
}
},
"node_modules/@vitest/coverage-v8": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.4.tgz",
"integrity": "sha512-x7FptB5oDruxNPDNY2+S8tCh0pcq7ymCe1gTHcsp733jYjrJl8V1gMUlVysuCD9Kz46Xz9t1akkv08dPcYDs1w==",
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.5.tgz",
"integrity": "sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@bcoe/v8-coverage": "^1.0.2",
"@vitest/utils": "4.1.4",
"@vitest/utils": "4.1.5",
"ast-v8-to-istanbul": "^1.0.0",
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",
@ -2125,8 +2130,8 @@
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@vitest/browser": "4.1.4",
"vitest": "4.1.4"
"@vitest/browser": "4.1.5",
"vitest": "4.1.5"
},
"peerDependenciesMeta": {
"@vitest/browser": {
@ -2135,16 +2140,16 @@
}
},
"node_modules/@vitest/expect": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.4.tgz",
"integrity": "sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==",
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz",
"integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.1.4",
"@vitest/utils": "4.1.4",
"@vitest/spy": "4.1.5",
"@vitest/utils": "4.1.5",
"chai": "^6.2.2",
"tinyrainbow": "^3.1.0"
},
@ -2153,13 +2158,13 @@
}
},
"node_modules/@vitest/mocker": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.4.tgz",
"integrity": "sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==",
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz",
"integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.1.4",
"@vitest/spy": "4.1.5",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
@ -2180,9 +2185,9 @@
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.4.tgz",
"integrity": "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==",
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz",
"integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -2193,13 +2198,13 @@
}
},
"node_modules/@vitest/runner": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.4.tgz",
"integrity": "sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==",
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz",
"integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.1.4",
"@vitest/utils": "4.1.5",
"pathe": "^2.0.3"
},
"funding": {
@ -2207,14 +2212,14 @@
}
},
"node_modules/@vitest/snapshot": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.4.tgz",
"integrity": "sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==",
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz",
"integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.4",
"@vitest/utils": "4.1.4",
"@vitest/pretty-format": "4.1.5",
"@vitest/utils": "4.1.5",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
@ -2223,9 +2228,9 @@
}
},
"node_modules/@vitest/spy": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.4.tgz",
"integrity": "sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==",
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz",
"integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==",
"dev": true,
"license": "MIT",
"funding": {
@ -2233,13 +2238,13 @@
}
},
"node_modules/@vitest/utils": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.4.tgz",
"integrity": "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==",
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz",
"integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.4",
"@vitest/pretty-format": "4.1.5",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0"
},
@ -3316,13 +3321,12 @@
"license": "ISC"
},
"node_modules/graphology": {
"version": "0.25.4",
"resolved": "https://registry.npmjs.org/graphology/-/graphology-0.25.4.tgz",
"integrity": "sha512-33g0Ol9nkWdD6ulw687viS8YJQBxqG5LWII6FI6nul0pq6iM2t5EKquOTFDbyTblRB3O9I+7KX4xI8u5ffekAQ==",
"version": "0.26.0",
"resolved": "https://registry.npmjs.org/graphology/-/graphology-0.26.0.tgz",
"integrity": "sha512-8SSImzgUUYC89Z042s+0r/vMibY7GX/Emz4LDO5e7jYXhuoWfHISPFJYjpRLUSJGq6UQ6xlenvX1p/hJdfXuXg==",
"license": "MIT",
"dependencies": {
"events": "^3.3.0",
"obliterator": "^2.0.2"
"events": "^3.3.0"
},
"peerDependencies": {
"graphology-types": ">=0.24.0"
@ -3614,6 +3618,12 @@
"integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==",
"license": "ISC"
},
"node_modules/jsonc-parser": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz",
"integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==",
"license": "MIT"
},
"node_modules/jsonfile": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz",
@ -4227,9 +4237,9 @@
}
},
"node_modules/onnxruntime-web": {
"version": "1.26.0-dev.20260410-5e55544225",
"resolved": "https://registry.npmjs.org/onnxruntime-web/-/onnxruntime-web-1.26.0-dev.20260410-5e55544225.tgz",
"integrity": "sha512-hHd9n8DzIfGSAjM4Dvslesc8i6h9HEEcl8qt7X3LfhUxMgls6FBJ32j2xrDtJjKJFEehFeJmyB/pvad1I8KS8w==",
"version": "1.26.0-dev.20260416-b7804b056c",
"resolved": "https://registry.npmjs.org/onnxruntime-web/-/onnxruntime-web-1.26.0-dev.20260416-b7804b056c.tgz",
"integrity": "sha512-MD6Ss4GSpQBo6zqoJzyT9LRbKYs7x/JVN23FT24EcEvlqF4VuzPOeH6X38orZPKHQDbprn7K+SBpu0/mj2CQiw==",
"license": "MIT",
"dependencies": {
"flatbuffers": "^25.1.24",
@ -4528,14 +4538,14 @@
}
},
"node_modules/rolldown": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.15.tgz",
"integrity": "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==",
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.16.tgz",
"integrity": "sha512-rzi5WqKzEZw3SooTt7cgm4eqIoujPIyGcJNGFL7iPEuajQw7vxMHUkXylu4/vhCkJGXsgRmxqMKXUpT6FEgl0g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@oxc-project/types": "=0.124.0",
"@rolldown/pluginutils": "1.0.0-rc.15"
"@oxc-project/types": "=0.126.0",
"@rolldown/pluginutils": "1.0.0-rc.16"
},
"bin": {
"rolldown": "bin/cli.mjs"
@ -4544,21 +4554,21 @@
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@rolldown/binding-android-arm64": "1.0.0-rc.15",
"@rolldown/binding-darwin-arm64": "1.0.0-rc.15",
"@rolldown/binding-darwin-x64": "1.0.0-rc.15",
"@rolldown/binding-freebsd-x64": "1.0.0-rc.15",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15",
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15",
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15",
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15",
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15",
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15",
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.15",
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.15",
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.15",
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15",
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15"
"@rolldown/binding-android-arm64": "1.0.0-rc.16",
"@rolldown/binding-darwin-arm64": "1.0.0-rc.16",
"@rolldown/binding-darwin-x64": "1.0.0-rc.16",
"@rolldown/binding-freebsd-x64": "1.0.0-rc.16",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.16",
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.16",
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.16",
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.16",
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.16",
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.16",
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.16",
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.16",
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.16",
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.16",
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.16"
}
},
"node_modules/router": {
@ -5412,9 +5422,9 @@
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"version": "7.19.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
"license": "MIT"
},
"node_modules/universalify": {
@ -5451,9 +5461,9 @@
}
},
"node_modules/uuid": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz",
"integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
@ -5473,17 +5483,17 @@
}
},
"node_modules/vite": {
"version": "8.0.8",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz",
"integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==",
"version": "8.0.9",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.9.tgz",
"integrity": "sha512-t7g7GVRpMXjNpa67HaVWI/8BWtdVIQPCL2WoozXXA7LBGEFK4AkkKkHx2hAQf5x1GZSlcmEDPkVLSGahxnEEZw==",
"dev": true,
"license": "MIT",
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",
"postcss": "^8.5.8",
"rolldown": "1.0.0-rc.15",
"tinyglobby": "^0.2.15"
"postcss": "^8.5.10",
"rolldown": "1.0.0-rc.16",
"tinyglobby": "^0.2.16"
},
"bin": {
"vite": "bin/vite.js"
@ -5551,19 +5561,19 @@
}
},
"node_modules/vitest": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.4.tgz",
"integrity": "sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==",
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz",
"integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "4.1.4",
"@vitest/mocker": "4.1.4",
"@vitest/pretty-format": "4.1.4",
"@vitest/runner": "4.1.4",
"@vitest/snapshot": "4.1.4",
"@vitest/spy": "4.1.4",
"@vitest/utils": "4.1.4",
"@vitest/expect": "4.1.5",
"@vitest/mocker": "4.1.5",
"@vitest/pretty-format": "4.1.5",
"@vitest/runner": "4.1.5",
"@vitest/snapshot": "4.1.5",
"@vitest/spy": "4.1.5",
"@vitest/utils": "4.1.5",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
@ -5591,12 +5601,12 @@
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.1.4",
"@vitest/browser-preview": "4.1.4",
"@vitest/browser-webdriverio": "4.1.4",
"@vitest/coverage-istanbul": "4.1.4",
"@vitest/coverage-v8": "4.1.4",
"@vitest/ui": "4.1.4",
"@vitest/browser-playwright": "4.1.5",
"@vitest/browser-preview": "4.1.5",
"@vitest/browser-webdriverio": "4.1.5",
"@vitest/coverage-istanbul": "4.1.5",
"@vitest/coverage-v8": "4.1.5",
"@vitest/ui": "4.1.5",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"

View file

@ -60,11 +60,12 @@
"cors": "^2.8.5",
"express": "^4.19.2",
"glob": "^13.0.6",
"graphology": "^0.25.4",
"graphology": "^0.26.0",
"graphology-indices": "^0.17.0",
"graphology-utils": "^2.3.0",
"ignore": "^7.0.5",
"js-yaml": "^4.1.1",
"jsonc-parser": "^3.3.1",
"lru-cache": "^11.0.0",
"mnemonist": "^0.40.3",
"onnxruntime-node": "^1.24.0",
@ -81,7 +82,7 @@
"tree-sitter-ruby": "^0.23.1",
"tree-sitter-rust": "0.23.1",
"tree-sitter-typescript": "^0.23.2",
"uuid": "^13.0.0"
"uuid": "^14.0.0"
},
"optionalDependencies": {
"node-addon-api": "^8.0.0",
@ -92,14 +93,14 @@
"tree-sitter-swift": "^0.6.0"
},
"devDependencies": {
"gitnexus-shared": "file:../gitnexus-shared",
"@types/cli-progress": "^3.11.6",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/js-yaml": "^4.0.9",
"@types/node": "^20.0.0",
"@types/uuid": "^10.0.0",
"@types/node": "^25.6.0",
"@types/uuid": "^11.0.0",
"@vitest/coverage-v8": "^4.0.18",
"gitnexus-shared": "file:../gitnexus-shared",
"tsx": "^4.0.0",
"typescript": "^5.4.5",
"vitest": "^4.0.18"

View file

@ -0,0 +1,134 @@
/**
* Synthetic benchmark for scope-resolution. Builds a large in-memory
* Python workspace and times runScopeResolution against it directly,
* isolating the resolution cost from parse / heritage / pipeline
* overhead.
*
* Usage: REGISTRY_PRIMARY_PYTHON=1 npx tsx scripts/bench-scope-resolution.ts
*/
process.env.REGISTRY_PRIMARY_PYTHON = '1';
import { generateId } from '../src/lib/utils.js';
import { createKnowledgeGraph } from '../src/core/graph/graph.js';
import { runScopeResolution } from '../src/core/ingestion/scope-resolution/index.js';
import { pythonScopeResolver } from '../src/core/ingestion/languages/python/scope-resolver.js';
const N_CLASSES = Number(process.env.BENCH_CLASSES ?? '60');
const N_USERS = Number(process.env.BENCH_USERS ?? '40');
const ITERS = Number(process.env.BENCH_ITERS ?? '5');
function buildWorkspace(): { path: string; content: string }[] {
const files: { path: string; content: string }[] = [];
// Build N_CLASSES "model" files, each defining a class with a few methods.
for (let i = 0; i < N_CLASSES; i++) {
const lines: string[] = [];
for (let j = 0; j < 5; j++) {
lines.push(`class Model${i}_${j}:`);
lines.push(` name: str`);
lines.push(` def save(self) -> bool:`);
lines.push(` return True`);
lines.push(` def update(self, name: str) -> "Model${i}_${j}":`);
lines.push(` self.name = name`);
lines.push(` return self`);
lines.push(` def get_other(self) -> "Model${i}_${(j + 1) % 5}":`);
lines.push(` return Model${i}_${(j + 1) % 5}()`);
lines.push('');
}
files.push({ path: `models/m${i}.py`, content: lines.join('\n') });
}
// Build N_USERS "user" files that import from a few model files
// and exercise the receiver-bound dispatcher heavily.
for (let u = 0; u < N_USERS; u++) {
const targets = [u % N_CLASSES, (u + 1) % N_CLASSES, (u + 2) % N_CLASSES];
const imports = targets
.map((t) => `from models.m${t} import Model${t}_0, Model${t}_1, Model${t}_2`)
.join('\n');
const calls: string[] = [];
for (let k = 0; k < 30; k++) {
const t = targets[k % 3]!;
const j = k % 3;
calls.push(` m${k} = Model${t}_${j}()`);
calls.push(` m${k}.save()`);
calls.push(` m${k}.update("x").save()`);
calls.push(` m${k}.get_other().save()`);
}
const content = `${imports}\n\ndef use_${u}() -> None:\n${calls.join('\n')}\n`;
files.push({ path: `app/u${u}.py`, content });
}
return files;
}
function buildGraph(files: { path: string; content: string }[]) {
const graph = createKnowledgeGraph();
// Pre-populate File / Class / Function nodes the resolver expects.
for (const f of files) {
const fileId = generateId('File', f.path);
graph.addNode({
id: fileId,
label: 'File',
properties: { name: f.path, filePath: f.path },
});
// Lightweight regex-extract class & def names so the lookup index
// has something to find. Real pipeline builds these via parse phase;
// for the bench this stand-in is enough to exercise the resolver.
const classRe = /^class (\w+)/gm;
const defRe = /^\s*def (\w+)/gm;
let m: RegExpExecArray | null;
while ((m = classRe.exec(f.content)) !== null) {
const name = m[1]!;
const id = generateId('Class', `${f.path}:${name}`);
graph.addNode({
id,
label: 'Class',
properties: { name, filePath: f.path, qualifiedName: name },
});
}
while ((m = defRe.exec(f.content)) !== null) {
const name = m[1]!;
const id = generateId('Function', `${f.path}:${name}`);
graph.addNode({
id,
label: 'Function',
properties: { name, filePath: f.path, qualifiedName: name },
});
}
}
return graph;
}
async function main() {
const files = buildWorkspace();
console.log(`bench: ${files.length} files (${N_CLASSES} models × 5 classes + ${N_USERS} users)`);
console.log(` × ${ITERS} iterations\n`);
// Warmup
for (let i = 0; i < 2; i++) {
const graph = buildGraph(files);
runScopeResolution({ graph, files, onWarn: () => {} }, pythonScopeResolver);
}
const samples: number[] = [];
for (let i = 0; i < ITERS; i++) {
const graph = buildGraph(files);
const start = process.hrtime.bigint();
runScopeResolution({ graph, files, onWarn: () => {} }, pythonScopeResolver);
const end = process.hrtime.bigint();
const ms = Number(end - start) / 1_000_000;
samples.push(ms);
console.log(` iter ${i + 1}: ${ms.toFixed(0)} ms`);
}
samples.sort((a, b) => a - b);
const median = samples[Math.floor(samples.length / 2)]!;
const min = samples[0]!;
console.log(`\nmin: ${min.toFixed(0)} ms · median: ${median.toFixed(0)} ms`);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View file

@ -0,0 +1,24 @@
/**
* CI helper emits the `MIGRATED_LANGUAGES` set as a JSON matrix array for
* GitHub Actions (`.github/workflows/ci-scope-parity.yml`).
*
* Consumed by the `discover` job in that workflow. Each entry has:
* - `slug`: lowercase language id, matching `test/integration/resolvers/<slug>.test.ts`.
* - `envvar`: uppercase suffix used to build the `REGISTRY_PRIMARY_<envvar>` toggle.
*
* Run with `npx tsx scripts/ci-list-migrated-languages.ts`. The script
* writes a single JSON array to stdout (no wrapper object) so the
* workflow can pipe it straight into `$GITHUB_OUTPUT`.
*/
import { MIGRATED_LANGUAGES } from '../src/core/ingestion/registry-primary-flag.js';
const entries = [...MIGRATED_LANGUAGES].map((slug) => {
const s = String(slug);
return {
slug: s,
envvar: s.toUpperCase().replace(/-/g, '_'),
};
});
process.stdout.write(JSON.stringify(entries));

View file

@ -32,6 +32,33 @@ export interface AIContextOptions {
const GITNEXUS_START_MARKER = '<!-- gitnexus:start -->';
const GITNEXUS_END_MARKER = '<!-- gitnexus:end -->';
/**
* Find the index of a section marker that occupies its own line.
* Unlike `indexOf`, this rejects inline prose references like
* `` See the `<!-- gitnexus:start -->` block `` that appear
* mid-sentence (#1041). A marker counts as section-position only when:
* - preceded by newline or start-of-file, AND
* - followed by newline, `\r` (CRLF files), or end-of-file.
* The generator always emits each marker alone on its line, so this
* matches every legitimate section and none of the inline mentions.
*
* `startFrom` lets the end-marker lookup start after the already-found
* start marker, avoiding a scan from 0 and guaranteeing we never pick
* up an end marker that appears earlier in the file than the start.
*/
function findSectionMarkerIndex(content: string, marker: string, startFrom = 0): number {
let idx = content.indexOf(marker, startFrom);
while (idx !== -1) {
const atLineStart = idx === 0 || content[idx - 1] === '\n';
const endPos = idx + marker.length;
const atLineEnd =
endPos === content.length || content[endPos] === '\n' || content[endPos] === '\r';
if (atLineStart && atLineEnd) return idx;
idx = content.indexOf(marker, idx + 1);
}
return -1;
}
/**
* Generate the full GitNexus context content.
*
@ -163,9 +190,18 @@ async function upsertGitNexusSection(
const existingContent = await fs.readFile(filePath, 'utf-8');
// Check if GitNexus section already exists
const startIdx = existingContent.indexOf(GITNEXUS_START_MARKER);
const endIdx = existingContent.indexOf(GITNEXUS_END_MARKER);
// Check if GitNexus section already exists. Matching is restricted
// to markers that occupy their own line so that inline prose
// references (e.g. `` See the `<!-- gitnexus:start -->` block `` in
// the shipped CLAUDE.md) are NOT treated as section delimiters
// (#1041). The end-marker scan starts after the start-marker so it
// can never pick up an earlier end in the file.
const startIdx = findSectionMarkerIndex(existingContent, GITNEXUS_START_MARKER);
const endIdx = findSectionMarkerIndex(
existingContent,
GITNEXUS_END_MARKER,
startIdx === -1 ? 0 : startIdx,
);
if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
// Replace existing section

View file

@ -20,6 +20,7 @@ import {
} from '../storage/repo-manager.js';
import { getGitRoot, hasGitDir } from '../storage/git.js';
import { runFullAnalysis } from '../core/run-analyze.js';
import { getMaxFileSizeBannerMessage } from '../core/ingestion/utils/max-file-size.js';
import fs from 'fs/promises';
const HEAP_MB = 8192;
@ -78,6 +79,12 @@ export interface AnalyzeOptions {
* `allowDuplicateName` option end-to-end.
*/
allowDuplicateName?: boolean;
/**
* Override the walker's large-file skip threshold (#991). Value in KB;
* clamped downstream to the tree-sitter 32 MB ceiling. Sets
* `GITNEXUS_MAX_FILE_SIZE` for the rest of the pipeline.
*/
maxFileSize?: string;
}
export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOptions) => {
@ -87,6 +94,10 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
process.env.GITNEXUS_VERBOSE = '1';
}
if (options?.maxFileSize) {
process.env.GITNEXUS_MAX_FILE_SIZE = options.maxFileSize;
}
console.log('\n GitNexus Analyzer\n');
let repoPath: string;
@ -132,6 +143,11 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
);
}
const maxFileSizeBanner = getMaxFileSizeBannerMessage();
if (maxFileSizeBanner) {
console.log(`${maxFileSizeBanner}\n`);
}
// ── CLI progress bar setup ─────────────────────────────────────────
const bar = new cliProgress.SingleBar(
{

View file

@ -6,7 +6,13 @@
*/
import fs from 'fs/promises';
import { findRepo, unregisterRepo, listRegisteredRepos } from '../storage/repo-manager.js';
import {
findRepo,
unregisterRepo,
listRegisteredRepos,
assertSafeStoragePath,
UnsafeStoragePathError,
} from '../storage/repo-manager.js';
export const cleanCommand = async (options?: { force?: boolean; all?: boolean }) => {
// --all flag: clean all indexed repos
@ -27,6 +33,24 @@ export const cleanCommand = async (options?: { force?: boolean; all?: boolean })
const entries = await listRegisteredRepos();
for (const entry of entries) {
// Safety guard (#1003 review — @magyargergo): same rationale as
// remove.ts. `~/.gitnexus/registry.json` is user-writable, so a
// corrupted or hand-edited entry could point storagePath at the
// repo root, an empty string, or anywhere else — and
// fs.rm(recursive: true) on any of those would be catastrophic.
// Skip poisoned entries without touching disk, but keep going
// through the rest of the registry (preserves the existing
// per-repo error-tolerance semantics of `clean --all`).
try {
assertSafeStoragePath(entry);
} catch (err) {
if (err instanceof UnsafeStoragePathError) {
console.error(`Refusing to clean ${entry.name}: ${err.message}`);
continue;
}
throw err;
}
try {
await fs.rm(entry.storagePath, { recursive: true, force: true });
await unregisterRepo(entry.path);

View file

@ -17,7 +17,7 @@ import {
addToGitignore,
registerRepo,
} from '../storage/repo-manager.js';
import { getGitRoot, isGitRepo } from '../storage/git.js';
import { getGitRoot, getRemoteUrl, isGitRepo } from '../storage/git.js';
export interface IndexOptions {
force?: boolean;
@ -107,6 +107,13 @@ export const indexCommand = async (inputPathParts?: string[], options?: IndexOpt
}
// ── Register in global registry ───────────────────────────────────
// Refresh the on-disk meta with a freshly captured `remoteUrl` if
// it's missing, so an `index` of an older `.gitnexus/` still gets
// sibling-clone fingerprinting on subsequent use without forcing a
// full re-analyze.
if (!meta.remoteUrl && isGitRepo(repoPath)) {
meta.remoteUrl = getRemoteUrl(repoPath);
}
await registerRepo(repoPath, meta);
await addToGitignore(repoPath);

View file

@ -39,9 +39,17 @@ program
'Leaves `-r <name>` ambiguous for the two paths; use -r <path> to disambiguate.',
)
.option('-v, --verbose', 'Enable verbose ingestion warnings (default: false)')
.option(
'--max-file-size <kb>',
'Skip files larger than this (KB). Default: 512. Hard cap: 32768 (tree-sitter limit).',
)
.addHelpText(
'after',
'\nEnvironment variables:\n GITNEXUS_NO_GITIGNORE=1 Skip .gitignore parsing (still reads .gitnexusignore)',
'\nEnvironment variables:\n' +
' GITNEXUS_NO_GITIGNORE=1 Skip .gitignore parsing (still reads .gitnexusignore)\n' +
' GITNEXUS_MAX_FILE_SIZE=N Override large-file skip threshold (KB). Default 512, max 32768.\n' +
'\nTip: `.gitnexusignore` supports `.gitignore`-style negation. Add e.g.\n' +
' `!__tests__/` to index a directory that is auto-filtered by default (#771).',
)
.action(createLazyAction(() => import('./analyze.js'), 'analyzeCommand'));
@ -83,6 +91,15 @@ program
.option('--all', 'Clean all indexed repos')
.action(createLazyAction(() => import('./clean.js'), 'cleanCommand'));
program
.command('remove <target>')
.description(
'Delete the GitNexus index for a registered repo (by alias, name, or absolute path). ' +
'Unlike `clean`, does not require being inside the repo. Idempotent on unknown targets.',
)
.option('-f, --force', 'Skip confirmation prompt')
.action(createLazyAction(() => import('./remove.js'), 'removeCommand'));
program
.command('wiki [path]')
.description('Generate repository wiki from knowledge graph')

110
gitnexus/src/cli/remove.ts Normal file
View file

@ -0,0 +1,110 @@
/**
* Remove Command (#664)
*
* Delete the `.gitnexus/` index for a registered repo and unregister it
* from the global registry (~/.gitnexus/registry.json). The target is
* identified by alias / basename-derived name / remote-inferred name /
* absolute path no `--repo` flag, just a positional argument so the
* destructive-command ergonomics match `clean` (which is also
* destructive but scoped to `process.cwd()`).
*
* Compared to `clean`:
* - `clean` acts on the repo discovered by walking up from cwd.
* - `remove` acts on any registered repo identified by name or path.
*
* Behaviour notes:
* - Idempotent on unknown targets: exits 0 with a warning so that
* `remove X && analyze Y` keeps working in scripts. Per #664:
* "behave atomically and idempotently so retries are safe".
* - Atomic order mirrors `clean`: fs.rm FIRST, then unregister. A
* partial failure leaves the registry pointing at a missing dir
* (recoverable by `listRegisteredRepos({ validate: true })` on
* next read) rather than the opposite, which would orphan
* .gitnexus/ directories on disk.
* - `-f` / `--force` matches the confirmation-skip semantics of
* `clean -f`. (Distinct from `analyze --force`, which re-indexes;
* here there is no pipeline, so no conflation.)
*/
import fs from 'fs/promises';
import {
readRegistry,
resolveRegistryEntry,
assertSafeStoragePath,
unregisterRepo,
RegistryNotFoundError,
RegistryAmbiguousTargetError,
UnsafeStoragePathError,
} from '../storage/repo-manager.js';
export const removeCommand = async (target: string, options?: { force?: boolean }) => {
// Read the registry snapshot once and pass it to the resolver — this
// lets us render the "before" state in the dry-run path without a
// second disk read.
const entries = await readRegistry();
let entry;
try {
entry = resolveRegistryEntry(entries, target);
} catch (err) {
if (err instanceof RegistryNotFoundError) {
// Idempotent: missing target is a no-op warning, not an error.
// The `availableNames` hint comes from the error itself so users
// can see what they might have meant.
console.warn(`Nothing to remove: ${err.message}`);
return;
}
if (err instanceof RegistryAmbiguousTargetError) {
// Duplicate aliases are allowed via --allow-duplicate-name (#829);
// refuse to guess which one the user meant — surface the full list
// and exit non-zero so scripts don't silently pick the wrong repo.
console.error(`Error: ${err.message}`);
process.exit(1);
}
throw err;
}
// Confirmation gate — same shape as `clean`. Default is a dry-run
// that describes what would be deleted; `--force` actually deletes.
if (!options?.force) {
console.log(`This will delete the GitNexus index for: ${entry.name}`);
console.log(` Path: ${entry.path}`);
console.log(` Storage: ${entry.storagePath}`);
console.log('\nRun with --force to confirm deletion.');
return;
}
// Safety guard (#1003 review — @magyargergo): refuse to proceed if
// the registry entry's `storagePath` isn't the canonical
// `<entry.path>/.gitnexus` subfolder. `~/.gitnexus/registry.json` is
// user-writable, so a corrupted or hand-edited entry could point
// storagePath at the repo root, an empty string (→ cwd), a parent
// dir, or anywhere else; `fs.rm(recursive: true, force: true)` on
// any of those would be a runtime disaster. Bail before touching
// disk, with an actionable hint for recovering a broken registry.
try {
assertSafeStoragePath(entry);
} catch (err) {
if (err instanceof UnsafeStoragePathError) {
console.error(`Error: ${err.message}`);
process.exit(1);
}
throw err;
}
// Deletion order: fs.rm first, then unregister. If fs.rm fails mid-way,
// the registry entry stays so the user can retry. If fs.rm succeeds but
// unregister throws (e.g. ENOSPC on registry write), the entry becomes
// orphaned — `listRegisteredRepos({ validate: true })` prunes those on
// next read, so the failure is self-healing.
try {
await fs.rm(entry.storagePath, { recursive: true, force: true });
await unregisterRepo(entry.path);
console.log(`Removed: ${entry.name}`);
console.log(` Path: ${entry.path}`);
console.log(` Storage: ${entry.storagePath}`);
} catch (err) {
console.error(`Failed to remove ${entry.name}:`, err);
process.exit(1);
}
};

View file

@ -13,6 +13,7 @@ import { execFile, execFileSync } from 'child_process';
import { promisify } from 'util';
import { fileURLToPath } from 'url';
import { glob } from 'glob';
import { parseTree, modify, applyEdits, ParseError } from 'jsonc-parser';
import { getGlobalDir } from '../storage/repo-manager.js';
const __filename = fileURLToPath(import.meta.url);
@ -75,6 +76,23 @@ function getMcpEntry() {
};
}
/**
* OpenCode uses a different MCP format: { type: "local", command: [...] }
* where command is a flat array (command + args combined).
*/
function getOpenCodeMcpEntry() {
const bin = resolveGitnexusBin();
if (bin) {
return { type: 'local', command: [bin, 'mcp'] };
}
if (process.platform === 'win32') {
return { type: 'local', command: ['cmd', '/c', 'npx', '-y', 'gitnexus@latest', 'mcp'] };
}
return { type: 'local', command: ['npx', '-y', 'gitnexus@latest', 'mcp'] };
}
/**
* Merge gitnexus entry into an existing MCP config JSON object.
* Returns the updated config.
@ -110,6 +128,62 @@ async function writeJsonFile(filePath: string, data: any): Promise<void> {
await fs.writeFile(filePath, JSON.stringify(data, null, 2) + '\n', 'utf-8');
}
/**
* Detect indentation style from file content.
* Returns formatting options matching the file's existing style.
*/
function detectIndentation(raw: string): { tabSize: number; insertSpaces: boolean } {
const firstIndented = raw.match(/^( +|\t)/m);
if (!firstIndented) return { tabSize: 2, insertSpaces: true };
if (firstIndented[1] === '\t') return { tabSize: 1, insertSpaces: false };
return { tabSize: firstIndented[1].length, insertSpaces: true };
}
/**
* Merge a key/value pair into a JSONC config file, preserving comments and formatting.
* If the file is genuinely corrupt (not valid JSONC), leaves it untouched.
*/
async function mergeJsoncFile(
filePath: string,
keyPath: string[],
value: unknown,
): Promise<boolean> {
let raw: string;
try {
raw = await fs.readFile(filePath, 'utf-8');
} catch {
raw = '';
}
if (raw.trim().length === 0) {
const config: any = {};
let parent: any = config;
for (let i = 0; i < keyPath.length; i++) {
if (i === keyPath.length - 1) {
parent[keyPath[i]] = value;
} else {
parent[keyPath[i]] = {};
parent = parent[keyPath[i]];
}
}
await writeJsonFile(filePath, config);
return true;
}
const parseErrors: ParseError[] = [];
const tree = parseTree(raw, parseErrors);
if (tree && tree.type === 'object' && parseErrors.length === 0) {
const formattingOptions = detectIndentation(raw);
const edits = modify(raw, keyPath, value, { formattingOptions });
const result = applyEdits(raw, edits);
await fs.writeFile(filePath, result, 'utf-8');
return true;
}
return false;
}
/**
* Check if a directory exists
*/
@ -267,12 +341,14 @@ async function setupOpenCode(result: SetupResult): Promise<void> {
const configPath = path.join(opencodeDir, 'opencode.json');
try {
const existing = await readJsonFile(configPath);
const config = existing || {};
if (!config.mcp) config.mcp = {};
config.mcp.gitnexus = getMcpEntry();
await writeJsonFile(configPath, config);
result.configured.push('OpenCode');
const ok = await mergeJsoncFile(configPath, ['mcp', 'gitnexus'], getOpenCodeMcpEntry());
if (ok) {
result.configured.push('OpenCode');
} else {
result.errors.push(
'OpenCode: opencode.json is corrupt — skipping to preserve existing content',
);
}
} catch (err: any) {
result.errors.push(`OpenCode: ${err.message}`);
}

View file

@ -270,17 +270,21 @@ const IGNORED_FILES = new Set([
'.env.example',
]);
// NOTE: Negation patterns in .gitnexusignore (e.g. `!vendor/`) cannot override
// entries in DEFAULT_IGNORE_LIST — this is intentional. The hardcoded list protects
// against indexing directories that are almost never source code (node_modules, .git, etc.).
// Users who need to include such directories should remove them from the hardcoded list.
// The hardcoded DEFAULT_IGNORE_LIST is the "safety net" default: directories
// that are almost never source code (node_modules, .git, dist, __tests__,
// etc.). Users who legitimately need to index one of these can negate the
// hardcoded rule via a `!pattern` line in `.gitnexusignore` (#771) — same
// semantics as `.gitignore` negation. That override is applied in
// `createIgnoreFilter` below; `shouldIgnorePath` itself stays a pure
// hardcoded-list check so its callers (wiki generator, tests) get
// deterministic results independent of per-repo config.
export const shouldIgnorePath = (filePath: string): boolean => {
const normalizedPath = filePath.replace(/\\/g, '/');
const parts = normalizedPath.split('/');
const fileName = parts[parts.length - 1];
const fileNameLower = fileName.toLowerCase();
// Check if any path segment is in ignore list
// Check if any path segment is in the hardcoded ignore list.
for (const part of parts) {
if (DEFAULT_IGNORE_LIST.has(part)) {
return true;
@ -369,6 +373,42 @@ export const loadIgnoreRules = async (
return hasRules ? ig : null;
};
/**
* Walk ancestor segments of `rel` and check whether `.gitnexusignore`
* (or `.gitignore`) contains an explicit `!pattern` negation that
* applies. Returns true as soon as any segment or the path itself
* is matched by a negation rule.
*
* Why this exists (#771): the hardcoded DEFAULT_IGNORE_LIST would
* otherwise block indexing of directories like `__tests__/` even when
* the user has an explicit `!__tests__/` line in `.gitnexusignore`.
* Mirroring `.gitignore` negation semantics: a user's explicit
* unignore of a parent directory implicitly unignores everything
* underneath, so we walk the ancestor chain rather than only testing
* the leaf.
*
* The `ignore` package's `test(path)` returns `{ignored, unignored}`;
* `unignored: true` is the "a negation rule matched this path"
* signal. Children of a negated directory return
* `{ignored: false, unignored: false}` on a direct test, which is why
* we also walk the ancestors here.
*/
const hasExplicitUnignore = (ig: Ignore, rel: string): boolean => {
// Direct match on the path (as a file).
if (ig.test(rel).unignored) return true;
// Direct match on the path treated as a directory — `!dir/` matches
// here when rel is the directory itself.
if (ig.test(rel + '/').unignored) return true;
// Walk ancestor segments. `!parent/` should propagate to every
// descendant the same way `.gitignore` negation propagates.
const parts = rel.split('/');
for (let i = parts.length - 1; i > 0; i--) {
const ancestor = parts.slice(0, i).join('/') + '/';
if (ig.test(ancestor).unignored) return true;
}
return false;
};
/**
* Create a glob-compatible ignore filter combining:
* - .gitignore / .gitnexusignore patterns (via `ignore` package)
@ -376,6 +416,15 @@ export const loadIgnoreRules = async (
*
* Returns an IgnoreLike object for glob's `ignore` option,
* enabling directory-level pruning during traversal.
*
* Precedence (#771): user's `.gitnexusignore` negation patterns take
* priority over the hardcoded list, matching `.gitignore` semantics.
* An explicit `!pattern` rule unignores descendants even when they
* would otherwise be blocked by DEFAULT_IGNORE_LIST UNLESS a more
* specific rule in the same file re-ignores a subset (e.g.
* `!__tests__/` paired with `__tests__/generated/` blocks the child
* while leaving the parent negated). Last-match-wins is enforced by
* consulting `ig.ignores(rel)` after `hasExplicitUnignore`.
*/
export const createIgnoreFilter = async (repoPath: string, options?: IgnoreOptions) => {
const ig = await loadIgnoreRules(repoPath, options);
@ -386,16 +435,33 @@ export const createIgnoreFilter = async (repoPath: string, options?: IgnoreOptio
// which is what the `ignore` package expects. No explicit normalization needed.
const rel = p.relative();
if (!rel) return false;
// User's .gitnexusignore negation takes precedence over hardcoded
// rules (#771). If any ancestor or the path itself was explicitly
// unignored AND no more-specific rule re-ignores this exact path,
// allow it through. The `!ig.ignores(rel)` guard matches
// .gitignore's last-match-wins semantics: `!__tests__/` followed
// by `__tests__/generated/` negates the parent but still blocks
// the re-ignored child.
if (ig && hasExplicitUnignore(ig, rel) && !ig.ignores(rel)) return false;
// Check .gitignore / .gitnexusignore patterns
if (ig && ig.ignores(rel)) return true;
// Fall back to hardcoded rules
return shouldIgnorePath(rel);
},
childrenIgnored(p: Path): boolean {
// Fast path: check directory name against hardcoded list.
// Note: dot-directories (.git, .vscode, etc.) are primarily excluded by
// glob's `dot: false` option in filesystem-walker.ts. This check is
// defense-in-depth — do not remove `dot: false` assuming this covers it.
// glob's `dot: false` option in filesystem-walker.ts. The hardcoded
// list check below is defense-in-depth — do not remove `dot: false`
// assuming this covers it.
const rel = p.relative();
// User's .gitnexusignore negation takes precedence (#771) — if the
// user explicitly unignored this directory or any ancestor via a
// !pattern rule, allow descent even if the directory name is in
// DEFAULT_IGNORE_LIST. The `!ig.ignores(rel + '/')` guard keeps
// last-match-wins: `!__tests__/` + `__tests__/generated/` still
// blocks descent into `__tests__/generated/`.
if (ig && rel && hasExplicitUnignore(ig, rel) && !ig.ignores(rel + '/')) return false;
// Hardcoded list: block descent into well-known noise directories.
if (DEFAULT_IGNORE_LIST.has(p.name)) return true;
// Check against .gitignore / .gitnexusignore patterns.
// Since childrenIgnored is only called for directories, always test with
@ -405,10 +471,7 @@ export const createIgnoreFilter = async (repoPath: string, options?: IgnoreOptio
// Bare-name patterns (e.g. `local`) still match `local/` per gitignore spec:
// the `ignore` package normalizes `dir` and `dir/` to match directories.
// See: https://github.com/kaelzhang/node-ignore#2-filenames-and-dirnames
if (ig) {
const rel = p.relative();
if (rel && ig.ignores(rel + '/')) return true;
}
if (ig && rel && ig.ignores(rel + '/')) return true;
return false;
},
};

View file

@ -4,6 +4,9 @@
*/
import { execFileSync } from 'node:child_process';
import path from 'path';
import { readRegistry, type RegistryEntry, type CwdMatch } from '../storage/repo-manager.js';
import { getGitRoot, getCurrentCommit, getRemoteUrl } from '../storage/git.js';
export interface StalenessInfo {
isStale: boolean;
@ -37,3 +40,111 @@ export function checkStaleness(repoPath: string, lastCommit: string): StalenessI
return { isStale: false, commitsBehind: 0 };
}
}
/**
* Compare a sibling-clone HEAD against an indexed `lastCommit`. Returns
* `undefined` when the indexed commit is not reachable from the sibling
* (e.g. divergent branches, shallow clone, missing ref). The caller
* should treat `undefined` as "drift unknown" rather than "no drift".
*/
function commitsAheadOfIndexed(siblingPath: string, indexedCommit: string): number | undefined {
if (!indexedCommit) return undefined;
try {
const result = execFileSync('git', ['rev-list', '--count', `${indexedCommit}..HEAD`], {
cwd: siblingPath,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
}).trim();
return parseInt(result, 10) || 0;
} catch {
return undefined;
}
}
/**
* Resolve a working directory against the global registry. Returns:
* - `match: 'path'` when `cwd` is inside a registered entry's path
* - `match: 'sibling-by-remote'` when `cwd` lives in a different on-disk clone
* of the same repo (same `remoteUrl`)
* - `match: 'none'` when neither match applies
*
* For sibling-by-remote matches, the caller's HEAD and the drift vs the
* indexed `lastCommit` are also returned so the MCP layer can warn
* before serving silently-stale answers (issue: silent graph drift
* across sibling clones).
*
* `path` matches deliberately use the longest-prefix rule so a cwd
* inside a sub-path of a registered repo still matches that repo, not
* a coincidentally-aliased shorter entry.
*/
export async function checkCwdMatch(cwd: string): Promise<CwdMatch> {
const entries = await readRegistry();
if (entries.length === 0) return { match: 'none' };
const isWin = process.platform === 'win32';
const norm = (p: string) => (isWin ? path.resolve(p).toLowerCase() : path.resolve(p));
const sep = path.sep;
const cwdResolved = path.resolve(cwd);
const cwdNorm = norm(cwdResolved);
// 1) Path-based match (longest prefix wins, boundary-safe).
let bestPath: RegistryEntry | undefined;
let bestLen = -1;
for (const e of entries) {
const p = norm(e.path);
if (cwdNorm === p || cwdNorm.startsWith(p + sep)) {
if (p.length > bestLen) {
bestPath = e;
bestLen = p.length;
}
}
}
if (bestPath) return { match: 'path', entry: bestPath };
// 2) Sibling-by-remote: locate the cwd's git root, get its remote
// URL, and look for any registered entry with the same fingerprint.
const cwdGitRoot = getGitRoot(cwdResolved);
if (!cwdGitRoot) return { match: 'none' };
const cwdRemote = getRemoteUrl(cwdGitRoot);
if (!cwdRemote) return { match: 'none' };
const sibling = entries.find(
(e) => e.remoteUrl === cwdRemote && norm(e.path) !== norm(cwdGitRoot),
);
if (!sibling) return { match: 'none' };
const cwdHead = getCurrentCommit(cwdGitRoot) || undefined;
const drift = commitsAheadOfIndexed(cwdGitRoot, sibling.lastCommit);
// Same commit on both clones → still report match=sibling-by-remote
// (the relationship is real and useful to callers like list_repos /
// future tooling) but leave `hint` unset: there's nothing to warn
// about, and `maybeWarnSiblingDrift` already short-circuits this
// case independently. Surfacing a no-op hint would force callers
// to second-guess whether they need to display it.
let hint: string | undefined;
if (cwdHead && cwdHead === sibling.lastCommit) {
hint = undefined;
} else if (drift && drift > 0) {
hint =
`⚠️ Index for "${sibling.name}" was built at ${sibling.path}; ` +
`your cwd (${cwdGitRoot}) is a sibling clone that is ${drift} commit${drift > 1 ? 's' : ''} ` +
`ahead of the indexed commit. Results may be stale or incorrect — re-run \`gitnexus analyze\` ` +
`to refresh the index.`;
} else {
hint =
`⚠️ Index for "${sibling.name}" was built at ${sibling.path}; ` +
`your cwd (${cwdGitRoot}) is a sibling clone whose HEAD differs from the indexed commit. ` +
`Results may be stale or incorrect — re-run \`gitnexus analyze\` to refresh the index.`;
}
return {
match: 'sibling-by-remote',
entry: sibling,
cwdGitRoot,
cwdHead,
drift,
hint,
};
}

View file

@ -1,35 +1,117 @@
import type { GraphNode, GraphRelationship } from 'gitnexus-shared';
import type { GraphNode, GraphRelationship, RelationshipType } from 'gitnexus-shared';
import { KnowledgeGraph } from './types.js';
/** Fresh empty iterator per call `[].values()` returns a new
* exhausted iterator each invocation, so empty-type lookups don't
* share a single already-exhausted iterator across callers. */
function emptyRelIter(): IterableIterator<GraphRelationship> {
return ([] as GraphRelationship[]).values();
}
export const createKnowledgeGraph = (): KnowledgeGraph => {
const nodeMap = new Map<string, GraphNode>();
const relationshipMap = new Map<string, GraphRelationship>();
// Per-type index maintained alongside `relationshipMap`. Bucket
// values are `Map<id, Relationship>` so per-type iteration is cheap
// and per-edge removal is O(1). See plan
// docs/plans/2026-04-20-002-perf-parse-heritage-mro-plan.md (Unit 1).
const relationshipsByType = new Map<RelationshipType, Map<string, GraphRelationship>>();
// Reverse-adjacency index: nodeId → Set<relId> of every edge where
// this node appears as source OR target. Maintained on writeRel /
// deleteRel so `removeNode` can delete a node's edges in
// O(edges-touching-node) instead of O(total-edges).
const edgeIdsByNode = new Map<string, Set<string>>();
// File index: filePath → Set<nodeId>. Maintained on addNode /
// removeNode so `removeNodesByFile` reaches its file's nodes
// directly instead of scanning the whole node map.
const nodeIdsByFile = new Map<string, Set<string>>();
// Private helpers that encode the dual-index invariants in one
// place. All mutation paths go through these — adding a new
// mutation method only needs to call the helper, not remember to
// touch every index.
const addToBucket = <K, V>(map: Map<K, Set<V>>, key: K, value: V): void => {
let bucket = map.get(key);
if (bucket === undefined) {
bucket = new Set();
map.set(key, bucket);
}
bucket.add(value);
};
const removeFromBucket = <K, V>(map: Map<K, Set<V>>, key: K, value: V): void => {
const bucket = map.get(key);
if (bucket === undefined) return;
bucket.delete(value);
if (bucket.size === 0) map.delete(key);
};
const writeRel = (rel: GraphRelationship): void => {
relationshipMap.set(rel.id, rel);
let typeBucket = relationshipsByType.get(rel.type);
if (typeBucket === undefined) {
typeBucket = new Map();
relationshipsByType.set(rel.type, typeBucket);
}
typeBucket.set(rel.id, rel);
addToBucket(edgeIdsByNode, rel.sourceId, rel.id);
// Guard against a self-edge writing the same rel.id into the
// same Set twice — Set dedup handles it, but we skip explicitly
// for clarity.
if (rel.targetId !== rel.sourceId) {
addToBucket(edgeIdsByNode, rel.targetId, rel.id);
}
};
const deleteRel = (rel: GraphRelationship): void => {
relationshipMap.delete(rel.id);
const typeBucket = relationshipsByType.get(rel.type);
if (typeBucket !== undefined) {
typeBucket.delete(rel.id);
if (typeBucket.size === 0) relationshipsByType.delete(rel.type);
}
removeFromBucket(edgeIdsByNode, rel.sourceId, rel.id);
if (rel.targetId !== rel.sourceId) {
removeFromBucket(edgeIdsByNode, rel.targetId, rel.id);
}
};
const addNode = (node: GraphNode) => {
if (!nodeMap.has(node.id)) {
nodeMap.set(node.id, node);
if (nodeMap.has(node.id)) return;
nodeMap.set(node.id, node);
const filePath = node.properties?.filePath;
if (typeof filePath === 'string' && filePath.length > 0) {
addToBucket(nodeIdsByFile, filePath, node.id);
}
};
const addRelationship = (relationship: GraphRelationship) => {
if (!relationshipMap.has(relationship.id)) {
relationshipMap.set(relationship.id, relationship);
}
if (relationshipMap.has(relationship.id)) return;
writeRel(relationship);
};
/**
* Remove a single node and all relationships involving it
* Remove a single node and all relationships involving it.
* O(edges-touching-node) via the reverse-adjacency index no full
* relationshipMap scan.
*/
const removeNode = (nodeId: string): boolean => {
if (!nodeMap.has(nodeId)) return false;
const node = nodeMap.get(nodeId);
if (node === undefined) return false;
nodeMap.delete(nodeId);
const filePath = node.properties?.filePath;
if (typeof filePath === 'string' && filePath.length > 0) {
removeFromBucket(nodeIdsByFile, filePath, nodeId);
}
// Remove all relationships involving this node
for (const [relId, rel] of relationshipMap) {
if (rel.sourceId === nodeId || rel.targetId === nodeId) {
relationshipMap.delete(relId);
const touchingEdgeIds = edgeIdsByNode.get(nodeId);
if (touchingEdgeIds !== undefined) {
// Snapshot the ids before iterating — deleteRel mutates the same
// Set via removeFromBucket, which would break mid-loop iteration.
for (const relId of [...touchingEdgeIds]) {
const rel = relationshipMap.get(relId);
if (rel !== undefined) deleteRel(rel);
}
edgeIdsByNode.delete(nodeId);
}
return true;
};
@ -39,21 +121,24 @@ export const createKnowledgeGraph = (): KnowledgeGraph => {
* Returns true if the relationship existed and was removed, false otherwise.
*/
const removeRelationship = (relationshipId: string): boolean => {
return relationshipMap.delete(relationshipId);
const rel = relationshipMap.get(relationshipId);
if (rel === undefined) return false;
deleteRel(rel);
return true;
};
/**
* Remove all nodes (and their relationships) belonging to a file.
* O(file-nodes × avg-edges-per-node) via the file index no full
* node-map scan.
*/
const removeNodesByFile = (filePath: string): number => {
let removed = 0;
for (const [nodeId, node] of nodeMap) {
if (node.properties?.filePath === filePath) {
removeNode(nodeId);
removed++;
}
}
return removed;
const nodeIds = nodeIdsByFile.get(filePath);
if (nodeIds === undefined) return 0;
// Snapshot before iterating — removeNode mutates nodeIdsByFile.
const snapshot = [...nodeIds];
for (const nodeId of snapshot) removeNode(nodeId);
return snapshot.length;
};
return {
@ -67,6 +152,10 @@ export const createKnowledgeGraph = (): KnowledgeGraph => {
iterNodes: () => nodeMap.values(),
iterRelationships: () => relationshipMap.values(),
iterRelationshipsByType: (type: RelationshipType) => {
const bucket = relationshipsByType.get(type);
return bucket === undefined ? emptyRelIter() : bucket.values();
},
forEachNode(fn: (node: GraphNode) => void) {
nodeMap.forEach(fn);
},

View file

@ -6,7 +6,7 @@
*
* This file only defines the CLI's KnowledgeGraph with mutation methods.
*/
import type { GraphNode, GraphRelationship } from 'gitnexus-shared';
import type { GraphNode, GraphRelationship, RelationshipType } from 'gitnexus-shared';
// CLI-specific: full KnowledgeGraph with mutation methods for incremental updates
export interface KnowledgeGraph {
@ -14,6 +14,17 @@ export interface KnowledgeGraph {
relationships: GraphRelationship[];
iterNodes: () => IterableIterator<GraphNode>;
iterRelationships: () => IterableIterator<GraphRelationship>;
/**
* Iterate ONLY relationships of the given type, backed by a per-type
* index maintained in `addRelationship` / `removeRelationship` /
* `removeNode` / `removeNodesByFile`. Returns an empty iterator when
* the graph contains no relationships of that type.
*
* Prefer this over `iterRelationships()` + per-edge type filtering
* for hot paths (MRO setup, heritage walks). Backwards-compatible:
* existing `iterRelationships()` callers keep working.
*/
iterRelationshipsByType: (type: RelationshipType) => IterableIterator<GraphRelationship>;
forEachNode: (fn: (node: GraphNode) => void) => void;
forEachRelationship: (fn: (rel: GraphRelationship) => void) => void;
getNode: (id: string) => GraphNode | undefined;

View file

@ -89,10 +89,25 @@ export function parseGroupConfig(yamlContent: string): GroupConfig {
};
}
export class GroupNotFoundError extends Error {
constructor(public readonly groupName: string) {
super(`Group "${groupName}" not found`);
this.name = 'GroupNotFoundError';
}
}
export async function loadGroupConfig(groupDir: string): Promise<GroupConfig> {
const fsp = await import('node:fs/promises');
const path = await import('node:path');
const yamlPath = path.join(groupDir, 'group.yaml');
const content = await fsp.readFile(yamlPath, 'utf-8');
let content: string;
try {
content = await fsp.readFile(yamlPath, 'utf-8');
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
throw new GroupNotFoundError(path.basename(groupDir));
}
throw err;
}
return parseGroupConfig(content);
}

View file

@ -15,7 +15,7 @@ import type {
OutOfScopeLink,
} from './types.js';
import type { GroupRepoHandle, GroupToolPort } from './service.js';
import { loadGroupConfig } from './config-parser.js';
import { GroupNotFoundError, loadGroupConfig } from './config-parser.js';
import {
fileMatchesServicePrefix,
normalizeServicePrefix,
@ -329,6 +329,8 @@ export async function runGroupImpact(
try {
config = await loadGroupConfig(groupDir);
} catch (e) {
if (e instanceof GroupNotFoundError)
return { error: `Group "${name}" not found. Run group_list to see configured groups.` };
return { error: e instanceof Error ? e.message : String(e) };
}
@ -344,9 +346,6 @@ export async function runGroupImpact(
minConfidence,
};
// Single shared deadline for Phase 1 (local walk) + Phase 2 (bridge fan-out).
// Phase 1 still gets the full budget; Phase 2 only uses whatever wall-clock
// time is left, so total work cannot exceed `timeoutMs`.
const deadline = Date.now() + Math.max(0, timeoutMs);
const { value: local, timedOut: localTimedOut } = await safeLocalImpact(
@ -357,7 +356,7 @@ export async function runGroupImpact(
);
if (localTimedOut) {
const base = local as Record<string, unknown>;
const _base = local as Record<string, unknown>;
return {
local,
group: name,
@ -380,24 +379,13 @@ export async function runGroupImpact(
const localObj = local as Record<string, unknown> | null;
if (localObj?.error && typeof localObj.error === 'string') {
const empty: GroupImpactResult = {
local,
group: name,
cross: [],
outOfScope: [],
truncated: false,
truncatedRepos: [],
summary: {
direct: 0,
processes_affected: 0,
modules_affected: 0,
cross_repo_hits: 0,
},
risk: 'UNKNOWN',
timeoutMs,
crossDepthWarning,
};
return empty;
// Fail closed: the local-impact phase errored (missing symbol, graph-load
// failure, thrown exception wrapped by safeLocalImpact, or port-returned
// `{ error }`). Do NOT wrap it into a zero-hit success payload — callers
// branch on top-level `error`, and a blast-radius tool reporting "no
// impact" on the failure path is a false negative on a safety-critical
// signal. Bubble the error so consumers treat it as a failure.
return { error: `Local impact failed for ${repoPath}: ${localObj.error}` };
}
if (servicePrefix) {
@ -475,7 +463,6 @@ export async function runGroupImpact(
continue;
}
if (!repoInSubgroup(n.neighborRepo, subgroup)) {
// CrossLink convention: consumer -> provider
outOfScope.push({
from: direction === 'upstream' ? n.neighborRepo : repoPath,
to: direction === 'upstream' ? repoPath : n.neighborRepo,

View file

@ -6,7 +6,7 @@
import fsp from 'node:fs/promises';
import path from 'node:path';
import { checkStaleness } from '../git-staleness.js';
import { loadGroupConfig } from './config-parser.js';
import { GroupNotFoundError, loadGroupConfig } from './config-parser.js';
import {
fileMatchesServicePrefix,
normalizeServicePrefix,
@ -221,7 +221,14 @@ export class GroupService {
return { groups };
}
const groupDir = getGroupDir(getDefaultGitnexusDir(), name);
const config = await loadGroupConfig(groupDir);
let config: GroupConfig;
try {
config = await loadGroupConfig(groupDir);
} catch (err) {
if (err instanceof GroupNotFoundError)
return { error: `Group "${name}" not found. Run group_list to see configured groups.` };
throw err;
}
return {
name: config.name,
description: config.description,
@ -234,7 +241,14 @@ export class GroupService {
const name = String(params.name ?? '').trim();
if (!name) return { error: 'name is required' };
const groupDir = getGroupDir(getDefaultGitnexusDir(), name);
const config = await loadGroupConfig(groupDir);
let config: GroupConfig;
try {
config = await loadGroupConfig(groupDir);
} catch (err) {
if (err instanceof GroupNotFoundError)
return { error: `Group "${name}" not found. Run group_list to see configured groups.` };
throw err;
}
const result = await syncGroup(config, {
groupDir,
exactOnly: Boolean(params.exactOnly),
@ -313,6 +327,14 @@ export class GroupService {
try {
config = await loadGroupConfig(groupDir);
} catch (e) {
if (e instanceof GroupNotFoundError)
return {
group: name,
target: target || uid,
service: servicePrefix,
error: `Group "${name}" not found. Run group_list to see configured groups.`,
results: [],
};
return {
group: name,
target: target || uid,
@ -326,9 +348,6 @@ export class GroupService {
repoInSubgroup(repoPath, subgroup, subgroupExact),
);
// Per-repo work is independent (each repo opens its own DB handle and the
// group-level result preserves repo iteration order via the indexed map).
// Errors are caught per repo so one slow/failed member does not block the rest.
const results: GroupContextResult['results'] = await Promise.all(
memberEntries.map(async ([repoPath, registryName]) => {
try {
@ -384,14 +403,19 @@ export class GroupService {
const subgroup = typeof params.subgroup === 'string' ? params.subgroup : undefined;
const subgroupExact = params.subgroupExact === true;
const groupDir = getGroupDir(getDefaultGitnexusDir(), name);
const config = await loadGroupConfig(groupDir);
let config: GroupConfig;
try {
config = await loadGroupConfig(groupDir);
} catch (err) {
if (err instanceof GroupNotFoundError)
return { error: `Group "${name}" not found. Run group_list to see configured groups.` };
throw err;
}
const memberEntries = Object.entries(config.repos).filter(([repoPath]) =>
repoInSubgroup(repoPath, subgroup, subgroupExact),
);
// Per-repo query is independent; run them concurrently and isolate
// failures so one slow/failed member does not block the rest.
const perRepo = await Promise.all(
memberEntries.map(async ([repoPath, registryName]) => {
try {
@ -436,7 +460,14 @@ export class GroupService {
const name = String(params.name ?? '').trim();
if (!name) return { error: 'name is required' };
const groupDir = getGroupDir(getDefaultGitnexusDir(), name);
const config = await loadGroupConfig(groupDir);
let config: GroupConfig;
try {
config = await loadGroupConfig(groupDir);
} catch (err) {
if (err instanceof GroupNotFoundError)
return { error: `Group "${name}" not found. Run group_list to see configured groups.` };
throw err;
}
const registry = await readContractRegistry(groupDir);
const repoStatuses: Record<

View file

@ -1,8 +1,24 @@
import { LRUCache } from 'lru-cache';
import Parser from 'tree-sitter';
/**
* Minimal structural shape consumers need when reading Trees back
* through a phase-dependency boundary. Declared here so phases that
* receive ASTCache via `getPhaseOutput<...>` don't hand-roll their
* own inline structural types that silently drift when ASTCache's
* contract changes.
*
* Typed as `unknown` at the Tree boundary because consumers on the
* other side of the phase-output map don't share tree-sitter's type
* graph (e.g. COBOL's standalone processor).
*/
export interface ASTCacheReader {
get(filePath: string): unknown;
clear(): void;
}
// Define the interface for the Cache
export interface ASTCache {
export interface ASTCache extends ASTCacheReader {
get: (filePath: string) => Parser.Tree | undefined;
set: (filePath: string, tree: Parser.Tree) => void;
clear: () => void;
@ -17,8 +33,20 @@ export const createASTCache = (maxSize: number = 50): ASTCache => {
max: effectiveMax,
dispose: (tree) => {
try {
// NOTE: web-tree-sitter has tree.delete(); native tree-sitter trees are GC-managed.
// Keep this try/catch so we don't crash on either runtime.
// NOTE: web-tree-sitter has tree.delete(); native tree-sitter
// trees are GC-managed and .delete is absent (no-op here).
//
// Single-owner invariant (load-bearing under WASM): a given
// Parser.Tree reference must live in AT MOST ONE ASTCache
// that disposes. The parse-phase chunk-local cache clears
// between chunks; the cross-phase `scopeTreeCache` (also an
// ASTCache today) holds the same Tree by reference. Under
// native tree-sitter this is benign (dispose is a no-op).
// If/when GitNexus adopts web-tree-sitter for sequential
// parsing, the cross-phase cache must either (a) skip
// writing Trees that are already owned by a disposing cache,
// or (b) use tree.copy() per entry. Failing to pick one
// will hand freed memory to scope-resolution.
(tree as unknown as { delete?: () => void }).delete?.();
} catch (e) {
console.warn('Failed to delete tree from WASM memory', e);

View file

@ -37,6 +37,7 @@ import { isLanguageAvailable, loadParser, loadLanguage } from '../tree-sitter/pa
import { getProvider } from './languages/index.js';
import { generateId } from '../../lib/utils.js';
import { getLanguageFromFilename, SupportedLanguages } from 'gitnexus-shared';
import { isRegistryPrimary } from './registry-primary-flag.js';
import { isVerboseIngestionEnabled } from './utils/verbose.js';
import { yieldToEventLoop } from './utils/event-loop.js';
import {
@ -750,6 +751,8 @@ export const processCalls = async (
const language = getLanguageFromFilename(file.path);
if (!language) continue;
// Registry-primary gate: scope-based phase owns CALLS for this lang.
if (isRegistryPrimary(language)) continue;
if (!isLanguageAvailable(language)) {
if (skippedByLang) {
skippedByLang.set(language, (skippedByLang.get(language) ?? 0) + 1);
@ -2731,6 +2734,11 @@ export const processCallsFromExtracted = async (
await yieldToEventLoop();
}
// Registry-primary gate: skip Python (etc.) entirely when the
// scope-based phase owns CALLS for this language.
const fileLanguage = getLanguageFromFilename(filePath);
if (fileLanguage && isRegistryPrimary(fileLanguage)) continue;
ctx.enableCache(filePath);
const widenCache: WidenCache = new Map();
const receiverMap = fileReceiverTypes.get(filePath);

View file

@ -1,4 +1,5 @@
import { isVerboseIngestionEnabled } from './utils/verbose.js';
import { DEFAULT_MAX_FILE_SIZE_BYTES, getMaxFileSizeBytes } from './utils/max-file-size.js';
import fs from 'fs/promises';
import path from 'path';
import { glob } from 'glob';
@ -22,9 +23,6 @@ export interface FilePath {
const READ_CONCURRENCY = 32;
/** Skip files larger than 512KB — they're usually generated/vendored and crash tree-sitter */
const MAX_FILE_SIZE = 512 * 1024;
/**
* Phase 1: Scan repository stat files to get paths + sizes, no content loaded.
* Memory: ~10MB for 100K files vs ~1GB+ with content.
@ -34,6 +32,7 @@ export const walkRepositoryPaths = async (
onProgress?: (current: number, total: number, filePath: string) => void,
): Promise<ScannedFile[]> => {
const ignoreFilter = await createIgnoreFilter(repoPath);
const maxFileSizeBytes = getMaxFileSizeBytes();
const filtered = await glob('**/*', {
cwd: repoPath,
@ -52,7 +51,7 @@ export const walkRepositoryPaths = async (
batch.map(async (relativePath) => {
const fullPath = path.join(repoPath, relativePath);
const stat = await fs.stat(fullPath);
if (stat.size > MAX_FILE_SIZE) {
if (stat.size > maxFileSizeBytes) {
skippedLarge++;
skippedLargePaths.push(relativePath.replace(/\\/g, '/'));
return null;
@ -73,9 +72,9 @@ export const walkRepositoryPaths = async (
}
if (skippedLarge > 0) {
console.warn(
` Skipped ${skippedLarge} large files (>${MAX_FILE_SIZE / 1024}KB, likely generated/vendored)`,
);
const isDefault = maxFileSizeBytes === DEFAULT_MAX_FILE_SIZE_BYTES;
const suffix = isDefault ? ', likely generated/vendored' : '';
console.warn(` Skipped ${skippedLarge} large files (>${maxFileSizeBytes / 1024}KB${suffix})`);
if (isVerboseIngestionEnabled()) {
for (const p of skippedLargePaths) {
console.warn(` - ${p}`);

View file

@ -25,6 +25,7 @@ import type {
import type { NamedBinding } from './named-bindings/types.js';
import type { SyntaxNode } from './utils/ast-helpers.js';
import { isDev } from './utils/env.js';
import { isRegistryPrimary } from './registry-primary-flag.js';
// Type: Map<FilePath, Set<ResolvedFilePath>>
// Stores all files that a given file imports from
@ -105,6 +106,8 @@ function createImportEdgeHelpers(graph: KnowledgeGraph, importMap: ImportMap) {
let totalImportsResolved = 0;
const addImportGraphEdge = (filePath: string, resolvedPath: string) => {
const language = getLanguageFromFilename(filePath);
if (language !== null && isRegistryPrimary(language)) return;
const sourceId = generateId('File', filePath);
const targetId = generateId('File', resolvedPath);
const relId = generateId('IMPORTS', `${filePath}->${resolvedPath}`);

View file

@ -53,11 +53,15 @@ export function resolvePythonImportInternal(
// Normalize for Windows backslashes
const importerDir = currentFile.replace(/\\/g, '/').split('/').slice(0, -1).join('/');
if (!importerDir) return null;
if (allFiles.has(`${importerDir}/${pathLike}/__init__.py`))
return `${importerDir}/${pathLike}/__init__.py`;
if (allFiles.has(`${importerDir}/${pathLike}.py`)) return `${importerDir}/${pathLike}.py`;
// Proximity check — only applies when the importer lives in a subdirectory.
// Root-level importers (importerDir === '') skip straight to the ancestor
// walk below, which handles the root case correctly (prefix becomes '').
if (importerDir) {
if (allFiles.has(`${importerDir}/${pathLike}/__init__.py`))
return `${importerDir}/${pathLike}/__init__.py`;
if (allFiles.has(`${importerDir}/${pathLike}.py`)) return `${importerDir}/${pathLike}.py`;
}
// Ancestor directory walk — Python resolves bare imports against sys.path entries,
// which typically includes the project root and package directories. Walk up from the

View file

@ -303,8 +303,9 @@ interface LanguageProviderConfig {
/**
* Emit scope captures from raw source, **pre-grouped per tree-sitter
* query match**. Tree-sitter-based providers run a `scopes.scm` query
* and emit one `CaptureMatch` per query match; standalone providers
* query match**. Tree-sitter-based providers run a scope query
* (embedded as a string constant in each language's `query.ts`) and
* emit one `CaptureMatch` per query match; standalone providers
* (COBOL) emit matches from a regex tagger. The return shape is
* parser-agnostic: the central `ScopeExtractor` consumes
* `CaptureMatch[]` without knowing which parser produced them.
@ -329,7 +330,21 @@ interface LanguageProviderConfig {
*
* Default: undefined (language continues to use legacy DAG).
*/
readonly emitScopeCaptures?: (sourceText: string, filePath: string) => readonly CaptureMatch[];
readonly emitScopeCaptures?: (
sourceText: string,
filePath: string,
/**
* Optional pre-parsed tree-sitter Tree the caller has already
* produced (e.g. from the parse phase's AST cache). When supplied,
* the provider SHOULD skip its own `parser.parse(sourceText)` and
* run its capture query against the supplied tree directly. Typed
* as `unknown` here to avoid leaking the tree-sitter dependency
* into the provider contract the provider casts at use site.
* Cache miss (parameter omitted or undefined) is always safe and
* MUST trigger a fresh parse.
*/
cachedTree?: unknown,
) => readonly CaptureMatch[];
/**
* Interpret a raw `@import.statement` capture group into a `ParsedImport`.
@ -372,19 +387,6 @@ interface LanguageProviderConfig {
*/
readonly resolveScopeKind?: (captures: CaptureMatch) => ScopeKind | null;
/**
* Should this scope capture materialize as a real `Scope` node? Return
* `false` to skip scope creation while still emitting declarations that
* would have gone inside (they attach to the enclosing real scope).
*
* Example: Python `if`/`for`/`while` bodies capture as `@scope.block` but
* Python has no block scope hook returns `false` and child declarations
* lift to the enclosing function/module.
*
* Default: undefined (treated as `true` always create).
*/
readonly shouldCreateScope?: (captures: CaptureMatch) => boolean;
/**
* Override where a declaration's name becomes visible. By default the name
* is bound in the innermost enclosing scope; return a different `ScopeId`
@ -496,19 +498,6 @@ interface LanguageProviderConfig {
// ── Resolution phase (RFC §4v2) ────────────────────────────────────
/**
* Does a binding at this scope shadow bindings of the same name in outer
* scopes? Default: any binding shadows (standard lexical scoping). Return
* `false` for transparent-scope edge cases (Python `from x import *`
* contexts, JS `var` hoisting quirks, COBOL PARAGRAPH transparency).
*
* Consulted by `Registry.lookup` Step 1 and by `resolveTypeRef` for
* shadowing decisions during the lexical chain walk.
*
* Default: undefined (treated as `true` any binding shadows).
*/
readonly shouldShadow?: (scope: Scope, bindings: readonly BindingRef[]) => boolean;
/**
* Is this callable definition compatible with the given call-site arity?
* Language-specific rules: Python `*args`/`**kwargs`/defaults, JS default

View file

@ -25,6 +25,17 @@ import { csharpMethodConfig } from '../method-extractors/configs/csharp.js';
import { createVariableExtractor } from '../variable-extractors/generic.js';
import { csharpVariableConfig } from '../variable-extractors/configs/csharp.js';
import { createHeritageExtractor } from '../heritage-extractors/generic.js';
import {
emitCsharpScopeCaptures,
interpretCsharpImport,
interpretCsharpTypeBinding,
csharpBindingScopeFor,
csharpImportOwningScope,
csharpMergeBindings,
csharpReceiverBinding,
csharpArityCompatibility,
resolveCsharpImportTarget,
} from './csharp/index.js';
const BUILT_INS: ReadonlySet<string> = new Set([
'Console',
@ -138,4 +149,18 @@ export const csharpProvider = defineLanguage({
classExtractor: createClassExtractor(csharpClassConfig),
heritageExtractor: createHeritageExtractor(SupportedLanguages.CSharp),
builtInNames: BUILT_INS,
// ── RFC #909 Ring 3: scope-based resolution hooks (RFC §5) ──────────
// C# is the second migration after Python. See ./csharp/index.ts for
// the full per-hook rationale and the canonical capture vocabulary
// in ./csharp/query.ts (CSHARP_SCOPE_QUERY constant).
emitScopeCaptures: emitCsharpScopeCaptures,
interpretImport: interpretCsharpImport,
interpretTypeBinding: interpretCsharpTypeBinding,
bindingScopeFor: csharpBindingScopeFor,
importOwningScope: csharpImportOwningScope,
mergeBindings: (_scope, bindings) => csharpMergeBindings(bindings),
receiverBinding: csharpReceiverBinding,
arityCompatibility: csharpArityCompatibility,
resolveImportTarget: resolveCsharpImportTarget,
});

View file

@ -0,0 +1,57 @@
/**
* C# collection-accessor unwrapping.
*
* When the compound-receiver resolver encounters a trailing
* `.Values` / `.Keys` on a dotted member-access chain, it calls the
* provider's `unwrapCollectionAccessor` hook to find the element
* type. This module supplies the C# implementation recognizing
* Dictionary-family generics and returning the value or key type.
*
* Other languages (Python, Java, TypeScript) use method-call syntax
* for the same access (`.values()` / `.keys()`), which the compound-
* receiver's call-expression branch already handles; they leave this
* hook undefined.
*/
/** Extract (K, V) from `Dictionary<K, V>` / `IDictionary<K, V>` /
* `IReadOnlyDictionary<K, V>` / `SortedDictionary<K, V>` /
* `ConcurrentDictionary<K, V>` / `ImmutableDictionary<K, V>`.
* Returns undefined if the type name doesn't match or the argument
* list isn't exactly two top-level args. */
function extractDictionaryArgs(rawName: string): { key: string; value: string } | undefined {
const match = rawName.match(
/^(?:[A-Za-z_][A-Za-z0-9_.]*\.)?(?:Dictionary|IDictionary|IReadOnlyDictionary|SortedDictionary|ConcurrentDictionary|ImmutableDictionary)<(.+)>$/,
);
if (match === null) return undefined;
const inner = match[1]!;
// Split on the top-level comma (tolerate nested `<...>`).
let depth = 0;
let commaIdx = -1;
for (let i = 0; i < inner.length; i++) {
const ch = inner[i];
if (ch === '<') depth++;
else if (ch === '>') depth--;
else if (ch === ',' && depth === 0) {
commaIdx = i;
break;
}
}
if (commaIdx === -1) return undefined;
return { key: inner.slice(0, commaIdx).trim(), value: inner.slice(commaIdx + 1).trim() };
}
/**
* Resolve `data.Values` / `data.Keys` on a Dictionary-like receiver
* to its element-type simple name. Returns `undefined` for any
* receiver / accessor combination we don't recognize, letting the
* compound-receiver pass fall through to the regular field walk.
*/
export function unwrapCsharpCollectionAccessor(
receiverType: string,
accessor: string,
): string | undefined {
if (accessor !== 'Values' && accessor !== 'Keys') return undefined;
const args = extractDictionaryArgs(receiverType);
if (args === undefined) return undefined;
return accessor === 'Values' ? args.value : args.key;
}

View file

@ -0,0 +1,54 @@
/**
* Extract C# arity metadata from a method-like tree-sitter node
* `method_declaration`, `constructor_declaration`, `destructor_declaration`,
* `operator_declaration`, `conversion_operator_declaration`, or
* `local_function_statement`.
*
* Reuses `csharpMethodConfig.extractParameters` so scope-extracted defs
* carry the same arity semantics as the legacy parse-worker path:
* - `params` variadic collapses `parameterCount` to `undefined`,
* which `csharpArityCompatibility` then treats as "max unknown"
* the candidate stays eligible at `argCount >= required`.
* - Defaulted parameters (`= expr`) contribute to `optionalCount`;
* `requiredParameterCount = total optionalCount`.
* - `parameterTypes` collects declared type names (with `ref`/`out`/
* `in` prefix) for overload narrowing; a literal `'params'` marker
* is appended for variadic methods so `csharpArityCompatibility`
* can detect them without re-reading the AST.
*/
import type { SyntaxNode } from '../../utils/ast-helpers.js';
import { csharpMethodConfig } from '../../method-extractors/configs/csharp.js';
interface CsharpArityMetadata {
readonly parameterCount: number | undefined;
readonly requiredParameterCount: number | undefined;
readonly parameterTypes: readonly string[] | undefined;
}
export function computeCsharpArityMetadata(fnNode: SyntaxNode): CsharpArityMetadata {
const params = csharpMethodConfig.extractParameters?.(fnNode) ?? [];
let hasVariadic = false;
let optionalCount = 0;
const types: string[] = [];
for (const p of params) {
if (p.isVariadic) hasVariadic = true;
else if (p.isOptional) optionalCount++;
if (p.type !== null) types.push(p.type);
}
if (hasVariadic) types.push('params');
const total = params.length;
// `params int[] args` declares one formal param but accepts any arg
// count ≥ required — mirror Python's treatment of `*args` and leave
// `parameterCount` undefined so the registry treats max as unknown.
const parameterCount = hasVariadic ? undefined : total;
const requiredParameterCount = hasVariadic ? undefined : total - optionalCount;
return {
parameterCount,
requiredParameterCount,
parameterTypes: types.length > 0 ? types : undefined,
};
}

View file

@ -0,0 +1,44 @@
/**
* C# arity check, accommodating `params` variadic and default parameters.
*
* The `def` metadata we care about (synthesized by `arity-metadata.ts`):
* - `parameterCount` total formal parameters; `undefined`
* when the method has `params T[]` variadic.
* - `requiredParameterCount` min required (excludes defaulted params
* and `params` variadic).
* - `parameterTypes` declared type strings; contains the
* literal `'params'` when the method is
* variadic.
*
* Verdicts:
* - `'compatible'` `requiredParameterCount <= argCount <= parameterCount`,
* OR the def takes `params` (then any `argCount >= required`).
* - `'incompatible'` argCount is below required, OR above max with no variadic.
* - `'unknown'` metadata is absent / incomplete.
*
* `'incompatible'` is a soft signal in `Registry.lookup` (penalized but
* still considered when no compatible candidate exists), per RFC §4.
*/
import type { Callsite, SymbolDefinition } from 'gitnexus-shared';
export function csharpArityCompatibility(
def: SymbolDefinition,
callsite: Callsite,
): 'compatible' | 'unknown' | 'incompatible' {
const max = def.parameterCount;
const min = def.requiredParameterCount;
if (max === undefined && min === undefined) return 'unknown';
const argCount = callsite.arity;
if (!Number.isFinite(argCount) || argCount < 0) return 'unknown';
const hasVarArgs =
def.parameterTypes !== undefined &&
def.parameterTypes.some((t) => t === 'params' || t.startsWith('params '));
if (min !== undefined && argCount < min) return 'incompatible';
if (max !== undefined && argCount > max && !hasVarArgs) return 'incompatible';
return 'compatible';
}

View file

@ -0,0 +1,30 @@
/**
* Dev-mode counters for the cross-phase scope-captures parse cache
* (C# mirror of `languages/python/cache-stats.ts`).
*
* Gated by `PROF_SCOPE_RESOLUTION=1`. Production builds fold every
* increment into dead code via the module-level `PROF` constant, so
* the hot path in `captures.ts` stays branch-free.
*/
const PROF = process.env.PROF_SCOPE_RESOLUTION === '1';
let CACHE_HITS = 0;
let CACHE_MISSES = 0;
export function recordCacheHit(): void {
if (PROF) CACHE_HITS++;
}
export function recordCacheMiss(): void {
if (PROF) CACHE_MISSES++;
}
export function getCsharpCaptureCacheStats(): { hits: number; misses: number } {
return { hits: CACHE_HITS, misses: CACHE_MISSES };
}
export function resetCsharpCaptureCacheStats(): void {
CACHE_HITS = 0;
CACHE_MISSES = 0;
}

View file

@ -0,0 +1,305 @@
/**
* `emitScopeCaptures` for C#.
*
* Drives the C# scope query against tree-sitter-c-sharp and groups raw
* matches into `CaptureMatch[]` for the central extractor. Layers one
* synthesized stream on top today:
*
* 1. **Decomposed using directives** each `using_directive` is
* re-emitted with `@import.kind/source/name/alias` markers so
* `interpretCsharpImport` can recover the ParsedImport shape
* without re-parsing raw text (see `import-decomposer.ts`).
*
* Receiver-binding synthesis (`this` / `base` type anchors) and arity
* metadata synthesis (Unit 5) layer on top later.
*
* Pure given the input source text. No I/O, no globals consulted.
*/
import type { Capture, CaptureMatch } from 'gitnexus-shared';
import { findNodeAtRange, nodeToCapture, syntheticCapture } from '../../utils/ast-helpers.js';
import { splitUsingDirective } from './import-decomposer.js';
import { computeCsharpArityMetadata } from './arity-metadata.js';
import { synthesizeCsharpReceiverBinding } from './receiver-binding.js';
import { getCsharpParser, getCsharpScopeQuery } from './query.js';
import { recordCacheHit, recordCacheMiss } from './cache-stats.js';
/** Declaration anchors that carry function-like arity metadata. */
const FUNCTION_DECL_TAGS = [
'@declaration.method',
'@declaration.constructor',
'@declaration.function',
] as const;
/** tree-sitter-c-sharp node types that the method extractor accepts. */
const FUNCTION_NODE_TYPES = [
'method_declaration',
'constructor_declaration',
'destructor_declaration',
'operator_declaration',
'conversion_operator_declaration',
'local_function_statement',
] as const;
export function emitCsharpScopeCaptures(
sourceText: string,
_filePath: string,
cachedTree?: unknown,
): readonly CaptureMatch[] {
// Skip the parse when the caller (parse phase's scopeTreeCache)
// already produced a Tree for this source. Cache miss = re-parse,
// same as before. The cachedTree parameter is typed as `unknown` at
// the LanguageProvider contract layer; cast here at the use site.
let tree = cachedTree as ReturnType<ReturnType<typeof getCsharpParser>['parse']> | undefined;
if (tree === undefined) {
tree = getCsharpParser().parse(sourceText);
recordCacheMiss();
} else {
recordCacheHit();
}
const rawMatches = getCsharpScopeQuery().matches(tree.rootNode);
const out: CaptureMatch[] = [];
for (const m of rawMatches) {
// Group captures by their tag name. Tree-sitter strips the leading
// `@`; we put it back so the central extractor's prefix lookups
// (`@scope.`, `@declaration.`, …) work.
const grouped: Record<string, Capture> = {};
for (const c of m.captures) {
const tag = '@' + c.name;
grouped[tag] = nodeToCapture(tag, c.node);
}
if (Object.keys(grouped).length === 0) continue;
// Decompose each `using_directive` so `interpretCsharpImport` sees
// the kind/source/name/alias markers it consumes. Raw query match
// only carries the @import.statement anchor.
if (grouped['@import.statement'] !== undefined) {
const stmtCapture = grouped['@import.statement'];
const stmtNode = findNodeAtRange(tree.rootNode, stmtCapture.range, 'using_directive');
if (stmtNode !== null) {
const decomposed = splitUsingDirective(stmtNode);
if (decomposed !== null) {
out.push(decomposed);
continue;
}
}
// Defensive fallback: emit the raw match so the extractor at
// least sees an anchor, even without markers.
out.push(grouped);
continue;
}
// Synthesize `this` / `base` receiver type-bindings on every
// instance method-like. Tree-sitter can't cleanly express "the
// implicit receiver of a non-static member of a class/struct/
// record/interface" via a static `.scm` pattern, so we walk up
// the AST in code. Mirrors Python's `self`/`cls` synthesis on
// `@scope.function` matches.
if (grouped['@scope.function'] !== undefined) {
out.push(grouped);
const anchor = grouped['@scope.function']!;
const fnNode = findFunctionNode(tree.rootNode, anchor.range);
if (fnNode !== null) {
for (const synth of synthesizeCsharpReceiverBinding(fnNode)) {
out.push(synth);
}
}
continue;
}
// Synthesize arity metadata on function-like declarations so the
// registry can narrow overloads (C# relies heavily on this). Mirrors
// Python's captures.ts pattern — one anchor per match, so we find
// the first tag that matches.
const declTag = FUNCTION_DECL_TAGS.find((t) => grouped[t] !== undefined);
if (declTag !== undefined) {
const anchor = grouped[declTag]!;
const fnNode = findFunctionNode(tree.rootNode, anchor.range);
if (fnNode !== null) {
const arity = computeCsharpArityMetadata(fnNode);
if (arity.parameterCount !== undefined) {
grouped['@declaration.parameter-count'] = syntheticCapture(
'@declaration.parameter-count',
fnNode,
String(arity.parameterCount),
);
}
if (arity.requiredParameterCount !== undefined) {
grouped['@declaration.required-parameter-count'] = syntheticCapture(
'@declaration.required-parameter-count',
fnNode,
String(arity.requiredParameterCount),
);
}
if (arity.parameterTypes !== undefined) {
grouped['@declaration.parameter-types'] = syntheticCapture(
'@declaration.parameter-types',
fnNode,
JSON.stringify(arity.parameterTypes),
);
}
}
}
// Synthesize `@reference.arity` on every callsite so the
// registry's arity filter can narrow overloads. Count the
// `argument` named children of the backing `argument_list`.
// Python doesn't synthesize this today; C# needs it because the
// language has method overloading and the suite asserts overload
// resolution.
const callTag = (
['@reference.call.free', '@reference.call.member', '@reference.call.constructor'] as const
).find((t) => grouped[t] !== undefined);
if (callTag !== undefined && grouped['@reference.arity'] === undefined) {
const anchor = grouped[callTag]!;
const callNode =
findNodeAtRange(tree.rootNode, anchor.range, 'invocation_expression') ??
findNodeAtRange(tree.rootNode, anchor.range, 'object_creation_expression');
if (callNode !== null) {
const argList = callNode.childForFieldName('arguments');
const args =
argList === null
? []
: argList.namedChildren.filter((c) => c !== null && c.type === 'argument');
grouped['@reference.arity'] = syntheticCapture(
'@reference.arity',
callNode,
String(args.length),
);
// Infer argument types from literal nodes so overload
// disambiguation can narrow same-arity candidates by param
// type. Non-literal arguments emit empty string to indicate
// "unknown" — consumers treat unknown as any-match.
const argTypes = args.map((arg) => inferArgType(arg!));
grouped['@reference.parameter-types'] = syntheticCapture(
'@reference.parameter-types',
callNode,
JSON.stringify(argTypes),
);
}
}
out.push(grouped);
// Synthesize primary-constructor declarations on class/record
// declarations that carry a `parameter_list` child (C# 12 syntax
// `public class User(string name, int age) { ... }` or
// `public record Person(string FirstName, string LastName)`).
// Legacy `csharpMethodConfig.extractPrimaryConstructor` runs via
// the parse phase; the scope-resolution path needs its own emit so
// `new User(...)` resolves to a Constructor def in memberByOwner.
if (
grouped['@declaration.class'] !== undefined ||
grouped['@declaration.record'] !== undefined
) {
const anchor = grouped['@declaration.class'] ?? grouped['@declaration.record']!;
const typeNode =
findNodeAtRange(tree.rootNode, anchor.range, 'class_declaration') ??
findNodeAtRange(tree.rootNode, anchor.range, 'record_declaration');
if (typeNode !== null) {
const synth = synthesizePrimaryConstructor(typeNode);
if (synth !== null) out.push(synth);
}
}
}
return out;
}
/** C# 12 primary constructor: `class X(a, b) { }` / `record X(a, b)`.
* The parameters are a bare `parameter_list` named child of the type
* declaration (no `constructor_declaration` node). Emit a synthetic
* @declaration.constructor match so the extractor creates a
* Constructor def in memberByOwner free-call-fallback's
* `pickConstructorOrClass` then targets it for `new X(...)` calls. */
function synthesizePrimaryConstructor(typeNode: SyntaxNode): CaptureMatch | null {
// Skip types with an explicit constructor_declaration — that would
// create duplicate defs.
const body = typeNode.childForFieldName('body');
if (body !== null) {
for (let i = 0; i < body.namedChildCount; i++) {
const child = body.namedChild(i);
if (child !== null && child.type === 'constructor_declaration') return null;
}
}
let paramList: SyntaxNode | null = null;
for (let i = 0; i < typeNode.namedChildCount; i++) {
const child = typeNode.namedChild(i);
if (child !== null && child.type === 'parameter_list') {
paramList = child;
break;
}
}
if (paramList === null) return null;
const nameNode = typeNode.childForFieldName('name');
if (nameNode === null) return null;
const paramCount = paramList.namedChildren.filter(
(c) => c !== null && c.type === 'parameter',
).length;
const m: Record<string, Capture> = {
'@declaration.constructor': nodeToCapture('@declaration.constructor', paramList),
'@declaration.name': syntheticCapture('@declaration.name', nameNode, nameNode.text),
'@declaration.parameter-count': syntheticCapture(
'@declaration.parameter-count',
paramList,
String(paramCount),
),
'@declaration.required-parameter-count': syntheticCapture(
'@declaration.required-parameter-count',
paramList,
String(paramCount),
),
};
return m;
}
type SyntaxNode = ReturnType<ReturnType<typeof getCsharpParser>['parse']>['rootNode'];
/** Infer a C# argument's static type from literal / constructor
* patterns. Returns `''` when the arg has no statically-derivable
* type (e.g. identifier would require full type inference). */
function inferArgType(argNode: SyntaxNode): string {
// `argument > expression` — tree-sitter-c-sharp wraps the value.
const expr = argNode.namedChild(0);
if (expr === null) return '';
switch (expr.type) {
case 'integer_literal':
return 'int';
case 'real_literal':
return 'double';
case 'string_literal':
case 'verbatim_string_literal':
case 'interpolated_string_expression':
case 'raw_string_literal':
return 'string';
case 'character_literal':
return 'char';
case 'boolean_literal':
return 'bool';
case 'null_literal':
return 'null';
case 'object_creation_expression': {
const typeNode = expr.childForFieldName('type');
return typeNode?.text ?? '';
}
default:
return '';
}
}
/** Find the first C# function-like node at the given range. The
* declaration anchor range covers the whole method/constructor/etc.
* node, but the tag alone doesn't tell us which node type. */
function findFunctionNode(rootNode: SyntaxNode, range: Capture['range']): SyntaxNode | null {
for (const nodeType of FUNCTION_NODE_TYPES) {
const n = findNodeAtRange(rootNode, range, nodeType);
if (n !== null) return n as SyntaxNode;
}
return null;
}

View file

@ -0,0 +1,115 @@
/**
* Decompose a C# `using_directive` into a `CaptureMatch` carrying the
* synthesized markers `@import.kind` / `@import.source` / `@import.name`
* / `@import.alias` that `interpretCsharpImport` consumes.
*
* Unlike Python's decomposer this is 1:1 each `using` produces exactly
* one import. The split layer exists to expose the kind (namespace vs
* alias vs static) without pushing raw-text parsing into `interpret.ts`.
*
* using System; namespace
* using System.Collections.Generic; namespace
* using Foo = System.Bar; alias
* using static System.Math; static
* global using System.IO; namespace (treated as file-scoped)
* using global::System.IO; namespace (global:: alias stripped)
*/
import type { Capture, CaptureMatch } from 'gitnexus-shared';
import { nodeToCapture, syntheticCapture, type SyntaxNode } from '../../utils/ast-helpers.js';
type ImportKind = 'namespace' | 'alias' | 'static';
interface ImportSpec {
readonly kind: ImportKind;
/** Full dotted path (generics stripped): `System.Collections.Generic`. */
readonly source: string;
/** Local binding name last source segment for namespace/static,
* the alias for alias imports. */
readonly name: string;
/** Present iff `kind === 'alias'`. */
readonly alias?: string;
/** Node to anchor the synthesized captures (range-wise). */
readonly atNode: SyntaxNode;
}
export function splitUsingDirective(stmtNode: SyntaxNode): CaptureMatch | null {
if (stmtNode.type !== 'using_directive') return null;
const spec = parseUsingDirective(stmtNode);
if (spec === null) return null;
return buildImportMatch(stmtNode, spec);
}
function parseUsingDirective(node: SyntaxNode): ImportSpec | null {
// tree-sitter-c-sharp's using_directive exposes named children
// corresponding to the parts of the directive but omits keyword tokens
// (`using`, `static`, `global`) from the named-child list. We inspect
// the raw source text to detect the flavor — the grammar doesn't give
// us a cleaner signal.
const raw = node.text;
// Named child layout:
// namespace form: [pathNode]
// alias form: [aliasIdNode, pathNode] (name field = aliasId)
// static form: [pathNode] (same as namespace)
// global using: [pathNode] (same as namespace)
const aliasField = node.childForFieldName('name');
const children = node.namedChildren;
if (children.length === 0) return null;
// Alias form — the `name:` field is the alias identifier; the
// remaining named child is the type/namespace path.
if (aliasField !== null) {
const pathNode = children.find((c) => c !== null && c.startIndex !== aliasField.startIndex) as
| SyntaxNode
| undefined;
if (pathNode === undefined) return null;
return {
kind: 'alias',
source: stripGenericArgs(unwrapGlobalAlias(pathNode.text)),
name: aliasField.text,
alias: aliasField.text,
atNode: node,
};
}
const pathNode = children[0];
if (pathNode === null) return null;
const source = stripGenericArgs(unwrapGlobalAlias(pathNode.text));
if (source === '') return null;
const lastSegment = source.split('.').pop() ?? source;
// `using static X.Y;` — detect by scanning the raw text before the path.
// `global using` behaves semantically as a file-scoped using for our
// purposes, so it isn't a separate kind here.
if (/^\s*(?:global\s+)?using\s+static\s/.test(raw)) {
return { kind: 'static', source, name: '*', atNode: node };
}
return { kind: 'namespace', source, name: lastSegment, atNode: node };
}
/** Strip `global::` prefix — `global::System.IO` → `System.IO`. */
function unwrapGlobalAlias(text: string): string {
return text.replace(/^global::/, '');
}
/** Strip generic type arguments — `Dictionary<string, int>` → `Dictionary`. */
function stripGenericArgs(text: string): string {
const lt = text.indexOf('<');
if (lt === -1) return text;
return text.slice(0, lt);
}
function buildImportMatch(stmtNode: SyntaxNode, spec: ImportSpec): CaptureMatch {
const m: Record<string, Capture> = {
'@import.statement': nodeToCapture('@import.statement', stmtNode),
'@import.kind': syntheticCapture('@import.kind', spec.atNode, spec.kind),
'@import.source': syntheticCapture('@import.source', spec.atNode, spec.source),
'@import.name': syntheticCapture('@import.name', spec.atNode, spec.name),
};
if (spec.alias !== undefined) {
m['@import.alias'] = syntheticCapture('@import.alias', spec.atNode, spec.alias);
}
return m;
}

View file

@ -0,0 +1,130 @@
/**
* Adapter from `(ParsedImport, WorkspaceIndex)` concrete file path.
*
* Unit 2 shape: suffix-match against the repo's `.cs` files. Each
* `using System.Collections.Generic;` could legally expand to multiple
* files (every `.cs` that declares `namespace System.Collections.Generic`
* partial classes, assembly-wide namespaces). The scope-resolver
* contract returns a single primary target, so we pick the first
* match. Cross-file partial-class aggregation runs at graph-bridge
* time (Unit 6) via `populateOwners`.
*
* The legacy csproj-based `resolveCSharpImportInternal` needs config
* objects the scope-resolver doesn't carry; the Unit 7 parity gate
* will surface cases where the suffix-match diverges from the
* namespace-based resolver and we'll adjust the contract if needed.
*
* Returning `null` lets the finalize algorithm mark the edge as
* `linkStatus: 'unresolved'`.
*/
import type { ParsedImport, WorkspaceIndex } from 'gitnexus-shared';
export interface CsharpResolveContext {
readonly fromFile: string;
readonly allFilePaths: ReadonlySet<string>;
}
export function resolveCsharpImportTarget(
parsedImport: ParsedImport,
workspaceIndex: WorkspaceIndex,
): string | null {
// WorkspaceIndex is `unknown` in the shared contract (Ring 1
// placeholder). The scope-resolution orchestrator hands us a
// CsharpResolveContext-shaped object; narrow structurally rather
// than via a cast chain so unexpected shapes return null cleanly.
const ctx = workspaceIndex as CsharpResolveContext | undefined;
if (
ctx === undefined ||
typeof (ctx as { fromFile?: unknown }).fromFile !== 'string' ||
!((ctx as { allFilePaths?: unknown }).allFilePaths instanceof Set)
) {
return null;
}
if (parsedImport.kind === 'dynamic-unresolved') return null;
if (parsedImport.targetRaw === null || parsedImport.targetRaw === '') return null;
// Namespace path: `System.Collections.Generic` → `System/Collections/Generic`.
const pathLike = parsedImport.targetRaw.replace(/\./g, '/');
const suffix = `/${pathLike}`;
// Exact file match: `System/Collections/Generic.cs` (rare but legal).
// Suffix match for nested layouts: `src/lib/System/Collections/Generic.cs`.
// Directory match: first `.cs` file directly inside the namespace dir
// (e.g. `System/Collections/Generic/List.cs` matches namespace Generic).
let exactFile: string | null = null;
let suffixFile: string | null = null;
let directoryChild: string | null = null;
const dirPrefix = `${pathLike}/`;
const suffixDirPrefix = `/${dirPrefix}`;
for (const raw of ctx.allFilePaths) {
const f = raw.replace(/\\/g, '/');
if (!f.endsWith('.cs')) continue;
if (f === `${pathLike}.cs`) {
exactFile = raw;
break;
}
if (suffixFile === null && f.endsWith(`${suffix}.cs`)) {
suffixFile = raw;
}
if (directoryChild === null) {
// Namespace-to-directory match: pick the first `.cs` directly in
// the namespace dir (not nested deeper). Legacy resolver emits
// all of them; we take one so the scope-resolver contract stays
// single-target.
const atRoot = f.startsWith(dirPrefix);
const atNested = f.includes(suffixDirPrefix);
if (atRoot || atNested) {
const idx = atRoot ? 0 : f.indexOf(suffixDirPrefix) + 1;
const after = f.slice(idx + dirPrefix.length);
if (after.length > 0 && !after.includes('/')) {
directoryChild = raw;
}
}
}
}
if (exactFile !== null) return exactFile;
if (suffixFile !== null) return suffixFile;
if (directoryChild !== null) return directoryChild;
// Progressive prefix stripping — mirrors csproj's root-namespace
// mapping without the csproj. `using CrossFile.Models;` in a repo
// laid out `Models/User.cs` (no `CrossFile/` prefix) works because
// the legacy resolver consults csproj; the scope-resolver layer
// doesn't have csproj, so we try each suffix of the namespace path
// against `.cs` files and directories.
//
// Also handles `using static CrossFile.Models.UserFactory;` —
// strip the leading segment, try `Models/UserFactory.cs`; strip
// two, try `UserFactory.cs`.
const segments = pathLike.split('/').filter(Boolean);
for (let skip = 1; skip < segments.length; skip++) {
const tail = segments.slice(skip).join('/');
if (tail === '') continue;
const tailFile = `${tail}.cs`;
const tailSuffix = `/${tailFile}`;
const tailDir = `${tail}/`;
const tailSuffixDir = `/${tailDir}`;
let tailDirectChild: string | null = null;
for (const raw of ctx.allFilePaths) {
const f = raw.replace(/\\/g, '/');
if (!f.endsWith('.cs')) continue;
if (f === tailFile) return raw;
if (f.endsWith(tailSuffix)) return raw;
if (tailDirectChild === null) {
const atRoot = f.startsWith(tailDir);
const atNested = f.includes(tailSuffixDir);
if (atRoot || atNested) {
const idx = atRoot ? 0 : f.indexOf(tailSuffixDir) + 1;
const after = f.slice(idx + tailDir.length);
if (after.length > 0 && !after.includes('/')) tailDirectChild = raw;
}
}
}
if (tailDirectChild !== null) return tailDirectChild;
}
return null;
}

View file

@ -0,0 +1,87 @@
/**
* C# scope-resolution hooks (RFC #909 Ring 3, RFC §5).
*
* Public API barrel. Consumers should import from this file rather than
* the individual modules.
*
* Module layout (each file is a single concern):
*
* - `query.ts` tree-sitter query + lazy parser/query singletons
* - `captures.ts` `emitCsharpScopeCaptures` orchestrator
* - `import-decomposer.ts` each `using` ParsedImport-shaped captures
* - `interpret.ts` capture-match `ParsedImport` / `ParsedTypeBinding`
* - `simple-hooks.ts` small/no-op hooks made explicit
* - `receiver-binding.ts` synthesize `this`/`base` type-bindings on
* instance-method entry
* - `merge-bindings.ts` C# `using` precedence
* - `arity.ts` C# arity compatibility (`params`, default values)
* - `arity-metadata.ts` synthesize arity metadata from declarations
* - `accessor-unwrap.ts` `.Values` / `.Keys` receiver-type unwrap for
* `Dictionary<K,V>` chains
* - `namespace-siblings.ts` AST-driven cross-file implicit-namespace
* visibility (file/namespace attribution, no
* regex; reuses orchestrator's treeCache)
* - `import-target.ts` `(ParsedImport, WorkspaceIndex) → file path` adapter
* - `scope-resolver.ts` `ScopeResolver` registered in `SCOPE_RESOLVERS`
* - `cache-stats.ts` PROF_SCOPE_RESOLUTION cache hit/miss counters
*
* ## Known limitations
*
* The C# registry-primary path intentionally does NOT resolve the
* following. Each is a conscious trade-off at migration time.
*
* 1. **csproj-driven namespace resolution** the legacy path
* consults `csharpConfigs` (the parsed .csproj workspace) to map
* `using X.Y;` back to the exact files declaring `namespace X.Y`.
* The scope-resolver contract passes only `allFilePaths`, so we
* fall back to suffix matching on `.cs` files. Unit 7's parity
* gate flags any divergence.
* 2. **Multi-file namespace expansion** a single `using X.Y;` in
* the legacy path can emit multiple IMPORTS edges (every file
* declaring that namespace). The scope-resolver contract returns
* a single target, so we pick the first match; partial-class
* aggregation runs at graph-bridge time.
* 3. **Overload resolution by parameter type** arity narrowing is
* wired (`arity.ts` + `arity-metadata.ts`), but type-based
* disambiguation (`F(int)` vs `F(string)` at a call with a typed
* argument) is left to the registry's type-binding layer.
* 4. **Generic type parameter resolution** `List<User>` binds the
* bound name to `User` via the single-arg-generic stripper;
* nested generics (`Dictionary<K, List<V>>`) fall through the
* receiver-type heuristic.
* 5. **`dynamic` typed expressions** runtime dispatch through
* `dynamic` is not followed.
* 6. **Preprocessor-conditional code** `#if DEBUG` blocks parse
* as usual; branch selection is ignored, so both arms contribute
* bindings.
* 7. **Global using propagation across files** treated as a
* file-scoped using for the declaring file. Unit 7 parity gate
* will flag cases where this matters.
* 8. **Expression-bodied `=>` members** handled by the method
* extractor, but receiver synthesis for `=> this.Field` shortcuts
* follows the same path as block-bodied methods.
* 9. **Multi-namespace file attribution** when a single file
* declares two namespaces (rare), all top-level classes are
* attributed to the first declared namespace via a `first-wins`
* rule in `namespace-siblings.ts`. Namespace detection itself is
* AST-driven (tree-sitter), so `global using static`, aliased
* `using static X = Y.Z;`, attributes, and preprocessor-gated
* declarations are all recognized correctly.
*
* Shadow-harness corpus parity is the authoritative signal for which
* of these matter in practice. The CI parity gate blocks any PR that
* regresses either the legacy or registry-primary run of
* `test/integration/resolvers/csharp.test.ts`.
*/
export { emitCsharpScopeCaptures } from './captures.js';
export { getCsharpCaptureCacheStats, resetCsharpCaptureCacheStats } from './cache-stats.js';
export { interpretCsharpImport, interpretCsharpTypeBinding } from './interpret.js';
export { csharpMergeBindings } from './merge-bindings.js';
export { csharpArityCompatibility } from './arity.js';
export { resolveCsharpImportTarget, type CsharpResolveContext } from './import-target.js';
export {
csharpBindingScopeFor,
csharpImportOwningScope,
csharpReceiverBinding,
} from './simple-hooks.js';

View file

@ -0,0 +1,137 @@
/**
* Capture-match semantic-shape interpreters for C#.
*
* - `interpretCsharpImport` `ParsedImport`
* - `interpretCsharpTypeBinding` `ParsedTypeBinding`
*
* The using-directive matches arrive pre-decomposed by
* `emitCsharpScopeCaptures` (one import per match, with synthesized
* `@import.kind/source/name/alias` markers). Type-binding matches arrive
* from the raw query captures each `@type-binding.*` anchor carries
* `@type-binding.name` + `@type-binding.type`.
*/
import type { CaptureMatch, ParsedImport, ParsedTypeBinding, TypeRef } from 'gitnexus-shared';
// ─── interpretImport ──────────────────────────────────────────────────────
export function interpretCsharpImport(captures: CaptureMatch): ParsedImport | null {
const kindCap = captures['@import.kind'];
const sourceCap = captures['@import.source'];
const nameCap = captures['@import.name'];
const aliasCap = captures['@import.alias'];
const kind = kindCap?.text;
if (kind === undefined || sourceCap === undefined) return null;
switch (kind) {
case 'namespace': {
// `using System;` / `using System.Collections.Generic;`
// Bind the last segment as the local name so `Generic.Foo`-style
// qualifier references resolve. Full path is the resolution target.
return {
kind: 'namespace',
localName: nameCap?.text ?? sourceCap.text.split('.').pop() ?? sourceCap.text,
importedName: sourceCap.text,
targetRaw: sourceCap.text,
};
}
case 'alias': {
// `using Dict = System.Collections.Generic.Dictionary<string, int>;`
// The decomposer already stripped generic args from source.
if (aliasCap === undefined) return null;
const importedName = sourceCap.text.split('.').pop() ?? sourceCap.text;
return {
kind: 'alias',
localName: aliasCap.text,
importedName,
alias: aliasCap.text,
targetRaw: sourceCap.text,
};
}
case 'static': {
// `using static System.Math;` — brings static members of Math into
// unqualified scope. Semantically closest to a wildcard, but we
// map to `namespace` here so finalize emits the File→File IMPORTS
// edge without requiring `expandsWildcardTo` (which would list
// every exported member). Static-member unqualified-access is a
// deferred limitation; the usual cross-file lookup via
// namespace-siblings covers `Target.Member` calls.
const lastSegment = sourceCap.text.split('.').pop() ?? sourceCap.text;
return {
kind: 'namespace',
localName: lastSegment,
importedName: sourceCap.text,
targetRaw: sourceCap.text,
};
}
default:
return null;
}
}
// ─── interpretTypeBinding ─────────────────────────────────────────────────
export function interpretCsharpTypeBinding(captures: CaptureMatch): ParsedTypeBinding | null {
const nameCap = captures['@type-binding.name'];
const typeCap = captures['@type-binding.type'];
if (nameCap === undefined || typeCap === undefined) return null;
// Strip nullable suffix (`User?` → `User`), single-arg generic wrapper
// (`List<User>` → `User`), and qualifier (`System.User` → `User`) so
// receiver-typed resolution treats these identically.
const rawType = stripQualifier(stripGeneric(stripNullable(typeCap.text.trim())));
// Anchor captures distinguish the source of the binding. Order
// matters: more-specific anchors take precedence.
let source: TypeRef['source'] = 'parameter-annotation';
if (captures['@type-binding.self'] !== undefined) source = 'self';
else if (captures['@type-binding.constructor'] !== undefined) source = 'constructor-inferred';
else if (captures['@type-binding.annotation'] !== undefined) source = 'annotation';
else if (captures['@type-binding.alias'] !== undefined) source = 'assignment-inferred';
else if (captures['@type-binding.return'] !== undefined) source = 'return-annotation';
return { boundName: nameCap.text, rawTypeName: rawType, source };
}
/** Member accesses we want to preserve through qualifier stripping.
* Dictionary/collection views (`data.Values`, `data.Keys`) survive
* so the compound-receiver pass can unwrap the receiver's generic
* type (Dictionary<K,V>) based on the suffix. */
const COLLECTION_ACCESSOR_SUFFIXES = new Set(['Values', 'Keys']);
/** `User?` → `User`. */
function stripNullable(text: string): string {
if (text.endsWith('?')) return text.slice(0, -1).trim();
return text;
}
/**
* Unwrap a single-arg generic collection wrapper `List<User>`,
* `IEnumerable<User>`, `Task<User>` to its element type. Mirrors
* Python's `stripGeneric` behavior so for-loop and chain propagation
* work on the element type.
*
* Multi-arg generics (`Dictionary<string, User>`, `Func<int, User>`)
* are left alone element semantics aren't unambiguous.
*/
function stripGeneric(text: string): string {
const single = text.match(
/^(?:[A-Za-z_][A-Za-z0-9_.]*\.)?(?:List|IList|IEnumerable|ICollection|IReadOnlyList|IReadOnlyCollection|HashSet|ISet|Task|ValueTask|Nullable|IAsyncEnumerable)<([^,<>]+)>$/,
);
if (single !== null) return single[1].trim();
return text;
}
/** `System.Collections.User` `User`. Preserves dotted paths whose
* final segment is a known Dictionary/collection accessor (`.Values`,
* `.Keys`, `.Count`, etc.) so downstream resolvers can unwrap the
* receiver's generic type based on the suffix `data.Values`
* element type of `data`'s Dictionary<K,V>. */
function stripQualifier(text: string): string {
const lastDot = text.lastIndexOf('.');
if (lastDot === -1) return text;
const tail = text.slice(lastDot + 1);
if (COLLECTION_ACCESSOR_SUFFIXES.has(tail)) return text;
return tail;
}

View file

@ -0,0 +1,59 @@
/**
* C# shadowing precedence for the `mergeBindings` hook.
*
* Tier ranking (lower wins in shadowing):
*
* - 0: `local` a class member, method, local variable, or parameter
* declared in this scope.
* - 1: `import` / `namespace` / `reexport` `using System;`,
* `using System.Collections.Generic;`, `using Alias = Foo;`.
* All three using flavors that introduce a name at this scope
* tier together; the compiler resolves ambiguity by requiring
* an explicit qualifier when two `using`s collide, but for
* receiver-typed dispatch we treat them as equivalent tiers.
* - 2: `wildcard` `using static System.Math;` brings static
* members in; any local or `using` with the same simple name
* shadows.
*
* Explicit interface implementations (`void IFoo.Bar() { }`) bind under
* the qualified name in the extractor layer, so they never collide with
* a plain `Bar` at this layer.
*
* Within a surviving tier we de-dup by `DefId`, last-write-wins so a
* `using` re-declared further down the file cleanly replaces the
* earlier binding.
*/
import type { BindingRef } from 'gitnexus-shared';
const TIER_LOCAL = 0;
const TIER_IMPORT = 1;
const TIER_WILDCARD = 2;
const TIER_UNKNOWN = 3;
function tierOf(b: BindingRef): number {
switch (b.origin) {
case 'local':
return TIER_LOCAL;
case 'reexport':
case 'import':
case 'namespace':
return TIER_IMPORT;
case 'wildcard':
return TIER_WILDCARD;
default:
return TIER_UNKNOWN;
}
}
export function csharpMergeBindings(bindings: readonly BindingRef[]): readonly BindingRef[] {
if (bindings.length === 0) return bindings;
let bestTier = Number.POSITIVE_INFINITY;
for (const b of bindings) bestTier = Math.min(bestTier, tierOf(b));
const survivors = bindings.filter((b) => tierOf(b) === bestTier);
const seen = new Map<string, BindingRef>();
for (const b of survivors) seen.set(b.def.nodeId, b);
return [...seen.values()];
}

View file

@ -0,0 +1,401 @@
/**
* C# same-namespace cross-file visibility.
*
* C# makes every type declared in `namespace X` visible to every other
* file that also declares `namespace X`, without any explicit `using`
* directive. Python has no equivalent every cross-file reference
* needs an explicit import so this is a C#-specific pass.
*
* Without this: `Service.cs` (namespace `FieldTypes`) can't see
* `User` declared in `Models.cs` (same namespace), so `user.Address`
* field-chain resolution fails at `findClassBindingInScope('User')`
* in the Service.cs scope chain.
*
* Implementation: after the finalize pass populates `indexes.bindings`
* (from explicit `using` directives), walk each file's tree-sitter
* AST for `namespace_declaration` / `file_scoped_namespace_declaration`
* and `using_directive` nodes. The orchestrator hands us its
* `treeCache` so files already parsed by `extractParsedFile` are
* re-used instead of re-parsed `ParsedFile`'s underlying tree is
* the single source of truth. Group classes by namespace, and inject
* cross-file sibling classes into each Namespace scope's finalized
* bindings with `origin: 'namespace'` a tier below `local` so a
* local declaration still shadows a cross-file sibling with the same
* name.
*
* The tree-sitter walk is authoritative: it sees `global using static`,
* aliased `using static X = Y.Z;`, attributed namespace declarations,
* and preprocessor-guarded declarations correctly because the
* tree-sitter grammar parses them as real nodes (not textual
* coincidences).
*/
import type { SyntaxNode } from 'tree-sitter';
import type { BindingRef, ParsedFile, Scope, ScopeId, SymbolDefinition } from 'gitnexus-shared';
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
import { getCsharpParser } from './query.js';
interface CsharpFileStructure {
/** Declared namespace names in file source order. Empty array means
* the file has no `namespace X;` / `namespace X { }` declaration
* and sits in the default (global) namespace. */
readonly namespaces: readonly string[];
/** Dotted paths from `using static X.Y.Z;` (including
* `global using static` and aliased `using static A = X.Y.Z;`). */
readonly usingStaticPaths: readonly string[];
}
/** Build a structural view of a C# file by walking the tree-sitter
* AST. Prefers `cachedTree` (handed in via `treeCache`) so we don't
* re-parse files the orchestrator already parsed for `extractParsedFile`;
* falls back to a fresh parse on cache miss. Parser singleton is
* shared across calls. */
function extractFileStructure(content: string, cachedTree: unknown): CsharpFileStructure {
type CsharpTree = ReturnType<ReturnType<typeof getCsharpParser>['parse']>;
const tree = (cachedTree as CsharpTree | undefined) ?? getCsharpParser().parse(content);
const namespaces: string[] = [];
const usingStaticPaths: string[] = [];
const visit = (node: SyntaxNode): void => {
if (
node.type === 'namespace_declaration' ||
node.type === 'file_scoped_namespace_declaration'
) {
const nameNode = node.childForFieldName('name');
if (nameNode !== null) namespaces.push(nameNode.text);
} else if (node.type === 'using_directive') {
// Inspect the directive's own text for the `static` keyword
// (tree-sitter-c-sharp does not expose it as a named child).
// This is a single-node-scoped text inspection, not a whole-file
// regex, so it stays well within AST semantics.
if (/^\s*(?:global\s+)?using\s+static\s/.test(node.text)) {
// Path lives on the `name:` field when the using-directive is
// aliased (`using static A = X.Y.Z;`); otherwise it's the
// first named child.
const aliasField = node.childForFieldName('name');
let pathNode: SyntaxNode | null = null;
if (aliasField !== null) {
for (const c of node.namedChildren) {
if (c !== null && c.startIndex !== aliasField.startIndex) {
pathNode = c;
break;
}
}
} else {
pathNode = node.namedChildren[0] ?? null;
}
if (pathNode !== null) usingStaticPaths.push(pathNode.text);
}
}
for (const child of node.namedChildren) {
if (child !== null) visit(child);
}
};
visit(tree.rootNode);
return { namespaces, usingStaticPaths };
}
/** Content + (optional) pre-parsed tree-sitter trees keyed by filePath.
* The orchestrator builds `fileContents` from the pipeline's file list;
* `treeCache` is the same `scopeTreeCache` already populated by the
* parse phase, so cache hits avoid a second `parser.parse()`. */
export interface CsharpSiblingInputs {
readonly fileContents: ReadonlyMap<string, string>;
readonly treeCache?: { get(filePath: string): unknown };
}
/**
* Mutate `indexes.bindings` in-place, adding cross-file sibling class
* defs to each Namespace scope. Class-like defs (Class / Interface /
* Struct / Record / Enum) are visible cross-file; method / field
* members are not.
*/
export function populateCsharpNamespaceSiblings(
parsedFiles: readonly ParsedFile[],
indexes: ScopeResolutionIndexes,
inputs: CsharpSiblingInputs,
): void {
// Build a structural view (namespaces + using-static paths) per
// file once up-front. Reuses the orchestrator's `treeCache` so
// files already parsed by `extractParsedFile` don't get re-parsed
// here — single-source-of-truth for the AST.
const structureByFile = new Map<string, CsharpFileStructure>();
for (const parsed of parsedFiles) {
const content = inputs.fileContents.get(parsed.filePath);
if (content === undefined) continue;
const cachedTree = inputs.treeCache?.get(parsed.filePath);
structureByFile.set(parsed.filePath, extractFileStructure(content, cachedTree));
}
// Group namespace scopes by their dotted name. Each entry carries
// the scope id so we can inject bindings post-hoc, plus the
// file's own class-like defs for cross-pollination.
interface NamespaceBucket {
readonly scopes: { filePath: string; scopeId: ScopeId; scope: Scope }[];
readonly classDefs: SymbolDefinition[];
}
const buckets = new Map<string, NamespaceBucket>();
const getBucket = (name: string): NamespaceBucket => {
let b = buckets.get(name);
if (b === undefined) {
b = { scopes: [], classDefs: [] };
buckets.set(name, b);
}
return b;
};
for (const parsed of parsedFiles) {
const struct = structureByFile.get(parsed.filePath);
if (struct === undefined) continue;
// Declared namespace names, source order (AST walk visits children
// left-to-right, matching the scope-extractor's ordering).
const names = struct.namespaces.length > 0 ? [...struct.namespaces] : [''];
const namespaceScopes = parsed.scopes.filter((s) => s.kind === 'Namespace');
// With file-scoped namespaces (`namespace X;`), the Namespace
// scope's range covers only the declaration line, not the rest of
// the file — so classes below it land under the Module scope, not
// the Namespace scope. Group top-level classes by "any class whose
// parent scope is Module or Namespace" and attribute them to the
// first declared namespace in the file. Multiple-namespace files
// are rare enough that first-wins is the right first pass; fix
// when the parity suite surfaces a case.
const moduleScope = parsed.scopes.find((s) => s.kind === 'Module');
const topLevelParentIds = new Set<ScopeId>();
if (moduleScope !== undefined) topLevelParentIds.add(moduleScope.id);
for (const ns of namespaceScopes) topLevelParentIds.add(ns.id);
// Attribute all top-level classes to the first-declared namespace
// in this file. Multiple-namespace files are rare and can be
// addressed if the parity suite surfaces a case. Inject into BOTH
// the Module and the Namespace scopes — the Module scope is on
// the ancestor chain of every function body (the Namespace scope
// is not, because file-scoped `namespace X;` has a 1-line range).
const firstName = names[0]!;
const bucket = getBucket(firstName);
if (moduleScope !== undefined) {
bucket.scopes.push({
filePath: parsed.filePath,
scopeId: moduleScope.id,
scope: moduleScope,
});
}
for (const ns of namespaceScopes) {
bucket.scopes.push({ filePath: parsed.filePath, scopeId: ns.id, scope: ns });
}
for (const s of parsed.scopes) {
if (s.kind !== 'Class') continue;
if (s.parent === null || !topLevelParentIds.has(s.parent)) continue;
for (const def of s.ownedDefs) {
if (isTypeDef(def)) {
bucket.classDefs.push(def);
break;
}
}
}
}
// Inject cross-file siblings into each namespace scope's finalized
// bindings. `indexes.bindings` is typed `ReadonlyMap<ScopeId, ...>`
// but is a plain Map at runtime; mutating here is the established
// pattern (see `propagateImportedReturnTypes` which does the same
// for module-scope typeBindings).
const finalized = indexes.bindings as Map<ScopeId, Map<string, BindingRef[]>>;
// Cross-namespace type-binding propagation: for each file, mirror
// method return-type bindings from same-namespace sibling files and
// from files in namespaces the importer `using`s, into the
// importer's Module scope typeBindings. This enables
// chain-follow from `var u = svc.GetUser()` → `GetUser → User`
// even across files — without it the chain stalls at `GetUser`
// because the return binding lives in the defining file's Module
// scope, which isn't an ancestor of the importer's scope chain.
for (const parsed of parsedFiles) {
const moduleScope = parsed.scopes.find((s) => s.kind === 'Module');
if (moduleScope === undefined) continue;
const moduleTypeBindings = moduleScope.typeBindings as Map<
string,
import('gitnexus-shared').TypeRef
>;
// Accessible namespaces = this file's own namespaces + every
// `using namespace X;` target. Source of truth is the cached AST
// structure captured above.
const accessibleNamespaces = new Set<string>();
const struct = structureByFile.get(parsed.filePath);
if (struct !== undefined) {
for (const n of struct.namespaces) accessibleNamespaces.add(n);
}
if (accessibleNamespaces.size === 0) accessibleNamespaces.add('');
for (const imp of parsed.parsedImports) {
if (imp.kind === 'namespace' && imp.targetRaw !== null) {
accessibleNamespaces.add(imp.targetRaw);
}
}
// For each accessible namespace, also walk up the dotted path —
// `using static X.Y.Z;` targets a type, so the real namespace is
// `X.Y`. Both parse into `accessibleNamespaces` as-is; we probe
// the bucket map with every prefix.
const expandedNamespaces = new Set<string>(accessibleNamespaces);
for (const ns of accessibleNamespaces) {
const segments = ns.split('.');
for (let i = segments.length - 1; i > 0; i--) {
expandedNamespaces.add(segments.slice(0, i).join('.'));
}
}
for (const nsName of expandedNamespaces) {
const bucket = buckets.get(nsName);
if (bucket === undefined) continue;
for (const scopeInfo of bucket.scopes) {
if (scopeInfo.filePath === parsed.filePath) continue;
if (scopeInfo.scope.kind !== 'Module') continue;
for (const [boundName, typeRef] of scopeInfo.scope.typeBindings) {
if (moduleTypeBindings.has(boundName)) continue;
moduleTypeBindings.set(boundName, typeRef);
}
}
}
}
// `using static X.Y.Z;` — expose every public static method of
// class Z as a free-callable binding in the importer's module
// scope, so `Record(...)` (without `Logger.` qualifier) resolves
// to `Logger.Record`. AST walk above captured these (including
// `global using static` and aliased forms).
for (const parsed of parsedFiles) {
const struct = structureByFile.get(parsed.filePath);
if (struct === undefined) continue;
const moduleScope = parsed.scopes.find((s) => s.kind === 'Module');
if (moduleScope === undefined) continue;
for (const fullPath of struct.usingStaticPaths) {
const lastDot = fullPath.lastIndexOf('.');
if (lastDot === -1) continue;
const className = fullPath.slice(lastDot + 1);
const enclosingNs = fullPath.slice(0, lastDot);
// Find the target class in the named namespace bucket.
const bucket = buckets.get(enclosingNs);
if (bucket === undefined) continue;
const targetDef = bucket.classDefs.find((d) => {
const q = d.qualifiedName ?? '';
const simple = q.includes('.') ? q.slice(q.lastIndexOf('.') + 1) : q;
return simple === className;
});
if (targetDef === undefined) continue;
// Inject the class's member methods into the importer's module
// scope. `memberByOwner` wasn't built yet here, so we walk the
// file's localDefs to find members with `ownerId === targetDef.nodeId`.
const targetFile = parsedFiles.find((p) => p.filePath === targetDef.filePath);
if (targetFile === undefined) continue;
for (const memberDef of targetFile.localDefs) {
if ((memberDef as { ownerId?: string }).ownerId !== targetDef.nodeId) continue;
if (memberDef.type !== 'Method' && memberDef.type !== 'Function') continue;
const mq = memberDef.qualifiedName ?? '';
const simpleName = mq.includes('.') ? mq.slice(mq.lastIndexOf('.') + 1) : mq;
if (simpleName === '') continue;
// Add to `indexes.bindings[moduleScope]` so
// `findCallableBindingInScope` picks it up.
let scopeBindings = finalized.get(moduleScope.id);
if (scopeBindings === undefined) {
scopeBindings = new Map<string, BindingRef[]>();
finalized.set(moduleScope.id, scopeBindings);
}
const existing = scopeBindings.get(simpleName) ?? [];
if (existing.some((b) => b.def.nodeId === memberDef.nodeId)) continue;
existing.push({ def: memberDef, origin: 'import' });
scopeBindings.set(simpleName, existing);
}
}
}
// Cross-namespace imports: for each file's `using X;` directive,
// if `X` matches a known namespace bucket, inject that bucket's
// classes into the importer's module scope. This is what makes
// `new User()` in `namespace App;` resolve to `User` declared in
// a sibling file with `namespace Models;` when the importer says
// `using Models;`. Legacy uses csproj directory↔namespace mapping;
// the scope-resolver layer uses the declared namespace directly.
for (const parsed of parsedFiles) {
const moduleScope = parsed.scopes.find((s) => s.kind === 'Module');
if (moduleScope === undefined) continue;
for (const imp of parsed.parsedImports) {
if (imp.kind !== 'namespace') continue;
const targetNs = imp.targetRaw;
if (targetNs === null || targetNs === '') continue;
const bucket = buckets.get(targetNs);
if (bucket === undefined) continue;
for (const def of bucket.classDefs) {
if (def.filePath === parsed.filePath) continue;
const q = def.qualifiedName ?? '';
const simpleName = q.includes('.') ? q.slice(q.lastIndexOf('.') + 1) : q;
if (simpleName === '') continue;
let scopeBindings = finalized.get(moduleScope.id);
if (scopeBindings === undefined) {
scopeBindings = new Map<string, BindingRef[]>();
finalized.set(moduleScope.id, scopeBindings);
}
const existing = scopeBindings.get(simpleName) ?? [];
if (existing.some((b) => b.def.nodeId === def.nodeId)) continue;
existing.push({ def, origin: 'namespace' });
scopeBindings.set(simpleName, existing);
}
}
}
for (const [, bucket] of buckets) {
// De-dup by (nodeId, filePath) across multiple declarations (e.g.
// partial classes declaring the same name in two files — we take
// both and leave de-dup to downstream consumers of bindings).
const defsByName = new Map<string, SymbolDefinition[]>();
for (const def of bucket.classDefs) {
// Simple name = last segment of qualifiedName (e.g. `App.User` → `User`).
const q = def.qualifiedName ?? '';
const key = q.includes('.') ? q.slice(q.lastIndexOf('.') + 1) : q;
if (key === '') continue;
const arr = defsByName.get(key) ?? [];
arr.push(def);
defsByName.set(key, arr);
}
for (const { scopeId, filePath } of bucket.scopes) {
let scopeBindings = finalized.get(scopeId);
if (scopeBindings === undefined) {
scopeBindings = new Map<string, BindingRef[]>();
finalized.set(scopeId, scopeBindings);
}
for (const [name, defs] of defsByName) {
// Skip names already present locally — `origin: 'local'` in
// scope.bindings would naturally shadow the cross-file
// namespace entry, but we also keep this index lean.
const local = bucket.scopes.find((s) => s.filePath === filePath)?.scope.bindings.get(name);
if (local !== undefined && local.some((b) => b.origin === 'local')) continue;
const existing = scopeBindings.get(name) ?? [];
for (const def of defs) {
if (def.filePath === filePath) continue; // don't self-reference
if (existing.some((b) => b.def.nodeId === def.nodeId)) continue;
existing.push({ def, origin: 'namespace' });
}
if (existing.length > 0) scopeBindings.set(name, existing);
}
}
}
}
function isTypeDef(def: SymbolDefinition): boolean {
return (
def.type === 'Class' ||
def.type === 'Interface' ||
def.type === 'Struct' ||
def.type === 'Record' ||
def.type === 'Enum'
);
}

View file

@ -0,0 +1,520 @@
/**
* Tree-sitter query for C# scope captures (RFC §5.1).
*
* Captures the structural skeleton the generic scope-resolution
* pipeline consumes: scopes (module/namespace/class/function),
* declarations (class-likes, method-likes, properties, variables,
* local functions), imports (using directives), type bindings
* (parameter annotations, variable annotations, constructor
* inference), and references (call sites, member writes).
*
* C# specifics that shape this query:
*
* - Both block-scoped (`namespace X { }`) and file-scoped
* (`namespace X;`) namespaces. tree-sitter-c-sharp emits them
* under distinct node types (`namespace_declaration` vs
* `file_scoped_namespace_declaration`); both map to
* `@scope.namespace` since the scope semantics are identical.
* - `partial class X` splits a Class def across files. Each file
* emits its own `@declaration.class`; cross-file resolution is
* handled at the graph-bridge layer via the qualified-name key.
* - `using X = Y;` aliases and `using static X;` are interpreted in
* `interpret.ts` via the `@import.*` captures. All three using
* flavors share the same anchor (`@import.statement`).
* - Explicit interface implementations (`void IFoo.Bar() { }`)
* expose the qualified name via the existing `@declaration.name`
* the extractor's `csharpMethodConfig.extractQualifiedName`
* picks up the explicit qualifier from the method declaration
* node.
*
* Exposes lazy `Parser` and `Query` singletons so callers don't pay
* tree-sitter init cost per file.
*/
import Parser from 'tree-sitter';
import CSharp from 'tree-sitter-c-sharp';
const CSHARP_SCOPE_QUERY = `
;; Scopes
(compilation_unit) @scope.module
(namespace_declaration) @scope.namespace
(file_scoped_namespace_declaration) @scope.namespace
(class_declaration) @scope.class
(interface_declaration) @scope.class
(struct_declaration) @scope.class
(record_declaration) @scope.class
(enum_declaration) @scope.class
(method_declaration) @scope.function
(constructor_declaration) @scope.function
(destructor_declaration) @scope.function
(local_function_statement) @scope.function
(operator_declaration) @scope.function
(conversion_operator_declaration) @scope.function
;; Property accessors are blocks within a property; not scoped here.
;; Anonymous methods / lambdas are not scoped out of scope per plan.
;; Declarations types
(class_declaration
name: (identifier) @declaration.name) @declaration.class
(interface_declaration
name: (identifier) @declaration.name) @declaration.interface
(struct_declaration
name: (identifier) @declaration.name) @declaration.struct
(record_declaration
name: (identifier) @declaration.name) @declaration.record
(enum_declaration
name: (identifier) @declaration.name) @declaration.enum
;; Declarations methods / constructors / properties
(method_declaration
name: (identifier) @declaration.name) @declaration.method
(constructor_declaration
name: (identifier) @declaration.name) @declaration.constructor
(destructor_declaration
name: (identifier) @declaration.name) @declaration.method
(local_function_statement
name: (identifier) @declaration.name) @declaration.function
;; Operator declarations \`public static T operator +(T a, T b)\`.
;; tree-sitter-c-sharp exposes the operator token under the \`operator:\`
;; field (an anonymous node like \`+\`, \`-\`, \`==\`). Capture the whole
;; node under @declaration.name so the extractor reads the operator
;; symbol as the declared name; downstream csharpMethodConfig can
;; normalize it (e.g. to \`op_Addition\`) when it runs.
(operator_declaration
operator: _ @declaration.name) @declaration.method
;; Conversion operators \`public static explicit operator int(T x)\`.
;; No operator token; the target type (\`int\`) identifies the conversion
;; and serves as the name anchor.
(conversion_operator_declaration
type: _ @declaration.name) @declaration.method
(property_declaration
name: (identifier) @declaration.name) @declaration.property
(indexer_declaration) @declaration.property
;; Fields \`int x;\` at class scope. variable_declarator inside
;; field_declaration carries the name.
(field_declaration
(variable_declaration
(variable_declarator
name: (identifier) @declaration.name))) @declaration.variable
;; Local variables \`int x = 1;\` inside a method body
(local_declaration_statement
(variable_declaration
(variable_declarator
name: (identifier) @declaration.name))) @declaration.variable
;; Imports single anchor per directive; interpretCsharpImport classifies
(using_directive) @import.statement
;; Type bindings parameter annotations: \`void F(User u)\`
(parameter
type: (identifier) @type-binding.type
name: (identifier) @type-binding.name) @type-binding.parameter
(parameter
type: (generic_name) @type-binding.type
name: (identifier) @type-binding.name) @type-binding.parameter
(parameter
type: (qualified_name) @type-binding.type
name: (identifier) @type-binding.name) @type-binding.parameter
(parameter
type: (nullable_type) @type-binding.type
name: (identifier) @type-binding.name) @type-binding.parameter
;; Type bindings local variable annotations: \`User u = new User();\`
;; Typed local with identifier type + \`new X()\` initializer — shape
;; matters so \`u\` binds to \`X\` (the constructor call's type), not the
;; declared type alias (which is usually the same, but \`new DerivedUser()\`
;; would be distinct).
(local_declaration_statement
(variable_declaration
type: (identifier) @type-binding.type
(variable_declarator
name: (identifier) @type-binding.name))) @type-binding.annotation
(local_declaration_statement
(variable_declaration
type: (generic_name) @type-binding.type
(variable_declarator
name: (identifier) @type-binding.name))) @type-binding.annotation
(local_declaration_statement
(variable_declaration
type: (qualified_name) @type-binding.type
(variable_declarator
name: (identifier) @type-binding.name))) @type-binding.annotation
;; Type bindings \`var u = new User();\` — constructor-inferred.
;; Captures object_creation_expression's type as the binding type.
;; variable_declarator wraps the \`= <expr>\` directly; tree-sitter-c-sharp
;; does not surface an equals_value_clause wrapper here.
(local_declaration_statement
(variable_declaration
(variable_declarator
name: (identifier) @type-binding.name
(object_creation_expression
type: (identifier) @type-binding.type)))) @type-binding.constructor
(local_declaration_statement
(variable_declaration
(variable_declarator
name: (identifier) @type-binding.name
(object_creation_expression
type: (generic_name) @type-binding.type)))) @type-binding.constructor
(local_declaration_statement
(variable_declaration
(variable_declarator
name: (identifier) @type-binding.name
(object_creation_expression
type: (qualified_name) @type-binding.type)))) @type-binding.constructor
;; Type bindings \`var u = factory();\` alias (chain-follow picks up
;; factory's return type via propagateImportedReturnTypes)
(local_declaration_statement
(variable_declaration
(variable_declarator
name: (identifier) @type-binding.name
(invocation_expression
function: (identifier) @type-binding.type)))) @type-binding.alias
;; Type bindings identifier-to-identifier alias: \`var alias = u;\`.
;; The resolver's chain-follow walks from \`alias\` → \`u\` → u's
;; declared type, so we only need to tag the rename here.
(local_declaration_statement
(variable_declaration
type: (implicit_type)
(variable_declarator
name: (identifier) @type-binding.name
(identifier) @type-binding.type))) @type-binding.alias
;; Type bindings chained method-call alias: \`var u = svc.GetUser();\`.
;; The chain-follow then walks GetUser's return-type binding.
(local_declaration_statement
(variable_declaration
type: (implicit_type)
(variable_declarator
name: (identifier) @type-binding.name
(invocation_expression
function: (member_access_expression
name: (identifier) @type-binding.type))))) @type-binding.alias
;; Type bindings \`await\` propagation: \`var u = await Factory();\`.
;; Strip the await wrapper to get the underlying invocation; interpret
;; layer's stripGeneric handles Task<T> / ValueTask<T>.
(local_declaration_statement
(variable_declaration
type: (implicit_type)
(variable_declarator
name: (identifier) @type-binding.name
(await_expression
(invocation_expression
function: (identifier) @type-binding.type))))) @type-binding.alias
(local_declaration_statement
(variable_declaration
type: (implicit_type)
(variable_declarator
name: (identifier) @type-binding.name
(await_expression
(invocation_expression
function: (member_access_expression
name: (identifier) @type-binding.type)))))) @type-binding.alias
;; Type bindings identifier-to-identifier assignment rebind:
;; \`alias = u;\` — aliases the rhs identifier's current type.
(assignment_expression
left: (identifier) @type-binding.name
right: (identifier) @type-binding.type) @type-binding.alias
;; Type bindings method return type: \`public User GetUser() { ... }\`.
;; Anchor on the method_declaration so bindingScopeFor can hoist the
;; binding from function scope to the enclosing class/module scope
;; (callers, not the function body, look up the return type by the
;; function's name). Required for cross-file return-type propagation
;; via propagateImportedReturnTypes.
(method_declaration
returns: (identifier) @type-binding.type
name: (identifier) @type-binding.name) @type-binding.return
(method_declaration
returns: (generic_name) @type-binding.type
name: (identifier) @type-binding.name) @type-binding.return
(method_declaration
returns: (qualified_name) @type-binding.type
name: (identifier) @type-binding.name) @type-binding.return
(method_declaration
returns: (nullable_type) @type-binding.type
name: (identifier) @type-binding.name) @type-binding.return
;; Type bindings field declaration: \`private City _city;\`. Attaches
;; to the enclosing class scope via positionIndex, so \`this._city.X\`
;; can look up _city's type on the class.
(field_declaration
(variable_declaration
type: (identifier) @type-binding.type
(variable_declarator
name: (identifier) @type-binding.name))) @type-binding.annotation
(field_declaration
(variable_declaration
type: (generic_name) @type-binding.type
(variable_declarator
name: (identifier) @type-binding.name))) @type-binding.annotation
(field_declaration
(variable_declaration
type: (qualified_name) @type-binding.type
(variable_declarator
name: (identifier) @type-binding.name))) @type-binding.annotation
;; Type bindings property declaration: \`public User Owner { get; set; }\`.
(property_declaration
type: (identifier) @type-binding.type
name: (identifier) @type-binding.name) @type-binding.annotation
(property_declaration
type: (generic_name) @type-binding.type
name: (identifier) @type-binding.name) @type-binding.annotation
(property_declaration
type: (qualified_name) @type-binding.type
name: (identifier) @type-binding.name) @type-binding.annotation
(property_declaration
type: (nullable_type) @type-binding.type
name: (identifier) @type-binding.name) @type-binding.annotation
;; Type bindings assignment rebind: \`alias = Factory();\` where
;; \`alias\` was previously declared. Same alias shape as \`var x = F();\`.
(assignment_expression
left: (identifier) @type-binding.name
right: (invocation_expression
function: (identifier) @type-binding.type)) @type-binding.alias
;; Type bindings assignment with constructor: \`alias = new User();\`.
(assignment_expression
left: (identifier) @type-binding.name
right: (object_creation_expression
type: (identifier) @type-binding.type)) @type-binding.constructor
(assignment_expression
left: (identifier) @type-binding.name
right: (object_creation_expression
type: (generic_name) @type-binding.type)) @type-binding.constructor
;; Type bindings \`is\` pattern: \`if (obj is User u) { u.Save(); }\`.
;; The declaration_pattern carries both the matched type and the
;; binding name; scope narrowing to the guarded branch is simplified
;; to function-scope (matches Python's match-case treatment) since we
;; don't emit @scope.block.
(is_pattern_expression
pattern: (declaration_pattern
type: (identifier) @type-binding.type
name: (identifier) @type-binding.name)) @type-binding.annotation
(is_pattern_expression
pattern: (declaration_pattern
type: (generic_name) @type-binding.type
name: (identifier) @type-binding.name)) @type-binding.annotation
(is_pattern_expression
pattern: (declaration_pattern
type: (qualified_name) @type-binding.type
name: (identifier) @type-binding.name)) @type-binding.annotation
;; Type bindings \`case User u:\` inside a switch section.
;; tree-sitter-c-sharp's switch_section directly contains the
;; declaration_pattern / recursive_pattern (no case_pattern_switch_label
;; wrapper as in other C# grammars).
(switch_section
(declaration_pattern
type: (identifier) @type-binding.type
name: (identifier) @type-binding.name)) @type-binding.annotation
(switch_section
(declaration_pattern
type: (generic_name) @type-binding.type
name: (identifier) @type-binding.name)) @type-binding.annotation
;; Type bindings recursive_pattern with named binding:
;; \`x is User { Age: 1 } u\` / \`case User { Age: 1 } u:\`. type + name
;; are named fields on recursive_pattern; inner property/positional
;; clauses don't affect the binding.
(is_pattern_expression
pattern: (recursive_pattern
type: (identifier) @type-binding.type
name: (identifier) @type-binding.name)) @type-binding.annotation
(switch_section
(recursive_pattern
type: (identifier) @type-binding.type
name: (identifier) @type-binding.name)) @type-binding.annotation
;; Type bindings switch-expression arms: \`obj switch { User u => …, Repo { Name: "x" } r => … }\`.
;; Distinct from the \`switch_statement\` shape above — expression-switch
;; uses \`switch_expression_arm\` nodes.
(switch_expression_arm
(declaration_pattern
type: (identifier) @type-binding.type
name: (identifier) @type-binding.name)) @type-binding.annotation
(switch_expression_arm
(declaration_pattern
type: (generic_name) @type-binding.type
name: (identifier) @type-binding.name)) @type-binding.annotation
(switch_expression_arm
(recursive_pattern
type: (identifier) @type-binding.type
name: (identifier) @type-binding.name)) @type-binding.annotation
;; Type bindings typed foreach: \`foreach (User u in xs)\`.
;; Shape parity with \`User u = …;\` — left binds to the declared type.
(foreach_statement
type: (identifier) @type-binding.type
left: (identifier) @type-binding.name) @type-binding.annotation
(foreach_statement
type: (generic_name) @type-binding.type
left: (identifier) @type-binding.name) @type-binding.annotation
(foreach_statement
type: (qualified_name) @type-binding.type
left: (identifier) @type-binding.name) @type-binding.annotation
(foreach_statement
type: (nullable_type) @type-binding.type
left: (identifier) @type-binding.name) @type-binding.annotation
;; Type bindings \`var\` foreach: \`foreach (var u in xs)\`. Alias to
;; the iterable's identifier / chain so chain-follow unwraps
;; \`List<User>\` / \`Dictionary<K,V>.Values\` to the element type via
;; the generic-stripper in interpret.ts. Mirrors Python's for-loop
;; alias patterns.
(foreach_statement
type: (implicit_type)
left: (identifier) @type-binding.name
right: (identifier) @type-binding.type) @type-binding.alias
(foreach_statement
type: (implicit_type)
left: (identifier) @type-binding.name
right: (member_access_expression) @type-binding.type) @type-binding.alias
(foreach_statement
type: (implicit_type)
left: (identifier) @type-binding.name
right: (invocation_expression
function: (identifier) @type-binding.type)) @type-binding.alias
;; Return-type captures on method_declaration / property_declaration /
;; field_declaration are deferred tree-sitter-c-sharp does not expose
;; the return/field type under a simple named field that pattern-matches
;; cleanly. When Unit 7's parity gate surfaces a gap requiring these
;; bindings, revisit with a positional pattern or a post-hoc lookup via
;; csharpMethodConfig.extractReturnType / csharpFieldConfig.extractType.
;; References free calls: \`Foo()\`
(invocation_expression
function: (identifier) @reference.name) @reference.call.free
;; References member calls: \`obj.Method()\`
;; \`(_)\` matches only named nodes in tree-sitter queries. \`this\` and
;; \`base\` are anonymous tokens in tree-sitter-c-sharp (unlike Python's
;; \`self\` which is a regular identifier), so they need explicit
;; patterns to emit a receiver capture.
(invocation_expression
function: (member_access_expression
expression: (_) @reference.receiver
name: (identifier) @reference.name)) @reference.call.member
(invocation_expression
function: (member_access_expression
expression: "this" @reference.receiver
name: (identifier) @reference.name)) @reference.call.member
(invocation_expression
function: (member_access_expression
expression: "base" @reference.receiver
name: (identifier) @reference.name)) @reference.call.member
;; References null-conditional member calls: \`obj?.Method()\`
;; conditional_access_expression wraps a receiver followed by a
;; member_binding_expression. Capture the receiver explicitly so
;; receiver-bound resolution doesn't silently downgrade the call to
;; a free-call (which would misresolve to an imported \`Save\`).
;; tree-sitter-c-sharp doesn't expose named fields here, so use
;; positional wildcards.
(invocation_expression
function: (conditional_access_expression
(_) @reference.receiver
(member_binding_expression
(identifier) @reference.name))) @reference.call.member
;; References constructor calls: \`new User(...)\`
(object_creation_expression
type: (identifier) @reference.name) @reference.call.constructor
(object_creation_expression
type: (generic_name
(identifier) @reference.name)) @reference.call.constructor
(object_creation_expression
type: (qualified_name) @reference.call.constructor.qualified) @reference.call.constructor
;; References field/property writes: \`obj.Name = "x"\` emits a write
;; ACCESSES edge from the enclosing method to the field/property on
;; obj's class.
(assignment_expression
left: (member_access_expression
expression: (_) @reference.receiver
name: (identifier) @reference.name)) @reference.write.member
(assignment_expression
left: (member_access_expression
expression: "this" @reference.receiver
name: (identifier) @reference.name)) @reference.write.member
(assignment_expression
left: (member_access_expression
expression: "base" @reference.receiver
name: (identifier) @reference.name)) @reference.write.member
`;
let _parser: Parser | null = null;
let _query: Parser.Query | null = null;
export function getCsharpParser(): Parser {
if (_parser === null) {
_parser = new Parser();
_parser.setLanguage(CSharp as Parameters<Parser['setLanguage']>[0]);
}
return _parser;
}
export function getCsharpScopeQuery(): Parser.Query {
if (_query === null) {
_query = new Parser.Query(CSharp as Parameters<Parser['setLanguage']>[0], CSHARP_SCOPE_QUERY);
}
return _query;
}

View file

@ -0,0 +1,142 @@
/**
* Synthesize `@type-binding.self` captures for C# instance methods
* one for `this` (always on non-static methods inside a type
* declaration) and optionally one for `base` (only on class methods
* when the enclosing class has an explicit base in its `base_list`).
*
* Mirrors `languages/python/receiver-binding.ts` in structure. The
* tree-sitter-c-sharp grammar doesn't give us a clean `.scm` pattern
* for "this-receiver on every instance method inside an enclosing
* type" because the binding isn't a parameter — it's an implicit
* receiver. Synthesis in code is the same approach Python uses for
* `self` / `cls`.
*/
import type { Capture, CaptureMatch } from 'gitnexus-shared';
import { nodeToCapture, syntheticCapture, type SyntaxNode } from '../../utils/ast-helpers.js';
const TYPE_DECL_NODE_TYPES = new Set([
'class_declaration',
'struct_declaration',
'record_declaration',
'interface_declaration',
]);
const FUNCTION_NODE_TYPES = new Set([
'method_declaration',
'constructor_declaration',
'destructor_declaration',
'operator_declaration',
'conversion_operator_declaration',
'local_function_statement',
]);
/** Walk up to the enclosing type declaration, stopping at any other
* function-like node (nested local functions shouldn't leak `this`
* from an outer class to an inner closure). */
function findEnclosingTypeDeclaration(node: SyntaxNode): SyntaxNode | null {
let cur: SyntaxNode | null = node.parent;
while (cur !== null) {
if (TYPE_DECL_NODE_TYPES.has(cur.type)) return cur;
// A local function nested inside another method still sees `this`
// from the enclosing class — don't break on function-like nodes.
cur = cur.parent;
}
return null;
}
function typeName(typeNode: SyntaxNode): string | null {
return typeNode.childForFieldName('name')?.text ?? null;
}
/** First entry in the type's `base_list`, read as raw text. C# allows
* generic and qualified bases (`Foo<T>`, `N.M.Base`); we keep the raw
* form so downstream interpret layer can strip generics/qualifiers
* the same way as other type-binding captures. Returns null when the
* type has no base (or an empty base_list). */
function firstBaseText(typeNode: SyntaxNode): string | null {
for (let i = 0; i < typeNode.namedChildCount; i++) {
const child = typeNode.namedChild(i);
if (child === null || child.type !== 'base_list') continue;
const firstBase = child.namedChild(0);
if (firstBase === null) return null;
return firstBase.text;
}
return null;
}
function isStaticMethod(fnNode: SyntaxNode): boolean {
// A `static` modifier appears as a named `modifier` child whose text
// is exactly "static". collectModifierTexts in field-extractors
// handles this, but duplicating the tiny scan here keeps the
// receiver-binding module dependency-free.
for (let i = 0; i < fnNode.namedChildCount; i++) {
const child = fnNode.namedChild(i);
if (child !== null && child.type === 'modifier' && child.text.trim() === 'static') return true;
}
return false;
}
/**
* Build zero, one, or two `@type-binding.self` matches for `fnNode`:
*
* - Returns `null` if the function is free (no enclosing type),
* static, or the enclosing type has no resolvable name.
* - Returns one match (`this`) for non-static methods inside a
* class/struct/record/interface.
* - Returns two matches (`this` + `base`) only when the function
* lives in a `class_declaration` (or `record_declaration`) that has
* at least one base entry. Structs cannot inherit classes;
* interfaces cannot call `base.X`.
*
* The caller is responsible for guaranteeing
* `FUNCTION_NODE_TYPES.has(fnNode.type)`.
*/
export function synthesizeCsharpReceiverBinding(fnNode: SyntaxNode): CaptureMatch[] {
if (!FUNCTION_NODE_TYPES.has(fnNode.type)) return [];
if (isStaticMethod(fnNode)) return [];
const enclosingType = findEnclosingTypeDeclaration(fnNode);
if (enclosingType === null) return [];
const enclosingName = typeName(enclosingType);
if (enclosingName === null) return [];
// Anchor the synthesized captures to a node clearly *inside* the
// function's scope (not at the method's start position, which maps
// to the enclosing class scope via positionIndex). The method's
// `body` field is the block statement — its range is guaranteed to
// be inside the function scope. If the method has no body (interface
// declaration, `abstract`), skip — there's no function scope to
// attach the binding to.
const anchorNode = fnNode.childForFieldName('body');
if (anchorNode === null) return [];
const out: CaptureMatch[] = [];
out.push(buildReceiverMatch(anchorNode, 'this', enclosingName));
// `base` applies only to class / record methods with an explicit
// base class. `struct` can't inherit a class; `interface` can't
// call `base.X`. The first entry of `base_list` is the base class
// (interfaces follow); we can't statically distinguish the two here,
// but `base.X` only compiles when the first entry IS a class, so we
// trust the source — if the user wrote `base.X` in a class with
// interface-only bases, their code wouldn't compile anyway.
if (enclosingType.type === 'class_declaration' || enclosingType.type === 'record_declaration') {
const baseText = firstBaseText(enclosingType);
if (baseText !== null) {
out.push(buildReceiverMatch(anchorNode, 'base', baseText));
}
}
return out;
}
function buildReceiverMatch(anchorNode: SyntaxNode, name: string, typeText: string): CaptureMatch {
const m: Record<string, Capture> = {
'@type-binding.self': nodeToCapture('@type-binding.self', anchorNode),
'@type-binding.name': syntheticCapture('@type-binding.name', anchorNode, name),
'@type-binding.type': syntheticCapture('@type-binding.type', anchorNode, typeText),
};
return m;
}

View file

@ -0,0 +1,88 @@
/**
* C# `ScopeResolver` registered in `SCOPE_RESOLVERS` and consumed by
* the generic `runScopeResolution` orchestrator (RFC #909 Ring 3).
*
* Second migration after Python see `pythonScopeResolver` for the
* canonical shape.
*/
import type { ParsedFile } from 'gitnexus-shared';
import { SupportedLanguages } from 'gitnexus-shared';
import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js';
import { populateClassOwnedMembers } from '../../scope-resolution/scope/walkers.js';
import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js';
import { csharpProvider } from '../csharp.js';
import {
csharpArityCompatibility,
csharpMergeBindings,
resolveCsharpImportTarget,
type CsharpResolveContext,
} from './index.js';
import { populateCsharpNamespaceSiblings } from './namespace-siblings.js';
import { unwrapCsharpCollectionAccessor } from './accessor-unwrap.js';
const csharpScopeResolver: ScopeResolver = {
language: SupportedLanguages.CSharp,
languageProvider: csharpProvider,
importEdgeReason: 'csharp-scope: using',
resolveImportTarget: (targetRaw, fromFile, allFilePaths) => {
const ws: CsharpResolveContext = { fromFile, allFilePaths };
// `WorkspaceIndex` is an opaque `unknown` placeholder in the
// shared contract, so `ws` passes structurally without a cast.
return resolveCsharpImportTarget(
{ kind: 'namespace', localName: '_', importedName: '_', targetRaw },
ws,
);
},
// C# shadowing: local > using > using static. The per-scope id is
// unused by the C# implementation (shadowing is computed purely
// from the binding tier), so we don't need to synthesize a Scope.
mergeBindings: (existing, incoming) => [...csharpMergeBindings([...existing, ...incoming])],
// Adapter: csharpArityCompatibility uses (def, callsite); the
// contract is (callsite, def).
arityCompatibility: (callsite, def) => csharpArityCompatibility(def, callsite),
buildMro: (graph, parsedFiles, nodeLookup) =>
buildMro(graph, parsedFiles, nodeLookup, defaultLinearize),
populateOwners: (parsed: ParsedFile) => populateClassOwnedMembers(parsed),
// C# uses `base` for super-class dispatch, not `super`. Match as a
// plain identifier (no `()` call like Python's `super(...)`) — `base`
// is a keyword-like receiver, not a callable.
isSuperReceiver: (text) => text.trim() === 'base',
// Same-namespace cross-file visibility — C# makes every type
// declared in `namespace X` visible to other files declaring the
// same namespace, without any `using` directive. See
// `namespace-siblings.ts` for the implementation.
populateNamespaceSiblings: populateCsharpNamespaceSiblings,
// C# is statically typed — type information is reliable. Field-
// fallback heuristic stays off (the type-binding layer already
// produces precise owner types); return-type propagation on is fine
// since signatures are authoritative.
fieldFallbackOnMethodLookup: false,
propagatesReturnTypesAcrossImports: true,
// `data.Values` / `data.Keys` on Dictionary-like receivers unwrap
// to the value / key element type. Other languages use method-call
// syntax for the same access and leave this hook undefined.
unwrapCollectionAccessor: unwrapCsharpCollectionAccessor,
// C# matches legacy DAG by collapsing member-call CALLS edges to
// `(caller, target)` — multiple `g.Greet(...)` sites from Main
// yield ONE edge, not one per site.
collapseMemberCallsByCallerTarget: true,
// C# hoists method return-type bindings to the enclosing Module
// scope so `propagateImportedReturnTypes` can mirror them across
// files. The compound-receiver walker needs to walk up from the
// class scope to find them; see the contract field for rationale.
hoistTypeBindingsToModule: true,
};
export { csharpScopeResolver };

View file

@ -0,0 +1,96 @@
/**
* Trivial / no-op-ish hooks for the C# provider. Kept together because
* each is a few lines and they share a common theme: they make the
* provider's choice explicit rather than relying on "absence == default"
* so reviewers don't have to re-derive the analysis.
*/
import type {
CaptureMatch,
ParsedImport,
Scope,
ScopeId,
ScopeTree,
TypeRef,
} from 'gitnexus-shared';
// ─── bindingScopeFor ──────────────────────────────────────────────────────
/** C# has block scope, but the central extractor's "innermost enclosing
* scope" default already handles it correctly: class-body declarations
* attach to the innermost Class scope, method-body declarations attach
* to the innermost Function scope, and namespace-body declarations
* attach to the innermost Namespace scope (which the scope query emits
* for both `namespace X { }` and `namespace X;` forms).
*
* Exception: **method return-type bindings** (`@type-binding.return`)
* must hoist all the way to the Module scope. The default auto-hoist
* in the central extractor only promotes one level (Function its
* parent). For C# methods the parent is always a Class, so without
* this override the return binding gets stuck at the Class scope,
* where it's invisible to:
* - chain-follow's parent-chain walk in `followChainPostFinalize`
* (tests: `var u = GetUser(); u.Save()` single-file);
* - cross-file `propagateImportedReturnTypes`, which reads only
* `sourceModule.typeBindings`.
* Walking to Module restores both paths. */
export function csharpBindingScopeFor(
decl: CaptureMatch,
innermost: Scope,
tree: ScopeTree,
): ScopeId | null {
if (decl['@type-binding.return'] !== undefined) {
let cur: Scope | undefined = innermost;
while (cur !== undefined && cur.kind !== 'Module') {
const parentId: ScopeId | null = cur.parent ?? null;
if (parentId === null) break;
cur = tree.getScope(parentId);
}
if (cur !== undefined && cur.kind === 'Module') return cur.id;
}
return null;
}
// ─── importOwningScope ────────────────────────────────────────────────────
/** `using` inside `namespace X { }` binds to that namespace's scope (its
* types are visible only within that namespace's members). File-level
* `using` delegates to the module default. Class-body `using` is not
* legal C# defensively handle it by attaching to the class if it
* ever slips through.
*
* `global using X;` (C# 10+) at the compilation_unit level is treated
* as a file-scoped using for Unit 2's purposes; cross-file propagation
* will be addressed if Unit 7's parity gate flags it. */
export function csharpImportOwningScope(
_imp: ParsedImport,
innermost: Scope,
_tree: ScopeTree,
): ScopeId | null {
if (innermost.kind === 'Namespace' || innermost.kind === 'Class' || innermost.kind === 'Function')
return innermost.id;
return null;
}
// ─── receiverBinding ──────────────────────────────────────────────────────
/** Look up `this` or `base` in the function scope's type bindings.
*
* `this` and `base` are synthesized as type bindings on instance
* methods during capture emission (`receiver-binding.ts`) `this`
* for every method inside a class/struct/record/interface body, and
* `base` additionally for methods of a class-like type with an
* explicit `base_list`. This hook therefore returns a non-null
* `TypeRef` for instance-method bodies.
*
* Returns `null` for:
* - static methods (no `this` synthesized)
* - free functions / module-level code (no enclosing class)
* - non-Function scopes
*
* Matches `pythonReceiverBinding`'s shape so the two provider
* wirings stay symmetric. */
export function csharpReceiverBinding(functionScope: Scope): TypeRef | null {
if (functionScope.kind !== 'Function') return null;
return functionScope.typeBindings.get('this') ?? functionScope.typeBindings.get('base') ?? null;
}

View file

@ -29,6 +29,17 @@ import { pythonVariableConfig } from '../variable-extractors/configs/python.js';
import { createCallExtractor } from '../call-extractors/generic.js';
import { pythonCallConfig } from '../call-extractors/configs/python.js';
import { createHeritageExtractor } from '../heritage-extractors/generic.js';
import {
emitPythonScopeCaptures,
interpretPythonImport,
interpretPythonTypeBinding,
pythonArityCompatibility,
pythonBindingScopeFor,
pythonImportOwningScope,
pythonMergeBindings,
pythonReceiverBinding,
resolvePythonImportTarget,
} from './python/index.js';
const BUILT_INS: ReadonlySet<string> = new Set([
'print',
@ -77,4 +88,18 @@ export const pythonProvider = defineLanguage({
classExtractor: createClassExtractor(pythonClassConfig),
heritageExtractor: createHeritageExtractor(SupportedLanguages.Python),
builtInNames: BUILT_INS,
// ── RFC #909 Ring 3: scope-based resolution hooks (RFC §5) ──────────
// Python is the first migration. See ./python/index.ts for the
// full per-hook rationale and the canonical capture vocabulary in
// ./python/query.ts (PYTHON_SCOPE_QUERY constant).
emitScopeCaptures: emitPythonScopeCaptures,
interpretImport: interpretPythonImport,
interpretTypeBinding: interpretPythonTypeBinding,
bindingScopeFor: pythonBindingScopeFor,
importOwningScope: pythonImportOwningScope,
mergeBindings: (_scope, bindings) => pythonMergeBindings(bindings),
receiverBinding: pythonReceiverBinding,
arityCompatibility: pythonArityCompatibility,
resolveImportTarget: resolvePythonImportTarget,
});

View file

@ -0,0 +1,54 @@
/**
* Extract Python arity metadata from a `function_definition` tree-sitter
* node parameter count, required count, and (where present) a type
* list that the existing `pythonArityCompatibility` hook reads.
*
* Mirrors the legacy `buildMethodProps` conversion so scope-extracted
* defs carry the same arity semantics as the parse-worker path:
* - `self` / `cls` are stripped (consumed by `extractPythonParameters`).
* - Defaulted params contribute to `optionalCount`, flipping
* `requiredParameterCount = total optionalCount`.
* - Variadic (`*args` / `**kwargs`) collapses `parameterCount` to
* `undefined`, which `pythonArityCompatibility` then treats as
* `'unknown'` keeping the candidate in the registry's lookup set.
* - `parameterTypes` is populated only with real type text, matching
* legacy behavior.
*/
import type { SyntaxNode } from '../../utils/ast-helpers.js';
import { pythonMethodConfig } from '../../method-extractors/configs/python.js';
interface PythonArityMetadata {
readonly parameterCount: number | undefined;
readonly requiredParameterCount: number | undefined;
readonly parameterTypes: readonly string[] | undefined;
}
export function computePythonArityMetadata(fnNode: SyntaxNode): PythonArityMetadata {
const params = pythonMethodConfig.extractParameters?.(fnNode) ?? [];
let hasVariadic = false;
let optionalCount = 0;
const types: string[] = [];
for (const p of params) {
if (p.isVariadic) hasVariadic = true;
else if (p.isOptional) optionalCount++;
if (p.type !== null) types.push(p.type);
}
const total = params.length;
const parameterCount = hasVariadic ? undefined : total;
// Unlike legacy `buildMethodProps`, we populate `requiredParameterCount`
// whenever the function isn't variadic — even when it equals
// `parameterCount`. The scope-resolution registry needs a concrete min
// to rule out under-application (e.g. picking `write_audit(x, y)` for
// a 1-arg call). Legacy could get away with leaving it undefined
// because its call-graph builder had a separate arity pre-filter.
const requiredParameterCount = hasVariadic ? undefined : total - optionalCount;
return {
parameterCount,
requiredParameterCount,
parameterTypes: types.length > 0 ? types : undefined,
};
}

View file

@ -0,0 +1,45 @@
/**
* Python arity check, accommodating `*args`, `**kwargs`, and defaults.
*
* The `def` metadata we care about (set by the existing Python method/
* function extractor):
* - `parameterCount` total positional + keyword params
* - `requiredParameterCount` min required (excludes defaults / `*args` / `**kwargs`)
* - `parameterTypes` present when types are known; we also use it
* as a "we have varargs" hint (`'*args'`,
* `'**kwargs'` literals appear in the array).
*
* Verdicts:
* - `'compatible'` `requiredParameterCount <= argCount <= parameterCount`,
* OR the def takes `*args` (then any `argCount >= required` ok).
* - `'incompatible'` argCount is below required, OR above max with no `*args`.
* - `'unknown'` def metadata is absent / incomplete.
*
* `'incompatible'` is a soft signal in `Registry.lookup` (penalized but
* still considered when no compatible candidate exists), per RFC §4.
*/
import type { Callsite, SymbolDefinition } from 'gitnexus-shared';
export function pythonArityCompatibility(
def: SymbolDefinition,
callsite: Callsite,
): 'compatible' | 'unknown' | 'incompatible' {
const max = def.parameterCount;
const min = def.requiredParameterCount;
if (max === undefined && min === undefined) return 'unknown';
const argCount = callsite.arity;
if (!Number.isFinite(argCount) || argCount < 0) return 'unknown';
// Detect varargs/kwargs from parameterTypes if present (the Python
// method extractor stores `'*args'`/`'**kwargs'` in this list).
const hasVarArgs =
def.parameterTypes !== undefined &&
def.parameterTypes.some((t) => t === '*args' || t === '**kwargs' || t.startsWith('*'));
if (min !== undefined && argCount < min) return 'incompatible';
if (max !== undefined && argCount > max && !hasVarArgs) return 'incompatible';
return 'compatible';
}

View file

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

View file

@ -0,0 +1,132 @@
/**
* `emitScopeCaptures` for Python.
*
* Drives the scope query against `tree-sitter-python` and groups raw
* matches into `CaptureMatch[]` for the central extractor, then layers
* two synthesized streams on top:
*
* 1. **Per-name import statements** `import a, b` and
* `from m import x, y` decompose to one match per imported name
* (see `import-decomposer.ts`).
* 2. **Receiver type bindings** each `function_definition` inside a
* class body emits a `@type-binding.self` (or `@type-binding.cls`
* for `@classmethod`) capture so Pass-4 attaches the implicit
* receiver (see `receiver-binding.ts`).
*
* Pure given the input source text. No I/O, no globals consulted.
*/
import type { Capture, CaptureMatch } from 'gitnexus-shared';
import { findNodeAtRange, nodeToCapture, syntheticCapture } from '../../utils/ast-helpers.js';
import { splitImportStatement } from './import-decomposer.js';
import { getPythonParser, getPythonScopeQuery } from './query.js';
import { synthesizeReceiverTypeBinding } from './receiver-binding.js';
import { computePythonArityMetadata } from './arity-metadata.js';
import { recordCacheHit, recordCacheMiss } from './cache-stats.js';
export function emitPythonScopeCaptures(
sourceText: string,
_filePath: string,
cachedTree?: unknown,
): readonly CaptureMatch[] {
// Skip the parse when the caller (parse phase's ASTCache) already
// produced a Tree for this source. Cache miss = re-parse, same as
// before. The cachedTree parameter is typed as `unknown` at the
// contract layer (see `LanguageProvider.emitScopeCaptures`); cast
// here at the use site.
let tree = cachedTree as ReturnType<ReturnType<typeof getPythonParser>['parse']> | undefined;
if (tree === undefined) {
tree = getPythonParser().parse(sourceText);
recordCacheMiss();
} else {
recordCacheHit();
}
const rawMatches = getPythonScopeQuery().matches(tree.rootNode);
const out: CaptureMatch[] = [];
for (const m of rawMatches) {
// Group captures by their tag name. Tree-sitter strips the leading
// `@`; we put it back so the central extractor's prefix lookups
// (`@scope.`, `@declaration.`, …) work.
const grouped: Record<string, Capture> = {};
for (const c of m.captures) {
const tag = '@' + c.name;
grouped[tag] = nodeToCapture(tag, c.node);
}
if (Object.keys(grouped).length === 0) continue;
if (grouped['@import.statement'] !== undefined) {
// Decompose multi-name imports. Both `import_statement` and
// `import_from_statement` share the matched range, so we try the
// `from` form first and fall back to plain.
const stmtCapture = grouped['@import.statement'];
const stmtNode =
findNodeAtRange(tree.rootNode, stmtCapture.range, 'import_from_statement') ??
findNodeAtRange(tree.rootNode, stmtCapture.range, 'import_statement');
if (stmtNode !== null) {
for (const piece of splitImportStatement(stmtNode)) out.push(piece);
} else {
// Defensive fallback: emit the raw match.
out.push(grouped);
}
continue;
}
if (grouped['@scope.function'] !== undefined) {
out.push(grouped);
const fnNode = findNodeAtRange(
tree.rootNode,
grouped['@scope.function']!.range,
'function_definition',
);
if (fnNode !== null) {
const synth = synthesizeReceiverTypeBinding(fnNode);
if (synth !== null) out.push(synth);
}
continue;
}
if (grouped['@declaration.function'] !== undefined) {
// Synthesize arity captures on the declaration match so the
// central scope-extractor picks them up alongside @declaration.name.
// The anchor range is the function_definition itself — we resolve
// the node and pipe it through the arity helper.
const anchorCap = grouped['@declaration.function']!;
const fnNode = findNodeAtRange(tree.rootNode, anchorCap.range, 'function_definition');
if (fnNode !== null) {
const arity = computePythonArityMetadata(fnNode);
if (arity.parameterCount !== undefined) {
grouped['@declaration.parameter-count'] = syntheticCapture(
'@declaration.parameter-count',
fnNode,
String(arity.parameterCount),
);
}
if (arity.requiredParameterCount !== undefined) {
grouped['@declaration.required-parameter-count'] = syntheticCapture(
'@declaration.required-parameter-count',
fnNode,
String(arity.requiredParameterCount),
);
}
if (arity.parameterTypes !== undefined) {
// Serialize as JSON so the consumer can round-trip without
// inventing a quoting convention for type names that may
// contain commas (`Dict[str, int]`).
grouped['@declaration.parameter-types'] = syntheticCapture(
'@declaration.parameter-types',
fnNode,
JSON.stringify(arity.parameterTypes),
);
}
}
out.push(grouped);
continue;
}
out.push(grouped);
}
return out;
}

View file

@ -0,0 +1,141 @@
/**
* Decompose a Python `import_statement` / `import_from_statement` into
* one `CaptureMatch` per imported name.
*
* Why split here? The `LanguageProvider.interpretImport` contract is
* one `ParsedImport` per call. Tree-sitter delivers `import a, b as c`
* and `from m import x, y, z` as a single match each, so without
* decomposition we'd lose names. The synthesized markers
* (`@import.kind` / `@import.name` / `@import.alias` / `@import.source`)
* carry everything `interpretPythonImport` needs to recover the original
* `ParsedImport` shape see `interpret.ts`.
*/
import type { Capture, CaptureMatch } from 'gitnexus-shared';
import {
findChild,
nodeToCapture,
syntheticCapture,
type SyntaxNode,
} from '../../utils/ast-helpers.js';
/** Tag a single decomposed import. Mirrors the `case` arms of
* `interpretPythonImport`. */
type ImportKind = 'plain' | 'aliased' | 'from' | 'from-alias' | 'wildcard' | 'dynamic';
interface ImportSpec {
readonly kind: ImportKind;
readonly source: string;
readonly name: string;
readonly alias?: string;
readonly atNode: SyntaxNode;
}
export function splitImportStatement(stmtNode: SyntaxNode): CaptureMatch[] {
if (stmtNode.type === 'import_statement') return splitImportStmt(stmtNode);
if (stmtNode.type === 'import_from_statement') return splitImportFromStmt(stmtNode);
return [];
}
function splitImportStmt(stmtNode: SyntaxNode): CaptureMatch[] {
// `import a, b as c, d.e`
const out: CaptureMatch[] = [];
for (let i = 0; i < stmtNode.namedChildCount; i++) {
const child = stmtNode.namedChild(i);
if (child === null) continue;
if (child.type === 'dotted_name') {
out.push(
buildImportMatch(stmtNode, {
kind: 'plain',
source: child.text,
name: child.text.split('.')[0]!,
atNode: child,
}),
);
} else if (child.type === 'aliased_import') {
const dotted = findChild(child, 'dotted_name');
const alias = findChild(child, 'identifier');
if (dotted !== null && alias !== null) {
out.push(
buildImportMatch(stmtNode, {
kind: 'aliased',
source: dotted.text,
name: dotted.text,
alias: alias.text,
atNode: child,
}),
);
}
}
}
return out;
}
function splitImportFromStmt(stmtNode: SyntaxNode): CaptureMatch[] {
// `from m import a, b as c` / `from m import *` / `from . import x`
const out: CaptureMatch[] = [];
const moduleField = stmtNode.childForFieldName('module_name');
const moduleText = moduleField?.text ?? '';
// Wildcard? tree-sitter-python represents `*` as a `wildcard_import`
// child and emits no name children.
const wildcardChild = findChild(stmtNode, 'wildcard_import');
if (wildcardChild !== null) {
out.push(
buildImportMatch(stmtNode, {
kind: 'wildcard',
source: moduleText,
name: '*',
atNode: wildcardChild,
}),
);
return out;
}
// Names = every dotted_name / aliased_import that isn't the module.
for (let i = 0; i < stmtNode.namedChildCount; i++) {
const child = stmtNode.namedChild(i);
if (child === null) continue;
if (moduleField !== null && child.startIndex === moduleField.startIndex) continue;
if (child.type === 'dotted_name') {
out.push(
buildImportMatch(stmtNode, {
kind: 'from',
source: moduleText,
name: child.text,
atNode: child,
}),
);
} else if (child.type === 'aliased_import') {
const dotted = findChild(child, 'dotted_name');
const alias = findChild(child, 'identifier');
if (dotted !== null && alias !== null) {
out.push(
buildImportMatch(stmtNode, {
kind: 'from-alias',
source: moduleText,
name: dotted.text,
alias: alias.text,
atNode: child,
}),
);
}
}
}
return out;
}
function buildImportMatch(stmtNode: SyntaxNode, spec: ImportSpec): CaptureMatch {
const stmtCap = nodeToCapture('@import.statement', stmtNode);
const m: Record<string, Capture> = {
'@import.statement': stmtCap,
'@import.kind': syntheticCapture('@import.kind', spec.atNode, spec.kind),
'@import.source': syntheticCapture('@import.source', spec.atNode, spec.source),
'@import.name': syntheticCapture('@import.name', spec.atNode, spec.name),
};
if (spec.alias !== undefined) {
m['@import.alias'] = syntheticCapture('@import.alias', spec.atNode, spec.alias);
}
return m;
}

View file

@ -0,0 +1,120 @@
/**
* Adapter from `(ParsedImport, WorkspaceIndex)` concrete file path.
*
* Delegates to the existing `resolvePythonImportInternal` (PEP-328
* relative resolution + standard suffix matching). The `WorkspaceIndex`
* is opaque at this layer; consumers wire a `PythonResolveContext`
* shape carrying `fromFile` + `allFilePaths`.
*
* Returning `null` lets the finalize algorithm mark the edge as
* `linkStatus: 'unresolved'`.
*/
import type { ParsedImport, WorkspaceIndex } from 'gitnexus-shared';
import { resolvePythonImportInternal } from '../../import-resolvers/python.js';
export interface PythonResolveContext {
readonly fromFile: string;
/** Mutable `Set` because the legacy `resolvePythonImportInternal`
* chain downstream is typed to accept `Set<string>`. Callers that
* only hold a `ReadonlySet` should copy via `new Set(...)` at the
* adapter boundary. */
readonly allFilePaths: Set<string>;
}
export function resolvePythonImportTarget(
parsedImport: ParsedImport,
workspaceIndex: WorkspaceIndex,
): string | null {
// WorkspaceIndex is `unknown` in the shared contract (Ring 1
// placeholder). The scope-resolution orchestrator hands us a
// PythonResolveContext-shaped object; narrow structurally rather
// than via a cast chain so unexpected shapes return null cleanly.
const ctx = workspaceIndex as PythonResolveContext | undefined;
if (
ctx === undefined ||
typeof (ctx as { fromFile?: unknown }).fromFile !== 'string' ||
!((ctx as { allFilePaths?: unknown }).allFilePaths instanceof Set)
) {
return null;
}
if (parsedImport.kind === 'dynamic-unresolved') return null;
if (parsedImport.targetRaw === null || parsedImport.targetRaw === '') return null;
// PEP-328 relative + single-segment proximity bare imports.
const internal = resolvePythonImportInternal(
ctx.fromFile,
parsedImport.targetRaw,
ctx.allFilePaths,
);
if (internal !== null) return internal;
// PEP-328: unresolved relative imports must NOT fall through to suffix
// matching. Mirrors `pythonImportStrategy` in `configs/python.ts`.
if (parsedImport.targetRaw.startsWith('.')) return null;
// External dotted imports like `django.apps` must not fall through to
// generic suffix matching when the repo has unrelated local files such
// as `accounts/apps.py`. Mirrors `pythonImportStrategy`'s
// `hasRepoCandidate` check: only suffix-match if the leading segment
// looks like a local package/module somewhere in-repo.
const pathLike = parsedImport.targetRaw.replace(/\./g, '/');
if (pathLike.includes('/')) {
const [leadingSegment] = pathLike.split('/').filter(Boolean);
if (!leadingSegment || !hasRepoCandidate(leadingSegment, ctx.allFilePaths)) {
return null;
}
}
// Multi-segment absolute resolve: try exact paths first, then suffix
// match in nested repos. Using direct `Set.has` + `endsWith` instead of
// `suffixResolve`'s shared helper because that helper requires a
// pre-built `SuffixIndex` to disambiguate ties — without one it falls
// back to an O(files) scan that silently picks the wrong file when
// the last segment collides across directories (e.g. `accounts.models`
// matching `billing/models.py` when both files exist).
return resolveAbsoluteFromFiles(pathLike, ctx.allFilePaths);
}
/**
* Resolve `package/sub/module` style paths (already dot-flattened) to a
* concrete file in `allFilePaths`. Tries the exact path first, then the
* `__init__.py` variant, then a suffix match for nested layouts.
* Returns the original (un-normalized) path from the set.
*/
function resolveAbsoluteFromFiles(pathLike: string, allFilePaths: Set<string>): string | null {
const directFile = `${pathLike}.py`;
const directPkg = `${pathLike}/__init__.py`;
const suffixFile = `/${directFile}`;
const suffixPkg = `/${directPkg}`;
let suffixMatch: string | null = null;
for (const raw of allFilePaths) {
const f = raw.replace(/\\/g, '/');
if (f === directFile || f === directPkg) return raw;
if (suffixMatch === null && (f.endsWith(suffixFile) || f.endsWith(suffixPkg))) {
suffixMatch = raw;
}
}
return suffixMatch;
}
/**
* Does the repo contain a module/package named `leadingSegment` at the top
* level? Used to guard against false-positive suffix matches on external
* dotted imports (e.g. `django.apps` matching a local `accounts/apps.py`).
*
* Checks, in order: `<segment>.py` root file, `<segment>/__init__.py`
* regular package, or any `<segment>/**.py` file (namespace package).
*/
function hasRepoCandidate(leadingSegment: string, allFilePaths: Set<string>): boolean {
const prefix = `${leadingSegment}/`;
const rootFile = `${leadingSegment}.py`;
const initFile = `${leadingSegment}/__init__.py`;
for (const raw of allFilePaths) {
const f = raw.replace(/\\/g, '/');
if (f === rootFile || f === initFile) return true;
if (f.startsWith(prefix) && f.endsWith('.py')) return true;
}
return false;
}

View file

@ -0,0 +1,85 @@
/**
* Python scope-resolution hooks (RFC #909 Ring 3, RFC §5).
*
* Public API barrel. Consumers should import from this file rather than
* the individual modules that keeps the per-hook organization an
* implementation detail we can refactor without touching the provider
* wiring.
*
* Module layout (each file is a single concern):
*
* - `query.ts` tree-sitter query string + lazy parser/query singletons
* - `ast-utils.ts` generic `SyntaxNode` helpers
* - `import-decomposer.ts` `import a, b` / `from m import x, y` one match per name
* - `receiver-binding.ts` synthesize `self`/`cls` type bindings on methods
* - `captures.ts` `emitPythonScopeCaptures` (top-level orchestrator)
* - `cache-stats.ts` PROF_SCOPE_RESOLUTION cache hit/miss counters
* - `interpret.ts` capture-match `ParsedImport` / `ParsedTypeBinding`
* - `merge-bindings.ts` Python LEGB precedence
* - `arity.ts` Python arity check (`*args`, `**kwargs`, defaults)
* - `import-target.ts` `(ParsedImport, WorkspaceIndex) → file path` adapter
* - `simple-hooks.ts` small/no-op hooks made explicit
*
* ## Known limitations
*
* The Python registry-primary path intentionally does NOT resolve the
* following. Each is a conscious trade-off at migration time; lifting any
* of them is tracked as a separate follow-up rather than silently
* "maybe-resolving" and emitting low-confidence edges.
*
* 1. **Dynamic attribute access** `getattr(obj, 'name')` and
* `setattr` bind at runtime. We emit no edge; the call site
* surfaces as an unresolved reference.
* 2. **Dynamic imports** `importlib.import_module(...)` and
* `__import__(...)` are not followed. Static `import x` and
* `from m import x` are fully resolved.
* 3. **Metaclass-driven dispatch** C3 linearization drives MRO
* (see `mro-processor.ts`), but method resolution that depends
* on `__getattribute__` overrides or metaclass `__call__`
* remains unresolved.
* 4. **Union / Optional type hints** `def f(x: Union[A, B])` or
* `x: Optional[A]`: `arity.ts` validates parameter count only;
* receiver-binding and field-type resolution pick the first arm
* and emit a single edge rather than branching. `List[T]` /
* `Dict[K, V]` strip the outer generic for receiver typing (see
* `interpret.ts`).
* 5. **Decorators that rewrite signatures** `@dataclass`,
* `@property`, `@classmethod`, `@staticmethod` are recognized
* by `receiver-binding.ts`. Arbitrary decorators (e.g.
* `functools.wraps`, custom retry wrappers) preserve the wrapped
* function's declared signature; a decorator that returns a
* different callable is followed only through the declared
* return type.
* 6. **`typing.TYPE_CHECKING`-guarded imports** treated like any
* other `import` for reference resolution. We do not distinguish
* runtime-visible from type-checker-only imports; this is
* intentional (type-only imports are still structurally valid
* type references).
* 7. **`*args` / `**kwargs` type flow-through** `arity.ts`
* accepts any call count when a variadic is present, but no
* type information flows through the variadic into the callee
* body. Receiver-binding still works for explicit parameters.
* 8. **`super()` outside a method with a literal class binding**
* resolved for the standard `class Child(Parent): def m(self):
* super().m()` pattern. Zero-arg `super()` inside a nested
* function, a `functools.wraps`-rewrapped method, or a call
* site where the enclosing class can't be statically determined
* is left unresolved.
*
* Shadow-harness corpus parity is the authoritative signal for which
* of these matter in practice. The CI parity gate blocks any PR that
* regresses either the legacy or registry-primary run of
* `test/integration/resolvers/python.test.ts`.
*/
export { emitPythonScopeCaptures } from './captures.js';
export { getPythonCaptureCacheStats, resetPythonCaptureCacheStats } from './cache-stats.js';
export { interpretPythonImport, interpretPythonTypeBinding } from './interpret.js';
export { pythonMergeBindings } from './merge-bindings.js';
export { pythonArityCompatibility } from './arity.js';
export { resolvePythonImportTarget, type PythonResolveContext } from './import-target.js';
export {
pythonBindingScopeFor,
pythonImportOwningScope,
pythonReceiverBinding,
} from './simple-hooks.js';

View file

@ -0,0 +1,193 @@
/**
* Capture-match semantic-shape interpreters.
*
* Two pure functions, both consumed by the central scope extractor:
*
* - `interpretPythonImport` `ParsedImport`
* - `interpretPythonTypeBinding` `ParsedTypeBinding`
*
* The matches arrive pre-decomposed by `emitPythonScopeCaptures`
* (one imported name per match; synthesized `self`/`cls` markers
* already attached) so these functions are straight-line tag readers.
*/
import type { CaptureMatch, ParsedImport, ParsedTypeBinding, TypeRef } from 'gitnexus-shared';
// ─── interpretImport ──────────────────────────────────────────────────────
export function interpretPythonImport(captures: CaptureMatch): ParsedImport | null {
// Markers attached by `splitImportStatement` (import-decomposer.ts):
// `@import.kind` : 'plain' | 'aliased' | 'from' | 'from-alias' | 'wildcard' | 'dynamic'
// `@import.name` : the imported symbol name (or module name for plain imports)
// `@import.alias` : the local alias name (for `as` forms)
// `@import.source`: the module path (always present except for `dynamic`)
const kindCap = captures['@import.kind'];
const nameCap = captures['@import.name'];
const aliasCap = captures['@import.alias'];
const sourceCap = captures['@import.source'];
const kind = kindCap?.text;
if (kind === undefined) return null;
switch (kind) {
case 'plain': {
// `import numpy`
if (sourceCap === undefined) return null;
return {
kind: 'namespace',
localName: sourceCap.text.split('.')[0]!, // `import a.b.c` exposes `a`
importedName: sourceCap.text,
targetRaw: sourceCap.text,
};
}
case 'aliased': {
// `import numpy as np`
if (sourceCap === undefined || aliasCap === undefined) return null;
return {
kind: 'namespace',
localName: aliasCap.text,
importedName: sourceCap.text,
targetRaw: sourceCap.text,
};
}
case 'from': {
// `from m import x`
if (sourceCap === undefined || nameCap === undefined) return null;
return {
kind: 'named',
localName: nameCap.text,
importedName: nameCap.text,
targetRaw: sourceCap.text,
};
}
case 'from-alias': {
// `from m import x as y`
if (sourceCap === undefined || nameCap === undefined || aliasCap === undefined) return null;
return {
kind: 'alias',
localName: aliasCap.text,
importedName: nameCap.text,
alias: aliasCap.text,
targetRaw: sourceCap.text,
};
}
case 'wildcard': {
// `from m import *`
if (sourceCap === undefined) return null;
return { kind: 'wildcard', targetRaw: sourceCap.text };
}
case 'dynamic': {
// `importlib.import_module(...)` — preserved for diagnostics.
return {
kind: 'dynamic-unresolved',
localName: '',
targetRaw: sourceCap?.text ?? null,
};
}
default:
return null;
}
}
// ─── interpretTypeBinding ─────────────────────────────────────────────────
export function interpretPythonTypeBinding(captures: CaptureMatch): ParsedTypeBinding | null {
// Synthesized `self` / `cls` captures carry `@type-binding.name` and
// `@type-binding.type` directly — same shape as parameter annotations,
// source differs.
const nameCap = captures['@type-binding.name'];
const typeCap = captures['@type-binding.type'];
if (nameCap === undefined || typeCap === undefined) return null;
// Strip surrounding quotes for PEP 484 forward references:
// `def f(x: "User")`. Then unwrap nullable unions — `User | None`,
// `None | User`, `Optional[User]` — to the concrete class name so
// receiver-typed resolution treats nullable receivers identically to
// non-nullable ones. Finally strip single-arg generic wrappers so
// `list[User]` / `Iterable[User]` behave like `User` for iterable
// for-loop chain propagation.
const rawType = stripGeneric(stripNullable(stripForwardRefQuotes(typeCap.text.trim())));
// Order matters: more specific anchor captures take precedence. `self`
// and `cls` are synthesized with their own marker captures; the SCM
// anchor topic captures (`@type-binding.parameter`,
// `@type-binding.annotation`, `@type-binding.constructor`) distinguish
// the variable-annotation and constructor-inferred forms from the
// classic parameter annotation.
let source: TypeRef['source'] = 'parameter-annotation';
if (captures['@type-binding.self'] !== undefined) source = 'self';
// `cls` is a self-like receiver; share the source label so downstream
// `Registry.lookup` Step 2 treats them identically.
else if (captures['@type-binding.cls'] !== undefined) source = 'self';
else if (captures['@type-binding.constructor'] !== undefined) source = 'constructor-inferred';
else if (captures['@type-binding.annotation'] !== undefined) source = 'annotation';
else if (captures['@type-binding.alias'] !== undefined) source = 'assignment-inferred';
else if (captures['@type-binding.return'] !== undefined) source = 'return-annotation';
return { boundName: nameCap.text, rawTypeName: rawType, source };
}
function stripForwardRefQuotes(text: string): string {
if (
(text.startsWith('"') && text.endsWith('"')) ||
(text.startsWith("'") && text.endsWith("'"))
) {
return text.slice(1, -1);
}
return text;
}
/**
* Unwrap a single-arg generic collection wrapper `list[User]`,
* `set[User]`, `Iterable[User]`, `Sequence[User]`, `Iterator[User]`,
* `Generator[User, ...]` to its element type.
*
* Point: for-loop and cross-file chain propagation need the element
* type, not the container. Multi-arg generics (`dict[str, User]`,
* `Callable[[int], User]`) are left alone the element semantics
* aren't unambiguous and the scope-chain fallback handles them at
* resolution time.
*/
function stripGeneric(text: string): string {
const single = text.match(
/^(?:[A-Za-z_][A-Za-z0-9_]*\.)?(?:list|List|set|Set|tuple|Tuple|Iterable|Iterator|Sequence|Generator|AsyncIterable|AsyncIterator)\[([^,\]]+)\]$/,
);
if (single !== null) return single[1].trim();
// dict[K, V] / Dict[K, V] / Mapping[K, V] — strip to value type V.
// For-loop destructuring of `for k, v in d.items()` binds `v` to
// `d`; the chain-follow then unwraps the dict annotation to V.
// Single-key dict `dict[K]` is not legal Python, so two args is the
// only shape worth handling. Match a top-level K up to the first
// comma and a V to the closing bracket; nested generics in V (e.g.
// `dict[str, list[User]]`) are left for a downstream strip pass.
const dict = text.match(
/^(?:[A-Za-z_][A-Za-z0-9_]*\.)?(?:dict|Dict|Mapping|MutableMapping|OrderedDict|DefaultDict)\[[^,\]]+,\s*([^\]]+)\]$/,
);
if (dict !== null) return dict[1].trim();
return text;
}
/**
* Unwrap nullable type annotations so downstream resolution treats
* `User | None`, `None | User`, and `Optional[User]` identically to
* `User`. A missing/unknown variant returns the input unchanged.
*
* This is a syntactic strip, not a semantic parse it handles the
* canonical PEP-604 and `typing.Optional` shapes that cover the
* overwhelming majority of real-world Python annotations and punts on
* exotic unions (e.g. `User | Error`, which is ambiguous and should not
* auto-bind to one arm).
*/
function stripNullable(text: string): string {
// `Optional[X]` / `typing.Optional[X]` / `t.Optional[X]`
const optMatch = text.match(/^(?:[A-Za-z_][A-Za-z0-9_]*\.)?Optional\[(.+)\]$/);
if (optMatch !== null) return optMatch[1].trim();
// Binary union forms. A three-arm or larger union (`User | None | Error`)
// is ambiguous for single-receiver inference, so we leave it alone.
const parts = text.split('|').map((p) => p.trim());
if (parts.length !== 2) return text;
if (parts[0] === 'None') return parts[1];
if (parts[1] === 'None') return parts[0];
return text;
}

View file

@ -0,0 +1,48 @@
/**
* Python LEGB precedence merge for the `mergeBindings` hook.
*
* Tier ranking (lower wins in shadowing):
*
* - 0: `local` an `x = …` or `def x` or `class x` in this scope
* - 1: `import` / `namespace` / `reexport` `from m import x`,
* `import m`, public re-exports
* - 2: `wildcard` `from m import *`
*
* Within a surviving tier we de-dup by `DefId`, last-write-wins (Python
* semantics: a later assignment replaces an earlier one for lookup
* purposes).
*/
import type { BindingRef } from 'gitnexus-shared';
const TIER_LOCAL = 0;
const TIER_IMPORT = 1;
const TIER_WILDCARD = 2;
const TIER_UNKNOWN = 3;
function tierOf(b: BindingRef): number {
switch (b.origin) {
case 'local':
return TIER_LOCAL;
case 'reexport':
case 'import':
case 'namespace':
return TIER_IMPORT;
case 'wildcard':
return TIER_WILDCARD;
default:
return TIER_UNKNOWN;
}
}
export function pythonMergeBindings(bindings: readonly BindingRef[]): readonly BindingRef[] {
if (bindings.length === 0) return bindings;
let bestTier = Number.POSITIVE_INFINITY;
for (const b of bindings) bestTier = Math.min(bestTier, tierOf(b));
const survivors = bindings.filter((b) => tierOf(b) === bestTier);
const seen = new Map<string, BindingRef>();
for (const b of survivors) seen.set(b.def.nodeId, b);
return [...seen.values()];
}

View file

@ -0,0 +1,272 @@
/**
* Tree-sitter query for Python scope captures (RFC §5.1).
*
* Exposes lazy `Parser` and `Query` singletons so callers don't
* pay tree-sitter init cost per file.
*/
import Parser from 'tree-sitter';
import Python from 'tree-sitter-python';
const PYTHON_SCOPE_QUERY = `
;; Scopes
(module) @scope.module
(class_definition) @scope.class
(function_definition) @scope.function
;; Declarations
(class_definition
name: (identifier) @declaration.name) @declaration.class
(function_definition
name: (identifier) @declaration.name) @declaration.function
(assignment
left: (identifier) @declaration.name) @declaration.variable
;; Declarations: for-loop target Python for-statements do NOT introduce
;; a new scope, so the loop variable binds in the enclosing function/module
;; scope. We emit it as a Variable declaration so Pass-2 attaches it.
(for_statement
left: (identifier) @declaration.name) @declaration.variable
;; Imports single anchor per statement; interpretImport decomposes
(import_statement) @import.statement
(import_from_statement) @import.statement
;; Type bindings (parameter annotations)
(typed_parameter
(identifier) @type-binding.name
type: (type) @type-binding.type) @type-binding.parameter
(typed_default_parameter
name: (identifier) @type-binding.name
type: (type) @type-binding.type) @type-binding.parameter
;; Type bindings (constructor-inferred: \`u = User(...)\`)
;; Listed BEFORE the annotation pattern so \`u: User = find()\` — which
;; matches BOTH patterns has the annotation (stronger source) win over
;; the constructor-inferred guess via the scope-extractor's source-
;; strength tie-break in pass4CollectTypeBindings.
(assignment
left: (identifier) @type-binding.name
right: (call
function: (identifier) @type-binding.type)) @type-binding.constructor
;; Qualified constructor (\`u = models.User(...)\`). Captures the
;; attribute node as the type its \`.text\` is the full dotted path
;; (\`models.User\`), which \`resolveTypeRef\` resolves via
;; \`QualifiedNameIndex\` Phase 2.
(assignment
left: (identifier) @type-binding.name
right: (call
function: (attribute) @type-binding.type)) @type-binding.constructor
;; Walrus operator: \`(u := User(...))\`. Python 3.8+ named expression.
;; Shares the constructor-inferred shape so the binding lands in the
;; enclosing function/module scope's typeBindings the same way a plain
;; assignment would.
(named_expression
name: (identifier) @type-binding.name
value: (call
function: (identifier) @type-binding.type)) @type-binding.constructor
(named_expression
name: (identifier) @type-binding.name
value: (call
function: (attribute) @type-binding.type)) @type-binding.constructor
;; Match-case as-pattern: \`case User() as u:\`\`u: User\`. The
;; class_pattern's dotted_name carries the type; the outer as_pattern's
;; second child is the binding name.
(as_pattern
(case_pattern
(class_pattern
(dotted_name) @type-binding.type))
(identifier) @type-binding.name) @type-binding.constructor
;; Assignment chain: \`alias = user\` — the new name inherits the
;; RHS-identifier's type. The pattern emits a TypeRef whose rawName is
;; the RHS identifier's text; the scope-extractor's post-pass follows
;; the chain so \`alias\` ends up pointing at whatever type \`user\` has.
(assignment
left: (identifier) @type-binding.name
right: (identifier) @type-binding.type) @type-binding.alias
;; For-loop iterable of an already-typed variable:
;; def f(users: list[User]):
;; for u in users: # u: users (chained via post-pass)
;; u.save()
;; The chain post-pass resolves \`users\` → its own type \`User\` via
;; the generic-arg stripping in \`interpret.ts\`.
(for_statement
left: (identifier) @type-binding.name
right: (identifier) @type-binding.type) @type-binding.alias
;; For-loop iterable of a free-call result:
;; def get_users() -> list[User]: ...
;; for u in get_users(): # u: get_users User via chain follow
;; u.save()
;; Captures the call's function identifier as the rawName. With
;; \`propagateImportedReturnTypes\`, this works cross-file too.
(for_statement
left: (identifier) @type-binding.name
right: (call
function: (identifier) @type-binding.type)) @type-binding.alias
;; for (i, u) in enumerate(X) paren-tuple, bind last element to X's
;; element type. \`enumerate(X)\` yields (int, X-element); the second
;; pattern var takes X (which the chain-follow then unwraps to its
;; element type via generic-strip in interpret.ts).
(for_statement
left: (tuple_pattern
(identifier)
(identifier) @type-binding.name)
right: (call
function: (identifier) @_enum
arguments: (argument_list
(identifier) @type-binding.type))
(#eq? @_enum "enumerate")) @type-binding.alias
;; for i, u in enumerate(X) pattern_list (no parens) variant.
(for_statement
left: (pattern_list
(identifier)
(identifier) @type-binding.name)
right: (call
function: (identifier) @_enum
arguments: (argument_list
(identifier) @type-binding.type))
(#eq? @_enum "enumerate")) @type-binding.alias
;; for k, v in d.items() bind v to d. The chain-follow unwraps d's
;; dict[K, V] annotation to V via the dict-aware stripGeneric in
;; interpret.ts. Covers both pattern_list and tuple_pattern shapes.
(for_statement
left: (pattern_list
(identifier)
(identifier) @type-binding.name)
right: (call
function: (attribute
object: (identifier) @type-binding.type
attribute: (identifier) @_items))
(#eq? @_items "items")) @type-binding.alias
(for_statement
left: (tuple_pattern
(identifier)
(identifier) @type-binding.name)
right: (call
function: (attribute
object: (identifier) @type-binding.type
attribute: (identifier) @_items))
(#eq? @_items "items")) @type-binding.alias
;; for i, (k, v) in enumerate(d.items()) nested tuple destructuring.
;; Bind v (last id of the nested tuple) to d (the dict).
(for_statement
left: (pattern_list
(identifier)
(tuple_pattern
(identifier)
(identifier) @type-binding.name))
right: (call
function: (identifier) @_enum
arguments: (argument_list
(call
function: (attribute
object: (identifier) @type-binding.type
attribute: (identifier) @_items))))
(#eq? @_enum "enumerate")
(#eq? @_items "items")) @type-binding.alias
;; for i, k, v in enumerate(d.items()) 3-var flat destructuring of
;; the (i, (k,v)) tuple emitted by enumerate over items(). Bind v
;; (last id) to d.
(for_statement
left: (pattern_list
(identifier)
(identifier)
(identifier) @type-binding.name)
right: (call
function: (identifier) @_enum
arguments: (argument_list
(call
function: (attribute
object: (identifier) @type-binding.type
attribute: (identifier) @_items))))
(#eq? @_enum "enumerate")
(#eq? @_items "items")) @type-binding.alias
;; for u in self.X heuristic: bind u to X (the attribute name).
;; The chain-follow then resolves X via the enclosing method's
;; parameter typeBinding, supporting fixtures that reference
;; \`self.X\` as a stand-in for a parameter X (matches legacy DAG
;; behavior).
(for_statement
left: (identifier) @type-binding.name
right: (attribute
object: (identifier) @_self
attribute: (identifier) @type-binding.type)
(#eq? @_self "self")) @type-binding.alias
;; for v in d.values() bind v to d (dict-strip yields value type).
(for_statement
left: (identifier) @type-binding.name
right: (call
function: (attribute
object: (identifier) @type-binding.type
attribute: (identifier) @_values))
(#eq? @_values "values")) @type-binding.alias
;; Type bindings (variable annotations: \`u: User\` / \`u: User = x\`)
(assignment
left: (identifier) @type-binding.name
type: (type) @type-binding.type) @type-binding.annotation
;; Return-type annotation: \`def get_user() -> User:\` binds the
;; FUNCTION'S NAME to its return type in the enclosing scope. Combined
;; with the constructor-inferred + chain-follow path, \`u = get_user()\`
;; then resolves \`u: User\` cross-call. The Python provider hoists the
;; binding via \`pythonBindingScopeFor\` to the function's parent scope
;; so callers in module/class scope see it.
(function_definition
name: (identifier) @type-binding.name
return_type: (type) @type-binding.type) @type-binding.return
;; References calls
(call
function: (identifier) @reference.name) @reference.call.free
(call
function: (attribute
object: (_) @reference.receiver
attribute: (identifier) @reference.name)) @reference.call.member
;; References attribute writes: \`obj.name = "x"\` emits a write
;; ACCESSES edge from the enclosing function to the field on obj's
;; class. The receiver-bound emit pass resolves obj its class and
;; \`name\` → the field def via the existing typeref-receiver path.
(assignment
left: (attribute
object: (_) @reference.receiver
attribute: (identifier) @reference.name)) @reference.write.member
`;
let _parser: Parser | null = null;
let _query: Parser.Query | null = null;
export function getPythonParser(): Parser {
if (_parser === null) {
_parser = new Parser();
_parser.setLanguage(Python as Parameters<Parser['setLanguage']>[0]);
}
return _parser;
}
export function getPythonScopeQuery(): Parser.Query {
if (_query === null) {
_query = new Parser.Query(Python as Parameters<Parser['setLanguage']>[0], PYTHON_SCOPE_QUERY);
}
return _query;
}

View file

@ -0,0 +1,115 @@
/**
* Synthesize `@type-binding.self` / `@type-binding.cls` captures for
* methods.
*
* Tree-sitter can't easily express "the first parameter of a function
* defined directly inside a class body" via a single static query.
* Doing this in code keeps the embedded scope query declarative and
* lets us encode the `@classmethod` / `@staticmethod` decorator
* awareness that Python's runtime depends on.
*/
import type { CaptureMatch } from 'gitnexus-shared';
import { nodeToCapture, syntheticCapture, type SyntaxNode } from '../../utils/ast-helpers.js';
/** Walk up to the enclosing `class_definition`, ignoring the immediate
* `decorated_definition` wrapper. Returns `null` when the function is
* free, lambda-bodied, or nested inside another function. */
function findEnclosingClassDefinition(node: SyntaxNode): SyntaxNode | null {
let cur: SyntaxNode | null = node.parent;
while (cur !== null) {
if (cur.type === 'class_definition') return cur;
if (cur.type === 'function_definition') return null;
cur = cur.parent;
}
return null;
}
function classDefinitionName(classNode: SyntaxNode): string | null {
return classNode.childForFieldName('name')?.text ?? null;
}
/** Does the function carry a `@<decoratorName>` decorator? Matches both
* bare `@classmethod` and module-qualified `@functools.classmethod`. */
function hasDecorator(fnNode: SyntaxNode, decoratorName: string): boolean {
const parent = fnNode.parent;
if (parent === null || parent.type !== 'decorated_definition') return false;
for (let i = 0; i < parent.namedChildCount; i++) {
const child = parent.namedChild(i);
if (child === null || child.type !== 'decorator') continue;
const text = child.text.replace(/^@/, '').split('(')[0]!.trim();
const tail = text.split('.').pop();
if (tail === decoratorName) return true;
}
return false;
}
function firstNamedParameter(parameters: SyntaxNode): SyntaxNode | null {
for (let i = 0; i < parameters.namedChildCount; i++) {
const child = parameters.namedChild(i);
if (child === null) continue;
// Skip `*` / `/` markers.
if (child.type === 'positional_separator' || child.type === 'keyword_separator') continue;
return child;
}
return null;
}
function firstParameterName(param: SyntaxNode): string | null {
if (param.type === 'identifier') return param.text;
// typed_parameter / default_parameter / typed_default_parameter:
// first child holds the identifier / pattern.
const ident = param.childForFieldName('name') ?? findIdentifierChild(param);
return ident?.text ?? null;
}
function findIdentifierChild(node: SyntaxNode): SyntaxNode | null {
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (child !== null && child.type === 'identifier') return child;
}
return null;
}
/**
* Build a `@type-binding.self` (instance method) or `@type-binding.cls`
* (`@classmethod`) match for `fnNode`, or `null` if `fnNode` is not a
* method, is `@staticmethod`, or has no parameters.
*
* The caller is responsible for guaranteeing `fnNode.type ===
* 'function_definition'`.
*/
export function synthesizeReceiverTypeBinding(fnNode: SyntaxNode): CaptureMatch | null {
const enclosingClass = findEnclosingClassDefinition(fnNode);
if (enclosingClass === null) return null;
// Skip @staticmethod-decorated methods (no implicit receiver).
if (hasDecorator(fnNode, 'staticmethod')) return null;
const isClassmethod = hasDecorator(fnNode, 'classmethod');
const params = fnNode.childForFieldName('parameters');
if (params === null) return null;
const first = firstNamedParameter(params);
if (first === null) return null;
const className = classDefinitionName(enclosingClass);
if (className === null) return null;
const firstName = firstParameterName(first);
if (firstName === null) return null;
// Receiver convention: instance methods get `self`, classmethods get `cls`.
// We trust the AST literal name (Python convention is strict in practice).
if (isClassmethod) {
return {
'@type-binding.cls': nodeToCapture('@type-binding.cls', first),
'@type-binding.name': syntheticCapture('@type-binding.name', first, firstName),
'@type-binding.type': syntheticCapture('@type-binding.type', first, className),
};
}
return {
'@type-binding.self': nodeToCapture('@type-binding.self', first),
'@type-binding.name': syntheticCapture('@type-binding.name', first, firstName),
'@type-binding.type': syntheticCapture('@type-binding.type', first, className),
};
}

View file

@ -0,0 +1,74 @@
/**
* Python `ScopeResolver` registered in `SCOPE_RESOLVERS` and consumed
* by the generic `runScopeResolution` orchestrator.
*
* The provider is a thin wiring object Python's specific bits
* (super recognizer, LEGB merge precedence, Python's relative-import
* resolver, the simplified MRO walk) plug into `runScopeResolution`.
*
* Migration reference: when bringing up the next language
* (TypeScript / Java / Kotlin / Ruby), copy this file's structure
* implement the 6 required `ScopeResolver` fields, optionally toggle
* the 2 booleans, and register in `scope-resolution/pipeline/registry.ts`.
*/
import type { ParsedFile } from 'gitnexus-shared';
import { SupportedLanguages } from 'gitnexus-shared';
import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js';
import { populateClassOwnedMembers } from '../../scope-resolution/scope/walkers.js';
import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js';
import { pythonProvider } from '../python.js';
import {
pythonArityCompatibility,
pythonMergeBindings,
resolvePythonImportTarget,
type PythonResolveContext,
} from './index.js';
const pythonScopeResolver: ScopeResolver = {
language: SupportedLanguages.Python,
languageProvider: pythonProvider,
importEdgeReason: 'python-scope: import',
resolveImportTarget: (targetRaw, fromFile, allFilePaths) => {
// Copy the orchestrator's `ReadonlySet` into a `Set` because the
// legacy Python resolver chain (`resolvePythonImportInternal` →
// `resolveAbsoluteFromFiles` / `hasRepoCandidate`) is typed to
// receive a mutable `Set<string>`. The copy is O(N) but called
// once per import — trivial compared to the parser work.
const ws: PythonResolveContext = { fromFile, allFilePaths: new Set(allFilePaths) };
// `WorkspaceIndex` is an opaque `unknown` placeholder in the
// shared contract, so `ws` passes structurally without a cast.
return resolvePythonImportTarget(
{ kind: 'named', localName: '_', importedName: '_', targetRaw },
ws,
);
},
// Python LEGB precedence: local > import/namespace/reexport > wildcard.
// The per-scope id is unused by pythonMergeBindings (tier ordering
// is computed purely from BindingRef.origin), so we don't need to
// synthesize a Scope.
mergeBindings: (existing, incoming) => [...pythonMergeBindings([...existing, ...incoming])],
// Adapter: pythonArityCompatibility predates RegistryProviders and
// uses (def, callsite). ScopeResolver contract is (callsite, def).
// Wrapper kept to honor both contracts without altering the legacy
// shape that LanguageProvider.arityCompatibility consumes.
arityCompatibility: (callsite, def) => pythonArityCompatibility(def, callsite),
buildMro: (graph, parsedFiles, nodeLookup) =>
buildMro(graph, parsedFiles, nodeLookup, defaultLinearize),
populateOwners: (parsed: ParsedFile) => populateClassOwnedMembers(parsed),
isSuperReceiver: (text) => /^super\s*\(/.test(text),
// Python is dynamically typed — field-fallback heuristic on, return-
// type propagation across imports on. Both default to true; listed
// explicitly here for documentation.
fieldFallbackOnMethodLookup: true,
propagatesReturnTypesAcrossImports: true,
};
export { pythonScopeResolver };

View file

@ -0,0 +1,56 @@
/**
* Trivial / no-op-ish hooks for the Python provider. Kept together
* because each is a few lines and they share a common theme: they exist
* to make the provider's choice explicit (rather than relying on
* "absence == default") so reviewers don't have to re-derive the
* analysis.
*/
import type {
CaptureMatch,
ParsedImport,
Scope,
ScopeId,
ScopeTree,
TypeRef,
} from 'gitnexus-shared';
// ─── bindingScopeFor ──────────────────────────────────────────────────────
/** Python has no block scope, so the central extractor's "innermost
* enclosing scope" default is already correct: `for x in …` creates
* `x` in the enclosing function/module scope (because we never emit a
* `@scope.block` for the for-loop body), comprehension variables stay
* in their expression context, etc. Returns `null` to delegate. */
export function pythonBindingScopeFor(
_decl: CaptureMatch,
_innermost: Scope,
_tree: ScopeTree,
): ScopeId | null {
return null;
}
// ─── importOwningScope ────────────────────────────────────────────────────
/** Function-local `from x import Y` should attach the binding to the
* function scope, not the module. Class-body imports (rare but legal
* `class A: import x` makes `x` a class attribute) attach to the class.
* Module-level imports delegate to the central default. */
export function pythonImportOwningScope(
_imp: ParsedImport,
innermost: Scope,
_tree: ScopeTree,
): ScopeId | null {
if (innermost.kind === 'Function' || innermost.kind === 'Class') return innermost.id;
return null;
}
// ─── receiverBinding ──────────────────────────────────────────────────────
/** Look up `self` or `cls` in the function scope's type bindings.
* Returns `null` for free functions (no `self`/`cls`) and for
* non-Function scopes. */
export function pythonReceiverBinding(functionScope: Scope): TypeRef | null {
if (functionScope.kind !== 'Function') return null;
return functionScope.typeBindings.get('self') ?? functionScope.typeBindings.get('cls') ?? null;
}

View file

@ -49,6 +49,16 @@ export interface MethodRegistry {
*/
lookupMethodByName(name: string): readonly SymbolDefinition[];
/**
* Return every overload registered under `(ownerNodeId, methodName)`,
* unfiltered by arity or return type. This is the raw owner-scoped
* view callers that need arity narrowing or unambiguous single-
* result semantics should use `lookupMethodByOwner` instead.
*
* Returns `[]` on miss so callers can iterate without null checks.
*/
lookupAllByOwner(ownerNodeId: string, methodName: string): readonly SymbolDefinition[];
/**
* True iff at least one registered def has `type === 'Function'` i.e.,
* a Python/Rust/Kotlin class method emitted by the worker as
@ -162,6 +172,13 @@ export const createMethodRegistry = (): MutableMethodRegistry => {
return methodsByName.get(name) ?? EMPTY;
};
const lookupAllByOwner = (
ownerNodeId: string,
methodName: string,
): readonly SymbolDefinition[] => {
return methodByOwner.get(`${ownerNodeId}\0${methodName}`) ?? EMPTY;
};
const register = (ownerNodeId: string, methodName: string, def: SymbolDefinition): void => {
const key = `${ownerNodeId}\0${methodName}`;
const existing = methodByOwner.get(key);
@ -195,6 +212,7 @@ export const createMethodRegistry = (): MutableMethodRegistry => {
return {
lookupMethodByOwner,
lookupMethodByName,
lookupAllByOwner,
register,
clear,
get hasFunctionMethods() {

View file

@ -44,6 +44,45 @@
* direct `createSymbolTable()` caller (e.g. an isolated unit test) gets
* the pure, registry-free behavior no surprises, no hidden side
* effects.
*
* ## Single-source-of-truth invariant
*
* `SemanticModel` is the authoritative symbol store for the whole
* ingestion pipeline. Both the legacy Call-Resolution DAG and the
* new scope-resolution pipeline read symbol-keyed lookups from here
* exclusively no parallel owner-keyed, name-keyed, or file-keyed
* symbol indexes exist outside this module. The scope-resolution
* pipeline does carry a small `WorkspaceResolutionIndex` for
* `Scope`-valued maps (`classScopeByDefId`, `moduleScopeByFile`) that
* `SemanticModel` structurally cannot hold, but nothing else.
*
* ## Write / read phase contract
*
* Writes to the model happen in three clearly-ordered phases during a
* single ingestion run:
*
* 1. **Legacy parse phase** (`parsing-processor`) calls
* `symbols.add(...)` per extracted symbol, which fans out via
* the dispatch table into `types` / `methods` / `fields`.
* 2. **Scope-resolution reconciliation** (`reconcileOwnership` in
* `scope-resolution/pipeline/reconcile-ownership.ts`) registers
* any `parsed.localDefs[i]` with a scope-resolution-corrected
* `ownerId` that the legacy pass missed (Python class-body
* methods are the canonical case). Idempotent.
* 3. **Finalize-orchestrator** calls `attachScopeIndexes(...)` to
* stamp the materialized `ScopeResolutionIndexes` bundle onto
* `model.scopes`. One-shot; throws on a second call.
*
* After these three phases, the model is effectively frozen:
* - `attachScopeIndexes` applied `Object.freeze` to its bundle.
* - Downstream passes receive the narrowed `SemanticModel` reader
* handle (not `MutableSemanticModel`), so `.register()` /
* `.clear()` / `attachScopeIndexes()` are structurally absent.
*
* See `scope-resolution/contract/scope-resolver.ts` Contract
* Invariant I9 for the scope-resolution-side rule and
* `ARCHITECTURE.md` § "Semantic-model source of truth" for the
* overall architecture.
*/
import type { NodeLabel } from 'gitnexus-shared';

View file

@ -64,32 +64,48 @@ function buildAdjacency(graph: KnowledgeGraph) {
// Track which edge type each parent link came from
const parentEdgeType = new Map<string, Map<string, 'EXTENDS' | 'IMPLEMENTS'>>();
graph.forEachRelationship((rel) => {
if (rel.type === 'EXTENDS' || rel.type === 'IMPLEMENTS') {
let parents = parentMap.get(rel.sourceId);
if (!parents) {
parents = [];
parentMap.set(rel.sourceId, parents);
}
parents.push(rel.targetId);
let edgeTypes = parentEdgeType.get(rel.sourceId);
if (!edgeTypes) {
edgeTypes = new Map();
parentEdgeType.set(rel.sourceId, edgeTypes);
}
edgeTypes.set(rel.targetId, rel.type);
// Three typed iterations replace one full-relationship-map scan
// with per-edge type checks. Each consumes only the edges of the
// type it cares about — see plan
// docs/plans/2026-04-20-002-perf-parse-heritage-mro-plan.md (Unit 2).
for (const rel of graph.iterRelationshipsByType('EXTENDS')) {
let parents = parentMap.get(rel.sourceId);
if (!parents) {
parents = [];
parentMap.set(rel.sourceId, parents);
}
parents.push(rel.targetId);
if (rel.type === 'HAS_METHOD') {
let methods = methodMap.get(rel.sourceId);
if (!methods) {
methods = [];
methodMap.set(rel.sourceId, methods);
}
methods.push(rel.targetId);
let edgeTypes = parentEdgeType.get(rel.sourceId);
if (!edgeTypes) {
edgeTypes = new Map();
parentEdgeType.set(rel.sourceId, edgeTypes);
}
});
edgeTypes.set(rel.targetId, 'EXTENDS');
}
for (const rel of graph.iterRelationshipsByType('IMPLEMENTS')) {
let parents = parentMap.get(rel.sourceId);
if (!parents) {
parents = [];
parentMap.set(rel.sourceId, parents);
}
parents.push(rel.targetId);
let edgeTypes = parentEdgeType.get(rel.sourceId);
if (!edgeTypes) {
edgeTypes = new Map();
parentEdgeType.set(rel.sourceId, edgeTypes);
}
edgeTypes.set(rel.targetId, 'IMPLEMENTS');
}
for (const rel of graph.iterRelationshipsByType('HAS_METHOD')) {
let methods = methodMap.get(rel.sourceId);
if (!methods) {
methods = [];
methodMap.set(rel.sourceId, methods);
}
methods.push(rel.targetId);
}
return { parentMap, methodMap, parentEdgeType };
}

View file

@ -11,6 +11,7 @@ import { ASTCache } from './ast-cache.js';
import { getLanguageFromFilename, SupportedLanguages } from 'gitnexus-shared';
import { extractVueScript, isVueSetupTopLevel } from './vue-sfc-extractor.js';
import { yieldToEventLoop } from './utils/event-loop.js';
import { isVerboseIngestionEnabled } from './utils/verbose.js';
import {
getDefinitionNodeFromCaptures,
findEnclosingClassInfo,
@ -318,11 +319,13 @@ const processParsingSequential = async (
files: { path: string; content: string }[],
symbolTable: SymbolTableWriter,
astCache: ASTCache,
scopeTreeCache: ASTCache | undefined,
onFileProgress?: FileProgressCallback,
) => {
const parser = await loadParser();
const total = files.length;
const skippedLanguages = new Map<string, number>();
const logSkipped = isVerboseIngestionEnabled();
const skippedByLang = logSkipped ? new Map<string, number>() : null;
for (let i = 0; i < files.length; i++) {
const file = files[i];
@ -341,10 +344,10 @@ const processParsingSequential = async (
const language = getLanguageFromFilename(file.path);
if (!language) continue;
// Skip unsupported languages (e.g. Swift when tree-sitter-swift not installed)
if (!isLanguageAvailable(language)) {
skippedLanguages.set(language, (skippedLanguages.get(language) || 0) + 1);
if (skippedByLang) {
skippedByLang.set(language, (skippedByLang.get(language) ?? 0) + 1);
}
continue;
}
@ -369,7 +372,7 @@ const processParsingSequential = async (
continue; // parser unavailable — safety net
}
let tree;
let tree: Parser.Tree;
try {
tree = parser.parse(parseContent, undefined, {
bufferSize: getTreeSitterBufferSize(parseContent.length),
@ -382,13 +385,20 @@ const processParsingSequential = async (
astCache.set(file.path, tree);
const provider = getProvider(language);
// Mirror into the cross-phase cache only when the language has a
// scope-resolution consumer — otherwise we retain Trees no one
// reads. parse-impl clears `astCache` between chunks;
// `scopeTreeCache` survives until scope-resolution disposes it.
if (provider.emitScopeCaptures !== undefined) {
scopeTreeCache?.set(file.path, tree);
}
const queryString = provider.treeSitterQueries;
if (!queryString) {
continue;
}
let query;
let matches;
let query: Parser.Query;
let matches: Parser.QueryMatch[];
try {
const language = parser.getLanguage();
query = new Parser.Query(language, queryString);
@ -682,11 +692,12 @@ const processParsingSequential = async (
});
}
if (skippedLanguages.size > 0) {
const summary = Array.from(skippedLanguages.entries())
.map(([lang, count]) => `${lang}: ${count}`)
.join(', ');
console.warn(` Skipped unsupported languages: ${summary}`);
if (skippedByLang && skippedByLang.size > 0) {
for (const [lang, count] of skippedByLang.entries()) {
console.warn(
`[ingestion] Skipped ${count} ${lang} file(s) in parsing processing — ${lang} parser not available.`,
);
}
}
};
@ -699,10 +710,27 @@ export const processParsing = async (
files: { path: string; content: string }[],
symbolTable: SymbolTableWriter,
astCache: ASTCache,
/**
* Persistent tree cache (separate from `astCache`, which the caller
* clears between chunks). Sequential parses additionally write the
* Tree here so cross-phase consumers (scope-resolution) can read it.
* Worker-mode parses skip Trees can't cross MessageChannels.
* Pass `undefined` if no consumer needs cross-phase access.
*/
scopeTreeCache: ASTCache | undefined,
onFileProgress?: FileProgressCallback,
workerPool?: WorkerPool,
): Promise<WorkerExtractedData | null> => {
if (workerPool) {
if (scopeTreeCache !== undefined && process.env.PROF_SCOPE_RESOLUTION === '1') {
// Trees can't cross MessageChannels, so worker-parsed files land
// in scope-resolution with an empty cache and get re-parsed.
// Surfacing this in PROF mode prevents silent perf cliffs when
// a repo crosses the worker-pool threshold.
console.warn(
`[scope-resolution prof] worker pool engaged for ${files.length} files — cross-phase tree cache will be empty; scope-resolution re-parses.`,
);
}
try {
return await processParsingWithWorkers(
graph,
@ -721,6 +749,13 @@ export const processParsing = async (
}
// Fallback: sequential parsing (no pre-extracted data)
await processParsingSequential(graph, files, symbolTable, astCache, onFileProgress);
await processParsingSequential(
graph,
files,
symbolTable,
astCache,
scopeTreeCache,
onFileProgress,
);
return null;
};

View file

@ -16,6 +16,10 @@ export { routesPhase, type RoutesOutput, type RouteEntry } from './routes.js';
export { toolsPhase, type ToolsOutput, type ToolDef } from './tools.js';
export { ormPhase, type ORMOutput } from './orm.js';
export { crossFilePhase, type CrossFileOutput } from './cross-file.js';
export {
scopeResolutionPhase,
type ScopeResolutionOutput,
} from '../scope-resolution/pipeline/phase.js';
export { mroPhase, type MROOutput } from './mro.js';
export { communitiesPhase, type CommunitiesOutput } from './communities.js';
export { processesPhase, type ProcessesOutput } from './processes.js';

View file

@ -41,7 +41,7 @@ import {
getHeritageStrategyForLanguage,
} from '../heritage-processor.js';
import { createResolutionContext } from '../model/resolution-context.js';
import { createASTCache } from '../ast-cache.js';
import { ASTCache, createASTCache } from '../ast-cache.js';
import { type PipelineProgress, getLanguageFromFilename } from 'gitnexus-shared';
import { readFileContents } from '../filesystem-walker.js';
import { isLanguageAvailable } from '../../tree-sitter/parser-loader.js';
@ -109,6 +109,15 @@ export async function runChunkedParseAndResolve(
bindingAccumulator: BindingAccumulator;
resolutionContext: ReturnType<typeof createResolutionContext>;
usedWorkerPool: boolean;
/** Cross-phase tree-sitter Tree cache populated by the sequential
* parse path. Distinct from the chunk-local `astCache` used inside
* the parse loop (that one is cleared between chunks). Empty when
* every chunk ran via the worker pool (workers can't return native
* tree-sitter Trees across the MessageChannel). Downstream phases
* (scope-resolution) read from this to skip re-parsing the same
* source. See plan
* docs/plans/2026-04-20-002-perf-parse-heritage-mro-plan.md (Unit 4). */
scopeTreeCache: ASTCache;
}> {
const ctx = createResolutionContext();
const symbolTable = ctx.model.symbols;
@ -220,9 +229,18 @@ export async function runChunkedParseAndResolve(
let filesParsedSoFar = 0;
// AST cache sized for one chunk (sequential fallback uses it for import/call/heritage)
// Two caches with different lifetimes:
// - `astCache` (chunk-local, cleared between chunks) — call /
// heritage / import processors read it during parse to avoid
// re-parsing within the same chunk.
// - `scopeTreeCache` (total-parseable-sized, never cleared by
// parse-impl) — exposed via ParseOutput so scope-resolution can
// skip a second tree-sitter parse. Worker-mode parses don't
// populate either; consumers fall back to a fresh parse.
// See plan docs/plans/2026-04-20-002-perf-parse-heritage-mro-plan.md (Unit 4).
const maxChunkFiles = chunks.reduce((max, c) => Math.max(max, c.length), 0);
let astCache = createASTCache(maxChunkFiles);
const scopeTreeCache = createASTCache(Math.max(parseableScanned.length, 1));
// Build import resolution context once — suffix index, file lists, resolve cache.
const importCtx = buildImportResolutionContext(allPaths);
@ -267,6 +285,7 @@ export async function runChunkedParseAndResolve(
chunkFiles,
symbolTable,
astCache,
scopeTreeCache,
(current, _total, filePath) => {
const globalCurrent = filesParsedSoFar + current;
const parsingProgress = 20 + (globalCurrent / totalParseable) * 62;
@ -595,5 +614,11 @@ export async function runChunkedParseAndResolve(
// sequential fallback handled every chunk (either due to `skipWorkers`,
// the file-count/byte thresholds, or a pool-creation failure).
usedWorkerPool: workerPool !== undefined,
// Surface the persistent scope cache so downstream phases
// (scope-resolution) can skip re-parsing files that the
// sequential path already parsed. Survives chunk boundaries; the
// chunk-local `astCache` above is intentionally NOT exposed
// because parse-impl clears it between chunks.
scopeTreeCache,
};
}

View file

@ -29,6 +29,7 @@ import type {
} from '../workers/parse-worker.js';
import type { createResolutionContext } from '../model/resolution-context.js';
import { runChunkedParseAndResolve } from './parse-impl.js';
import type { ASTCache } from '../ast-cache.js';
export interface ParseOutput {
/**
@ -63,6 +64,23 @@ export interface ParseOutput {
* see `PipelineOptions.workerThresholdsForTest`.
*/
readonly usedWorkerPool: boolean;
/**
* Cross-phase tree-sitter Tree cache populated by the sequential
* parse path. Separate from the chunk-local `astCache` used *inside*
* the parse phase (which is cleared between chunks) this one
* survives the whole phase and hands Trees to scope-resolution so
* it can skip a second parse.
*
* Empty entries for files that ran through the worker pool
* (workers can't return native tree-sitter Trees across the
* MessageChannel). Cache miss is safe consumers fall back to a
* fresh parse. See plan
* docs/plans/2026-04-20-002-perf-parse-heritage-mro-plan.md (Unit 4).
*
* Disposed by `scopeResolutionPhase` (the sole consumer) via
* `scopeTreeCache.clear()` after its extract loop finishes.
*/
readonly scopeTreeCache: ASTCache;
}
export const parsePhase: PipelinePhase<ParseOutput> = {

View file

@ -30,6 +30,7 @@ import {
toolsPhase,
ormPhase,
crossFilePhase,
scopeResolutionPhase,
mroPhase,
communitiesPhase,
processesPhase,
@ -80,6 +81,7 @@ function buildPhaseList(options?: PipelineOptions): PipelinePhase[] {
toolsPhase,
ormPhase,
crossFilePhase,
scopeResolutionPhase,
];
if (!options?.skipGraphPhases) {

View file

@ -38,6 +38,37 @@
import { SupportedLanguages } from 'gitnexus-shared';
/**
* Languages whose RFC #909 Ring 3 scope-resolution migration is complete.
*
* This is the single source of truth for "migrated" the list drives:
*
* 1. **Production default behavior.** `isRegistryPrimary(lang)` returns
* `true` by default for languages in this set (env-var override to
* any falsy value still wins e.g. `REGISTRY_PRIMARY_PYTHON=0`).
* 2. **CI parity gate.** `.github/workflows/ci-scope-parity.yml` auto-
* discovers this set and, for every language in it, runs the
* resolver integration test at `test/integration/resolvers/<slug>.test.ts`
* TWICE on every PR once with the legacy DAG (flag forced off)
* and once with the registry-primary path (flag forced on). BOTH
* must pass. Adding a language is automatic no workflow edit,
* no JSON registry.
* 3. **Legacy-path gating.** `call-processor.ts` / `import-processor.ts`
* skip per-language work when `isRegistryPrimary(lang)` is `true`,
* so this set also controls what gets silenced in the legacy DAG.
*
* Add a language here ONLY after shadow parity 99% fixtures / 98%
* corpus per RFC §6.4. The parity CI gate will block the PR otherwise.
*
* The set is intentionally a static TypeScript literal (not a JSON import,
* not an env lookup) so CI can discover it via `tsx` without a build step
* and reviewers see the change inline with the code that consumes it.
*/
export const MIGRATED_LANGUAGES: ReadonlySet<SupportedLanguages> = new Set<SupportedLanguages>([
SupportedLanguages.Python,
SupportedLanguages.CSharp,
]);
/**
* Return the env-var name that controls a given language's registry-
* primary flag. Exported for test assertions and for the PR-labeling
@ -48,15 +79,17 @@ export function envVarNameFor(lang: SupportedLanguages): string {
}
/**
* Whether `lang` has been flipped to registry-primary call resolution.
* Whether `lang` runs through the registry-primary call-resolution path.
*
* Returns `false` by default a language must explicitly set its env
* var to a truthy value to opt in. The flag is the sole control surface:
* flipping it requires no code change, and reverting it requires no code
* change.
* Resolution order: an explicit env-var value wins (so operators and CI
* can force either path for a given run), and the default falls back to
* `MIGRATED_LANGUAGES.has(lang)` so languages whose migration is
* complete default to registry-primary without touching any env.
*/
export function isRegistryPrimary(lang: SupportedLanguages): boolean {
return parseFlag(process.env[envVarNameFor(lang)]);
const raw = process.env[envVarNameFor(lang)];
if (raw !== undefined) return parseFlag(raw);
return MIGRATED_LANGUAGES.has(lang);
}
/**

View file

@ -0,0 +1,229 @@
/**
* `resolveReferenceSites` drain `ReferenceSite[]` from a finalized
* `ScopeResolutionIndexes` into a `ReferenceIndex` by routing each site
* through the appropriate scope-aware `Registry.lookup` (RFC §3.2 Phase 4).
*
* This is the missing producer that `emit-references.ts` (#925) was
* waiting on. The two together form the registry-primary resolution
* pipeline:
*
* ScopeResolutionIndexes.referenceSites
* resolveReferenceSites
*
* ReferenceIndex
* emitReferencesToGraph
*
* graph: CALLS / ACCESSES / INHERITS / USES edges
*
* ## What this module does
*
* - For each `ReferenceSite`, picks the registry by `kind`:
* · `call` / `inherits` MethodRegistry / ClassRegistry (call-form aware)
* · `read` / `write` FieldRegistry (falls through to MethodRegistry for free names)
* · `type-reference` ClassRegistry
* · `import-use` all three (best-effort name-lookup)
* - Calls `Registry.lookup` with the site's `inScope`, optional
* explicit receiver, and arity.
* - Takes the top-ranked `Resolution` (best by confidence + tie-break
* cascade); folds it into a `Reference` record and bins by source scope.
*
* ## What this module does NOT do
*
* - No AST walks. The `ReferenceSite[]` is already extracted.
* - No language switches. Per-language behavior flows through
* `RegistryProviders.arityCompatibility` (see `RegistryContext`).
* - No multi-candidate fan-out. We pick `[0]` per RFC §4.3 ("one-shot
* answer"). The full ranked list is preserved in the per-site
* resolution but not emitted as multiple edges; callers that want
* branch-on-ambiguity behavior should consume the registries directly.
*/
import {
buildClassRegistry,
buildFieldRegistry,
buildMethodRegistry,
CLASS_KINDS,
FIELD_KINDS,
METHOD_KINDS,
type ClassRegistry,
type FieldRegistry,
type MethodRegistry,
type Reference,
type ReferenceIndex,
type ReferenceSite,
type RegistryContext,
type RegistryProviders,
type Resolution,
type ScopeId,
} from 'gitnexus-shared';
import type { ScopeResolutionIndexes } from './model/scope-resolution-indexes.js';
// ─── Public API ─────────────────────────────────────────────────────────────
export interface ResolveReferencesInput {
readonly scopes: ScopeResolutionIndexes;
/** Provider hooks consumed by the registries (e.g. `arityCompatibility`). */
readonly providers?: RegistryProviders;
}
export interface ResolveStats {
readonly sitesProcessed: number;
readonly referencesEmitted: number;
/** Sites where `Registry.lookup` returned no candidates. */
readonly unresolved: number;
}
export interface ResolveReferencesOutput {
readonly referenceIndex: ReferenceIndex;
readonly stats: ResolveStats;
}
/**
* Resolve every `ReferenceSite` in `scopes.referenceSites` against the
* matching registry and produce a `ReferenceIndex` keyed by source scope
* + target def.
*/
export function resolveReferenceSites(input: ResolveReferencesInput): ResolveReferencesOutput {
const { scopes } = input;
const providers: RegistryProviders = input.providers ?? {};
const ctx: RegistryContext = {
scopes: scopes.scopeTree,
defs: scopes.defs,
qualifiedNames: scopes.qualifiedNames,
moduleScopes: scopes.moduleScopes,
methodDispatch: scopes.methodDispatch,
providers,
};
const classRegistry = buildClassRegistry(ctx);
const methodRegistry = buildMethodRegistry(ctx);
const fieldRegistry = buildFieldRegistry(ctx);
// bySourceScope is the canonical index; byTargetDef is derived from it.
const bySourceScope = new Map<ScopeId, Reference[]>();
const byTargetDef = new Map<string, Reference[]>();
let sitesProcessed = 0;
let referencesEmitted = 0;
let unresolved = 0;
for (const site of scopes.referenceSites) {
sitesProcessed++;
const resolutions = lookupForSite(site, classRegistry, methodRegistry, fieldRegistry);
if (resolutions.length === 0) {
unresolved++;
continue;
}
const top = resolutions[0]!;
const ref = buildReference(site, top);
referencesEmitted++;
let bySource = bySourceScope.get(site.inScope);
if (bySource === undefined) {
bySource = [];
bySourceScope.set(site.inScope, bySource);
}
bySource.push(ref);
let byTarget = byTargetDef.get(top.def.nodeId);
if (byTarget === undefined) {
byTarget = [];
byTargetDef.set(top.def.nodeId, byTarget);
}
byTarget.push(ref);
}
// Freeze inner arrays so consumers don't accidentally mutate.
const frozenBySource = new Map<ScopeId, readonly Reference[]>();
for (const [k, v] of bySourceScope) frozenBySource.set(k, Object.freeze([...v]));
const frozenByTarget = new Map<string, readonly Reference[]>();
for (const [k, v] of byTargetDef) frozenByTarget.set(k, Object.freeze([...v]));
return {
referenceIndex: { bySourceScope: frozenBySource, byTargetDef: frozenByTarget },
stats: { sitesProcessed, referencesEmitted, unresolved },
};
}
// ─── Internal ───────────────────────────────────────────────────────────────
/**
* Pick the right registry for the site's `kind` and call `lookup`.
*
* The kindregistry mapping mirrors `mapKindToType` in `emit-references.ts`:
*
* | site.kind | primary registry | acceptedKinds source |
* |------------------|-------------------|------------------------------|
* | `call` | MethodRegistry | METHOD_KINDS (Method/Func/Ctor)
* | `inherits` | ClassRegistry | CLASS_KINDS |
* | `type-reference` | ClassRegistry | CLASS_KINDS |
* | `read`/`write` | FieldRegistry | FIELD_KINDS |
* | `import-use` | tiered fallback | METHOD CLASS FIELD |
*
* `import-use` doesn't have a single registry the imported name might
* be a class, a function, or a constant. Try each in priority order and
* return the first non-empty result. Provenance still flows through the
* scope's `bindings` (Step 1 lexical hit), so the lookup is correct
* regardless of which registry surfaces the def.
*/
function lookupForSite(
site: ReferenceSite,
classRegistry: ClassRegistry,
methodRegistry: MethodRegistry,
fieldRegistry: FieldRegistry,
): readonly Resolution[] {
switch (site.kind) {
case 'call': {
const opts: Parameters<MethodRegistry['lookup']>[2] = {
...(site.arity !== undefined ? { callsite: { arity: site.arity } } : {}),
...(site.explicitReceiver !== undefined ? { explicitReceiver: site.explicitReceiver } : {}),
};
return methodRegistry.lookup(site.name, site.inScope, opts);
}
case 'inherits':
case 'type-reference': {
return classRegistry.lookup(site.name, site.inScope);
}
case 'read':
case 'write': {
// Try field first; fall through to method then class so bare-name
// reads of a function (e.g. `cb = save`) still resolve.
const fieldHits = fieldRegistry.lookup(site.name, site.inScope);
if (fieldHits.length > 0) return fieldHits;
const methodHits = methodRegistry.lookup(site.name, site.inScope);
if (methodHits.length > 0) return methodHits;
return classRegistry.lookup(site.name, site.inScope);
}
case 'import-use': {
// Try class, method, then field. The lexical-hit Step 1 in
// `lookupCore` handles the actual binding lookup; the choice of
// registry only narrows `acceptedKinds`.
const classHits = classRegistry.lookup(site.name, site.inScope);
if (classHits.length > 0) return classHits;
const methodHits = methodRegistry.lookup(site.name, site.inScope);
if (methodHits.length > 0) return methodHits;
return fieldRegistry.lookup(site.name, site.inScope);
}
}
}
/** Compose a `Reference` record from a site + its top resolution. */
function buildReference(site: ReferenceSite, top: Resolution): Reference {
return {
fromScope: site.inScope,
toDef: top.def.nodeId,
atRange: site.atRange,
kind: site.kind,
confidence: top.confidence,
evidence: top.evidence,
};
}
// Re-export the kind sets so consumers don't have to import them
// separately when constructing custom resolution flows. The mappings
// stay in `gitnexus-shared` (single source of truth); this is a
// convenience pass-through only.
export { CLASS_KINDS, METHOD_KINDS, FIELD_KINDS };

View file

@ -38,10 +38,11 @@ export function extractParsedFile(
sourceText: string,
filePath: string,
onWarn?: ScopeBridgeWarn,
cachedTree?: unknown,
): ParsedFile | undefined {
if (provider.emitScopeCaptures === undefined) return undefined;
try {
const captures = provider.emitScopeCaptures(sourceText, filePath);
const captures = provider.emitScopeCaptures(sourceText, filePath, cachedTree);
return extractScope(captures, filePath, provider);
} catch (err) {
const message = `scope extraction failed for ${filePath}: ${

View file

@ -25,7 +25,6 @@
* ## The five passes
*
* 1. **Build scope tree.** Walk `@scope.*` matches. For each, consult
* `provider.shouldCreateScope` (default true) and
* `provider.resolveScopeKind` (default: suffix of the capture name).
* Derive parent by lexical-range containment. Hand the resulting
* `Scope[]` to `buildScopeTree` for validation.
@ -95,7 +94,6 @@ import type { LanguageProvider } from './language-provider.js';
*/
export type ScopeExtractorHooks = Pick<
LanguageProvider,
| 'shouldCreateScope'
| 'resolveScopeKind'
| 'bindingScopeFor'
| 'interpretImport'
@ -308,9 +306,7 @@ function draftToScope(draft: ScopeDraft): Scope {
/**
* Convert `@scope.*` matches into `ScopeDraft[]`. Parent relationships
* are derived from range containment (outermost scope containing `range`
* becomes the parent). Scopes with `shouldCreateScope === false` are
* silently omitted their children reparent to the next enclosing
* real scope.
* becomes the parent).
*/
function pass1BuildScopes(
matches: readonly CaptureMatch[],
@ -321,7 +317,6 @@ function pass1BuildScopes(
readonly match: CaptureMatch;
readonly range: Range;
readonly kind: ScopeKind;
readonly create: boolean;
readonly id: ScopeId;
}
@ -331,9 +326,8 @@ function pass1BuildScopes(
if (anchor === undefined) continue;
const kind = resolveKindForScopeMatch(match, anchor, provider);
if (kind === null) continue;
const create = provider.shouldCreateScope?.(match) ?? true;
const id = makeScopeId({ filePath, range: anchor.range, kind });
candidates.push({ match, range: anchor.range, kind, create, id });
candidates.push({ match, range: anchor.range, kind, id });
}
// Sort by (startLine, startCol) ASC, (endLine, endCol) DESC so outer
@ -354,13 +348,9 @@ function pass1BuildScopes(
stack.pop();
}
if (cand.create) {
const parent = stack.length > 0 ? stack[stack.length - 1]!.id : null;
drafts.push(makeDraft(cand.id, parent, cand.kind, cand.range, filePath));
stack.push(cand);
}
// If `cand.create === false`, we don't push it onto the stack — child
// scopes will reparent to whatever's below it.
const parent = stack.length > 0 ? stack[stack.length - 1]!.id : null;
drafts.push(makeDraft(cand.id, parent, cand.kind, cand.range, filePath));
stack.push(cand);
}
return drafts;
@ -465,8 +455,20 @@ function pass2AttachDeclarations(
// populated during Pass 2: those fields are written across passes,
// so reading them mid-extraction yields a partial view. The
// `scopeTree` argument is similarly snapshot-before-mutation.
//
// Auto-hoist for scope-creating declarations: when the declaration's
// anchor range is the same node that produced `innermost` (e.g. a
// `function_definition` is both `@scope.function` and the
// `@declaration.function` anchor), the name is visible OUTSIDE the
// body, not inside. Hoisting to the parent scope is what every
// mainstream language wants for function/class declarations. Hooks
// can override by returning a non-null scope id.
const autoHostedId =
innermost.parent !== null && rangesEqual(anchor.range, innermost.range)
? innermost.parent
: innermost.id;
const bindingScopeId =
provider.bindingScopeFor?.(match, draftToScope(innermost), scopeTree) ?? innermost.id;
provider.bindingScopeFor?.(match, draftToScope(innermost), scopeTree) ?? autoHostedId;
const bindingHost = draftById.get(bindingScopeId) ?? innermost;
const nameKey = deriveDeclarationName(match, def);
@ -495,14 +497,44 @@ function buildDefFromDeclarationMatch(
const qualifiedCap = match['@declaration.qualified_name'];
const qualifiedName = qualifiedCap?.text;
// Optional arity metadata — producers (e.g. Python emit-captures)
// synthesize these on function/method declarations. Their absence is
// the normal case for other producers; readers treat undefined as
// "unknown" per `SymbolDefinition` contract.
const parameterCount = parseIntCapture(match['@declaration.parameter-count']);
const requiredParameterCount = parseIntCapture(match['@declaration.required-parameter-count']);
const parameterTypes = parseJsonStringArrayCapture(match['@declaration.parameter-types']);
return {
nodeId: makeDefId(filePath, anchor.range, type, nameCap.text),
filePath,
type,
...(qualifiedName !== undefined ? { qualifiedName } : { qualifiedName: nameCap.text }),
...(parameterCount !== undefined ? { parameterCount } : {}),
...(requiredParameterCount !== undefined ? { requiredParameterCount } : {}),
...(parameterTypes !== undefined ? { parameterTypes } : {}),
};
}
function parseIntCapture(cap: { readonly text: string } | undefined): number | undefined {
if (cap === undefined) return undefined;
const n = Number.parseInt(cap.text, 10);
return Number.isFinite(n) ? n : undefined;
}
function parseJsonStringArrayCapture(
cap: { readonly text: string } | undefined,
): string[] | undefined {
if (cap === undefined) return undefined;
try {
const parsed = JSON.parse(cap.text) as unknown;
if (!Array.isArray(parsed)) return undefined;
return parsed.every((x): x is string => typeof x === 'string') ? parsed : undefined;
} catch {
return undefined;
}
}
function deriveDeclarationName(match: CaptureMatch, def: SymbolDefinition): string | undefined {
const nameCap =
match['@declaration.name'] ??
@ -624,9 +656,19 @@ function pass4CollectTypeBindings(
const innermost = draftById.get(innermostId);
if (innermost === undefined) continue;
// Auto-hoist for scope-creating type bindings (e.g. Python's
// `@type-binding.return` whose anchor is the function_definition
// itself). Same condition as Pass 2 — when the anchor coincides
// with the innermost scope's range, the binding belongs in the
// enclosing scope (callers, not the function body, look up the
// return type by the function's name).
const autoHostedId =
innermost.parent !== null && rangesEqual(anchor.range, innermost.range)
? innermost.parent
: innermost.id;
// `bindingScopeFor` may hoist the type binding to an outer scope.
const hostId =
provider.bindingScopeFor?.(match, draftToScope(innermost), scopeTree) ?? innermost.id;
provider.bindingScopeFor?.(match, draftToScope(innermost), scopeTree) ?? autoHostedId;
const host = draftById.get(hostId) ?? innermost;
const typeRef: TypeRef = {
@ -634,7 +676,96 @@ function pass4CollectTypeBindings(
declaredAtScope: host.id,
source: parsed.source,
};
host.typeBindings.set(parsed.boundName, typeRef);
// Prefer stronger sources when multiple matches fire for the same
// bound name in the same scope. Example: `u: User = find()` matches
// both the annotation and constructor-inferred patterns; the explicit
// annotation (stronger source) must win over the call-site guess
// regardless of query-match arrival order.
const existing = host.typeBindings.get(parsed.boundName);
if (
existing === undefined ||
typeBindingStrength(typeRef.source) >= typeBindingStrength(existing.source)
) {
host.typeBindings.set(parsed.boundName, typeRef);
}
}
// ── Transitive closure over identifier-chain type bindings ─────────
// Captures like `(assignment left: (ident) right: (ident))` emit a
// TypeRef whose `rawName` is the RHS identifier. When the RHS name is
// itself a bound variable with a known type in the same scope (or a
// parent scope), follow the chain so `alias` ultimately points at the
// class type — not at another local variable name. Without this,
// `resolveTypeRef` hits the chained name, sees it's a local Variable
// (non-type kind), and strict-returns null.
for (const draft of drafts) {
for (const [name, ref] of draft.typeBindings) {
const resolved = followChainedRef(ref, draftById);
if (resolved !== ref) draft.typeBindings.set(name, resolved);
}
}
}
/** Max chain depth: practical programs rarely exceed 4-5 re-bindings;
* the cap just prevents runaway loops when providers emit cycles. */
const CHAIN_MAX_DEPTH = 16;
/**
* Follow an identifier-chain TypeRef through successive typeBindings
* lookups in the declaring scope and its ancestors. Returns the terminal
* TypeRef (or the original if the chain dead-ends or cycles).
*/
function followChainedRef(start: TypeRef, draftById: ReadonlyMap<ScopeId, ScopeDraft>): TypeRef {
let current = start;
const visited = new Set<string>();
for (let depth = 0; depth < CHAIN_MAX_DEPTH; depth++) {
// A rawName containing a dot (`models.User`) goes through
// `QualifiedNameIndex` at resolution time — don't follow it here.
if (current.rawName.includes('.')) return current;
// Look up the current rawName in the declaring scope and walk up
// the chain until we hit a scope that has a binding for it.
let scopeId: ScopeId | null = current.declaredAtScope;
let next: TypeRef | undefined;
while (scopeId !== null) {
const scope = draftById.get(scopeId);
if (scope === undefined) break;
next = scope.typeBindings.get(current.rawName);
if (next !== undefined) break;
scopeId = scope.parent;
}
if (next === undefined) return current; // dead end — nothing to chain to
if (next === current) return current; // self-ref
if (visited.has(next.rawName)) return current; // cycle guard
visited.add(next.rawName);
current = next;
}
return current;
}
/**
* Priority ordering when multiple `TypeRef`s compete for the same bound
* name in the same scope. Higher number wins; ties keep the later match
* (last-write-wins preserves historical order within a tier).
*
* Rationale: explicit annotations always beat inferred ones because they
* reflect user intent. `self`/`cls` are treated as strongly as annotations
* because they are language-required receiver types.
*/
function typeBindingStrength(source: TypeRef['source']): number {
switch (source) {
case 'annotation':
case 'parameter-annotation':
case 'return-annotation':
case 'self':
return 2;
case 'assignment-inferred':
case 'constructor-inferred':
case 'receiver-propagated':
return 1;
default:
return 0;
}
}
@ -669,6 +800,7 @@ function pass5CollectReferences(
: undefined;
const explicitReceiver = extractExplicitReceiver(match);
const arity = extractArity(match);
const argumentTypes = extractArgumentTypes(match);
const site: ReferenceSite = {
name: nameCap.text,
@ -678,6 +810,7 @@ function pass5CollectReferences(
...(callForm !== undefined ? { callForm } : {}),
...(explicitReceiver !== undefined ? { explicitReceiver } : {}),
...(arity !== undefined ? { arity } : {}),
...(argumentTypes !== undefined ? { argumentTypes } : {}),
};
referenceSites.push(site);
}
@ -751,8 +884,29 @@ function extractArity(match: CaptureMatch): number | undefined {
return Number.isFinite(n) ? n : undefined;
}
function extractArgumentTypes(match: CaptureMatch): readonly string[] | undefined {
const cap = match['@reference.parameter-types'];
if (cap === undefined) return undefined;
try {
const parsed = JSON.parse(cap.text);
if (Array.isArray(parsed) && parsed.every((x) => typeof x === 'string')) return parsed;
} catch {
/* malformed — fall through */
}
return undefined;
}
// ─── Internal: range + capture utilities ───────────────────────────────────
function rangesEqual(a: Range, b: Range): boolean {
return (
a.startLine === b.startLine &&
a.startCol === b.startCol &&
a.endLine === b.endLine &&
a.endCol === b.endCol
);
}
function rangeStrictlyContains(outer: Range, inner: Range): boolean {
if (
outer.startLine === inner.startLine &&
@ -791,6 +945,10 @@ const KNOWN_SUB_TAGS: ReadonlySet<string> = new Set<string>([
'@reference.name',
'@reference.receiver',
'@reference.arity',
'@reference.parameter-types',
'@declaration.parameter-count',
'@declaration.required-parameter-count',
'@declaration.parameter-types',
]);
/**

View file

@ -0,0 +1,424 @@
/**
* `ScopeResolver` the per-language contract consumed by the generic
* scope-resolution orchestrator (`runScopeResolution`).
*
* ## Migration cookbook (next language)
*
* To add a language to the registry-primary path:
*
* 1. Implement `ScopeResolver` in
* `gitnexus/src/core/ingestion/languages/<lang>/scope-resolver.ts`.
* Nine required fields (language, languageProvider,
* importEdgeReason, resolveImportTarget, mergeBindings,
* arityCompatibility, buildMro, populateOwners, isSuperReceiver)
* plus optional toggles / hooks:
* - propagatesReturnTypesAcrossImports (default true)
* - fieldFallbackOnMethodLookup (default true turn OFF for
* statically-typed languages; the heuristic over-connects)
* - unwrapCollectionAccessor property-style collection views
* - collapseMemberCallsByCallerTarget one edge per caller/target
* - populateNamespaceSiblings cross-file implicit visibility
* - hoistTypeBindingsToModule enable ONLY when method return
* types are stored on the enclosing Module scope; most
* languages attach them to the class scope and leave this off
* 2. Export a thin entry point:
* `runYourLangScopeResolution(input) = runScopeResolution(input, yourScopeResolver)`.
* 3. Register the provider in
* `gitnexus/src/core/ingestion/scope-resolution/pipeline/registry.ts`
* (the `SCOPE_RESOLVERS` map).
* 4. Add `SupportedLanguages.YourLang` to `MIGRATED_LANGUAGES` in
* `registry-primary-flag.ts`.
* 5. Verify the resolver integration test at
* `gitnexus/test/integration/resolvers/<lang>.test.ts` passes
* under both `REGISTRY_PRIMARY_<LANG>=0` (legacy) and `=1`
* (registry-primary). The CI parity gate enforces this.
*
* No new pipeline phase, no orchestrator copy-paste, no workflow
* change. The generic `scopeResolutionPhase` and the CI parity
* workflow auto-discover everything via `MIGRATED_LANGUAGES`.
*
* ## ScopeResolver vs LanguageProvider
*
* The codebase has two provider contracts. Their lifecycles differ:
*
* - `LanguageProvider` (`language-provider.ts`) is the
* **parsing-side** contract how to emit captures, classify
* scopes, interpret imports / typeBindings. ~40 fields covering
* both legacy and new pipelines. Consumed by `ScopeExtractor`,
* once per file at extract time.
* - `ScopeResolver` (this file) is the **emit-side** contract how
* the resolution pipeline dispatches references to graph edges.
* 8 fields total. Consumed by `runScopeResolution`, once per
* workspace at resolve time.
*
* They share three concept names (`arityCompatibility`, `mergeBindings`,
* `resolveImportTarget`) because the emit pipeline reuses a few
* finalize hooks. Per-language wiring passes the SAME function
* reference through both interfaces no second copy of the logic.
* Rationale for not collapsing: lifecycle separation, and merging
* would create a god-interface complicating future migrations.
*
* ## Reference implementation
*
* `gitnexus/src/core/ingestion/languages/python/scope-resolver.ts`
* `pythonScopeResolver` is the canonical example. Read that file when
* migrating a new language; this interface lists the fields that
* implementation populates.
*
* ## Contract Invariants the orchestrator depends on
*
* These are non-obvious behaviors that the orchestrator and the
* existing Python + C# resolvers depend on. Future implementers will
* break them silently if not documented.
*
* - **I1 Phase 4 emission order is load-bearing.** `emitReceiverBoundCalls`
* runs FIRST (populates `handledSites`), then `emitFreeCallFallback`,
* then `emitReferencesViaLookup` (consumes `handledSites` as a skip
* set), then `emitImportEdges`. Reordering breaks same-name collision
* resolution: the shared lookup can mis-resolve `app_metrics.get_metrics()`
* to a same-named local function, and only the precise per-receiver
* pass running first prevents the wrong edge.
*
* - **I2 `handledSites` semantics.** A site is added to
* `handledSites` IFF a `tryEmitEdge` call returned `true` for it.
* Sites a pass touched but couldn't resolve do NOT get marked
* they still get a chance from the shared resolver. Exception:
* the free-call fallback marks the site unconditionally after
* attempting emission (even on dedup-collapse), because the
* per-(caller, target) collapse semantics require multiple call
* sites in the same caller body not produce multiple edges.
*
* - **I3 `propagateImportedReturnTypes` mutation timing.** The
* pass mutates `Scope.typeBindings` (a plain `new Map(...)` from
* `draftToScope`, NOT frozen). It MUST run AFTER `finalizeScopeModel`
* (so `indexes.bindings` is populated) and BEFORE
* `resolveReferenceSites` (so resolution sees the propagated types).
* The pass also re-runs `followChainPostFinalize` on every scope's
* typeBindings because scope-extractor's pass-4 already ran and
* missed any chain whose terminal lives in a foreign file.
*
* - **I4 `emitReceiverBoundCalls` case order.** Cases are evaluated
* in this order; the FIRST that emits an edge wins:
* 1. super branch (`provider.isSuperReceiver(receiverName)`)
* 2. Case 0 compound (`receiverName` has `.` or `(`)
* 3. Case 1 namespace-receiver
* 4. Case 2 class-name receiver
* 5. Case 3 dotted typeBinding for namespace prefix
* 6. Case 3b chain-typebinding (compound resolver)
* 7. Case 4 simple typeBinding (MRO walk + findOwnedMember)
* Reordering or merging cases changes resolution semantics. The
* numbering is part of the contract keep the comments.
*
* - **I5 Pre-seeding `seen` from `referenceIndex` is forbidden.**
* Earlier versions of the receiver-bound pass pre-populated `seen`
* to avoid double-emit. After Phase 4 was reordered, pre-seeding
* became actively harmful: it suppresses correct emissions for
* sites the shared resolver happened to resolve to a wrong target.
* The orchestrator MUST NOT pre-seed.
*
* - **I6 `Scope.typeBindings` is mutable post-finalize.** `draftToScope`
* (in `scope-extractor.ts`) builds `typeBindings` as a plain
* `new Map(...)` not frozen, intentionally. Passes below rely on
* this. Do NOT freeze `typeBindings` in any downstream refactor.
*
* - **I7 `ScopeResolver` and `LanguageProvider` are distinct contracts.**
* Python and C# pass the SAME function reference through both
* interfaces where they share a hook name no second copy of the
* logic. Rationale for not collapsing them: lifecycles differ
* (parsing-side runs once per file at extract time, emit-side runs
* once per workspace at resolve time), and merging would create a
* god-interface that complicates future migrations.
*
* - **I8 Post-finalize hooks may mutate `Scope.typeBindings` and
* `indexes.bindings`.** `propagateImportedReturnTypes` and
* `populateNamespaceSiblings` both write to these structures via
* `as Map<...>` casts through `ReadonlyMap` facades. Downstream
* consumers MUST NOT freeze or snapshot these maps before all
* post-finalize hooks have run. The `ReadonlyMap<...>` type on
* `ScopeResolutionIndexes` is a read-guidance surface for
* consumers, NOT an immutability promise during the resolve phase.
*
* - **I9 `SemanticModel` is the single authoritative symbol store.**
* Every symbol-indexed lookup (key = `nodeId | simpleName |
* qualifiedName | filePath`) resolves through
* `SemanticModel.{symbols,types,methods,fields}`. Scope-resolution
* passes MUST NOT maintain parallel owner-keyed or name-keyed
* symbol indexes `WorkspaceResolutionIndex` is reserved for
* `Scope`-valued lookups that `SemanticModel` structurally cannot
* carry.
*
* The `runScopeResolution` orchestrator guarantees this invariant
* in two steps:
* 1. The legacy `parse` phase populates `SemanticModel` via
* `symbolTable.add(...)`. For languages whose extractor
* resolves `enclosingClassId` at parse time, class-body defs
* are correctly owner-keyed there.
* 2. The `reconcileOwnership` pass runs after
* `provider.populateOwners(parsed)` and registers any def in
* `parsed.localDefs[i]` with a corrected `ownerId` that the
* legacy pass missed (primarily Python class-body methods).
* Idempotent duplicates are skipped by `nodeId`.
*
* Contract for consumers: `model` is `MutableSemanticModel` only
* during those two write phases. Downstream passes receive a
* narrowed `SemanticModel` (read-only) handle. This is enforced by
* `runScopeResolution`'s type-level narrowing at the phase
* boundary.
*
* The dev-mode runtime validator (`validateOwnershipParity`)
* surfaces any drift between `parsed.localDefs` ownership and the
* registries via `onWarn` when
* `NODE_ENV !== 'production' && VALIDATE_SEMANTIC_MODEL !== '0'`.
*
* This invariant is a **transitional shim**: the architectural
* end state is for every language's parse-time extractor to emit
* the correct `ownerId` directly, removing the need for
* reconciliation. Tracked as a follow-up; see ARCHITECTURE.md §
* "Semantic-model source of truth".
*
* ## Semantic-model source of truth
*
* `ParsedFile` (from `gitnexus-shared/src/scope-resolution/parsed-file.ts`)
* is the single semantic model consumed by both the legacy DAG and the
* scope-resolution pipeline. Scope-resolution passes MUST NOT build a
* parallel parse representation; if a pass needs AST-level facts that
* `ParsedFile` doesn't expose, it should reuse the orchestrator's
* `treeCache` (see `RunScopeResolutionInput.treeCache`) rather than
* re-invoke `parser.parse(...)` on its own.
*
* ## Same-graph guarantee
*
* Edges emitted by `runScopeResolution` and edges emitted by the legacy
* DAG are indistinguishable to downstream consumers:
* - Node identity: same `generateId(...)` helper, same qualified-name
* keyspace, same File/Folder/Method/Class node labels.
* - Edge vocabulary: `'import-resolved' | 'global' | 'local-call' |
* 'same-file' | 'interface-dispatch' | 'read' | 'write'` — both
* paths emit the same reasons (see
* `gitnexus/src/core/ingestion/call-processor.ts` for the legacy
* emitter and `passes/receiver-bound-calls.ts` /
* `passes/free-call-fallback.ts` for the scope-resolution emitters).
* - Overload disambiguation: both paths use
* `generateId('Method', ...)` suffixed with `parameterTypes` when a
* method has overloads see `graph-bridge/ids.ts`.
*
* The CI parity workflow (`.github/workflows/ci-scope-parity.yml`)
* runs both paths on every migrated language's fixture corpus and
* fails if the graph outputs diverge.
*
* Plan that introduced most of these invariants:
* `docs/plans/2026-04-20-001-refactor-emit-pipeline-generalization-plan.md`.
*/
import type {
BindingRef,
Callsite,
ParsedFile,
ScopeId,
SupportedLanguages,
SymbolDefinition,
} from 'gitnexus-shared';
import type { KnowledgeGraph } from '../../../graph/types.js';
import type { GraphNodeLookup } from '../graph-bridge/node-lookup.js';
import { LanguageProvider } from '../../language-provider.js';
import { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
/** A LinearizeStrategy receives the full ancestor map so C3-style
* algorithms (which need to merge each parent's MRO) can implement
* themselves. Python's depth-first first-seen only consumes
* `directParents` and `parentsByDefId`. */
export type LinearizeStrategy = (
classDefId: string,
directParents: readonly string[],
parentsByDefId: ReadonlyMap<string, readonly string[]>,
) => string[];
/** Result of `ScopeResolver.arityCompatibility` — mirrors `RegistryProviders.arityCompatibility`. */
export type ArityVerdict = 'compatible' | 'unknown' | 'incompatible';
export interface ScopeResolver {
/** Identity for telemetry + per-language flag check. */
readonly language: SupportedLanguages;
/** Parsing-side hook bag consumed by `extractParsedFile`. The
* same `LanguageProvider` reference flows through both interfaces
* to keep parsing and emit semantics in sync. */
readonly languageProvider: LanguageProvider;
/** Reason text on emitted IMPORTS edges. Mirrors the legacy DAG's
* per-language convention so consumers asserting on reason keep
* working. */
readonly importEdgeReason: string;
// ─── Pipeline hooks ────────────────────────────────────────────────────────
/**
* Resolve an import statement's `targetRaw` (e.g. `models.user`,
* `./helpers`) into an absolute repo-relative file path, or `null`
* for unresolvable / external modules.
*
* Called once per `ParsedImport` during `finalizeScopeModel`. The
* Python implementation wraps `resolvePythonImportTarget`.
*
* `allFilePaths` is the workspace's file set needed by per-language
* resolvers that must distinguish "this module exists in the repo"
* from "this module is external" (Python's fallback resolver, for
* example).
*/
resolveImportTarget(
targetRaw: string,
fromFile: string,
allFilePaths: ReadonlySet<string>,
): string | null;
/**
* Per-scope binding-merge precedence. The shared finalize pass
* collects bindings from multiple sources (local declarations,
* imports, namespace, wildcard, reexport) and asks the language
* how to order them.
*
* Python uses LEGB: local > import / namespace / reexport > wildcard.
*/
mergeBindings(
existing: readonly BindingRef[],
incoming: readonly BindingRef[],
scopeId: ScopeId,
): BindingRef[];
/**
* Per-language arity compatibility between a callsite and a
* candidate def. The shared `MethodRegistry.lookup` consults this
* to penalize incompatible candidates without disqualifying them
* outright. Note arg order `(callsite, def)` matches the
* `RegistryProviders` contract; some legacy provider impls use
* `(def, callsite)` and need an adapter at the wiring site.
*/
arityCompatibility(callsite: Callsite, def: SymbolDefinition): ArityVerdict;
// ─── Per-language strategies ───────────────────────────────────────────────
/**
* Compute the method-dispatch order for every Class def in the
* workspace. Python uses depth-first first-seen via
* `pythonLinearize`; future languages may use C3 (Ruby, Python's
* real MRO when we go beyond the simplified walk), single-
* inheritance only (Java), or empty-map (languages without
* inheritance).
*/
buildMro(
graph: KnowledgeGraph,
parsedFiles: readonly ParsedFile[],
nodeLookup: GraphNodeLookup,
): Map<string /* DefId */, string[] /* ancestor DefIds */>;
/**
* Mutate `parsed.localDefs[i].ownerId` to point at the structural
* owner. Python's rule: methods (Function defs whose parent scope
* is Class) AND class-body fields (defs in Class scopes) are owned
* by the enclosing class. Other languages may have richer rules
* (e.g., Java inner-class qualification).
*/
populateOwners(parsed: ParsedFile): void;
/**
* Recognize a `super(...)`-style receiver text. Python returns
* `/^super\s*\(/.test(t)`. Java returns `t === 'super'`. C++ may
* also need `this` capture. Languages without inheritance return
* constant `false`.
*/
isSuperReceiver(receiverText: string): boolean;
// ─── Optional toggles ──────────────────────────────────────────────────────
/**
* Whether the orchestrator should run `propagateImportedReturnTypes`
* after finalize. Default `true`. TypeScript with explicit type
* exports may want a different propagation strategy and opt out.
*/
readonly propagatesReturnTypesAcrossImports?: boolean;
/**
* Whether the compound-receiver resolver should fall back to
* walking field types when method lookup on the receiver's class
* fails (the "Phase-9C unified fixpoint" heuristic). Default
* `true`. Strictly-typed languages should set `false` because the
* heuristic can produce edges that wouldn't survive a real type
* check.
*/
readonly fieldFallbackOnMethodLookup?: boolean;
/**
* Unwrap a property-style collection accessor on a typed receiver
* to its element type. Called by `resolveCompoundReceiverClass`
* when walking dotted member-access chains of the form
* `receiver.Accessor`. The provider returns the element type's
* simple name, or `undefined` when the accessor doesn't unwrap
* in which case the regular field-walk resumes.
*
* Use this only for languages that expose collection views as
* properties rather than method calls; languages whose collection
* views are `.values()` / `.keys()` method calls leave this
* undefined and let the normal call-expression branch handle them.
*/
readonly unwrapCollectionAccessor?: (
receiverType: string,
accessor: string,
) => string | undefined;
/**
* Collapse member-call CALLS edges by `(caller, target)` rather
* than per-site. Default `false` scope-resolution's contract
* invariant is per-site dedup.
*
* Enable this when the language's graph convention is one edge per
* caller/target pair regardless of how many syntactic sites exist,
* e.g. to match a legacy graph's edge count so downstream
* consumers don't see a migration-induced inflation.
*/
readonly collapseMemberCallsByCallerTarget?: boolean;
/**
* Optional post-finalize hook to inject cross-file bindings that
* aren't modeled via explicit imports. Runs after
* `buildWorkspaceResolutionIndex` and before
* `propagateImportedReturnTypes`.
*
* Use this for languages where a compiler-implicit visibility rule
* makes names visible across files without a syntactic import
* for example a shared-namespace convention where types declared
* in the same namespace see each other without a `using` / `import`
* statement. Languages that require explicit imports for cross-file
* visibility leave this undefined.
*/
readonly populateNamespaceSiblings?: (
parsedFiles: readonly ParsedFile[],
indexes: ScopeResolutionIndexes,
ctx: {
readonly fileContents: ReadonlyMap<string, string>;
/** Pre-parsed tree-sitter trees keyed by file path. Same cache
* the orchestrator hands to `extractParsedFile`; passing it
* through here lets per-language hooks read the AST without
* triggering a second parse. Cache miss = the hook re-parses
* itself; the cache is opt-in for hooks that need AST-level
* facts beyond what `ParsedFile` exposes. */
readonly treeCache?: { get(filePath: string): unknown };
},
) => void;
/**
* Whether the compound-receiver resolver should walk up from a
* class scope to ancestor (Module) scopes when looking up a
* method's return-type typeBinding. Default `false`.
*
* Set `true` only when the provider stores method return-type
* bindings on the enclosing Module scope rather than on the class
* scope. Without this walk-up, chain resolution fails for methods
* whose return types were hoisted to module scope.
*
* Providers that attach return-type bindings directly to the class
* scope leave this undefined enabling the walk-up for them would
* add an unnecessary branch and risk picking up unrelated module-
* level bindings.
*/
readonly hoistTypeBindingsToModule?: boolean;
}

View file

@ -0,0 +1,100 @@
/**
* Graph edge emission primitives.
*
* Two functions:
* - `mapReferenceKindToEdgeType` translate a scope-resolution
* `Reference.kind` into the corresponding graph edge type.
* - `tryEmitEdge` given a reference site + target def, resolve
* caller + target to graph ids and emit the edge with
* language-provided reason text, dedup-keyed by
* `(edgeType, callerId, targetId, line, col)`.
*
* Next-consumer contract: any language provider can call `tryEmitEdge`
* from its own post-pass to emit edges it resolves Python-specific
* (or TypeScript-specific, etc.) logic. The dedup key is
* language-agnostic no language needs to change it.
*/
import type { Reference, ScopeId, SymbolDefinition } from 'gitnexus-shared';
import type { KnowledgeGraph } from '../../../graph/types.js';
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
import type { GraphNodeLookup } from '../graph-bridge/node-lookup.js';
import { resolveCallerGraphId, resolveDefGraphId } from '../graph-bridge/ids.js';
/**
* Map a `Reference.kind` to a graph edge type. `import-use` is dropped
* (no edge type today provenance lives on the IMPORTS edge emitted
* by `emitImportEdges`).
*/
export function mapReferenceKindToEdgeType(
kind: Reference['kind'],
): 'CALLS' | 'ACCESSES' | 'EXTENDS' | 'USES' | undefined {
switch (kind) {
case 'call':
return 'CALLS';
case 'read':
case 'write':
return 'ACCESSES';
case 'inherits':
return 'EXTENDS';
case 'type-reference':
return 'USES';
case 'import-use':
return undefined;
default:
return undefined;
}
}
/**
* Resolve caller + target to graph ids and emit the edge. Returns true
* if the edge was emitted (not deduped, not skipped).
*
* `seen` is a language-shared dedup set keyed by
* `${edgeType}:${callerGraphId}->${targetGraphId}:${line}:${col}` so
* multiple language-specific post-passes can share it and never
* double-emit a resolution one of them already produced.
*/
export function tryEmitEdge(
graph: KnowledgeGraph,
scopes: ScopeResolutionIndexes,
nodeLookup: GraphNodeLookup,
site: {
readonly inScope: ScopeId;
readonly atRange: { startLine: number; startCol: number };
readonly kind: string;
},
targetDef: SymbolDefinition,
reason: string,
seen: Set<string>,
confidence = 0.85,
collapseByCallerTarget = false,
): boolean {
const callerGraphId = resolveCallerGraphId(site.inScope, scopes, nodeLookup);
const targetGraphId = resolveDefGraphId(targetDef.filePath, targetDef, nodeLookup);
const edgeType = mapReferenceKindToEdgeType(site.kind as Reference['kind']);
if (callerGraphId === undefined) return false;
if (targetGraphId === undefined) return false;
if (edgeType === undefined) return false;
// CALLS edges may collapse to `(caller, target)` granularity when
// the provider opts in (C# matches legacy DAG behavior this way).
// Write/read ACCESSES keep per-site dedup so multiple writes to the
// same field on different lines produce distinct edges.
const useCollapsed = collapseByCallerTarget && edgeType === 'CALLS';
const dedupKey = useCollapsed
? `${edgeType}:${callerGraphId}->${targetGraphId}`
: `${edgeType}:${callerGraphId}->${targetGraphId}:${site.atRange.startLine}:${site.atRange.startCol}`;
if (seen.has(dedupKey)) return false;
seen.add(dedupKey);
graph.addRelationship({
id: `rel:${dedupKey}`,
sourceId: callerGraphId,
targetId: targetGraphId,
type: edgeType,
confidence,
reason,
});
return true;
}

View file

@ -0,0 +1,128 @@
/**
* Scope-resolution legacy graph-node ID bridging.
*
* Two functions:
* - `resolveDefGraphId` turn a scope-resolution `SymbolDefinition`
* into the graph's node id for the corresponding legacy node.
* - `resolveCallerGraphId` walk a scope chain from a reference
* site upward to find the enclosing function/method/class and
* return its graph-node id. Falls back to the File node for
* module-level calls so those still get an edge source.
*
* Next-consumer contract: language-agnostic. Any OO language with
* file-level module semantics (TypeScript, Java, Go, Kotlin) can
* reuse `resolveCallerGraphId` as-is. Languages with different
* top-level semantics (COBOL programs, Rust crate modules) may want
* a different file-level fallback cross that bridge when they
* migrate.
*/
import type { NodeLabel, ScopeId, SymbolDefinition } from 'gitnexus-shared';
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
import { generateId } from '../../../../lib/utils.js';
import {
isLinkableLabel,
qualifiedKey,
simpleKey,
type GraphNodeLookup,
} from '../graph-bridge/node-lookup.js';
/**
* Look up a `SymbolDefinition` in the graph node lookup.
*
* Tries the type-prefixed fully-qualified key FIRST. That's the only
* correct key when:
* - Two classes in the same file define a method with the same
* simple name (`class User: def save` + `class Document: def save`).
* - A top-level function and a class method share a simple name
* (`def save` + `class User: def save` the Function's qualifier
* is just `save`, which would alias the Method's simple-key slot
* without the type prefix).
*
* Falls back to the simple name for definitions whose qualifier the
* lookup didn't capture (rare, but keeps cross-file simple-name
* resolution working for languages that don't yet synthesize
* qualifiers).
*/
export function resolveDefGraphId(
filePath: string,
def: { qualifiedName?: string; type?: NodeLabel; parameterTypes?: readonly string[] },
nodeLookup: GraphNodeLookup,
): string | undefined {
const qn = def.qualifiedName;
if (qn === undefined || qn.length === 0) return undefined;
if (def.type !== undefined) {
// Overload disambiguation: when the def carries parameter types,
// try the parameter-typed key first so same-name same-arity
// overloads route to their distinct graph nodes.
if (
def.type === 'Method' &&
def.parameterTypes !== undefined &&
def.parameterTypes.length > 0
) {
const pKey = qualifiedKey(filePath, def.type, `${qn}~${def.parameterTypes.join(',')}`);
const pHit = nodeLookup.get(pKey);
if (pHit !== undefined) return pHit;
}
const qualifiedHit = nodeLookup.get(qualifiedKey(filePath, def.type, qn));
if (qualifiedHit !== undefined) return qualifiedHit;
}
const simpleName = qn.lastIndexOf('.') === -1 ? qn : qn.slice(qn.lastIndexOf('.') + 1);
return nodeLookup.get(simpleKey(filePath, simpleName));
}
/** Derive the simple (unqualified) name of a def from its `qualifiedName`. */
export function simpleQualifiedName(def: SymbolDefinition): string | undefined {
const q = def.qualifiedName;
if (q === undefined || q.length === 0) return undefined;
const dot = q.lastIndexOf('.');
return dot === -1 ? q : q.slice(dot + 1);
}
/**
* Walk the scope chain from `startScope` upward looking for the first
* scope whose `ownedDefs` contains a Function/Method/Class that's
* our caller anchor. Translate via `nodeLookup` to the graph-node ID.
*
* Module-level references (e.g. Python `u = models.User()` at top
* level) have no enclosing function/method/class. Fall back to the
* File node for the scope's filePath so those calls still get an
* edge source. Matches legacy DAG behavior where module-level CALLS
* edges originate from the file symbol.
*/
export function resolveCallerGraphId(
startScope: ScopeId,
scopes: ScopeResolutionIndexes,
nodeLookup: GraphNodeLookup,
): string | undefined {
let current: ScopeId | null = startScope;
const visited = new Set<ScopeId>();
let lastFilePath: string | undefined;
while (current !== null) {
if (visited.has(current)) return undefined;
visited.add(current);
const scope = scopes.scopeTree.getScope(current);
if (scope === undefined) break;
lastFilePath = scope.filePath;
// Prefer Function/Method anchors; fall back to Class.
const fnDef = scope.ownedDefs.find(
(d) => d.type === 'Function' || d.type === 'Method' || d.type === 'Constructor',
);
if (fnDef !== undefined) {
const id = resolveDefGraphId(scope.filePath, fnDef, nodeLookup);
if (id !== undefined) return id;
}
const classDef = scope.ownedDefs.find((d) => isLinkableLabel(d.type));
if (classDef !== undefined) {
const id = resolveDefGraphId(scope.filePath, classDef, nodeLookup);
if (id !== undefined) return id;
}
current = scope.parent;
}
// Module-level calls — fall back to the File node for the scope's filePath.
if (lastFilePath !== undefined) {
return generateId('File', lastFilePath);
}
return undefined;
}

View file

@ -0,0 +1,57 @@
/**
* FileFile IMPORTS edge emission from a finalized `ImportEdge` map.
*
* Deduplicates by `(sourceFile, targetFile)` so multi-symbol imports
* from the same module collapse to a single edge matching the
* legacy schema.
*
* Next-consumer contract: language-agnostic. Any provider with a
* scope-resolution ImportEdge stream emits FileFile edges via this
* single function. The `reason` defaults to
* `'scope-resolution: import'`; provider may override if downstream
* filters on reason.
*/
import type { ImportEdge, ScopeId } from 'gitnexus-shared';
import type { KnowledgeGraph } from '../../../graph/types.js';
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
import { generateId } from '../../../../lib/utils.js';
export function emitImportEdges(
graph: KnowledgeGraph,
imports: ReadonlyMap<ScopeId, readonly ImportEdge[]>,
scopeTree: ScopeResolutionIndexes['scopeTree'],
reason = 'scope-resolution: import',
): number {
const seen = new Set<string>();
let emitted = 0;
for (const [scopeId, edges] of imports) {
const scope = scopeTree.getScope(scopeId);
if (scope === undefined) continue;
const sourceFile = scope.filePath;
for (const edge of edges) {
if (edge.targetFile === null) continue;
if (edge.targetFile === sourceFile) continue;
const dedupKey = `${sourceFile}->${edge.targetFile}`;
if (seen.has(dedupKey)) continue;
seen.add(dedupKey);
const sourceId = generateId('File', sourceFile);
const targetId = generateId('File', edge.targetFile);
graph.addRelationship({
id: generateId('IMPORTS', dedupKey),
sourceId,
targetId,
type: 'IMPORTS',
confidence: 1.0,
reason,
});
emitted++;
}
}
return emitted;
}

View file

@ -0,0 +1,36 @@
/**
* Wrap a `DefId → ancestor DefId[]` MRO map in the shared
* `MethodDispatchIndex` shape so it slots into
* `ScopeResolutionIndexes.methodDispatch`.
*
* `finalizeScopeModel` builds an empty `MethodDispatchIndex` by design
* (per the comment in `finalize-orchestrator.ts`). Per-language
* providers compute MRO their own way (Python C3 walk, Java class
* hierarchy, Ruby mixin chains, etc.) and use this bridge to plug the
* result back into the shared index shape.
*
* Next-consumer contract: any language that computes its own MRO map
* calls `buildPopulatedMethodDispatch(mroByOwnerDefId)` and assigns the
* result to `indexes.methodDispatch`. Interface-implementer tracking
* (`implsByInterfaceDefId`) stays empty in V1 providers that need it
* can extend the return shape without breaking existing consumers.
*/
import type { MethodDispatchIndex } from 'gitnexus-shared';
const EMPTY_DEFS: readonly string[] = Object.freeze([]);
export function buildPopulatedMethodDispatch(
mroByDefId: ReadonlyMap<string, readonly string[]>,
): MethodDispatchIndex {
return {
mroByOwnerDefId: mroByDefId,
implsByInterfaceDefId: new Map(),
mroFor(ownerDefId) {
return mroByDefId.get(ownerDefId) ?? EMPTY_DEFS;
},
implementorsOf() {
return EMPTY_DEFS;
},
};
}

View file

@ -0,0 +1,126 @@
/**
* Build a `(filePath, name) → graphNodeId` lookup over the graph's
* Function/Method/Class/Constructor nodes. Two keys per node:
*
* - simple name (`User` / `save`) legacy fallback
* - qualified name when derivable from the node id (`User.save`)
*
* The qualified key is the authoritative one when two classes in the
* same file define a method with the same simple name
* (`class User: def save` + `class Document: def save`). Without it,
* the simple-name key collides and every `document.save()` CALLS edge
* would silently target `User.save`. Method node ids encode the
* qualifier (`Method:file.py:User.save#1`), so we parse it back out.
*
* Language-agnostic seam. Any language provider migrating to the
* registry-primary path can consume this to translate scope-resolution
* `SymbolDefinition.nodeId` values into the legacy graph-node ID
* format that downstream consumers (queries, edges, MCP) expect.
*/
import type { NodeLabel } from 'gitnexus-shared';
import type { KnowledgeGraph } from '../../../graph/types.js';
export type GraphNodeLookup = ReadonlyMap<string, string>;
/**
* Parse a qualified name out of a Function/Method node id.
*
* Node id format: `${label}:${filePath}:${qualifiedName}${arityTag}`,
* where `arityTag` is `#<n>` (or empty). Strips the known-length
* label + filePath prefix so colons inside `filePath` (Windows
* `C:\...`) don't break the parse. Returns `undefined` when the id
* doesn't match the expected shape.
*/
function parseQualifiedFromId(id: string, label: NodeLabel, filePath: string): string | undefined {
const prefix = `${label}:${filePath}:`;
if (!id.startsWith(prefix)) return undefined;
const suffix = id.slice(prefix.length);
if (suffix.length === 0) return undefined;
const hash = suffix.indexOf('#');
return hash === -1 ? suffix : suffix.slice(0, hash);
}
/**
* Build a qualified-key string in a separate keyspace from simple-key
* strings. Prefix `<q>` can't appear in a valid filePath on any OS, so
* no collision between the two keyspaces is possible.
*
* Includes the node label so a top-level `def save` (Function,
* qualifier = `save`) doesn't alias a class method `User.save` (Method,
* simple name = `save`) whose Function-typed qualifier would collapse
* to the same simple-key slot in a single map.
*/
export function qualifiedKey(filePath: string, label: NodeLabel, qualifiedName: string): string {
return `<q>:${filePath}::${label}::${qualifiedName}`;
}
/** Simple-name key (legacy fallback keyspace — no `<q>` prefix). */
export function simpleKey(filePath: string, name: string): string {
return `${filePath}::${name}`;
}
export function buildGraphNodeLookup(graph: KnowledgeGraph): GraphNodeLookup {
const lookup = new Map<string, string>();
for (const node of graph.iterNodes()) {
const props = node.properties as {
filePath?: string;
name?: string;
qualifiedName?: string;
};
if (props.filePath === undefined || props.name === undefined) continue;
if (!isLinkableLabel(node.label)) continue;
// Primary key: fully-qualified name + label, in a separate
// keyspace from simple names. Class nodes carry `qualifiedName`
// in their properties (set by the parsing processor).
// Method/Function nodes do not, so derive the qualifier from the
// node id — that's where the parse-phase encoded it. Including
// the label avoids a collision when a free Function's qualifier
// happens to equal a Method's simple name (e.g. top-level
// `def save` vs `class User: def save`).
const qualified =
props.qualifiedName ?? parseQualifiedFromId(node.id, node.label, props.filePath);
if (qualified !== undefined && qualified.length > 0) {
const qKey = qualifiedKey(props.filePath, node.label, qualified);
if (!lookup.has(qKey)) lookup.set(qKey, node.id);
// Overload-disambiguating key: include parameter types so two
// same-arity overloads (e.g. `Lookup(int)` vs `Lookup(string)`)
// map to distinct graph nodes. Legacy parse-phase encodes the
// type tag into the node id; we register both that node id and
// a parameter-types-suffixed key so resolveDefGraphId can find
// the right overload by matching its def's parameterTypes.
const pTypes = (props as { parameterTypes?: readonly string[] }).parameterTypes;
if (pTypes !== undefined && pTypes.length > 0 && node.label === 'Method') {
const pKey = qualifiedKey(props.filePath, node.label, `${qualified}~${pTypes.join(',')}`);
// Each overload is unique — set unconditionally.
lookup.set(pKey, node.id);
}
}
// Fallback key: simple name. First-wins within a file — used when
// the caller doesn't know the qualifier (unqualified free-call
// fallback, cross-file resolution where MethodRegistry already
// disambiguated the owner).
const sKey = simpleKey(props.filePath, props.name);
if (!lookup.has(sKey)) lookup.set(sKey, node.id);
}
return lookup;
}
export function isLinkableLabel(label: NodeLabel): boolean {
return (
label === 'Function' ||
label === 'Method' ||
label === 'Constructor' ||
label === 'Class' ||
label === 'Interface' ||
label === 'Struct' ||
label === 'Enum' ||
// Variable / Property are linkable too — receiver-bound write/read
// ACCESSES edges target field nodes (e.g. `user.name = "x"` →
// ACCESSES edge to User's `name` Variable/Property node).
label === 'Variable' ||
label === 'Property'
);
}

View file

@ -0,0 +1,99 @@
/**
* Translate the resolved `ReferenceIndex` into legacy graph edges.
*
* Per reference:
* 1. Resolve `fromScope` caller graph-node id by walking the scope
* chain looking for an enclosing Function/Method/Class.
* 2. Resolve `toDef` target graph-node id via `nodeLookup`.
* 3. Emit the edge (`CALLS` / `READS` / `WRITES` / `EXTENDS` / `USES`)
* with the standard reason format.
*
* Skips (without throwing) when either side fails to map either side
* may legitimately not exist as a graph node (e.g. a resolved target
* lives in an external file that wasn't ingested into the graph).
*
* Next-consumer contract: this function is the canonical bridge from
* a shared `ReferenceIndex` into per-language graph edges. Every
* registry-primary language provider calls this exactly once with its
* `referenceIndex` output and its own `nodeLookup`.
*/
import type { Reference, ScopeId } from 'gitnexus-shared';
import type { KnowledgeGraph } from '../../../graph/types.js';
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
import { resolveCallerGraphId, resolveDefGraphId } from '../graph-bridge/ids.js';
import { mapReferenceKindToEdgeType } from '../graph-bridge/edges.js';
import type { GraphNodeLookup } from '../graph-bridge/node-lookup.js';
/**
* Optional opaque skip key providers may pre-emit edges (e.g. via
* receiver-bound post-passes) and want this loop to skip references at
* the same source position so the shared resolver's potentially-wrong
* fallback resolution doesn't fight the precise emission. The key is
* `${filePath}:${startLine}:${startCol}`.
*/
type ReferenceSiteSkipSet = ReadonlySet<string>;
export function emitReferencesViaLookup(
graph: KnowledgeGraph,
scopes: ScopeResolutionIndexes,
referenceIndex: { readonly bySourceScope: ReadonlyMap<ScopeId, readonly Reference[]> },
nodeLookup: GraphNodeLookup,
skipSites?: ReferenceSiteSkipSet,
): { emitted: number; skipped: number } {
let emitted = 0;
let skipped = 0;
const seen = new Set<string>();
for (const [fromScope, refs] of referenceIndex.bySourceScope) {
const callerGraphId = resolveCallerGraphId(fromScope, scopes, nodeLookup);
if (callerGraphId === undefined) {
skipped += refs.length;
continue;
}
const fromScopeMeta = scopes.scopeTree.getScope(fromScope);
const fromFilePath = fromScopeMeta?.filePath;
for (const ref of refs) {
if (skipSites !== undefined && fromFilePath !== undefined) {
const siteKey = `${fromFilePath}:${ref.atRange.startLine}:${ref.atRange.startCol}`;
if (skipSites.has(siteKey)) {
skipped++;
continue;
}
}
const targetDef = scopes.defs.get(ref.toDef);
if (targetDef === undefined) {
skipped++;
continue;
}
const targetGraphId = resolveDefGraphId(targetDef.filePath, targetDef, nodeLookup);
if (targetGraphId === undefined) {
skipped++;
continue;
}
const edgeType = mapReferenceKindToEdgeType(ref.kind);
if (edgeType === undefined) {
skipped++;
continue;
}
const dedupKey = `${edgeType}:${callerGraphId}->${targetGraphId}:${ref.atRange.startLine}:${ref.atRange.startCol}`;
if (seen.has(dedupKey)) continue;
seen.add(dedupKey);
graph.addRelationship({
id: `rel:${dedupKey}`,
sourceId: callerGraphId,
targetId: targetGraphId,
type: edgeType,
confidence: ref.confidence,
reason: `scope-resolution: ${ref.kind}`,
});
emitted++;
}
}
return { emitted, skipped };
}

View file

@ -0,0 +1,240 @@
/**
* Resolve a compound-receiver expression's TYPE `user.address.save()`,
* `svc.get_user().save()`, `c.greet().save()` to the class def of
* the value the receiver expression produces.
*
* Three shapes (parsed C-family-style):
* - bare identifier `name` look up via typeBinding chain
* - dotted `obj.field[.field]…` walk fields via class-scope typeBindings
* - call `expr.method()` recurse into expr, find method's return-type
* typeBinding on its class, resolve to a class
*
* **Field-fallback heuristic** (Phase-9C "unified fixpoint"): when the
* receiver class has no `methodName`, walk its fields and try the
* lookup on each field's type. Useful for dynamically-typed languages
* (Python). Strictly-typed languages should pass
* `fieldFallbackOnMethodLookup: false` via `ScopeResolver`.
*
* Generic for any C-family language (`.` member access, `()` call
* syntax). Languages with non-C-family syntax (Ruby blocks, COBOL)
* either don't trigger the call branch or skip this pass entirely.
*/
import type { ScopeId, SymbolDefinition, TypeRef } from 'gitnexus-shared';
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
import type { WorkspaceResolutionIndex } from '../workspace-index.js';
import {
findClassBindingInScope,
findExportedDefByName,
findReceiverTypeBinding,
} from '../scope/walkers.js';
/** Max depth for compound-receiver chain resolution (`a().b().c().d()`).
* Practical code rarely exceeds 3-4 hops; the cap prevents
* pathological recursion if the receiver text is malformed. */
const COMPOUND_RECEIVER_MAX_DEPTH = 4;
interface ResolveCompoundReceiverOptions {
/** When true (default), if method lookup fails on the receiver's
* class, walk its fields and try the lookup on each field's class.
* Phase-9C "unified fixpoint" Python-shaped heuristic. */
readonly fieldFallback?: boolean;
/** Language-specific accessor unwrap `data.Values` on a
* Dictionary<K,V>-typed receiver yields V (C#), etc. Returns the
* element type's simple name, or `undefined` to let the regular
* field-walk handle the access. */
readonly unwrapCollectionAccessor?: (
receiverType: string,
accessor: string,
) => string | undefined;
/** Walk up from the class scope to ancestor (Module) scopes when
* looking up a method's return-type typeBinding. Only enable for
* languages that hoist return-type bindings to Module scope (C#);
* otherwise we risk picking up unrelated module-level bindings. */
readonly hoistTypeBindingsToModule?: boolean;
}
export function resolveCompoundReceiverClass(
receiverText: string,
inScope: ScopeId,
scopes: ScopeResolutionIndexes,
index: WorkspaceResolutionIndex,
options: ResolveCompoundReceiverOptions = {},
depth = 0,
): SymbolDefinition | undefined {
const classScopeByDefId = index.classScopeByDefId;
if (depth > COMPOUND_RECEIVER_MAX_DEPTH) return undefined;
const text = receiverText.trim();
if (text.length === 0) return undefined;
const fieldFallback = options.fieldFallback ?? true;
// Bare identifier — resolve via typeBinding then class lookup.
if (!text.includes('.') && !text.includes('(')) {
const tb = findReceiverTypeBinding(inScope, text, scopes);
if (tb === undefined) return undefined;
return findClassBindingInScope(tb.declaredAtScope, tb.rawName, scopes);
}
// Trailing `()` — call expression. Strip it and resolve the function
// expression's return type. We only handle the canonical `f()` /
// `obj.method()` shape; nested-arg expressions like `f(g())` are
// out of scope for V1 (depth-capped recursion catches infinite loops).
if (text.endsWith(')')) {
const openIdx = matchingOpenParen(text);
if (openIdx === -1) return undefined;
const fnExpr = text.slice(0, openIdx).trim();
if (fnExpr.length === 0) return undefined;
const lastDot = fnExpr.lastIndexOf('.');
if (lastDot === -1) {
// Free call `name()`. Look up function in scope, then its
// return-type typeBinding (which lives in the function's
// enclosing scope per the language's return-type hoist rule).
const fnDef = findExportedDefByName(fnExpr, inScope, scopes, index);
if (fnDef === undefined) return undefined;
const retType = findReceiverTypeBinding(inScope, fnExpr, scopes);
if (retType === undefined) return undefined;
return findClassBindingInScope(retType.declaredAtScope, retType.rawName, scopes);
}
// `obj.method()` — resolve obj's class, look up method's return
// type on that class scope (or the MRO).
const objExpr = fnExpr.slice(0, lastDot);
const methodName = fnExpr.slice(lastDot + 1);
const objClass = resolveCompoundReceiverClass(
objExpr,
inScope,
scopes,
index,
options,
depth + 1,
);
if (objClass === undefined) return undefined;
let retType: TypeRef | undefined;
const ownerChain = [objClass.nodeId, ...scopes.methodDispatch.mroFor(objClass.nodeId)];
for (const ownerId of ownerChain) {
const cs = classScopeByDefId.get(ownerId);
const candidate = cs?.typeBindings.get(methodName);
if (candidate !== undefined) {
retType = candidate;
break;
}
// Fallback: walk up from the class scope looking for a return-
// type binding on an ancestor (Module) scope. Gated on
// `hoistTypeBindingsToModule` because only languages that hoist
// method return-type bindings to Module scope need this path;
// enabling it unconditionally would let other languages pick up
// unrelated module-level bindings. See contract doc for the
// invariant and `propagateImportedReturnTypes` for how the
// hoisted bindings originate.
if (cs !== undefined && options.hoistTypeBindingsToModule === true) {
let curId: ScopeId | null = cs.parent;
while (curId !== null) {
const curScope = scopes.scopeTree.getScope(curId);
if (curScope === undefined) break;
const cand = curScope.typeBindings.get(methodName);
if (cand !== undefined) {
retType = cand;
break;
}
curId = curScope.parent;
}
if (retType !== undefined) break;
}
}
if (retType === undefined && fieldFallback) {
const objCs = classScopeByDefId.get(objClass.nodeId);
if (objCs !== undefined) {
for (const [, fieldType] of objCs.typeBindings) {
const fieldClass = findClassBindingInScope(
fieldType.declaredAtScope,
fieldType.rawName,
scopes,
);
if (fieldClass === undefined) continue;
const fcs = classScopeByDefId.get(fieldClass.nodeId);
const candidate = fcs?.typeBindings.get(methodName);
if (candidate !== undefined) {
retType = candidate;
break;
}
}
}
}
if (retType === undefined) return undefined;
return findClassBindingInScope(retType.declaredAtScope, retType.rawName, scopes);
}
// Pure dotted access `obj.field[.field]…` — walk fields.
const parts = text.split('.');
// Language-specific collection-accessor suffix (C#'s `data.Values`
// on Dictionary<K,V>, etc.). When the provider hook recognizes
// the final segment and unwraps the receiver's generic, return
// the element class directly. Resolved before the field-walk
// because Dictionary-family types aren't local class defs.
if (options.unwrapCollectionAccessor !== undefined && parts.length >= 2) {
const last = parts[parts.length - 1]!;
const prefix = parts.slice(0, -1).join('.');
let prefixType: TypeRef | undefined;
if (parts.length === 2) {
prefixType = findReceiverTypeBinding(inScope, prefix, scopes);
} else {
// Recursive resolution: walk the prefix as a dotted class chain
// to find its typeRef. We need the TypeRef (not the class def)
// because the hook inspects the raw generic args (e.g.
// `Dictionary<string, User>`).
const headInner = parts[0]!;
let cur = findReceiverTypeBinding(inScope, headInner, scopes);
for (let i = 1; i < parts.length - 1 && cur !== undefined; i++) {
const cls = findClassBindingInScope(cur.declaredAtScope, cur.rawName, scopes);
if (cls === undefined) {
cur = undefined;
break;
}
const cs = classScopeByDefId.get(cls.nodeId);
cur = cs?.typeBindings.get(parts[i]!);
}
prefixType = cur;
}
if (prefixType !== undefined) {
const elemName = options.unwrapCollectionAccessor(prefixType.rawName, last);
if (elemName !== undefined) {
return findClassBindingInScope(prefixType.declaredAtScope, elemName, scopes);
}
}
}
const head = parts[0]!;
const headType = findReceiverTypeBinding(inScope, head, scopes);
let currentClass: SymbolDefinition | undefined = headType
? findClassBindingInScope(headType.declaredAtScope, headType.rawName, scopes)
: undefined;
for (let i = 1; i < parts.length && currentClass !== undefined; i++) {
const fieldName = parts[i]!;
const cs = classScopeByDefId.get(currentClass.nodeId);
const fieldType = cs?.typeBindings.get(fieldName);
if (fieldType === undefined) return undefined;
currentClass = findClassBindingInScope(fieldType.declaredAtScope, fieldType.rawName, scopes);
}
return currentClass;
}
/** Find the index of the `(` that matches the trailing `)` of a
* call-expression text. Returns -1 if unbalanced. */
function matchingOpenParen(text: string): number {
if (!text.endsWith(')')) return -1;
let depth = 0;
for (let i = text.length - 1; i >= 0; i--) {
const ch = text[i];
if (ch === ')') depth++;
else if (ch === '(') {
depth--;
if (depth === 0) return i;
}
}
return -1;
}

View file

@ -0,0 +1,155 @@
/**
* Emit CALLS edges for free-call reference sites whose target is
* imported (or otherwise visible only via post-finalize scope.bindings).
*
* The shared `MethodRegistry.lookup` only consults `scope.bindings`
* (pre-finalize / local-only) for free calls. Cross-file imports land
* in `indexes.bindings` (post-finalize). Without this fallback, every
* `from x import f; f()` resolves to "unresolved".
*
* **Free-call dedup contract (Contract Invariant I2):** free calls
* collapse to one CALLS edge per (caller, target) pair regardless of
* how many call sites the caller contains. Mirrors the legacy DAG's
* dedup semantics (what the `default-params` / `variadic` / `overload`
* fixtures expect). Member calls keep position-based dedup elsewhere.
*
* Generic; promoted from `languages/python/scope-resolver.ts` per the scope-resolution
* generalization plan.
*/
import type { ParsedFile, Reference, ScopeId, SymbolDefinition } from 'gitnexus-shared';
import type { KnowledgeGraph } from '../../../graph/types.js';
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
import type { SemanticModel } from '../../model/semantic-model.js';
import type { WorkspaceResolutionIndex } from '../workspace-index.js';
import type { GraphNodeLookup } from '../graph-bridge/node-lookup.js';
import { resolveCallerGraphId, resolveDefGraphId } from '../graph-bridge/ids.js';
import { findCallableBindingInScope, findClassBindingInScope } from '../scope/walkers.js';
import { narrowOverloadCandidates } from './overload-narrowing.js';
export function emitFreeCallFallback(
graph: KnowledgeGraph,
scopes: ScopeResolutionIndexes,
parsedFiles: readonly ParsedFile[],
nodeLookup: GraphNodeLookup,
_referenceIndex: { readonly bySourceScope: ReadonlyMap<ScopeId, readonly Reference[]> },
handledSites: Set<string>,
model: SemanticModel,
workspaceIndex: WorkspaceResolutionIndex,
): number {
let emitted = 0;
const seen = new Set<string>();
for (const parsed of parsedFiles) {
for (const site of parsed.referenceSites) {
if (site.kind !== 'call') continue;
if (site.explicitReceiver !== undefined) continue;
// Constructor form (`new User(...)`): resolve the class, then
// emit CALLS to its explicit Constructor def (when present) or
// to the Class node itself (implicit constructor). Legacy emits
// the same two targets; see test expectations.
let fnDef: SymbolDefinition | undefined;
if (site.callForm === 'constructor') {
const classDef = findClassBindingInScope(site.inScope, site.name, scopes);
if (classDef !== undefined) {
fnDef = pickConstructorOrClass(classDef, workspaceIndex);
}
}
// Implicit-this overload narrowing: an unqualified call inside
// a method body might be calling a sibling overload on the
// enclosing class. When the workspace has multiple methods of
// the same name in a single class, choose the best match by
// arity + argument types.
if (fnDef === undefined) {
fnDef = pickImplicitThisOverload(site, scopes, workspaceIndex, model);
}
if (fnDef === undefined) {
fnDef = findCallableBindingInScope(site.inScope, site.name, scopes);
}
if (fnDef === undefined) continue;
const callerGraphId = resolveCallerGraphId(site.inScope, scopes, nodeLookup);
if (callerGraphId === undefined) continue;
const tgtGraphId = resolveDefGraphId(fnDef.filePath, fnDef, nodeLookup);
if (tgtGraphId === undefined) continue;
// Always mark the site as handled — even when the dedup-collapse
// means we don't add a new edge — so `emit-references` skips its
// potentially-wrong fallback for the same site.
handledSites.add(`${parsed.filePath}:${site.atRange.startLine}:${site.atRange.startCol}`);
const relId = `rel:CALLS:${callerGraphId}->${tgtGraphId}`;
if (seen.has(relId)) continue;
seen.add(relId);
graph.addRelationship({
id: relId,
sourceId: callerGraphId,
targetId: tgtGraphId,
type: 'CALLS',
confidence: 0.85,
// Match legacy DAG's reason convention so consumers that
// assert `reason === 'import-resolved'` keep working.
reason: fnDef.filePath !== parsed.filePath ? 'import-resolved' : 'local-call',
});
emitted++;
}
}
return emitted;
}
/** For a constructor call `new X(...)`, return the X class's explicit
* Constructor def (by walking the class scope's ownedDefs) or the
* Class def itself when no explicit Constructor exists. Matches
* legacy behavior tests assert targetLabel === 'Class' for implicit
* ctors and targetLabel === 'Constructor' for explicit ones. */
function pickConstructorOrClass(
classDef: SymbolDefinition,
workspaceIndex: WorkspaceResolutionIndex,
): SymbolDefinition {
const classScope = workspaceIndex.classScopeByDefId.get(classDef.nodeId);
if (classScope === undefined) return classDef;
for (const def of classScope.ownedDefs) {
if (def.type === 'Constructor') return def;
}
return classDef;
}
/** Walk up from the call-site scope to the enclosing class scope,
* pick a method member by name with overload narrowing on arity +
* argument types. Returns undefined if there's no enclosing class
* or no matching method. Used for implicit-this calls inside a
* class body where multiple overloads share the call name. */
function pickImplicitThisOverload(
site: {
readonly inScope: ScopeId;
readonly name: string;
readonly arity?: number;
readonly argumentTypes?: readonly string[];
},
scopes: ScopeResolutionIndexes,
workspaceIndex: WorkspaceResolutionIndex,
model: SemanticModel,
): SymbolDefinition | undefined {
// Find the enclosing Class scope by walking parents.
let curId: ScopeId | null = site.inScope;
let classScopeId: ScopeId | undefined;
while (curId !== null) {
const sc = scopes.scopeTree.getScope(curId);
if (sc === undefined) break;
if (sc.kind === 'Class') {
classScopeId = sc.id;
break;
}
curId = sc.parent;
}
if (classScopeId === undefined) return undefined;
// O(1) reverse-lookup via inverse map on WorkspaceResolutionIndex.
const classDefId = workspaceIndex.classScopeIdToDefId.get(classScopeId);
if (classDefId === undefined) return undefined;
const overloads = model.methods.lookupAllByOwner(classDefId, site.name);
if (overloads.length === 0) return undefined;
if (overloads.length === 1) return overloads[0];
const candidates = narrowOverloadCandidates(overloads, site.arity, site.argumentTypes);
return candidates[0];
}

View file

@ -0,0 +1,140 @@
/**
* Cross-file return-type typeBinding propagation + post-finalize
* chain re-follow.
*
* **Why this lives in scope-resolution:** the algorithm is language-agnostic.
* Every language with cross-file callable imports needs the same
* mirror-binding step, otherwise `u = f(); u.save()` only resolves
* when `f` is in the same file as the call.
*
* **Mutation contract (Contract Invariant I3 + I6):**
* - Mutates `Scope.typeBindings` (a plain `new Map(...)` from
* `draftToScope`, NOT frozen intentional, do not freeze).
* - MUST run AFTER `finalizeScopeModel` (so `indexes.bindings` is
* populated) but BEFORE `resolveReferenceSites` (so resolution
* sees the propagated types).
*
* Generic; promoted from `languages/python/scope-resolver.ts` per the scope-resolution
* generalization plan.
*/
import type { ParsedFile, ScopeId, TypeRef } from 'gitnexus-shared';
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
import type { WorkspaceResolutionIndex } from '../workspace-index.js';
/** Max chain depth for the post-finalize re-follow. */
const RECHAIN_MAX_DEPTH = 8;
/** Walk `ref.rawName` through the scope chain's typeBindings looking
* for a terminal class-like rawName. Mirrors the in-extractor
* `followChainedRef` but operates on post-finalize Scope objects so
* it can see imported return-types propagated by
* `propagateImportedReturnTypes`. */
function followChainPostFinalize(
start: TypeRef,
fromScopeId: ScopeId,
scopes: ScopeResolutionIndexes,
): TypeRef {
let current = start;
const visited = new Set<string>();
for (let depth = 0; depth < RECHAIN_MAX_DEPTH; depth++) {
if (current.rawName.includes('.')) return current;
let scopeId: ScopeId | null = fromScopeId;
let next: TypeRef | undefined;
while (scopeId !== null) {
const scope = scopes.scopeTree.getScope(scopeId);
if (scope === undefined) break;
next = scope.typeBindings.get(current.rawName);
if (next !== undefined && next !== current) break;
next = undefined;
scopeId = scope.parent;
}
if (next === undefined) return current;
if (visited.has(next.rawName)) return current;
visited.add(next.rawName);
current = next;
}
return current;
}
/**
* Copy return-type typeBindings across module boundaries via import
* bindings. For each module-scope import like `from x import f`, look
* up `f` in the source file's module-scope typeBindings (which carries
* `f → ReturnType` from the language's return-type annotation
* capture) and mirror that binding into the importer's module scope.
*
* After propagation, re-runs the chain-follow on every scope's
* typeBindings the in-extractor pass-4 ran before propagation and
* missed any chain whose terminal lived in a foreign file.
*
* Scope-chain concern (verified 2026-04-21): `pythonImportOwningScope`
* documents that function-local `from x import y` binds `y` to the
* inner function scope, which would make a module-only write miss
* non-module importers. In practice `finalize-algorithm` hoists those
* bindings into `indexes.bindings[moduleScope]` regardless of where
* the `import` statement appears the integration fixture
* `python-function-local-import-chain` exercises a chained
* receiver-bound call `u = get_user(); u.save()` inside a function
* body and emits the expected `do_work → User.save` edge. The
* module-scope write is sufficient today. If finalize routing ever
* changes to honor the hook's per-scope contract, this pass must
* iterate `indexes.bindings` over every scope and mirror into the
* binding-owning scope's `typeBindings`, not just the module's.
*/
export function propagateImportedReturnTypes(
parsedFiles: readonly ParsedFile[],
indexes: ScopeResolutionIndexes,
index: WorkspaceResolutionIndex,
): void {
const moduleScopeByFile = index.moduleScopeByFile;
for (const parsed of parsedFiles) {
const importerModule = moduleScopeByFile.get(parsed.filePath);
if (importerModule === undefined) continue;
const finalizedBindings = indexes.bindings.get(importerModule.id);
if (finalizedBindings === undefined) continue;
for (const [localName, refs] of finalizedBindings) {
// Skip if importer already has a typeBinding for this name (e.g.
// an explicit local annotation should win over import-derived).
if (importerModule.typeBindings.has(localName)) continue;
for (const ref of refs) {
if (ref.origin !== 'import' && ref.origin !== 'reexport') continue;
const sourceModule = moduleScopeByFile.get(ref.def.filePath);
if (sourceModule === undefined) continue;
// The source file's typeBinding is keyed by the def's simple
// name (e.g. `get_user`), not the importer's local alias. Use
// the def's qualifiedName tail.
const qn = ref.def.qualifiedName;
if (qn === undefined) continue;
const dot = qn.lastIndexOf('.');
const sourceName = dot === -1 ? qn : qn.slice(dot + 1);
const sourceTypeRef = sourceModule.typeBindings.get(sourceName);
if (sourceTypeRef === undefined) continue;
// Mirror the binding under the importer's local alias —
// mutating typeBindings is safe because draftToScope produced
// a non-frozen Map.
(importerModule.typeBindings as Map<string, TypeRef>).set(localName, sourceTypeRef);
break;
}
}
}
// Re-follow chains across every scope so chains terminating in a
// freshly-propagated import binding resolve to their terminal type.
for (const parsed of parsedFiles) {
for (const scope of parsed.scopes) {
for (const [name, ref] of scope.typeBindings) {
const resolved = followChainPostFinalize(ref, scope.id, indexes);
if (resolved !== ref) {
(scope.typeBindings as Map<string, TypeRef>).set(name, resolved);
}
}
}
}
}

View file

@ -0,0 +1,108 @@
/**
* Generic MRO (method-resolution-order) builder.
*
* Walks the graph's `EXTENDS` edges to recover an inheritance map,
* then asks the per-language `LinearizeStrategy` to order each class's
* ancestors. Returns `Map<classDefId, ancestorDefId[]>` ready to plug
* into `MethodDispatchIndex` via `buildPopulatedMethodDispatch`.
*
* **Why a strategy hook:** linearization differs across languages.
* - Python (depth-first first-seen, single inheritance): trivially
* correct; multi-inheritance falls back to BFS dedup. Real C3
* would handle diamond hierarchies defer until we hit one.
* - Java (single-inheritance only): walk one parent.
* - C++ (multiple inheritance): C3-like or BFS depending on how
* strict the consumer needs to be.
* - Languages without inheritance (COBOL): return empty list.
*
* The strategy receives the FULL ancestry context (`directParents` +
* `parentsByDefId`) so C3 implementations have what they need.
*/
import type { ParsedFile } from 'gitnexus-shared';
import type { KnowledgeGraph } from '../../../graph/types.js';
import type { GraphNodeLookup } from '../graph-bridge/node-lookup.js';
import type { LinearizeStrategy } from '../contract/scope-resolver.js';
import { resolveDefGraphId } from '../graph-bridge/ids.js';
/**
* Build an MRO map keyed by scope-resolution Class `DefId`.
*
* Steps:
* 1. Collect EXTENDS edges from the graph `parentsByGraphId`.
* 2. Collect Class defs from `parsedFiles` and translate to graph
* ids via `nodeLookup` `defIdByGraphId` (the bridge between
* scope-resolution DefId and the legacy graph node id).
* 3. For each Class def, ask `linearize` for its ancestor order.
*/
export function buildMro(
graph: KnowledgeGraph,
parsedFiles: readonly ParsedFile[],
nodeLookup: GraphNodeLookup,
linearize: LinearizeStrategy,
): Map<string /* DefId */, string[] /* DefId[] */> {
// Step 1: parentsByGraphId — typed iterator skips the per-edge type
// check and the millions of CALLS/ACCESSES/IMPORTS/DEFINES edges
// that aren't relevant to MRO.
const parentsByGraphId = new Map<string, string[]>();
for (const rel of graph.iterRelationshipsByType('EXTENDS')) {
let list = parentsByGraphId.get(rel.sourceId);
if (list === undefined) {
list = [];
parentsByGraphId.set(rel.sourceId, list);
}
list.push(rel.targetId);
}
// Step 2: defIdByGraphId — translate graph ids to scope-resolution DefIds.
const defIdByGraphId = new Map<string, string>();
for (const parsed of parsedFiles) {
for (const def of parsed.localDefs) {
if (def.type !== 'Class') continue;
const graphId = resolveDefGraphId(parsed.filePath, def, nodeLookup);
if (graphId !== undefined) defIdByGraphId.set(graphId, def.nodeId);
}
}
// Step 2b: invert parentsByGraphId into parentsByDefId — the
// strategy works in DefId space.
const parentsByDefId = new Map<string, string[]>();
for (const [childGraphId, parents] of parentsByGraphId) {
const childDefId = defIdByGraphId.get(childGraphId);
if (childDefId === undefined) continue;
const parentDefIds: string[] = [];
for (const p of parents) {
const pd = defIdByGraphId.get(p);
if (pd !== undefined) parentDefIds.push(pd);
}
parentsByDefId.set(childDefId, parentDefIds);
}
// Step 3: linearize per class.
const mroByDefId = new Map<string, string[]>();
for (const defId of defIdByGraphId.values()) {
const directParents = parentsByDefId.get(defId) ?? [];
mroByDefId.set(defId, linearize(defId, directParents, parentsByDefId));
}
return mroByDefId;
}
/**
* Default linearization: depth-first BFS-with-visited, first-seen
* wins. Correct for single-inheritance languages and for Python's
* simplified MRO. Multi-inheritance diamond hierarchies need a real
* C3 implementation; per-language overrides land here.
*/
export const defaultLinearize: LinearizeStrategy = (_classDefId, directParents, parentsByDefId) => {
const ancestors: string[] = [];
const visited = new Set<string>();
const queue: string[] = [...directParents];
while (queue.length > 0) {
const cur = queue.shift()!;
if (visited.has(cur)) continue;
visited.add(cur);
ancestors.push(cur);
for (const p of parentsByDefId.get(cur) ?? []) queue.push(p);
}
return ancestors;
};

View file

@ -0,0 +1,68 @@
/**
* Overload narrowing pick candidates from a list of same-named
* method / function overloads using the call-site's arity and
* argument-type signals.
*
* Used by both `receiver-bound-calls.ts::pickOverload` (explicit
* receiver member call) and `free-call-fallback.ts::pickImplicitThisOverload`
* (implicit `this` free-call inside a class-like body). Shared to keep
* narrowing semantics in lockstep across the two sites.
*
* Semantics (first-wins; callers take `result[0]`):
* 1. If `argCount` is undefined, arity is a pass-through.
* 2. Exact-required-match wins over variadic. Variadic is detected
* via a `parameterTypes` entry equal to `'params'` or starting
* with `'params '` (C# `params` / variadic marker).
* 3. If the arity filter empties the set, fall back to the full
* overload list rather than returning nothing the caller still
* needs a best-effort candidate.
* 4. If `argTypes` is present, filter further by per-slot type
* equality. An empty string in `argTypes[i]` means "unknown" and
* counts as a match. Mismatches disqualify. A non-empty typed
* result wins; otherwise return the arity-filtered candidates.
* 5. Empty input returns empty output.
*/
import type { SymbolDefinition } from 'gitnexus-shared';
export function narrowOverloadCandidates(
overloads: readonly SymbolDefinition[],
argCount: number | undefined,
argTypes: readonly string[] | undefined,
): readonly SymbolDefinition[] {
if (overloads.length === 0) return [];
const arityMatches: readonly SymbolDefinition[] =
argCount === undefined
? overloads
: overloads.filter((d) => {
const max = d.parameterCount;
const min = d.requiredParameterCount;
if (max !== undefined && argCount > max) {
const variadic =
d.parameterTypes !== undefined &&
d.parameterTypes.some((t) => t === 'params' || t.startsWith('params '));
if (!variadic) return false;
}
if (min !== undefined && argCount < min) return false;
return true;
});
const candidates: readonly SymbolDefinition[] =
arityMatches.length > 0 ? arityMatches : overloads;
if (argTypes !== undefined && argTypes.length > 0) {
const typed = candidates.filter((d) => {
const params = d.parameterTypes;
if (params === undefined) return false;
for (let i = 0; i < argTypes.length && i < params.length; i++) {
if (argTypes[i] === '') continue;
if (argTypes[i] !== params[i]) return false;
}
return true;
});
if (typed.length > 0) return typed;
}
return candidates;
}

View file

@ -0,0 +1,467 @@
/**
* Receiver-bound CALLS / ACCESSES emit pass generic 7-case
* dispatcher consuming `ScopeResolver` for the language-specific bits
* (super recognizer, field-fallback toggle).
*
* **Contract Invariant I4 case order is load-bearing.** The cases
* are evaluated in this order; the FIRST that emits an edge wins:
*
* 1. **super branch** `provider.isSuperReceiver(receiverName)`
* MRO walk skipping self
* 2. **Case 0 (compound)** receiver has `.` or `(` compound resolver
* 3. **Case 1 (namespace)** receiver in `namespaceTargets` exported def
* 4. **Case 2 (class-name / static receiver)** receiver resolves to a
* class-like binding (Class/Interface/Struct/Record/Enum/Trait) MRO
* walk on that class. Also handles static-style invocations
* (`ILogger.Warn(...)`) with kind-aware reason/confidence for
* read/write ACCESSES.
* 5. **Case 3 (dotted typeBinding for namespace prefix)**
* `typeRef.rawName` like `models.User`
* 6. **Case 3b (chain-typebinding)** `typeRef.rawName` has a dot
* but not a namespace prefix compound resolver
* 7. **Case 4 (simple typeBinding)** `typeRef.rawName` has no dot
* MRO walk + `findOwnedMember`
*
* Reordering or merging cases changes resolution semantics.
*
* **Contract Invariant I5 pre-seeding `seen` is forbidden.** The
* orchestrator runs this pass FIRST (before `emitReferencesViaLookup`)
* and consumes the populated `handledSites` set. Pre-seeding `seen`
* from the shared resolver's emissions (an old optimization) actively
* suppresses correct emissions for sites the shared resolver also
* resolved to a wrong target.
*/
import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared';
import type { KnowledgeGraph } from '../../../graph/types.js';
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
import type { SemanticModel } from '../../model/semantic-model.js';
import type { ScopeResolver } from '../contract/scope-resolver.js';
import type { GraphNodeLookup } from '../graph-bridge/node-lookup.js';
import type { WorkspaceResolutionIndex } from '../workspace-index.js';
import { collectNamespaceTargets } from '../scope/namespace-targets.js';
import {
findClassBindingInScope,
findEnclosingClassDef,
findExportedDef,
findOwnedMember,
findReceiverTypeBinding,
} from '../scope/walkers.js';
import { tryEmitEdge } from '../graph-bridge/edges.js';
import { resolveCompoundReceiverClass } from '../passes/compound-receiver.js';
import { resolveDefGraphId } from '../graph-bridge/ids.js';
import { narrowOverloadCandidates } from './overload-narrowing.js';
/** Subset of `ScopeResolver` consumed by this pass. Accepting the
* subset rather than the full provider keeps tests and partial
* refactors lighter callers only need to populate what we read. */
type ReceiverBoundProviderSubset = Pick<
ScopeResolver,
| 'isSuperReceiver'
| 'fieldFallbackOnMethodLookup'
| 'collapseMemberCallsByCallerTarget'
| 'unwrapCollectionAccessor'
| 'hoistTypeBindingsToModule'
>;
export function emitReceiverBoundCalls(
graph: KnowledgeGraph,
scopes: ScopeResolutionIndexes,
parsedFiles: readonly ParsedFile[],
nodeLookup: GraphNodeLookup,
handledSites: Set<string>,
provider: ReceiverBoundProviderSubset,
index: WorkspaceResolutionIndex,
model: SemanticModel,
): number {
let emitted = 0;
// Per-pass dedup so the multiple cases don't double-emit if two of
// them resolve the same site to the same target. NEVER pre-seed
// from the reference index — see Contract Invariant I5.
const seen = new Set<string>();
const fieldFallback = provider.fieldFallbackOnMethodLookup ?? true;
const collapse = provider.collapseMemberCallsByCallerTarget === true;
const hoistTypeBindingsToModule = provider.hoistTypeBindingsToModule === true;
const compoundOpts = {
fieldFallback,
unwrapCollectionAccessor: provider.unwrapCollectionAccessor,
hoistTypeBindingsToModule,
};
// Build an interface → implementors map from IMPLEMENTS edges.
// Maps Interface graph-id → list of implementor class scope-def-ids.
// We translate graph-ids back to scope-resolution DefIds via
// `parsedFiles.localDefs` lookup so downstream `findOwnedMember`
// (which keys by DefId) can find the implementor's members.
const graphIdToClassDef = new Map<string, SymbolDefinition>();
for (const parsed of parsedFiles) {
for (const def of parsed.localDefs) {
if (def.type !== 'Class' && def.type !== 'Interface') continue;
const graphId = resolveDefGraphId(parsed.filePath, def, nodeLookup);
if (graphId !== undefined) graphIdToClassDef.set(graphId, def);
}
}
const implementorsByInterfaceDefId = new Map<string, SymbolDefinition[]>();
for (const rel of graph.iterRelationshipsByType('IMPLEMENTS')) {
const ifaceDef = graphIdToClassDef.get(rel.targetId);
const implDef = graphIdToClassDef.get(rel.sourceId);
if (ifaceDef === undefined || implDef === undefined) continue;
let list = implementorsByInterfaceDefId.get(ifaceDef.nodeId);
if (list === undefined) {
list = [];
implementorsByInterfaceDefId.set(ifaceDef.nodeId, list);
}
list.push(implDef);
}
/** Emit secondary CALLS edges with reason='interface-dispatch'
* when the primary receiver-typed edge targeted an Interface's
* method. Each implementing class's same-named method gets a
* secondary edge (excluding the primary target itself). */
const emitInterfaceDispatchFor = (
ownerDef: SymbolDefinition,
memberName: string,
primaryMemberDef: SymbolDefinition,
site: ParsedFile['referenceSites'][number],
confidence: number,
): number => {
if (ownerDef.type !== 'Interface') return 0;
const impls = implementorsByInterfaceDefId.get(ownerDef.nodeId);
if (impls === undefined) return 0;
let n = 0;
for (const implDef of impls) {
const implMember = findOwnedMember(implDef.nodeId, memberName, model);
if (implMember === undefined) continue;
if (implMember.nodeId === primaryMemberDef.nodeId) continue;
const ok = tryEmitEdge(
graph,
scopes,
nodeLookup,
site,
implMember,
'interface-dispatch',
seen,
confidence,
collapse,
);
if (ok) n++;
}
return n;
};
for (const parsed of parsedFiles) {
const namespaceTargets = collectNamespaceTargets(parsed, scopes);
for (const site of parsed.referenceSites) {
if (site.kind !== 'call' && site.kind !== 'read' && site.kind !== 'write') continue;
if (site.explicitReceiver === undefined) continue;
const receiverName = site.explicitReceiver.name;
const memberName = site.name;
const siteKey = `${parsed.filePath}:${site.atRange.startLine}:${site.atRange.startCol}`;
// ── super branch ─────────────────────────────────────────────
if (provider.isSuperReceiver(receiverName)) {
const enclosingClass = findEnclosingClassDef(site.inScope, scopes);
if (enclosingClass !== undefined) {
const ancestors = scopes.methodDispatch.mroFor(enclosingClass.nodeId);
let memberDef: SymbolDefinition | undefined;
for (const ownerId of ancestors) {
memberDef = findOwnedMember(ownerId, memberName, model);
if (memberDef !== undefined) break;
}
if (memberDef !== undefined) {
// Super/base calls resolve through the MRO chain, not
// through imports — the ancestor method is found by
// walking `methodDispatch.mroFor(enclosingClass)`, which
// is independent of whether a `using` / `import` directive
// brought the ancestor into scope. We emit the canonical
// `'global'` tier (ARCHITECTURE.md § Scope-Resolution
// Pipeline — edge vocabulary).
//
// Known legacy-path asymmetry: the C# legacy DAG also
// classifies `base.Save()` as `'global'` (same-graph); the
// Python legacy DAG classifies `super().save()` as
// `'import-resolved'` because Python's ancestor lookup
// flows through `typeEnv.lookup(...)` which resolves the
// superclass via its `import`/`from … import …` binding.
// Closing that gap requires realigning the legacy tier
// classifier and is tracked separately.
const ok = tryEmitEdge(
graph,
scopes,
nodeLookup,
site,
memberDef,
'global',
seen,
0.85,
collapse,
);
if (ok) emitted++;
// Always mark handled when the site was resolved, even
// if the edge was deduplicated (collapse mode), so
// `emitReferencesViaLookup` doesn't re-emit from the
// reference index.
handledSites.add(siteKey);
continue;
}
}
}
// ── Case 0: compound receiver ────────────────────────────────
if (receiverName.includes('.') || receiverName.includes('(')) {
const currentClass = resolveCompoundReceiverClass(
receiverName,
site.inScope,
scopes,
index,
compoundOpts,
);
if (currentClass !== undefined) {
const chain = [currentClass.nodeId, ...scopes.methodDispatch.mroFor(currentClass.nodeId)];
let memberDef: SymbolDefinition | undefined;
for (const ownerId of chain) {
memberDef = findOwnedMember(ownerId, memberName, model);
if (memberDef !== undefined) break;
}
if (memberDef !== undefined) {
const ok = tryEmitEdge(
graph,
scopes,
nodeLookup,
site,
memberDef,
memberDef.filePath !== parsed.filePath ? 'import-resolved' : 'global',
seen,
0.85,
collapse,
);
if (ok) emitted++;
// Always mark handled when the site was resolved, even
// if the edge was deduplicated (collapse mode), so
// `emitReferencesViaLookup` doesn't re-emit from the
// reference index.
handledSites.add(siteKey);
continue;
}
}
}
// ── Case 1: namespace receiver ───────────────────────────────
const targetFile = namespaceTargets.get(receiverName);
if (targetFile !== undefined) {
const memberDef = findExportedDef(targetFile, memberName, index);
if (memberDef !== undefined) {
const ok = tryEmitEdge(
graph,
scopes,
nodeLookup,
site,
memberDef,
memberDef.filePath !== parsed.filePath ? 'import-resolved' : 'global',
seen,
0.85,
collapse,
);
if (ok) emitted++;
handledSites.add(siteKey);
continue;
}
}
// ── Case 2: class-name receiver ──────────────────────────────
const classDef = findClassBindingInScope(site.inScope, receiverName, scopes);
if (classDef !== undefined) {
const chain = [classDef.nodeId, ...scopes.methodDispatch.mroFor(classDef.nodeId)];
let memberDef: SymbolDefinition | undefined;
for (const ownerId of chain) {
memberDef = findOwnedMember(ownerId, memberName, model);
if (memberDef !== undefined) break;
}
if (memberDef !== undefined) {
const reason =
site.kind === 'write' || site.kind === 'read'
? site.kind
: memberDef.filePath !== parsed.filePath
? 'import-resolved'
: 'global';
const confidence = site.kind === 'write' || site.kind === 'read' ? 1.0 : 0.85;
const ok = tryEmitEdge(
graph,
scopes,
nodeLookup,
site,
memberDef,
reason,
seen,
confidence,
collapse,
);
if (ok) emitted++;
handledSites.add(siteKey);
continue;
}
}
// ── Case 3: dotted typeBinding (`u: models.User`) ────────────
const typeRef = findReceiverTypeBinding(site.inScope, receiverName, scopes);
if (typeRef !== undefined && typeRef.rawName.includes('.')) {
const [nsName, ...classNameParts] = typeRef.rawName.split('.');
const className = classNameParts.join('.');
const targetFile3 = namespaceTargets.get(nsName);
if (targetFile3 !== undefined && className.length > 0) {
const classDef3 = findExportedDef(targetFile3, className, index);
if (classDef3 !== undefined) {
const memberDef = findOwnedMember(classDef3.nodeId, memberName, model);
if (memberDef !== undefined) {
const ok = tryEmitEdge(
graph,
scopes,
nodeLookup,
site,
memberDef,
memberDef.filePath !== parsed.filePath ? 'import-resolved' : 'global',
seen,
);
if (ok) {
emitted++;
handledSites.add(siteKey);
}
continue;
}
}
}
}
// ── Case 3b: chain-typebinding (`city → user.get_city`) ──────
if (
typeRef !== undefined &&
typeRef.rawName.includes('.') &&
!typeRef.rawName.includes('(') &&
!namespaceTargets.has(typeRef.rawName.split('.')[0]!)
) {
// Try the plain dotted-field walk first — covers property /
// collection-accessor shapes (`.Values`, Kotlin `.size`) and
// field chains. Fall back to call-form (`x()`) which treats
// the last segment as a method invocation.
let ownerDef = resolveCompoundReceiverClass(
typeRef.rawName,
typeRef.declaredAtScope,
scopes,
index,
compoundOpts,
);
if (ownerDef === undefined) {
ownerDef = resolveCompoundReceiverClass(
typeRef.rawName + '()',
typeRef.declaredAtScope,
scopes,
index,
compoundOpts,
);
}
if (ownerDef !== undefined) {
const chain = [ownerDef.nodeId, ...scopes.methodDispatch.mroFor(ownerDef.nodeId)];
let memberDef: SymbolDefinition | undefined;
for (const ownerId of chain) {
memberDef = findOwnedMember(ownerId, memberName, model);
if (memberDef !== undefined) break;
}
if (memberDef !== undefined) {
const ok = tryEmitEdge(
graph,
scopes,
nodeLookup,
site,
memberDef,
memberDef.filePath !== parsed.filePath ? 'import-resolved' : 'global',
seen,
0.85,
collapse,
);
if (ok) emitted++;
// Always mark handled when the site was resolved, even
// if the edge was deduplicated (collapse mode), so
// `emitReferencesViaLookup` doesn't re-emit from the
// reference index.
handledSites.add(siteKey);
continue;
}
}
}
// ── Case 4: simple typeBinding (`u: U`) ──────────────────────
if (typeRef !== undefined && !typeRef.rawName.includes('.')) {
const ownerDef = findClassBindingInScope(site.inScope, typeRef.rawName, scopes);
if (ownerDef !== undefined) {
const chain = [ownerDef.nodeId, ...scopes.methodDispatch.mroFor(ownerDef.nodeId)];
let memberDef: SymbolDefinition | undefined;
for (const ownerId of chain) {
memberDef = pickOverload(ownerId, memberName, site, model);
if (memberDef !== undefined) break;
}
if (memberDef !== undefined) {
// For read/write ACCESSES, mirror the legacy DAG's reason
// convention so consumers asserting `reason === 'write'`
// keep working.
const reason =
site.kind === 'write' || site.kind === 'read'
? site.kind
: memberDef.filePath !== parsed.filePath
? 'import-resolved'
: 'global';
const confidence = site.kind === 'write' || site.kind === 'read' ? 1.0 : 0.85;
const ok = tryEmitEdge(
graph,
scopes,
nodeLookup,
site,
memberDef,
reason,
seen,
confidence,
collapse,
);
if (ok) emitted++;
// Interface dispatch: when the primary owner is an
// Interface, emit secondary CALLS edges to every
// implementing class's same-named method.
emitted += emitInterfaceDispatchFor(ownerDef, memberName, memberDef, site, confidence);
// Always mark handled when the site was resolved, even
// if the edge was deduplicated (collapse mode), so
// `emitReferencesViaLookup` doesn't re-emit from the
// reference index.
handledSites.add(siteKey);
continue;
}
}
}
}
}
return emitted;
}
/** Resolve a member by name on a class def, narrowing by argument
* types when multiple overloads share the name. Falls back to the
* first-seen def (legacy `findOwnedMember` semantics) when there's
* no narrowing signal or when `argumentTypes` is unavailable. */
function pickOverload(
ownerId: string,
memberName: string,
site: ParsedFile['referenceSites'][number],
model: SemanticModel,
): SymbolDefinition | undefined {
const overloads = model.methods.lookupAllByOwner(ownerId, memberName);
if (overloads.length === 0) {
// Non-callable member (field / property / variable) — ACCESSES
// write/read sites target these too. Fall back to the field
// registry so owner-scoped attribute access resolves.
return model.fields.lookupFieldByOwner(ownerId, memberName);
}
if (overloads.length === 1) return overloads[0];
const candidates = narrowOverloadCandidates(overloads, site.arity, site.argumentTypes);
return candidates[0] ?? overloads[0];
}

Some files were not shown because too many files have changed in this diff Show more