Merge branch 'feat/Desktop-app' of https://github.com/prajapatisparsh/GitNexus into feat/Desktop-app

This commit is contained in:
Sparsh 2026-04-25 14:36:59 +05:30
commit 1775c90f5a
217 changed files with 18146 additions and 2633 deletions

View file

@ -17,10 +17,11 @@ npx gitnexus analyze
Run from the project root. This parses all source files, builds the knowledge graph, writes it to `.gitnexus/`, and generates CLAUDE.md / AGENTS.md context files.
| Flag | Effect |
| -------------- | ---------------------------------------------------------------- |
| `--force` | Force full re-index even if up to date |
| `--embeddings` | Enable embedding generation for semantic search (off by default) |
| Flag | Effect |
| ------------------- | ------------------------------------------------------------------------------------------------------- |
| `--force` | Force full re-index even if up to date |
| `--embeddings` | Enable embedding generation for semantic search (off by default) |
| `--drop-embeddings` | Drop existing embeddings on rebuild. By default, an `analyze` without `--embeddings` preserves them. |
**When to run:** First time in a project, after major code changes, or when `gitnexus://repo/{name}/context` reports the index is stale. In Claude Code, a PostToolUse hook runs `analyze` automatically after `git commit` and `git merge`, preserving embeddings if previously generated.

View file

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

View file

@ -1,4 +1,8 @@
# Images (signed Cosign keyless on every push from main / vX.Y.Z tags)
# Images (signed Cosign keyless on every push from main / vX.Y.Z tags).
# Available from both GHCR (default below) and Docker Hub — pick one:
# GHCR: ghcr.io/abhigyanpatwari/gitnexus{,-web}:latest
# Docker Hub: akonlabs/gitnexus{,-web}:latest
# Both registries receive the same digest from a single signed build.
SERVER_IMAGE=ghcr.io/abhigyanpatwari/gitnexus:latest
WEB_IMAGE=ghcr.io/abhigyanpatwari/gitnexus-web:latest

View file

@ -0,0 +1,105 @@
# Wraps docker/build-push-action with one automatic retry. Upstream explicitly
# keeps retry out of the action (docker/build-push-action#1422); a local
# composite keeps docker.yml readable and pins the same action SHA in one place.
name: Docker build-push (with retry)
description: >-
Runs docker/build-push-action twice on failure with a configurable backoff,
then exposes the digest from whichever attempt succeeded.
inputs:
context:
description: Build context path
required: false
default: '.'
file:
description: Dockerfile path (relative to repo root)
required: true
platforms:
description: Comma-separated platforms list for buildx
required: true
push:
description: Whether to push (string 'true' or 'false')
required: true
tags:
description: Newline-separated image tags (from docker/metadata-action)
required: true
labels:
description: Labels string (from docker/metadata-action)
required: true
cache-from:
description: buildx cache-from value
required: true
cache-to:
description: buildx cache-to value (include ignore-error=true for GHA cache flakes)
required: true
retry-wait-seconds:
description: Seconds to sleep before the second attempt
required: false
default: '45'
outputs:
digest:
description: Manifest digest from the successful build attempt
value: ${{ steps.resolve.outputs.digest }}
runs:
using: composite
steps:
- name: Build and push (attempt 1)
id: try1
continue-on-error: true
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: ${{ inputs.context }}
file: ${{ inputs.file }}
platforms: ${{ inputs.platforms }}
push: ${{ inputs.push == 'true' }}
tags: ${{ inputs.tags }}
labels: ${{ inputs.labels }}
cache-from: ${{ inputs.cache-from }}
cache-to: ${{ inputs.cache-to }}
provenance: mode=max
sbom: true
- name: Backoff before Docker build retry
if: steps.try1.outcome == 'failure'
shell: bash
env:
RETRY_WAIT_SECONDS: ${{ inputs.retry-wait-seconds }}
run: |
echo "::warning::Docker build-push attempt 1 failed; retrying in ${RETRY_WAIT_SECONDS}s…"
sleep "${RETRY_WAIT_SECONDS}"
- name: Build and push (attempt 2)
id: try2
if: steps.try1.outcome == 'failure'
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: ${{ inputs.context }}
file: ${{ inputs.file }}
platforms: ${{ inputs.platforms }}
push: ${{ inputs.push == 'true' }}
tags: ${{ inputs.tags }}
labels: ${{ inputs.labels }}
cache-from: ${{ inputs.cache-from }}
cache-to: ${{ inputs.cache-to }}
provenance: mode=max
sbom: true
- name: Resolve image digest
id: resolve
if: always()
shell: bash
run: |
set -euo pipefail
if [ "${{ steps.try1.outcome }}" = "success" ]; then
echo "digest=${{ steps.try1.outputs.digest }}" >> "$GITHUB_OUTPUT"
exit 0
fi
if [ "${{ steps.try2.outcome }}" = "success" ]; then
echo "::notice::docker-build-push retry succeeded (attempt 2); investigate if this recurs across runs."
echo "digest=${{ steps.try2.outputs.digest }}" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "::error::Docker build and push failed after two attempts (registry/cache flake or real build error)."
exit 1

View file

@ -1,12 +1,15 @@
name: Setup GitNexus Web
description: Setup Node.js 20, build gitnexus-shared, install web dependencies
description: Setup Node.js 20.19+ (vite 7 floor), build gitnexus-shared, install web dependencies
runs:
using: composite
steps:
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 20
# Vite 7 requires Node ^20.19.0 || >=22.12.0 (require(esm) support).
# Pin explicitly so we don't depend on the floating "20" alias resolving
# to a high enough patch version on every runner image.
node-version: '20.19.0'
cache: npm
cache-dependency-path: gitnexus-web/package-lock.json

View file

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

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

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

View file

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

View file

@ -4,9 +4,18 @@ on:
push:
tags:
- 'v*'
# No workflow_dispatch: publishing is exclusively tag-driven so that every
# signed image corresponds 1:1 to a published `gitnexus@X.Y.Z` on npm. A
# manual run from a branch ref would fail the version check below anyway.
pull_request:
# workflow_dispatch is allowed for dry-run testing only. Publishing is still
# exclusively tag-driven so that every signed image corresponds 1:1 to a
# published `gitnexus@X.Y.Z` on npm. dry_run:true (the default) skips all
# push, sign, and attestation steps — the build runs but nothing is published.
workflow_dispatch:
inputs:
dry_run:
description: 'Build only — skip push, signing, and attestations'
required: false
default: true
type: boolean
workflow_call:
inputs:
tag:
@ -60,6 +69,12 @@ jobs:
slug: gitnexus
steps:
# Only the workflow_call path requires a non-empty `inputs.tag` — callers
# (e.g. release-candidate.yml) must pass the RC tag explicitly. On direct
# tag pushes the tag comes from `github.ref`, so `inputs.tag` is always
# empty and validating it here would break every real release (#1064).
# The downstream "Verify tag matches gitnexus/package.json version" step
# handles both event types by falling back to GITHUB_REF.
- name: Validate tag input
if: github.event_name == 'workflow_call'
shell: bash
@ -85,6 +100,7 @@ jobs:
# `gitnexus@X.Y.Z` published to npm — no drift, no surprises.
- name: Verify tag matches gitnexus/package.json version
id: version
if: github.event_name != 'workflow_dispatch' && github.event_name != 'pull_request'
shell: bash
env:
# For workflow_call the tag comes from the caller input; for push events
@ -119,12 +135,27 @@ jobs:
uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
- name: Log in to GitHub Container Registry
if: ${{ github.event_name != 'pull_request' && !inputs.dry_run }}
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Docker Hub is a mirror of GHCR: same tags, same digests, same Cosign
# signatures. GHCR remains authoritative (it is the registry the
# ClusterImagePolicy globs against by default), but Docker Hub is the
# registry most users reach for first, so we publish there too.
# Requires repo secrets DOCKERHUB_USERNAME and DOCKERHUB_TOKEN (a scoped
# access token, NOT the account password) with write access to the
# `akonlabs/gitnexus` and `akonlabs/gitnexus-web` repos.
- name: Log in to Docker Hub
if: ${{ github.event_name != 'pull_request' && !inputs.dry_run }}
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
# Computes image tags and labels from the verified semver tag:
# v1.2.3 → :1.2.3, :1.2, :1, :latest (auto, only for non-prerelease)
# v1.2.3-rc.1 → :1.2.3-rc.1 only (prereleases never become :latest)
@ -142,7 +173,16 @@ jobs:
id: meta
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
with:
images: ghcr.io/${{ github.repository_owner }}/${{ matrix.image.slug }}
# Dual-registry publish. metadata-action expands the same tag set
# against every image ref listed here, and build-push-action pushes
# one build to all of them, so the GHCR and Docker Hub images share
# a digest and are byte-identical. The Docker Hub namespace
# (`akonlabs`) is hardcoded because it differs from the GitHub org
# (`abhigyanpatwari`) — `github.repository_owner` would produce the
# wrong ref.
images: |
ghcr.io/${{ github.repository_owner }}/${{ matrix.image.slug }}
docker.io/akonlabs/${{ matrix.image.slug }}
flavor: latest=auto
tags: |
type=semver,pattern={{version}}
@ -150,20 +190,23 @@ jobs:
type=semver,pattern={{major}}
type=raw,value=${{ steps.version.outputs.version }},enable=${{ inputs.tag != '' }}
# Transient 502s from GHCR / Docker Hub / GHA cache during multi-platform
# exports are retried inside `.github/actions/docker-build-push-retry`
# (see docker/build-push-action#1422 — retry policy stays out of the
# upstream action). `ignore-error=true` on cache-to avoids cache export
# flakes failing an otherwise successful push.
- name: Build and push
id: build
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
uses: ./.github/actions/docker-build-push-retry
with:
context: .
file: ${{ matrix.image.dockerfile }}
platforms: linux/amd64,linux/arm64
push: true
push: ${{ github.event_name != 'pull_request' && !inputs.dry_run }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha,scope=${{ matrix.image.slug }}
cache-to: type=gha,mode=max,scope=${{ matrix.image.slug }}
provenance: mode=max
sbom: true
cache-to: type=gha,mode=max,scope=${{ matrix.image.slug }},ignore-error=true
# Cosign keyless signing. Each pushed tag is signed by the workflow's
# OIDC identity, so consumers can verify the image with the strict,
@ -178,6 +221,7 @@ jobs:
# Do NOT relax to `@.*` — that accepts signatures from any ref, including
# unprotected branches and PRs, and defeats the supply-chain guarantee.
- name: Sign image with Cosign (keyless)
if: ${{ github.event_name != 'pull_request' && !inputs.dry_run }}
env:
# Cosign v2 (installed by sigstore/cosign-installer above) makes
# keyless the default. COSIGN_EXPERIMENTAL is a v1-only opt-in flag
@ -193,10 +237,23 @@ jobs:
[[ -n "$tag" ]] && cosign sign --yes "${tag}@${DIGEST}"
done <<< "$TAGS"
# Attach the SBOM produced by buildx as a verifiable attestation on the digest.
- name: Generate build provenance attestation
# Attach the SBOM produced by buildx as a verifiable attestation on the
# digest. Attestations are pushed as OCI referrers to the registry named
# in `subject-name`, so we call the action once per registry. The digest
# is identical across registries (same build, same push), so consumers
# pulling from either GHCR or Docker Hub see the same provenance.
- name: Generate build provenance attestation (GHCR)
if: ${{ github.event_name != 'pull_request' && !inputs.dry_run }}
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
with:
subject-name: ghcr.io/${{ github.repository_owner }}/${{ matrix.image.slug }}
subject-digest: ${{ steps.build.outputs.digest }}
push-to-registry: true
- name: Generate build provenance attestation (Docker Hub)
if: ${{ github.event_name != 'pull_request' && !inputs.dry_run }}
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
with:
subject-name: docker.io/akonlabs/${{ matrix.image.slug }}
subject-digest: ${{ steps.build.outputs.digest }}
push-to-registry: true

View file

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

View file

@ -133,7 +133,7 @@ jobs:
fetch-depth: 0
fetch-tags: true
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 20
registry-url: https://registry.npmjs.org
@ -379,6 +379,10 @@ jobs:
needs: [guard, publish]
if: needs.guard.outputs.should_run == 'true' && needs.publish.outputs.vtag != ''
uses: ./.github/workflows/docker.yml
# Reusable workflows do not receive caller secrets unless inherited; without
# this, DOCKERHUB_* / GITHUB_TOKEN are empty in docker.yml → "Username and
# password required" on Docker Hub login (see same pattern on `ci:` above).
secrets: inherit
permissions:
contents: read
packages: write

1
.gitignore vendored
View file

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

View file

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

View file

@ -210,6 +210,137 @@ Both hooks are optional on `LanguageProvider`. Ruby is the only current implemen
| `core/ingestion/languages/ruby.ts` | Both hooks + `mroStrategy: 'ruby-mixin'` |
| `core/ingestion/utils/ruby-self-call.ts` | Bare-call rewrite for `inferImplicitReceiver` |
### Coexistence with the scope-resolution pipeline
The Call-Resolution DAG is the **legacy path**. RFC #909 Ring 3 introduces a parallel **scope-resolution pipeline** (next section) that replaces stages 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

View file

@ -129,9 +129,10 @@ Two publish workflows ship `gitnexus` to npm:
registry. First rc for a given base is `rc.1`.
- After the npm publish succeeds, the workflow calls `docker.yml` as a
reusable workflow to build and push the corresponding RC Docker images
(e.g. `ghcr.io/abhigyanpatwari/gitnexus:1.7.0-rc.1`). The images are
signed with Cosign; the OIDC identity is `docker.yml@refs/heads/main`
(the caller's ref — see README.md § Docker for the verify command).
(e.g. `ghcr.io/abhigyanpatwari/gitnexus:1.7.0-rc.1`, mirrored to
`docker.io/akonlabs/gitnexus:1.7.0-rc.1`). The images are signed
with Cosign; the OIDC identity is `docker.yml@refs/heads/main` (the
caller's ref — see README.md § Docker for the verify command).
Idempotency: the workflow pushes an `rc/<HEAD_SHA>` marker tag and a
`v<RC>` release tag **atomically, before** calling `npm publish`. The guard

209
DoD.md Normal file
View file

@ -0,0 +1,209 @@
# Definition of Done — GitNexus
Last reviewed: 2026-04-23 · Version: 2.0.0
This document defines the repo-wide completion bar for production-ready changes in GitNexus. It is the stable baseline. Implementation prompts, agent behavior, and review workflows may add task-specific checks, but they must never weaken this bar.
Use it together with:
- `AGENTS.md` — agent-facing rules of engagement
- `GUARDRAILS.md` — hard safety constraints
- `CONTRIBUTING.md` — contributor workflow
- `TESTING.md` — test strategy and coverage expectations
- `ARCHITECTURE.md` — pipeline boundaries, Call-Resolution DAG, LanguageProvider contract
## 1. Scope and Intent
A change is **Done** when it is correct, safely integrated, appropriately tested, operationally sound, and a net improvement to the codebase — not merely "the code compiles and a test passes."
This DoD applies to:
- CLI, MCP, and HTTP-bridge behavior in `gitnexus/`
- Browser UI in `gitnexus-web/`
- Shared contracts in `gitnexus-shared/`
- CI workflows, release pipelines, and repo-level docs
Out of scope: full agent personas, step-by-step implementation prompts, verbose review formatting rules, repo walkthroughs already covered elsewhere, temporary task-specific acceptance criteria. Those belong in prompts, PR templates, or other repo docs.
## 2. Core Definition of Done
Every change must satisfy **every relevant item** below. If an item does not apply, say so explicitly in the PR description.
### 2.1 Correctness and Completeness
- [ ] The requested behavior is implemented end-to-end in the **real runtime path** for the affected surface — no dead code, partial wiring, test-only shims, or "works in isolation but not in production" seams.
- [ ] Edge cases relevant to the changed surface are handled or explicitly documented as out of scope.
- [ ] Error handling is proportionate: inputs at system boundaries (user input, external APIs, filesystem, process spawn) are validated; internal, framework-guaranteed paths are trusted.
- [ ] The change produces the same result on re-run (idempotent where expected) and does not rely on accidental ordering.
### 2.2 Architecture and Placement
- [ ] The change is placed in the correct package and layer:
- `gitnexus/` for CLI, MCP, HTTP bridge, ingestion, graph, and runtime logic
- `gitnexus-web/` for browser UI (thin client — no WASM workers, all queries via HTTP API)
- `gitnexus-shared/` for shared contracts, types, and constants
- [ ] Pipeline and architecture boundaries remain explicit. Shared ingestion code in `gitnexus/src/core/ingestion/` must not name languages — use `LanguageProvider` hooks (see `AGENTS.md` and `ARCHITECTURE.md` § Call-Resolution DAG).
- [ ] No hidden cross-phase coupling; no leaking of language-specific logic into shared infrastructure without a documented architectural reason.
- [ ] Runtime and graph behavior are consistent — the real source of truth is fixed at the source, not symptom-patched in a downstream layer.
- [ ] Direct imports from `gitnexus-shared` are used. No barrel re-exports introduced to paper over drift between packages.
### 2.3 Design and Readability
- [ ] The implementation is the **smallest correct solution** for the requirement. No speculative abstraction, unnecessary indirection, clever but hard-to-follow control flow, or unrelated cleanup.
- [ ] Naming, control flow, ownership, and extension points are clear enough that the next contributor can extend the code without archaeology.
- [ ] Comments are minimal and useful — they explain intent, invariants, contracts, or non-obvious constraints. No stale comments, placeholder comments, narrated code, commented-out code, or "what" comments where a good name would do.
- [ ] No copy-paste duplication created for convenience; no premature deduplication of three similar lines.
### 2.4 Contracts and Compatibility
- [ ] Existing contracts (types in `gitnexus-shared/`, CLI flags, MCP tools/resources, HTTP routes, graph node/edge shapes, persisted IDs) are preserved unless the task explicitly requires a contract change.
- [ ] Any contract change is intentional, explicit, and reflected in **every direct consumer** in the same change, with types aligned end-to-end.
- [ ] Persisted data changes (graph schema, IDs, embeddings) are backward-compatible or accompanied by a documented migration / reindex path.
- [ ] If user-visible behavior, public usage, CLI help, or README examples change, the relevant docs, examples, help text, or migration notes are updated in the same change.
### 2.5 Security
- [ ] No new injection surfaces (command, path, SQL/Cypher-style, prompt) introduced on paths that consume untrusted input.
- [ ] No secrets, tokens, or credentials committed to the repo, to logs, or to error messages.
- [ ] Filesystem access honors the repo-scope and indexed-repo boundaries documented in `AGENTS.md` and `GUARDRAILS.md`.
- [ ] Third-party dependencies added or bumped are justified, from reputable sources, and do not regress the supply-chain posture.
### 2.6 Performance and Resource Use
- [ ] No repeated avoidable work, unnecessary scans, unnecessary round-trips, unbounded caches, or obvious hot-path regressions.
- [ ] Tree-sitter buffer sizing follows the adaptive 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. |

View file

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

View file

@ -19,7 +19,7 @@ Maintainer may widen scope per task.
2. **Never rename with find-and-replace** in GitNexus-indexed projects — use `rename` MCP tool with `dry_run: true` first, review `graph` vs `text_search` edits. No separate `gitnexus rename` CLI exists.
3. **Run impact analysis before editing shared symbols** — `impact` (upstream) for functions/classes/methods others call. Do not ignore HIGH/CRITICAL without maintainer sign-off.
4. **Run `detect_changes` before commit** — confirm diffs map to expected symbols/processes when the graph is available.
5. **Preserve embeddings** — if `.gitnexus/meta.json` shows embeddings, use `npx gitnexus analyze --embeddings`; plain `analyze` drops them.
5. **Preserve embeddings** — plain `npx gitnexus analyze` now preserves any embeddings recorded in `.gitnexus/meta.json` (the previous behavior wiped them). Use `--embeddings` to also generate vectors for new/changed nodes; use `--drop-embeddings` only when an explicit wipe is intended (e.g., model swap).
---
@ -36,8 +36,8 @@ Format: **Trigger → Instruction → Reason**. Append new Signs when the same m
### Embeddings vanished after analyze
- **Trigger:** Semantic search quality drops; `stats.embeddings` in `meta.json` is 0 after refresh.
- **Do:** `npx gitnexus analyze --embeddings`, confirm `meta.json` reflects stored embeddings.
- **Why:** Embedding generation is opt-in; analyze without the flag does not preserve prior vectors.
- **Do:** Re-run `npx gitnexus analyze --embeddings` to regenerate. Check the analyze log for a `Warning: could not load cached embeddings` line — if present, the cache restore failed (corrupt DB / schema mismatch) and the rebuild had nothing to preserve. If you intentionally passed `--drop-embeddings`, this is expected.
- **Why:** Plain `analyze` preserves prior vectors by re-inserting them after the rebuild; the only ways to end up at zero are an explicit `--drop-embeddings`, a cache-load failure (now logged), or a model/dimension change that invalidates the cache.
### MCP lists no repos

View file

@ -208,11 +208,11 @@ gitnexus wiki --model <model> # Wiki with custom LLM model (default: gpt-4o-m
gitnexus wiki --base-url <url> # Wiki with custom LLM API base URL
# Repository groups (multi-repo / monorepo service tracking)
gitnexus group create <name> # Create a repository group
gitnexus group add <name> <repo> # Add a repo to a group
gitnexus group remove <name> <repo> # Remove a repo from a group
gitnexus group list [name] # List groups, or show one group's config
gitnexus group sync <name> # Extract contracts and match across repos/services
gitnexus group create <name> # Create a repository group
gitnexus group add <group> <groupPath> <registryName> # Add a repo to a group. <groupPath> is a hierarchy path (e.g. hr/hiring/backend); <registryName> is the repo's name from the registry (see `gitnexus list`)
gitnexus group remove <group> <groupPath> # Remove a repo from a group by its hierarchy path
gitnexus group list [name] # List groups, or show one group's config
gitnexus group sync <name> # Extract contracts and match across repos/services
gitnexus group contracts <name> # Inspect extracted contracts and cross-links
gitnexus group query <name> <q> # Search execution flows across all repos in a group
gitnexus group status <name> # Check staleness of repos in a group
@ -338,12 +338,12 @@ npm run dev
## Docker
The official Docker setup ships **two signed images** orchestrated by `docker-compose.yaml`:
The official Docker setup ships **two signed images** orchestrated by `docker-compose.yaml`. Each image is published to both **GitHub Container Registry** (GHCR) and **Docker Hub** — same build, same digest, same Cosign signature — so pick whichever registry you prefer:
| Image | Purpose |
| -------------------------------------------------- | ---------------------------------------------------------------------- |
| `ghcr.io/abhigyanpatwari/gitnexus:latest` | CLI / `gitnexus serve` backend (HTTP API on port `4747`, MCP, indexer) |
| `ghcr.io/abhigyanpatwari/gitnexus-web:latest` | Static web UI (port `4173`) |
| Purpose | GHCR (default in `docker-compose.yaml`) | Docker Hub mirror |
| ---------------------------------------------------------------------- | --------------------------------------------- | ------------------------------------------- |
| CLI / `gitnexus serve` backend (HTTP API on port `4747`, MCP, indexer) | `ghcr.io/abhigyanpatwari/gitnexus:latest` | `akonlabs/gitnexus:latest` |
| Static web UI (port `4173`) | `ghcr.io/abhigyanpatwari/gitnexus-web:latest` | `akonlabs/gitnexus-web:latest` |
> **Heads-up — image rename.** Earlier releases published the web UI under
> `ghcr.io/abhigyanpatwari/gitnexus`. Starting with the introduction of the
@ -404,8 +404,11 @@ The Docker images are version-locked to the npm package:
- Stable images are **only published from `vX.Y.Z` git tags** (via `docker.yml`
triggered directly by the tag push), and the workflow refuses to build unless
the tag exactly matches `gitnexus/package.json`'s version. So
`ghcr.io/abhigyanpatwari/gitnexus:1.6.2` is byte-for-byte the same release
as `npm install gitnexus@1.6.2` — no drift, no floating builds from `main`.
`ghcr.io/abhigyanpatwari/gitnexus:1.6.2` (and its Docker Hub mirror
`akonlabs/gitnexus:1.6.2`) is byte-for-byte the same release as
`npm install gitnexus@1.6.2` — no drift, no floating builds from `main`.
Both registries receive the same digest from a single build step, so you can
pull from either and the signature verifies identically.
- Release-candidate images (e.g. `:1.7.0-rc.1`) are published alongside each
RC npm release. They are built by `release-candidate.yml` calling `docker.yml`
as a reusable workflow after the RC tag is created and pushed.
@ -426,11 +429,18 @@ sensitive environments:
cosign verify ghcr.io/abhigyanpatwari/gitnexus:1.6.2 \
--certificate-identity-regexp '^https://github\.com/abhigyanpatwari/GitNexus/\.github/workflows/docker\.yml@refs/tags/v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
# Same signature verifies the Docker Hub mirror (identical digest):
cosign verify docker.io/akonlabs/gitnexus:1.6.2 \
--certificate-identity-regexp '^https://github\.com/abhigyanpatwari/GitNexus/\.github/workflows/docker\.yml@refs/tags/v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
```
The regex pins the certificate identity to this repo's `docker.yml` workflow
**run from a `v*` tag** — rejecting unsigned images, images signed by other
workflows, and images signed from unprotected refs.
workflows, and images signed from unprotected refs. It is identical for both
registries because both sets of tags were signed at the same digest in one
workflow run.
**Release candidates** — signed from `refs/heads/main` (the caller's ref when
`release-candidate.yml` invokes `docker.yml` as a reusable workflow):

View file

@ -36,12 +36,24 @@ kind: ClusterImagePolicy
metadata:
name: gitnexus-signed-images
spec:
# Apply to both published GitNexus images on GHCR. Image references always
# carry a tag or digest at admission time, so these two globs cover every
# `gitnexus:<tag>`, `gitnexus@sha256:...`, `gitnexus-web:<tag>`, and
# `gitnexus-web@sha256:...` reference.
# Apply to both published GitNexus images on both registries. Image
# references always carry a tag or digest at admission time, so these globs
# cover every `gitnexus:<tag>`, `gitnexus@sha256:...`, `gitnexus-web:<tag>`,
# and `gitnexus-web@sha256:...` reference on either GHCR or Docker Hub.
# The Docker Hub images are byte-for-byte mirrors of the GHCR images (same
# build, same digest, same Cosign signature), so the same keyless identity
# authority verifies both.
images:
- glob: 'ghcr.io/abhigyanpatwari/gitnexus*'
# Docker Hub references can appear in three forms at admission time
# (`docker.io/...`, `index.docker.io/...`, and bare `akonlabs/...` with
# the default registry implied). List all three so the policy cannot be
# sidestepped by the choice of registry prefix. The Docker Hub namespace
# is `akonlabs` rather than `abhigyanpatwari` because the Docker Hub org
# differs from the GitHub org.
- glob: 'docker.io/akonlabs/gitnexus*'
- glob: 'index.docker.io/akonlabs/gitnexus*'
- glob: 'akonlabs/gitnexus*'
authorities:
- name: gitnexus-cosign-keyless
keyless:

View file

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

View file

@ -21,6 +21,7 @@ Run from the project root. This parses all source files, builds the knowledge gr
|------|--------|
| `--force` | Force full re-index even if up to date |
| `--embeddings` | Enable embedding generation for semantic search (off by default) |
| `--drop-embeddings` | Drop existing embeddings on rebuild. By default, an `analyze` without `--embeddings` preserves them. |
**When to run:** First time in a project, after major code changes, or when `gitnexus://repo/{name}/context` reports the index is stale.

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

@ -3,7 +3,7 @@
"private": true,
"version": "0.0.0",
"engines": {
"node": ">=20.0.0"
"node": "^20.19.0 || >=22.12.0"
},
"type": "module",
"scripts": {
@ -19,14 +19,14 @@
},
"dependencies": {
"gitnexus-shared": "file:../gitnexus-shared",
"@langchain/anthropic": "^1.3.10",
"@langchain/core": "^1.1.15",
"@langchain/google-genai": "^2.1.10",
"@langchain/langgraph": "^1.1.0",
"@langchain/ollama": "^1.2.0",
"@langchain/openai": "^1.2.2",
"@langchain/anthropic": "^1.3.27",
"@langchain/core": "^1.1.41",
"@langchain/google-genai": "^2.1.28",
"@langchain/langgraph": "^1.2.9",
"@langchain/ollama": "^1.2.6",
"@langchain/openai": "^1.4.4",
"@sigma/edge-curve": "^3.1.0",
"@tailwindcss/vite": "^4.1.18",
"@tailwindcss/vite": "^4.2.4",
"axios": "^1.13.2",
"d3": "^7.9.0",
"dompurify": "^3.3.3",
@ -36,9 +36,9 @@
"graphology-layout-forceatlas2": "^0.10.1",
"graphology-layout-noverlap": "^0.4.2",
"graphology-utils": "^2.3.0",
"langchain": "^1.2.10",
"langchain": "^1.3.4",
"lru-cache": "^11.2.4",
"lucide-react": "^0.562.0",
"lucide-react": "^1.11.0",
"mermaid": "^11.14.0",
"mnemonist": "^0.39.0",
"pandemonium": "^2.4.0",
@ -49,12 +49,12 @@
"react-zoom-pan-pinch": "^3.7.0",
"remark-gfm": "^4.0.1",
"sigma": "^3.0.2",
"tailwindcss": "^4.2.2",
"tailwindcss": "^4.2.4",
"uuid": "^13.0.0",
"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",
@ -65,13 +65,13 @@
"@types/react-dom": "^18.3.0",
"@types/react-syntax-highlighter": "^15.5.13",
"@vercel/node": "^5.5.16",
"@vitejs/plugin-react": "^5.1.0",
"@vitest/coverage-v8": "^3.2.4",
"@vitejs/plugin-react": "^5.1.4",
"@vitest/coverage-v8": "^4.1.5",
"jsdom": "^29.0.2",
"tree-sitter-wasms": "^0.1.13",
"typescript": "^5.4.5",
"vite": "^5.2.0",
"vitest": "^3.2.4",
"vite": "^8.0.10",
"vitest": "^4.1.5",
"wait-on": "^9.0.5"
}
}

View file

@ -5,7 +5,42 @@
* than directly from lucide-react. This provides a single place to manage
* which icons are used and allows future optimization (e.g., tree-shaking
* configuration, icon subset bundling) without touching every component.
*
* --- Why a local `Github` icon? ---
*
* Lucide removed all brand icons in v1
* (https://lucide.dev/guide/react/migration,
* https://github.com/lucide-icons/lucide/blob/main/BRAND_LOGOS_STATEMENT.md),
* so `import { Github } from 'lucide-react'` no longer compiles.
*
* We replace it with the official mark from Primer Octicons — the icon set
* GitHub itself uses on github.com — copied verbatim. This was preferred over
* the alternatives because it:
*
* 1. Is the canonical GitHub-maintained source for the mark, kept in sync
* with what users see on github.com.
* 2. Is MIT-licensed (Copyright (c) GitHub Inc.,
* https://github.com/primer/octicons/blob/main/LICENSE), so embedding the
* path data is permitted.
* 3. Adds zero new npm dependencies (vs. `@primer/octicons-react`,
* `react-icons`, or `simple-icons`), keeping the web bundle lean.
* 4. Ships per-size hand-tuned glyphs (16 + 24) — the same approach Primer
* uses — so the mark stays crisp at the small `h-4 w-4` sites in the
* header and onboarding screens as well as at full size.
*
* Trademark note: the GitHub mark is a registered trademark of GitHub, Inc.
* (https://brand.github.com/foundations/logo). The MIT license covers our
* right to copy the SVG; trademark rules still govern *use*. We use the mark
* here only to link to GitHub and to indicate GitHub source-repo integration,
* which are explicitly permitted by GitHub's brand toolkit.
*
* SVG sources (copied verbatim, MIT, Copyright (c) GitHub Inc.):
* - https://github.com/primer/octicons/blob/main/icons/mark-github-16.svg
* - https://github.com/primer/octicons/blob/main/icons/mark-github-24.svg
*/
import { forwardRef } from 'react';
import type { LucideProps } from 'lucide-react';
export {
AlertCircle,
AlertTriangle,
@ -31,7 +66,6 @@ export {
Folder,
FolderOpen,
GitBranch,
Github,
Globe,
Hash,
Heart,
@ -75,3 +109,63 @@ export {
ZoomIn,
ZoomOut,
} from 'lucide-react';
/**
* GitHub mark — local copy of Primer Octicons `mark-github-{16,24}`.
*
* Why this exists, why Primer Octicons, and the trademark caveat are documented
* at the top of this file. Please read that header before changing the SVG
* paths or swapping the source.
*
* API-compatible with `lucide-react` icons (`LucideProps`). The Octicons mark
* is a *filled* glyph, so the lucide-only stroke props (`strokeWidth`,
* `absoluteStrokeWidth`) are accepted for type parity but ignored. Color
* defaults to `currentColor`, so Tailwind `text-*` utilities work the same as
* with any other icon in this module.
*/
export const Github = forwardRef<SVGSVGElement, LucideProps>(function Github(
{
size = 24,
color = 'currentColor',
className,
strokeWidth: _strokeWidth,
absoluteStrokeWidth: _absoluteStrokeWidth,
...rest
},
ref,
) {
const numericSize = typeof size === 'string' ? Number.parseFloat(size) : size;
const useSmallVariant = Number.isFinite(numericSize) && (numericSize as number) <= 16;
if (useSmallVariant) {
return (
<svg
ref={ref}
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 16 16"
fill={color}
className={className}
{...rest}
>
<path d="M6.766 11.328c-2.063-.25-3.516-1.734-3.516-3.656 0-.781.281-1.625.75-2.188-.203-.515-.172-1.609.063-2.062.625-.078 1.468.25 1.968.703.594-.187 1.219-.281 1.985-.281.765 0 1.39.094 1.953.265.484-.437 1.344-.765 1.969-.687.218.422.25 1.515.046 2.047.5.593.766 1.39.766 2.203 0 1.922-1.453 3.375-3.547 3.64.531.344.89 1.094.89 1.954v1.625c0 .468.391.734.86.547C13.781 14.359 16 11.53 16 8.03 16 3.61 12.406 0 7.984 0 3.563 0 0 3.61 0 8.031a7.88 7.88 0 0 0 5.172 7.422c.422.156.828-.125.828-.547v-1.25c-.219.094-.5.156-.75.156-1.031 0-1.64-.562-2.078-1.609-.172-.422-.36-.672-.719-.719-.187-.015-.25-.093-.25-.187 0-.188.313-.328.625-.328.453 0 .844.281 1.25.86.313.452.64.655 1.031.655s.641-.14 1-.5c.266-.265.47-.5.657-.656" />
</svg>
);
}
return (
<svg
ref={ref}
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill={color}
className={className}
{...rest}
>
<path d="M10.226 17.284c-2.965-.36-5.054-2.493-5.054-5.256 0-1.123.404-2.336 1.078-3.144-.292-.741-.247-2.314.09-2.965.898-.112 2.111.36 2.83 1.01.853-.269 1.752-.404 2.853-.404 1.1 0 1.999.135 2.807.382.696-.629 1.932-1.1 2.83-.988.315.606.36 2.179.067 2.942.72.854 1.101 2 1.101 3.167 0 2.763-2.089 4.852-5.098 5.234.763.494 1.28 1.572 1.28 2.807v2.336c0 .674.561 1.056 1.235.786 4.066-1.55 7.255-5.615 7.255-10.646C23.5 6.188 18.334 1 11.978 1 5.62 1 .5 6.188.5 12.545c0 4.986 3.167 9.12 7.435 10.669.606.225 1.19-.18 1.19-.786V20.63a2.9 2.9 0 0 1-1.078.224c-1.483 0-2.359-.808-2.987-2.313-.247-.607-.517-.966-1.034-1.033-.27-.023-.359-.135-.359-.27 0-.27.45-.471.898-.471.652 0 1.213.404 1.797 1.235.45.651.921.943 1.483.943.561 0 .92-.202 1.437-.719.382-.381.674-.718.944-.943" />
</svg>
);
});

View file

@ -16,9 +16,12 @@ let lastEventSource: MockEventSource | null = null;
beforeEach(() => {
lastEventSource = null;
// vitest 4 enforces that mock implementations used with `new` must have a
// [[Construct]] slot. Arrow functions don't, so we use a regular function
// declaration here. The production code calls `new EventSource(...)`.
vi.stubGlobal(
'EventSource',
vi.fn().mockImplementation(() => {
vi.fn().mockImplementation(function () {
lastEventSource = new MockEventSource();
return lastEventSource;
}),

View file

@ -38,11 +38,15 @@ export default defineConfig({
'src/main.tsx', // Entry point
'src/vite-env.d.ts', // Type declarations
],
// Thresholds set to the post-vitest-4 baseline (AST-aware remapping
// measures coverage more accurately than the old istanbul-style mapping,
// so the same 220 tests now report slightly lower percentages). These
// are soft floors for regression detection, not coverage targets.
thresholds: {
statements: 10,
branches: 10,
functions: 10,
lines: 10,
statements: 9,
branches: 4,
functions: 7,
lines: 9,
},
},
},

View file

@ -4,9 +4,73 @@ All notable changes to GitNexus will be documented in this file.
## [Unreleased]
### Performance
## [1.6.3] - 2026-04-24
- **`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.
### Added
- **Cross-repo impact analysis** — `@repo` MCP routing plus group resources let impact queries span multiple indexed repositories in a group (#794, #984)
- **Python scope-based call resolution** — registry-primary flip, performance, and generalization work from RFC #909 Ring 3 (#980)
- **C# scope-resolution migration** — C# now runs on the registry-primary path alongside Python (#934, #1019)
- **RFC #909 Ring 1 & Ring 2 scope-resolution infrastructure** — the shared foundation for language-agnostic scope resolution:
- Scope-resolution types and constants, `LanguageProvider` hook extension (#910, #911, #949, #950)
- `ScopeTree` + `PositionIndex` + `makeScopeId` (#912, #961)
- `DefIndex` / `ModuleScopeIndex` / `QualifiedNameIndex` (#913, #958)
- `MethodDispatchIndex` materialized view over `HeritageMap` (#914, #960)
- `resolveTypeRef` strict single-return type resolver (#916, #959)
- SCC-aware finalize with bounded fixpoint (#915, #962)
- `ClassRegistry` / `MethodRegistry` / `FieldRegistry` + 7-step lookup (#917, #963)
- Shadow-mode diff + aggregate, parity harness + static dashboard (#918, #923, #951, #972)
- `ScopeExtractor` driver with 5-pass CaptureMatch → ParsedFile (#919, #965)
- `ScopeExtractor` wired into parse-worker + processor (#920, #969)
- `finalize-orchestrator` materializes `ScopeResolutionIndexes` (#921, #970)
- Per-language `resolveImportTarget` adapter (#922, #971)
- `REGISTRY_PRIMARY_<LANG>` per-language flag reader (#924, #968)
- `emit-references` drains `ReferenceIndex` to graph edges (#925, #973)
- **`gitnexus analyze --name <alias>`** with duplicate-name guard in the repo registry (#955)
- **`gitnexus remove <target>`** unindexes a registered repo by name or path (#664, #1003)
- **Auto-infer registry name** from `git remote.origin.url` when `--name` is omitted (#981)
- **Sibling-clone drift detection** — indexed repos are fingerprinted by remote URL so duplicate registrations are caught before graph divergence (#982)
- **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, #1045)
- **`GITNEXUS_INDEX_TEST_DIRS` opt-in** for `__tests__` / `__mocks__` traversal (#771, #1046)
- **`analyze` embedding preservation** — existing embeddings are preserved by default, `--force` regenerates them, `--drop-embeddings` opts out entirely (CLI + HTTP API) (#1055)
- **Structural embedding chunking** with data-driven `CHUNKING_RULES` dispatch, replacing the flat line-based split (#987)
- **PHP HTTP consumer detection** for the extractor catalogue (#993)
- **Per-phase search timing** instrumentation across the query pipeline (#953)
- **MCP disambiguation ranking** — `context` / `impact` candidates are ranked and expose `kind` / `file_path` hints (#888)
- **Docker images for UI + CLI/server** shipped via `docker-compose` with cosign signing (#967), RC image builds (#978), and GHCR → Docker Hub mirroring (#1029)
### Fixed
- **Go CALLS edges for receiver methods** — worker source IDs now align with the main pipeline, restoring receiver-method call edges (#1043)
- **Node 22 DEP0151 warning** from `tree-sitter-c-sharp` import silenced (#1013, #1049)
- **FTS index bootstrap** tries a local `LOAD` before `INSTALL` so offline/air-gapped runs no longer fail on network errors (#726)
- **FTS ensure failures** are no longer cached and are invalidated on pool teardown (#1006)
- **`groupImpact` local-impact errors** now bubble to the caller instead of being swallowed (#1004, #1007)
- **Friendly error** when a group name is not found, with regression test for #903 (#989)
- **`bm25` results** return FTS-matched symbols instead of an arbitrary `LIMIT 3` slice (#806)
- **Embedding AST traversal** switched from recursion to iterative DFS, fixing stack overflow on deeply nested files (#990)
- **React component path detection** runs before lowercasing, so mixed-case `.jsx`/`.tsx` files are recognised (#260)
- **`detect-changes` ENOBUFS** by setting `maxBuffer` on `git` / `rg` `execFileSync` invocations (#957)
- **`detect-changes` in direct CLI** — command was wired to MCP only; now exposed on the CLI as well (#892)
- **CLI gitnexus markers** — `<!-- gitnexus:* -->` is only matched at section position, no longer inside code/prose (#1041, #1042)
- **`opencode.json` setup** preserves existing comments and config during install (#998)
- **Sequential parser logging** — skipped languages are now logged instead of silently dropped (#1021)
- **`cli-e2e` fixture isolation** from the shared mini-repo, plus stabilised `rel-csv-split` stream teardown on Windows via `expect.poll` (#954, #1052)
- **Docker** — RC build guarded against empty `vtag`, `inputs.tag` used to detect `workflow_call` context, web builder stage now copies `gitnexus/package.json`, base image switched from alpine to debian (#983, #996, #997, #1014)
- **CI** — reusable `docker.yml` now inherits secrets from `release-candidate.yml` (#1054)
### Changed
- **`setup` config I/O unified** on `mergeJsoncFile` across all writers (#1031)
- **Docker CI** gains a retry wrapper for `build-push` with visibility and hardened shell
### Chore / Dependencies
- Dependency bumps: `graphology` 0.25.4 → 0.26.0 (#1001), `uuid` 13 → 14 (#1000), `@huggingface/transformers` (#1035), `@types/node` (#1002), `@types/uuid` (#1016), `vitest` 4.1.4 → 4.1.5 (#1017), `@vitest/coverage-v8` (#1018)
- gitnexus-web dependency bumps: `vite` 5.4.21 → 6.4.2 → 7.3.2 → 8.0.10 + `vitest` 4 (#1061, #1062, #1063), `lucide-react` 0.562.0 → 1.11.0 with local GitHub SVG fallback (#1038), `@langchain/anthropic` 1.3.10 → 1.3.27 (#1039), `@babel/types` (#1037)
- gitnexus-shared dependency bumps: `typescript` (#1034)
- GitHub Actions bumps: `actions/setup-node` 6.3.0 → 6.4.0 (#1033)
- Documentation: repo-wide `DoD.md` Definition of Done (#1032), gRPC microservices group guide (#906, #994), `group add` / `group remove` README fixes (#1020), CLI docs include `--skip-git` (#750), README Discord link updated
## [1.6.2] - 2026-04-18

View file

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

View file

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

View file

@ -1,6 +1,6 @@
{
"name": "gitnexus",
"version": "1.6.2",
"version": "1.6.3",
"description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.",
"author": "Abhigyan Patwari",
"license": "PolyForm-Noncommercial-1.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"

View file

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

View file

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

View file

@ -21,6 +21,7 @@ Run from the project root. This parses all source files, builds the knowledge gr
| -------------- | ---------------------------------------------------------------- |
| `--force` | Force full re-index even if up to date |
| `--embeddings` | Enable embedding generation for semantic search (off by default) |
| `--drop-embeddings` | Drop existing embeddings on rebuild. By default, an `analyze` without `--embeddings` preserves them. |
**When to run:** First time in a project, after major code changes, or when `gitnexus://repo/{name}/context` reports the index is stale. In Claude Code, a PostToolUse hook runs `analyze` automatically after `git commit` and `git merge`, preserving embeddings if previously generated.

View file

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

View file

@ -20,6 +20,7 @@ import {
} from '../storage/repo-manager.js';
import { getGitRoot, hasGitDir } from '../storage/git.js';
import { runFullAnalysis } from '../core/run-analyze.js';
import { getMaxFileSizeBannerMessage } from '../core/ingestion/utils/max-file-size.js';
import fs from 'fs/promises';
const HEAP_MB = 8192;
@ -55,6 +56,12 @@ function ensureHeap(): boolean {
export interface AnalyzeOptions {
force?: boolean;
embeddings?: boolean;
/**
* Explicitly drop existing embeddings on rebuild instead of preserving
* them. Without this flag, a routine `analyze` keeps any embeddings
* already present in the index even when `--embeddings` is omitted.
*/
dropEmbeddings?: boolean;
skills?: boolean;
verbose?: boolean;
/** Skip AGENTS.md and CLAUDE.md gitnexus block updates. */
@ -78,6 +85,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 +100,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 +149,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(
{
@ -210,6 +232,7 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
// collision guard (see allowDuplicateName below).
force: options?.force || options?.skills,
embeddings: options?.embeddings,
dropEmbeddings: options?.dropEmbeddings,
skipGit: options?.skipGit,
skipAgentsMd: options?.skipAgentsMd,
noStats: options?.noStats,

View file

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

View file

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

View file

@ -24,6 +24,11 @@ program
.description('Index a repository (full analysis)')
.option('-f, --force', 'Force full re-index even if up to date')
.option('--embeddings', 'Enable embedding generation for semantic search (off by default)')
.option(
'--drop-embeddings',
'Drop existing embeddings on rebuild. By default, an `analyze` without `--embeddings` ' +
'preserves any embeddings already present in the index.',
)
.option('--skills', 'Generate repo-specific skill files from detected communities')
.option('--skip-agents-md', 'Skip updating the gitnexus section in AGENTS.md and CLAUDE.md')
.option('--no-stats', 'Omit volatile file/symbol counts from AGENTS.md and CLAUDE.md')
@ -39,9 +44,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 +96,15 @@ program
.option('--all', 'Clean all indexed repos')
.action(createLazyAction(() => import('./clean.js'), 'cleanCommand'));
program
.command('remove <target>')
.description(
'Delete the GitNexus index for a registered repo (by alias, name, or absolute path). ' +
'Unlike `clean`, does not require being inside the repo. Idempotent on unknown targets.',
)
.option('-f, --force', 'Skip confirmation prompt')
.action(createLazyAction(() => import('./remove.js'), 'removeCommand'));
program
.command('wiki [path]')
.description('Generate repository wiki from knowledge graph')

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

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

View file

@ -13,6 +13,7 @@ import { execFile, execFileSync } from 'child_process';
import { promisify } from 'util';
import { fileURLToPath } from 'url';
import { glob } from 'glob';
import { parseTree, modify, applyEdits, ParseError, parse as parseJsonc } from 'jsonc-parser';
import { getGlobalDir } from '../storage/repo-manager.js';
const __filename = fileURLToPath(import.meta.url);
@ -76,38 +77,70 @@ function getMcpEntry() {
}
/**
* Merge gitnexus entry into an existing MCP config JSON object.
* Returns the updated config.
* OpenCode uses a different MCP format: { type: "local", command: [...] }
* where command is a flat array (command + args combined).
*/
function mergeMcpConfig(existing: any): any {
if (!existing || typeof existing !== 'object') {
existing = {};
function getOpenCodeMcpEntry() {
const bin = resolveGitnexusBin();
if (bin) {
return { type: 'local', command: [bin, 'mcp'] };
}
if (!existing.mcpServers || typeof existing.mcpServers !== 'object') {
existing.mcpServers = {};
if (process.platform === 'win32') {
return { type: 'local', command: ['cmd', '/c', 'npx', '-y', 'gitnexus@latest', 'mcp'] };
}
existing.mcpServers.gitnexus = getMcpEntry();
return existing;
return { type: 'local', command: ['npx', '-y', 'gitnexus@latest', 'mcp'] };
}
/**
* Try to read a JSON file, returning null if it doesn't exist or is invalid.
* Detect indentation style from file content.
* Returns formatting options matching the file's existing style.
*/
async function readJsonFile(filePath: string): Promise<any | null> {
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 {
const raw = await fs.readFile(filePath, 'utf-8');
return JSON.parse(raw);
raw = await fs.readFile(filePath, 'utf-8');
} catch {
return null;
raw = '';
}
}
/**
* Write JSON to a file, creating parent directories if needed.
*/
async function writeJsonFile(filePath: string, data: any): Promise<void> {
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, JSON.stringify(data, null, 2) + '\n', 'utf-8');
if (raw.trim().length === 0) {
await fs.mkdir(path.dirname(filePath), { recursive: true });
const formattingOptions = { tabSize: 2, insertSpaces: true };
const edits = modify('{}', keyPath, value, { formattingOptions });
const result = applyEdits('{}', edits);
await fs.writeFile(filePath, result, 'utf-8');
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;
}
/**
@ -133,10 +166,12 @@ async function setupCursor(result: SetupResult): Promise<void> {
const mcpPath = path.join(cursorDir, 'mcp.json');
try {
const existing = await readJsonFile(mcpPath);
const updated = mergeMcpConfig(existing);
await writeJsonFile(mcpPath, updated);
result.configured.push('Cursor');
const ok = await mergeJsoncFile(mcpPath, ['mcpServers', 'gitnexus'], getMcpEntry());
if (ok) {
result.configured.push('Cursor');
} else {
result.errors.push('Cursor: mcp.json is corrupt — skipping to preserve existing content');
}
} catch (err: any) {
result.errors.push(`Cursor: ${err.message}`);
}
@ -152,10 +187,14 @@ async function setupClaudeCode(result: SetupResult): Promise<void> {
// Claude Code stores MCP config in ~/.claude.json
const mcpPath = path.join(os.homedir(), '.claude.json');
try {
const existing = await readJsonFile(mcpPath);
const updated = mergeMcpConfig(existing);
await writeJsonFile(mcpPath, updated);
result.configured.push('Claude Code');
const ok = await mergeJsoncFile(mcpPath, ['mcpServers', 'gitnexus'], getMcpEntry());
if (ok) {
result.configured.push('Claude Code');
} else {
result.errors.push(
'Claude Code: .claude.json is corrupt — skipping to preserve existing content',
);
}
} catch (err: any) {
result.errors.push(`Claude Code: ${err.message}`);
}
@ -179,9 +218,90 @@ async function installClaudeCodeSkills(result: SetupResult): Promise<void> {
}
}
/**
* Check whether an event array already contains a gitnexus-hook entry.
*/
function hasGitnexusHook(hooksObj: any, eventName: string): boolean {
const entries = hooksObj?.[eventName];
if (!Array.isArray(entries)) return false;
return entries.some(
(h: any) =>
Array.isArray(h.hooks) &&
h.hooks.some(
(hh: any) => typeof hh.command === 'string' && hh.command.includes('gitnexus-hook'),
),
);
}
/**
* Merge hook entries into a JSONC settings file, preserving comments and formatting.
* Uses chained modify()+applyEdits() calls to append to arrays without a full
* JSON.stringify roundtrip that would strip comments.
*/
async function mergeHooksJsonc(
filePath: string,
entries: Array<{ eventName: string; value: unknown }>,
): Promise<boolean> {
let raw: string;
try {
raw = await fs.readFile(filePath, 'utf-8');
} catch {
raw = '';
}
if (raw.trim().length === 0) {
await fs.mkdir(path.dirname(filePath), { recursive: true });
const hooks: any = {};
for (const { eventName, value } of entries) {
hooks[eventName] = [value];
}
const formattingOptions = { tabSize: 2, insertSpaces: true };
const edits = modify('{}', ['hooks'], hooks, { formattingOptions });
await fs.writeFile(filePath, applyEdits('{}', edits), 'utf-8');
return true;
}
const parseErrors: ParseError[] = [];
const tree = parseTree(raw, parseErrors);
if (!tree || tree.type !== 'object' || parseErrors.length > 0) {
return false;
}
const formattingOptions = detectIndentation(raw);
let current = raw;
for (const { eventName, value } of entries) {
// Re-parse after each edit to get a fresh insertion index.
const currentTree = parseTree(current, []);
const hooksNode = currentTree?.children?.find(
(c) => c.type === 'property' && c.children?.[0]?.value === 'hooks',
);
const eventNode = hooksNode?.children?.[1]?.children?.find(
(c: any) => c.type === 'property' && c.children?.[0]?.value === eventName,
);
let insertIndex: number;
if (eventNode?.children?.[1] && Array.isArray(eventNode.children[1].children)) {
insertIndex = eventNode.children[1].children.length;
} else {
insertIndex = 0;
}
const edits = modify(current, ['hooks', eventName, insertIndex], value, {
formattingOptions,
});
current = applyEdits(current, edits);
}
await fs.writeFile(filePath, current, 'utf-8');
return true;
}
/**
* Install GitNexus hooks to ~/.claude/settings.json for Claude Code.
* Merges hook config without overwriting existing hooks.
* Merges hook config without overwriting existing hooks, preserving
* comments and formatting in the JSONC file.
*/
async function installClaudeCodeHooks(result: SetupResult): Promise<void> {
const claudeDir = path.join(os.homedir(), '.claude');
@ -202,8 +322,6 @@ async function installClaudeCodeHooks(result: SetupResult): Promise<void> {
const dest = path.join(destHooksDir, 'gitnexus-hook.cjs');
try {
let content = await fs.readFile(src, 'utf-8');
// Inject resolved CLI path so the copied hook can find the CLI
// even when it's no longer inside the npm package tree
const resolvedCli = path.join(__dirname, '..', 'cli', 'index.js');
const normalizedCli = path.resolve(resolvedCli).replace(/\\/g, '/');
const jsonCli = JSON.stringify(normalizedCli);
@ -219,40 +337,67 @@ async function installClaudeCodeHooks(result: SetupResult): Promise<void> {
const hookPath = path.join(destHooksDir, 'gitnexus-hook.cjs').replace(/\\/g, '/');
const hookCmd = `node "${hookPath.replace(/"/g, '\\"')}"`;
// Merge hook config into ~/.claude/settings.json
const existing = (await readJsonFile(settingsPath)) || {};
if (!existing.hooks) existing.hooks = {};
// Check which hook events need entries (idempotent: skip if already registered)
const parsed = await (async () => {
try {
const r = await fs.readFile(settingsPath, 'utf-8');
return parseJsonc(r);
} catch {
return null;
}
})();
const hookEntries: Array<{ eventName: string; value: unknown }> = [];
// NOTE: SessionStart hooks are broken on Windows (Claude Code bug #23576).
// Session context is delivered via CLAUDE.md / skills instead.
// Helper: add a hook entry if one with 'gitnexus-hook' isn't already registered
interface HookEntry {
hooks?: Array<{ command?: string }>;
if (!hasGitnexusHook(parsed?.hooks, 'PreToolUse')) {
hookEntries.push({
eventName: 'PreToolUse',
value: {
matcher: 'Grep|Glob|Bash',
hooks: [
{
type: 'command',
command: hookCmd,
timeout: 10,
statusMessage: 'Enriching with GitNexus graph context...',
},
],
},
});
}
function ensureHookEntry(
eventName: string,
matcher: string,
timeout: number,
statusMessage: string,
) {
if (!existing.hooks[eventName]) existing.hooks[eventName] = [];
const hasHook = existing.hooks[eventName].some((h: HookEntry) =>
h.hooks?.some((hh) => hh.command?.includes('gitnexus-hook')),
if (!hasGitnexusHook(parsed?.hooks, 'PostToolUse')) {
hookEntries.push({
eventName: 'PostToolUse',
value: {
matcher: 'Bash',
hooks: [
{
type: 'command',
command: hookCmd,
timeout: 10,
statusMessage: 'Checking GitNexus index freshness...',
},
],
},
});
}
if (hookEntries.length === 0) {
result.configured.push('Claude Code hooks (already configured)');
return;
}
const ok = await mergeHooksJsonc(settingsPath, hookEntries);
if (ok) {
result.configured.push('Claude Code hooks (PreToolUse, PostToolUse)');
} else {
result.errors.push(
'Claude Code hooks: settings.json is corrupt — skipping to preserve existing content',
);
if (!hasHook) {
existing.hooks[eventName].push({
matcher,
hooks: [{ type: 'command', command: hookCmd, timeout, statusMessage }],
});
}
}
ensureHookEntry('PreToolUse', 'Grep|Glob|Bash', 10, 'Enriching with GitNexus graph context...');
ensureHookEntry('PostToolUse', 'Bash', 10, 'Checking GitNexus index freshness...');
await writeJsonFile(settingsPath, existing);
result.configured.push('Claude Code hooks (PreToolUse, PostToolUse)');
} catch (err: any) {
result.errors.push(`Claude Code hooks: ${err.message}`);
}
@ -267,12 +412,14 @@ async function setupOpenCode(result: SetupResult): Promise<void> {
const configPath = path.join(opencodeDir, 'opencode.json');
try {
const existing = await readJsonFile(configPath);
const config = existing || {};
if (!config.mcp) config.mcp = {};
config.mcp.gitnexus = getMcpEntry();
await writeJsonFile(configPath, config);
result.configured.push('OpenCode');
const ok = await mergeJsoncFile(configPath, ['mcp', 'gitnexus'], getOpenCodeMcpEntry());
if (ok) {
result.configured.push('OpenCode');
} else {
result.errors.push(
'OpenCode: opencode.json is corrupt — skipping to preserve existing content',
);
}
} catch (err: any) {
result.errors.push(`OpenCode: ${err.message}`);
}

View file

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

View file

@ -0,0 +1,54 @@
/**
* Pure derivation of the embedding-mode flags for `runFullAnalysis`.
*
* Lives in its own module (no native imports) so the branching contract can
* be unit-tested without spinning up LadybugDB, tree-sitter, or any of the
* other side-effecting dependencies pulled in by `run-analyze.ts`.
*
* Semantics:
* --drop-embeddings -> wipe (skip cache load entirely)
* --embeddings -> load cache, restore, then generate
* --force + existing>0 -> load cache, restore, then generate (regenerate top-up)
* (default) + existing>0 -> preserve only (load + restore, no generation)
* any path with existing=0 -> no cache work, no preservation
*/
export interface EmbeddingModeInput {
force?: boolean;
embeddings?: boolean;
dropEmbeddings?: boolean;
}
export interface EmbeddingMode {
/** True when phase 4 should run the embedding generation pipeline. */
shouldGenerateEmbeddings: boolean;
/** True when we should load the cache to re-insert vectors after rebuild without generating new ones. */
preserveExistingEmbeddings: boolean;
/** True when `--force` upgraded a default analyze into a regeneration because the repo was already embedded. */
forceRegenerateEmbeddings: boolean;
/** True when we need to load cached embeddings from the existing DB before the rebuild. */
shouldLoadCache: boolean;
}
export function deriveEmbeddingMode(
options: EmbeddingModeInput,
existingEmbeddingCount: number,
): EmbeddingMode {
const hasExisting = existingEmbeddingCount > 0;
const drop = !!options.dropEmbeddings;
const explicit = !!options.embeddings;
const force = !!options.force;
const forceRegenerateEmbeddings = force && !explicit && !drop && hasExisting;
const preserveExistingEmbeddings =
!explicit && !drop && !forceRegenerateEmbeddings && hasExisting;
const shouldGenerateEmbeddings = explicit || forceRegenerateEmbeddings;
const shouldLoadCache = !drop && (shouldGenerateEmbeddings || preserveExistingEmbeddings);
return {
shouldGenerateEmbeddings,
preserveExistingEmbeddings,
forceRegenerateEmbeddings,
shouldLoadCache,
};
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,229 @@
/**
* `resolveReferenceSites` — drain `ReferenceSite[]` from a finalized
* `ScopeResolutionIndexes` into a `ReferenceIndex` by routing each site
* through the appropriate scope-aware `Registry.lookup` (RFC §3.2 Phase 4).
*
* This is the missing producer that `emit-references.ts` (#925) was
* waiting on. The two together form the registry-primary resolution
* pipeline:
*
* ScopeResolutionIndexes.referenceSites
* │ resolveReferenceSites
* ▼
* ReferenceIndex
* │ emitReferencesToGraph
* ▼
* graph: CALLS / ACCESSES / INHERITS / USES edges
*
* ## What this module does
*
* - For each `ReferenceSite`, picks the registry by `kind`:
* · `call` / `inherits` → MethodRegistry / ClassRegistry (call-form aware)
* · `read` / `write` → FieldRegistry (falls through to MethodRegistry for free names)
* · `type-reference` → ClassRegistry
* · `import-use` → all three (best-effort name-lookup)
* - Calls `Registry.lookup` with the site's `inScope`, optional
* explicit receiver, and arity.
* - Takes the top-ranked `Resolution` (best by confidence + tie-break
* cascade); folds it into a `Reference` record and bins by source scope.
*
* ## What this module does NOT do
*
* - No AST walks. The `ReferenceSite[]` is already extracted.
* - No language switches. Per-language behavior flows through
* `RegistryProviders.arityCompatibility` (see `RegistryContext`).
* - No multi-candidate fan-out. We pick `[0]` per RFC §4.3 ("one-shot
* answer"). The full ranked list is preserved in the per-site
* resolution but not emitted as multiple edges; callers that want
* branch-on-ambiguity behavior should consume the registries directly.
*/
import {
buildClassRegistry,
buildFieldRegistry,
buildMethodRegistry,
CLASS_KINDS,
FIELD_KINDS,
METHOD_KINDS,
type ClassRegistry,
type FieldRegistry,
type MethodRegistry,
type Reference,
type ReferenceIndex,
type ReferenceSite,
type RegistryContext,
type RegistryProviders,
type Resolution,
type ScopeId,
} from 'gitnexus-shared';
import type { ScopeResolutionIndexes } from './model/scope-resolution-indexes.js';
// ─── Public API ─────────────────────────────────────────────────────────────
export interface ResolveReferencesInput {
readonly scopes: ScopeResolutionIndexes;
/** Provider hooks consumed by the registries (e.g. `arityCompatibility`). */
readonly providers?: RegistryProviders;
}
export interface ResolveStats {
readonly sitesProcessed: number;
readonly referencesEmitted: number;
/** Sites where `Registry.lookup` returned no candidates. */
readonly unresolved: number;
}
export interface ResolveReferencesOutput {
readonly referenceIndex: ReferenceIndex;
readonly stats: ResolveStats;
}
/**
* Resolve every `ReferenceSite` in `scopes.referenceSites` against the
* matching registry and produce a `ReferenceIndex` keyed by source scope
* + target def.
*/
export function resolveReferenceSites(input: ResolveReferencesInput): ResolveReferencesOutput {
const { scopes } = input;
const providers: RegistryProviders = input.providers ?? {};
const ctx: RegistryContext = {
scopes: scopes.scopeTree,
defs: scopes.defs,
qualifiedNames: scopes.qualifiedNames,
moduleScopes: scopes.moduleScopes,
methodDispatch: scopes.methodDispatch,
providers,
};
const classRegistry = buildClassRegistry(ctx);
const methodRegistry = buildMethodRegistry(ctx);
const fieldRegistry = buildFieldRegistry(ctx);
// bySourceScope is the canonical index; byTargetDef is derived from it.
const bySourceScope = new Map<ScopeId, Reference[]>();
const byTargetDef = new Map<string, Reference[]>();
let sitesProcessed = 0;
let referencesEmitted = 0;
let unresolved = 0;
for (const site of scopes.referenceSites) {
sitesProcessed++;
const resolutions = lookupForSite(site, classRegistry, methodRegistry, fieldRegistry);
if (resolutions.length === 0) {
unresolved++;
continue;
}
const top = resolutions[0]!;
const ref = buildReference(site, top);
referencesEmitted++;
let bySource = bySourceScope.get(site.inScope);
if (bySource === undefined) {
bySource = [];
bySourceScope.set(site.inScope, bySource);
}
bySource.push(ref);
let byTarget = byTargetDef.get(top.def.nodeId);
if (byTarget === undefined) {
byTarget = [];
byTargetDef.set(top.def.nodeId, byTarget);
}
byTarget.push(ref);
}
// Freeze inner arrays so consumers don't accidentally mutate.
const frozenBySource = new Map<ScopeId, readonly Reference[]>();
for (const [k, v] of bySourceScope) frozenBySource.set(k, Object.freeze([...v]));
const frozenByTarget = new Map<string, readonly Reference[]>();
for (const [k, v] of byTargetDef) frozenByTarget.set(k, Object.freeze([...v]));
return {
referenceIndex: { bySourceScope: frozenBySource, byTargetDef: frozenByTarget },
stats: { sitesProcessed, referencesEmitted, unresolved },
};
}
// ─── Internal ───────────────────────────────────────────────────────────────
/**
* Pick the right registry for the site's `kind` and call `lookup`.
*
* The 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 };

View file

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

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