mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-17 23:52:36 +00:00
Merge branch 'abhigyanpatwari:main' into feat/desktop-release
This commit is contained in:
commit
f893dfa16c
196 changed files with 16215 additions and 503 deletions
|
|
@ -14,6 +14,8 @@ coverage
|
|||
.env.local
|
||||
.env.*.local
|
||||
|
||||
**/*.tsbuildinfo
|
||||
|
||||
.gitnexus
|
||||
gitnexus-web/playwright-report
|
||||
gitnexus-web/test-results
|
||||
|
|
|
|||
4
.github/workflows/ci-quality.yml
vendored
4
.github/workflows/ci-quality.yml
vendored
|
|
@ -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
109
.github/workflows/ci-scope-parity.yml
vendored
Normal 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"
|
||||
34
.github/workflows/ci.yml
vendored
34
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
2
.github/workflows/publish.yml
vendored
2
.github/workflows/publish.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
2
.github/workflows/release-candidate.yml
vendored
2
.github/workflows/release-candidate.yml
vendored
|
|
@ -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
1
.gitignore
vendored
|
|
@ -108,3 +108,4 @@ _bmad/
|
|||
# Local agent scratch / review prompts (never commit)
|
||||
.tmp/
|
||||
.agents/
|
||||
.context/
|
||||
|
|
|
|||
12
AGENTS.md
12
AGENTS.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
131
ARCHITECTURE.md
131
ARCHITECTURE.md
|
|
@ -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 1–6 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
209
DoD.md
Normal 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 512KB–32MB 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. |
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
10
README.md
10
README.md
|
|
@ -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
|
||||
|
|
|
|||
295
docs/guides/microservices-grpc.md
Normal file
295
docs/guides/microservices-grpc.md
Normal 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).
|
||||
8
gitnexus-shared/package-lock.json
generated
8
gitnexus-shared/package-lock.json
generated
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -20,6 +20,6 @@
|
|||
"src"
|
||||
],
|
||||
"devDependencies": {
|
||||
"typescript": "^6.0.2"
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
}
|
||||
|
|
|
|||
8
gitnexus-web/package-lock.json
generated
8
gitnexus-web/package-lock.json
generated
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
328
gitnexus/package-lock.json
generated
328
gitnexus/package-lock.json
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
134
gitnexus/scripts/bench-scope-resolution.ts
Normal file
134
gitnexus/scripts/bench-scope-resolution.ts
Normal 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);
|
||||
});
|
||||
24
gitnexus/scripts/ci-list-migrated-languages.ts
Normal file
24
gitnexus/scripts/ci-list-migrated-languages.ts
Normal 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));
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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
110
gitnexus/src/cli/remove.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
|
|
@ -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}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
44
gitnexus/src/core/ingestion/languages/csharp/arity.ts
Normal file
44
gitnexus/src/core/ingestion/languages/csharp/arity.ts
Normal 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';
|
||||
}
|
||||
30
gitnexus/src/core/ingestion/languages/csharp/cache-stats.ts
Normal file
30
gitnexus/src/core/ingestion/languages/csharp/cache-stats.ts
Normal 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;
|
||||
}
|
||||
305
gitnexus/src/core/ingestion/languages/csharp/captures.ts
Normal file
305
gitnexus/src/core/ingestion/languages/csharp/captures.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
130
gitnexus/src/core/ingestion/languages/csharp/import-target.ts
Normal file
130
gitnexus/src/core/ingestion/languages/csharp/import-target.ts
Normal 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;
|
||||
}
|
||||
87
gitnexus/src/core/ingestion/languages/csharp/index.ts
Normal file
87
gitnexus/src/core/ingestion/languages/csharp/index.ts
Normal 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';
|
||||
137
gitnexus/src/core/ingestion/languages/csharp/interpret.ts
Normal file
137
gitnexus/src/core/ingestion/languages/csharp/interpret.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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()];
|
||||
}
|
||||
|
|
@ -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'
|
||||
);
|
||||
}
|
||||
520
gitnexus/src/core/ingestion/languages/csharp/query.ts
Normal file
520
gitnexus/src/core/ingestion/languages/csharp/query.ts
Normal 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;
|
||||
}
|
||||
142
gitnexus/src/core/ingestion/languages/csharp/receiver-binding.ts
Normal file
142
gitnexus/src/core/ingestion/languages/csharp/receiver-binding.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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 };
|
||||
96
gitnexus/src/core/ingestion/languages/csharp/simple-hooks.ts
Normal file
96
gitnexus/src/core/ingestion/languages/csharp/simple-hooks.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
45
gitnexus/src/core/ingestion/languages/python/arity.ts
Normal file
45
gitnexus/src/core/ingestion/languages/python/arity.ts
Normal 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';
|
||||
}
|
||||
32
gitnexus/src/core/ingestion/languages/python/cache-stats.ts
Normal file
32
gitnexus/src/core/ingestion/languages/python/cache-stats.ts
Normal 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;
|
||||
}
|
||||
132
gitnexus/src/core/ingestion/languages/python/captures.ts
Normal file
132
gitnexus/src/core/ingestion/languages/python/captures.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
120
gitnexus/src/core/ingestion/languages/python/import-target.ts
Normal file
120
gitnexus/src/core/ingestion/languages/python/import-target.ts
Normal 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;
|
||||
}
|
||||
85
gitnexus/src/core/ingestion/languages/python/index.ts
Normal file
85
gitnexus/src/core/ingestion/languages/python/index.ts
Normal 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';
|
||||
193
gitnexus/src/core/ingestion/languages/python/interpret.ts
Normal file
193
gitnexus/src/core/ingestion/languages/python/interpret.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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()];
|
||||
}
|
||||
272
gitnexus/src/core/ingestion/languages/python/query.ts
Normal file
272
gitnexus/src/core/ingestion/languages/python/query.ts
Normal 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;
|
||||
}
|
||||
115
gitnexus/src/core/ingestion/languages/python/receiver-binding.ts
Normal file
115
gitnexus/src/core/ingestion/languages/python/receiver-binding.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
|
|
@ -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 };
|
||||
56
gitnexus/src/core/ingestion/languages/python/simple-hooks.ts
Normal file
56
gitnexus/src/core/ingestion/languages/python/simple-hooks.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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> = {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
229
gitnexus/src/core/ingestion/resolve-references.ts
Normal file
229
gitnexus/src/core/ingestion/resolve-references.ts
Normal 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 kind→registry 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 };
|
||||
|
|
@ -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}: ${
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
]);
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
128
gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts
Normal file
128
gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
/**
|
||||
* File→File 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 File→File 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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'
|
||||
);
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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];
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
108
gitnexus/src/core/ingestion/scope-resolution/passes/mro.ts
Normal file
108
gitnexus/src/core/ingestion/scope-resolution/passes/mro.ts
Normal 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;
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
Loading…
Add table
Reference in a new issue