mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-30 01:51:20 +00:00
Merge remote-tracking branch 'origin/main' into codex/feat-thrift-contracts-impl
# Conflicts: # gitnexus/src/core/group/matching.ts # gitnexus/src/core/group/sync.ts
This commit is contained in:
commit
40f224fd9f
311 changed files with 280362 additions and 3969 deletions
|
|
@ -17,12 +17,13 @@ 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.
|
||||
**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 detects staleness after `git commit` and `git merge` and notifies the agent to run `analyze` — the hook does not run analyze itself, to avoid blocking the agent for up to 120s and risking KuzuDB corruption on timeout.
|
||||
|
||||
### status — Check index freshness
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
105
.github/actions/docker-build-push-retry/action.yml
vendored
Normal file
105
.github/actions/docker-build-push-retry/action.yml
vendored
Normal 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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
4
.github/workflows/ci-quality.yml
vendored
4
.github/workflows/ci-quality.yml
vendored
|
|
@ -9,7 +9,7 @@ jobs:
|
|||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
|
|
@ -22,7 +22,7 @@ jobs:
|
|||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
|
|
|
|||
3
.github/workflows/ci-scope-parity.yml
vendored
3
.github/workflows/ci-scope-parity.yml
vendored
|
|
@ -11,7 +11,8 @@ name: Scope Resolution Parity
|
|||
# TWICE on every PR:
|
||||
#
|
||||
# 1. `REGISTRY_PRIMARY_<LANG>=0` — legacy DAG path (guarantees we haven't
|
||||
# broken the old path while migrating).
|
||||
# broken the old path while migrating). Known legacy gaps may be skipped
|
||||
# through the resolver test helper's expected-failure list.
|
||||
# 2. `REGISTRY_PRIMARY_<LANG>=1` — registry-primary path (guarantees the
|
||||
# new path carries the same behavior — the parity gate).
|
||||
#
|
||||
|
|
|
|||
79
.github/workflows/docker.yml
vendored
79
.github/workflows/docker.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
2
.github/workflows/pr-labeler.yml
vendored
2
.github/workflows/pr-labeler.yml
vendored
|
|
@ -105,7 +105,7 @@ jobs:
|
|||
# Pinned to v7.2.0. Verify SHA via:
|
||||
# gh api repos/release-drafter/release-drafter/git/refs/tags/v7.2.0
|
||||
# v7 removed `disable-releaser`; use `dry-run: true` to only autolabel.
|
||||
- uses: release-drafter/release-drafter@5de93583980a40bd78603b6dfdcda5b4df377b32 # v7.2.0
|
||||
- uses: release-drafter/release-drafter@563bf132657a13ded0b01fcb723c5a58cdd824e2 # v7.2.1
|
||||
with:
|
||||
config-name: release-drafter.yml
|
||||
dry-run: true
|
||||
|
|
|
|||
2
.github/workflows/publish.yml
vendored
2
.github/workflows/publish.yml
vendored
|
|
@ -33,7 +33,7 @@ jobs:
|
|||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 20
|
||||
registry-url: https://registry.npmjs.org
|
||||
|
|
|
|||
6
.github/workflows/release-candidate.yml
vendored
6
.github/workflows/release-candidate.yml
vendored
|
|
@ -133,7 +133,7 @@ jobs:
|
|||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 20
|
||||
registry-url: https://registry.npmjs.org
|
||||
|
|
@ -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
1
.gitignore
vendored
|
|
@ -106,3 +106,4 @@ local_docs/
|
|||
# Local agent scratch / review prompts (never commit)
|
||||
.tmp/
|
||||
.agents/
|
||||
.context/
|
||||
|
|
|
|||
18
AGENTS.md
18
AGENTS.md
|
|
@ -1,7 +1,7 @@
|
|||
<!-- version: 1.6.0 -->
|
||||
<!-- Last updated: 2026-04-20 -->
|
||||
<!-- version: 1.7.0 -->
|
||||
<!-- Last updated: 2026-04-23 -->
|
||||
|
||||
Last reviewed: 2026-04-20
|
||||
Last reviewed: 2026-04-23
|
||||
|
||||
**Project:** GitNexus · **Environment:** dev · **Maintainer:** repository maintainers (see GitHub)
|
||||
|
||||
|
|
@ -40,7 +40,7 @@ Commands and gotchas live under **Repo reference** below and in **[CONTRIBUTING.
|
|||
|
||||
- **[ARCHITECTURE.md](ARCHITECTURE.md)**, **[CONTRIBUTING.md](CONTRIBUTING.md)**, **[GUARDRAILS.md](GUARDRAILS.md)**
|
||||
- **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.
|
||||
- **Scope-resolution pipeline (RFC #909 Ring 3):** See ARCHITECTURE.md § Scope-Resolution Pipeline. Replaces the legacy DAG for languages in `MIGRATED_LANGUAGES` (see `registry-primary-flag.ts`). 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.
|
||||
|
||||
|
|
@ -48,6 +48,7 @@ Commands and gotchas live under **Repo reference** below and in **[CONTRIBUTING.
|
|||
|
||||
| Date | Version | Change |
|
||||
|------|---------|--------|
|
||||
| 2026-04-23 | 1.7.0 | TypeScript added to `MIGRATED_LANGUAGES` (registry-primary call resolution by default). |
|
||||
| 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. |
|
||||
|
|
@ -148,13 +149,14 @@ Indexed as **GitNexus** (4325 symbols, 10556 relationships, 300 execution flows)
|
|||
## Keeping the Index Fresh
|
||||
|
||||
```bash
|
||||
npx gitnexus analyze # basic refresh
|
||||
npx gitnexus analyze --embeddings # preserve 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
|
||||
```
|
||||
|
||||
Check `.gitnexus/meta.json` `stats.embeddings` (0 = none). Running without `--embeddings` deletes existing vectors.
|
||||
Check `.gitnexus/meta.json` `stats.embeddings` (0 = none). A plain `analyze` no longer drops existing vectors — pass `--drop-embeddings` to wipe.
|
||||
|
||||
> Claude Code: PostToolUse hook handles this after `git commit` and `git merge`.
|
||||
> Claude Code: PostToolUse hook detects a stale index after `git commit` and `git merge` and prompts the agent to run `analyze`. The hook does not invoke `analyze` itself.
|
||||
|
||||
## CLI Skills
|
||||
|
||||
|
|
|
|||
|
|
@ -215,10 +215,52 @@ Both hooks are optional on `LanguageProvider`. Ruby is the only current implemen
|
|||
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) → 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`.
|
||||
- **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)
|
||||
|
|
@ -260,6 +302,11 @@ Single interface a language implements to plug into the pipeline. Contract fully
|
|||
| `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
|
||||
|
||||
|
|
@ -284,12 +331,16 @@ CI auto-discovers the set via `tsx`. No workflow edit required.
|
|||
| `registry-primary-flag.ts` | `MIGRATED_LANGUAGES` set + `isRegistryPrimary(lang)` |
|
||||
| `languages/python/index.ts` | Python `ScopeResolver` hooks + known-limitation docs |
|
||||
| `languages/python/captures.ts` | `emitPythonScopeCaptures` (honors cross-phase Tree cache) |
|
||||
| `languages/csharp/index.ts` | C# `ScopeResolver` hooks + known-limitation docs |
|
||||
| `languages/csharp/captures.ts` | `emitCsharpScopeCaptures` (honors cross-phase Tree cache) |
|
||||
| `languages/csharp/namespace-siblings.ts` | Cross-file implicit-namespace visibility hook (reads `treeCache`) |
|
||||
|
||||
### Performance notes
|
||||
|
||||
- **Cross-phase Tree cache**: parse phase writes Trees into `scopeTreeCache` (separate from the chunk-local `astCache`) ONLY for languages with `emitScopeCaptures`. Scope-resolution reads from it to skip the second parse. Cleared at end of the phase. Workers leave the cache empty — Trees can't cross MessageChannels; cache miss = fresh parse. `PROF_SCOPE_RESOLUTION=1` emits hit/miss counters and a worker-engaged warning.
|
||||
- **Typed relationship iteration**: heritage + MRO walk only the EXTENDS / IMPLEMENTS / HAS_METHOD edges via `iterRelationshipsByType`, not the full relationship map.
|
||||
- **Workspace-resolution-index**: O(1) `findOwnedMember` / `findExportedDef` / `classScopeByDefId` built once per run.
|
||||
- **SCC-ordered cross-file return-type propagation** (PR #1050): `propagateImportedReturnTypes` walks `indexes.sccs` in reverse-topological order (leaves first), so multi-hop alias chains like `models.User → service.user → app.user` collapse to the terminal class in a single linear pass. Within each importer, the source module's `typeBindings` is chain-followed BEFORE mirroring (so we mirror terminal types, not intermediate refs), and the importer's own `typeBindings` is chain-followed AFTER mirroring (so local `const x = importedFn()` resolves before downstream importers run). Cyclic SCCs reach a partial fixpoint within a single pass without iterating to convergence — see the `ts-circular` cross-file-binding fixture which only asserts pipeline-no-throw. PROF output (`PROF_SCOPE_RESOLUTION=1`) splits `finalize` from `propagate` so quadratic regressions in the chain-follow surface independently.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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
209
DoD.md
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
# Definition of Done — GitNexus
|
||||
|
||||
Last reviewed: 2026-04-23 · Version: 2.0.0
|
||||
|
||||
This document defines the repo-wide completion bar for production-ready changes in GitNexus. It is the stable baseline. Implementation prompts, agent behavior, and review workflows may add task-specific checks, but they must never weaken this bar.
|
||||
|
||||
Use it together with:
|
||||
|
||||
- `AGENTS.md` — agent-facing rules of engagement
|
||||
- `GUARDRAILS.md` — hard safety constraints
|
||||
- `CONTRIBUTING.md` — contributor workflow
|
||||
- `TESTING.md` — test strategy and coverage expectations
|
||||
- `ARCHITECTURE.md` — pipeline boundaries, Call-Resolution DAG, LanguageProvider contract
|
||||
|
||||
## 1. Scope and Intent
|
||||
|
||||
A change is **Done** when it is correct, safely integrated, appropriately tested, operationally sound, and a net improvement to the codebase — not merely "the code compiles and a test passes."
|
||||
|
||||
This DoD applies to:
|
||||
|
||||
- CLI, MCP, and HTTP-bridge behavior in `gitnexus/`
|
||||
- Browser UI in `gitnexus-web/`
|
||||
- Shared contracts in `gitnexus-shared/`
|
||||
- CI workflows, release pipelines, and repo-level docs
|
||||
|
||||
Out of scope: full agent personas, step-by-step implementation prompts, verbose review formatting rules, repo walkthroughs already covered elsewhere, temporary task-specific acceptance criteria. Those belong in prompts, PR templates, or other repo docs.
|
||||
|
||||
## 2. Core Definition of Done
|
||||
|
||||
Every change must satisfy **every relevant item** below. If an item does not apply, say so explicitly in the PR description.
|
||||
|
||||
### 2.1 Correctness and Completeness
|
||||
|
||||
- [ ] The requested behavior is implemented end-to-end in the **real runtime path** for the affected surface — no dead code, partial wiring, test-only shims, or "works in isolation but not in production" seams.
|
||||
- [ ] Edge cases relevant to the changed surface are handled or explicitly documented as out of scope.
|
||||
- [ ] Error handling is proportionate: inputs at system boundaries (user input, external APIs, filesystem, process spawn) are validated; internal, framework-guaranteed paths are trusted.
|
||||
- [ ] The change produces the same result on re-run (idempotent where expected) and does not rely on accidental ordering.
|
||||
|
||||
### 2.2 Architecture and Placement
|
||||
|
||||
- [ ] The change is placed in the correct package and layer:
|
||||
- `gitnexus/` for CLI, MCP, HTTP bridge, ingestion, graph, and runtime logic
|
||||
- `gitnexus-web/` for browser UI (thin client — no WASM workers, all queries via HTTP API)
|
||||
- `gitnexus-shared/` for shared contracts, types, and constants
|
||||
- [ ] Pipeline and architecture boundaries remain explicit. Shared ingestion code in `gitnexus/src/core/ingestion/` must not name languages — use `LanguageProvider` hooks (see `AGENTS.md` and `ARCHITECTURE.md` § Call-Resolution DAG).
|
||||
- [ ] No hidden cross-phase coupling; no leaking of language-specific logic into shared infrastructure without a documented architectural reason.
|
||||
- [ ] Runtime and graph behavior are consistent — the real source of truth is fixed at the source, not symptom-patched in a downstream layer.
|
||||
- [ ] Direct imports from `gitnexus-shared` are used. No barrel re-exports introduced to paper over drift between packages.
|
||||
|
||||
### 2.3 Design and Readability
|
||||
|
||||
- [ ] The implementation is the **smallest correct solution** for the requirement. No speculative abstraction, unnecessary indirection, clever but hard-to-follow control flow, or unrelated cleanup.
|
||||
- [ ] Naming, control flow, ownership, and extension points are clear enough that the next contributor can extend the code without archaeology.
|
||||
- [ ] Comments are minimal and useful — they explain intent, invariants, contracts, or non-obvious constraints. No stale comments, placeholder comments, narrated code, commented-out code, or "what" comments where a good name would do.
|
||||
- [ ] No copy-paste duplication created for convenience; no premature deduplication of three similar lines.
|
||||
|
||||
### 2.4 Contracts and Compatibility
|
||||
|
||||
- [ ] Existing contracts (types in `gitnexus-shared/`, CLI flags, MCP tools/resources, HTTP routes, graph node/edge shapes, persisted IDs) are preserved unless the task explicitly requires a contract change.
|
||||
- [ ] Any contract change is intentional, explicit, and reflected in **every direct consumer** in the same change, with types aligned end-to-end.
|
||||
- [ ] Persisted data changes (graph schema, IDs, embeddings) are backward-compatible or accompanied by a documented migration / reindex path.
|
||||
- [ ] If user-visible behavior, public usage, CLI help, or README examples change, the relevant docs, examples, help text, or migration notes are updated in the same change.
|
||||
|
||||
### 2.5 Security
|
||||
|
||||
- [ ] No new injection surfaces (command, path, SQL/Cypher-style, prompt) introduced on paths that consume untrusted input.
|
||||
- [ ] No secrets, tokens, or credentials committed to the repo, to logs, or to error messages.
|
||||
- [ ] Filesystem access honors the repo-scope and indexed-repo boundaries documented in `AGENTS.md` and `GUARDRAILS.md`.
|
||||
- [ ] Third-party dependencies added or bumped are justified, from reputable sources, and do not regress the supply-chain posture.
|
||||
|
||||
### 2.6 Performance and Resource Use
|
||||
|
||||
- [ ] No repeated avoidable work, unnecessary scans, unnecessary round-trips, unbounded caches, or obvious hot-path regressions.
|
||||
- [ ] Tree-sitter buffer sizing follows the adaptive 512KB–32MB convention (`getTreeSitterBufferSize`) — do not hard-code new buffer sizes.
|
||||
- [ ] Memory and handle lifecycles are explicit: database handles (LadybugDB) close cleanly, no dangling process watchers, no leaked tree-sitter parsers.
|
||||
- [ ] Long-running or large-graph paths remain bounded or are measurably streamed; degradation on large real repos is considered, not assumed benign.
|
||||
|
||||
### 2.7 Tests
|
||||
|
||||
- [ ] Tests cover the **real changed path** — they would fail if behavior, wiring, or contracts were broken, not only if a mock were misconfigured.
|
||||
- [ ] Integration tests hit a real database where the production path does; do not introduce mocks that hide migration or schema drift.
|
||||
- [ ] Assertions are meaningful. Use `toBe` / `toEqual` for exact expectations; avoid `toBeGreaterThanOrEqual` and other bounds-only assertions that mask regressions.
|
||||
- [ ] Fixtures are realistic enough for the risk of the change — a one-file fixture is not sufficient for a pipeline-wide behavior change.
|
||||
- [ ] New tests are deterministic and do not depend on network, clock, or host-specific paths without explicit isolation.
|
||||
|
||||
### 2.8 Observability and Operability
|
||||
|
||||
- [ ] Errors surfaced to users or callers are actionable: they name what failed, what input was involved (without leaking secrets), and how to recover where possible.
|
||||
- [ ] Logging is proportionate — no noisy debug logs left in hot paths, no silent catches that swallow diagnostics.
|
||||
- [ ] CLI exit codes and MCP tool responses are correct for each outcome (success, user error, internal error).
|
||||
- [ ] Progress reporting (`PipelineProgress` and similar shared contracts) remains accurate after the change.
|
||||
|
||||
### 2.9 Reversibility and Risk
|
||||
|
||||
- [ ] The change has a clear rollback story: revert is safe, or migration is accompanied by a documented rollback / reindex procedure.
|
||||
- [ ] Residual risks, compatibility impacts, and operational concerns are either resolved or **clearly stated** in the PR description.
|
||||
- [ ] Destructive or hard-to-reverse operations (graph rebuild, schema change, `git` state manipulation) are opt-in or guarded.
|
||||
|
||||
## 3. Agent-Assisted Workflow Guardrails
|
||||
|
||||
When the change is produced with or reviewed by an AI agent, the following additional gates apply:
|
||||
|
||||
- [ ] **Scope match.** The final diff matches the intended symbols, files, and processes — no speculative refactors, unrelated formatting churn, or collateral edits outside the task scope.
|
||||
- [ ] **Evidence-based edits.** Claims about repo state are verified against the current code, not trusted from memory or stale documentation.
|
||||
- [ ] **Impact analysis.** Where GitNexus graph tooling is available and relevant, impact of non-trivial symbol, contract, or runtime-path changes is checked **before** editing.
|
||||
- [ ] **Embeddings preserved.** If an indexed repo already has embeddings and re-analysis is required, embeddings are preserved — not accidentally dropped by a destructive reindex.
|
||||
- [ ] **No false-done.** "Done" is claimed only after the Validation Baseline below has been run or any gap is explicitly named. Green tests on an unrelated path do not constitute validation.
|
||||
- [ ] **Five-axis self-review** before handing off: correctness, readability, architecture, security, performance.
|
||||
|
||||
## 4. Validation Baseline
|
||||
|
||||
Run the commands relevant to the touched area. If something cannot be run in the current environment, state it explicitly in the handoff.
|
||||
|
||||
### 4.1 Build ordering
|
||||
|
||||
- [ ] `gitnexus-shared/` dist is built before consuming packages are typechecked or tested (CI uses the `setup-gitnexus` action for this — local runs must match).
|
||||
|
||||
### 4.2 If `gitnexus/` changed
|
||||
|
||||
- [ ] `cd gitnexus && npx tsc --noEmit`
|
||||
- [ ] `cd gitnexus && npm test`
|
||||
- [ ] `cd gitnexus && npx prettier --check .` for files in the diff (pre-commit runs the affected-tests subset; do not expand scope)
|
||||
|
||||
### 4.3 If `gitnexus-web/` changed
|
||||
|
||||
- [ ] `cd gitnexus-web && npx tsc -b --noEmit`
|
||||
- [ ] `cd gitnexus-web && npm test`
|
||||
- [ ] `cd gitnexus-web && npm run test:e2e` when browser flows or user-facing UI behavior changed
|
||||
|
||||
### 4.4 If `gitnexus-shared/` changed
|
||||
|
||||
- [ ] Shared package builds cleanly (`npm run build` in `gitnexus-shared/`)
|
||||
- [ ] Dependent packages still typecheck and test after the shared change — verify both CLI and web consumers together
|
||||
|
||||
### 4.5 If CI workflows or release pipelines changed
|
||||
|
||||
- [ ] The workflow passes a dry-run or triggered run before merge; concurrency (`cancel-in-progress`) and the `setup-gitnexus` action remain wired correctly.
|
||||
- [ ] `CHANGELOG.md` is **not** edited here — it is owned by the release process.
|
||||
|
||||
## 5. Review Gates
|
||||
|
||||
A reviewer (human or agent) should be able to answer **yes** to each of the following before approving:
|
||||
|
||||
1. **Correctness** — Does the change do what it claims on the real runtime path?
|
||||
2. **Readability** — Will the next contributor understand this in six months without asking?
|
||||
3. **Architecture** — Is it in the right package, layer, and phase? Are boundaries respected?
|
||||
4. **Security** — No new injection, leak, or trust-boundary violation?
|
||||
5. **Performance** — No obvious regression on realistic inputs?
|
||||
6. **Tests** — Would a regression in the changed behavior fail loudly?
|
||||
7. **Scope** — Does the diff match the intended change, with no unrelated churn?
|
||||
|
||||
## 6. "Not Done" Signals
|
||||
|
||||
A change is **not** Done if any of the following is true, even if CI is green:
|
||||
|
||||
- The runtime path is not actually exercised by the tests.
|
||||
- A contract drifted between `gitnexus/`, `gitnexus-web/`, and `gitnexus-shared/` and only one side was updated.
|
||||
- A language-specific concern leaked into shared ingestion code.
|
||||
- The diff contains unrelated reformatting, refactors, or cleanup beyond the stated task.
|
||||
- Logs, comments, or TODOs were added as placeholders for work not done.
|
||||
- The change depends on a manual step that is not documented.
|
||||
- `CHANGELOG.md` was edited during PR work.
|
||||
- Pre-commit, prettier, or typecheck was bypassed without explicit justification.
|
||||
|
||||
## 7. Task-Specific DoD Template
|
||||
|
||||
Use this in implementation and review prompts. Keep it short and tailor it to the actual change:
|
||||
|
||||
```md
|
||||
# Definition of Done for this implementation
|
||||
|
||||
- [ ] Runtime wiring is complete for the affected path.
|
||||
- [ ] Requested behavior is correct and relevant contracts are preserved or explicitly updated.
|
||||
- [ ] The design stays scoped, readable, and proportionate to the task.
|
||||
- [ ] Tests prove the changed behavior and catch broken wiring.
|
||||
- [ ] Required validation for touched packages has been run, or any gap is explicitly noted.
|
||||
- [ ] Repo boundaries, security, performance, and operational safety are respected.
|
||||
- [ ] The diff contains only the intended change — no unrelated churn.
|
||||
```
|
||||
|
||||
## 8. How to Use This File in Claude Review
|
||||
|
||||
Reference this file as the repo-wide completion bar. Add a task-specific review instruction such as:
|
||||
|
||||
```md
|
||||
Review this change against `DoD.md` and the repo docs (`AGENTS.md`, `GUARDRAILS.md`,
|
||||
`CONTRIBUTING.md`, `TESTING.md`, `ARCHITECTURE.md`). Treat `DoD.md` as the minimum
|
||||
bar for production readiness. Flag anything that is partially wired, contract-unsafe,
|
||||
under-tested, architecturally misplaced, scope-creeping, or harder to maintain than
|
||||
necessary. Apply the five-axis review gate: correctness, readability, architecture,
|
||||
security, performance.
|
||||
```
|
||||
|
||||
## 9. Evolution
|
||||
|
||||
This DoD is living. Revisit it when:
|
||||
|
||||
- A class of incident slips past it (add a gate).
|
||||
- A gate becomes consistently ceremonial without catching issues (remove or merge it).
|
||||
- The architecture evolves in a way that changes what "done" means (update placement, validation, or contracts sections).
|
||||
|
||||
Track material updates in the changelog below. Keep the file tight — if it grows past a single read-in-one-sitting, something has drifted into the wrong place.
|
||||
|
||||
## Changelog
|
||||
|
||||
| Date | Version | Change |
|
||||
| ---------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 2026-04-23 | 2.0.0 | Restructured into numbered sections; added Security, Observability, Reversibility, Agent-Assisted Guardrails, Review Gates, Not-Done Signals; expanded validation baseline (shared-first build, prettier, CI workflow checks). |
|
||||
| 2026-04-13 | 1.0.0 | Initial repo-wide Definition of Done. |
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
43
README.md
43
README.md
|
|
@ -1,5 +1,5 @@
|
|||
# GitNexus
|
||||
⚠️ Important Notice:** GitNexus has NO official cryptocurrency, token, or coin. Any token/coin using the GitNexus name on Pump.fun or any other platform is **not affiliated with, endorsed by, or created by** this project or its maintainers. Do not purchase any cryptocurrency claiming association with GitNexus.
|
||||
**⚠️ Important Notice:** GitNexus has NO official cryptocurrency, token, or coin. Any token/coin using the GitNexus name on Pump.fun or any other platform is **not affiliated with, endorsed by, or created by** this project or its maintainers. Do not purchase any cryptocurrency claiming association with GitNexus.
|
||||
|
||||
<div align="center">
|
||||
|
||||
|
|
@ -36,7 +36,7 @@ https://github.com/user-attachments/assets/172685ba-8e54-4ea7-9ad1-e31a3398da72
|
|||
|
||||
> *Like DeepWiki, but deeper.* DeepWiki helps you *understand* code. GitNexus lets you *analyze* it — because a knowledge graph tracks every relationship, not just descriptions.
|
||||
|
||||
**TL;DR:** The **Web UI** is a quick way to chat with any repo. The **CLI + MCP** is how you make your AI agent actually reliable — it gives Cursor, Claude Code, Codex, and friends a deep architectural view of your codebase so they stop missing dependencies, breaking call chains, and shipping blind edits. Even smaller models get full architectural clarity, making it compete with goliath models.
|
||||
**TL;DR:** The **Web UI** is a quick way to chat with any repo. The **CLI + MCP** is how you make your AI agent actually reliable — it gives Cursor, Claude Code, Codex, and friends a deep architectural view of your codebase so they stop missing dependencies, breaking call chains, and shipping blind edits. Even smaller models get full architectural clarity, making it compete with Goliath models.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -120,7 +120,7 @@ To configure MCP for your editor, run `npx gitnexus setup` once — or set it up
|
|||
| **Windsurf** | Yes | — | — | MCP |
|
||||
| **OpenCode** | Yes | Yes | — | MCP + Skills |
|
||||
|
||||
> **Claude Code** gets the deepest integration: MCP tools + agent skills + PreToolUse hooks that enrich searches with graph context + PostToolUse hooks that auto-reindex after commits.
|
||||
> **Claude Code** gets the deepest integration: MCP tools + agent skills + PreToolUse hooks that enrich searches with graph context + PostToolUse hooks that detect a stale index after commits and prompt the agent to reindex.
|
||||
|
||||
## Community Integrations
|
||||
|
||||
|
|
@ -197,6 +197,7 @@ gitnexus analyze --skip-agents-md # Preserve custom AGENTS.md/CLAUDE.md gitnexu
|
|||
gitnexus analyze --skip-git # Index folders that are not Git repositories
|
||||
gitnexus analyze --embeddings # Enable embedding generation (slower, better search)
|
||||
gitnexus analyze --verbose # Log skipped files when parsers are unavailable
|
||||
gitnexus analyze --worker-timeout 60 # Increase worker idle timeout for slow parses
|
||||
gitnexus mcp # Start MCP server (stdio) — serves all indexed repos
|
||||
gitnexus serve # Start local HTTP server (multi-repo) for web UI connection
|
||||
gitnexus list # List all indexed repositories
|
||||
|
|
@ -218,6 +219,8 @@ gitnexus group query <name> <q> # Search execution flows across all repos in a
|
|||
gitnexus group status <name> # Check staleness of repos in a group
|
||||
```
|
||||
|
||||
If `analyze` reports a worker parse timeout on a large or unusual repository, it keeps running and falls back safely. To give slow worker jobs more time, use `gitnexus analyze --worker-timeout 60` or set `GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=60000`. For very large files, `GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES` controls the worker job byte budget.
|
||||
|
||||
### What Your AI Agent Gets
|
||||
|
||||
**16 tools** exposed via MCP (11 per-repo + 5 group):
|
||||
|
|
@ -321,29 +324,31 @@ flowchart TD
|
|||
|
||||
## Web UI (browser-based)
|
||||
|
||||
A fully client-side graph explorer and AI chat. No server, no install — your code never leaves the browser.
|
||||
A client-side graph explorer and AI chat — your code never leaves your machine.
|
||||
|
||||
**Try it now:** [gitnexus.vercel.app](https://gitnexus.vercel.app) — drag & drop a ZIP and start exploring.
|
||||
**Try it now:** [gitnexus.vercel.app](https://gitnexus.vercel.app) — run `npx gitnexus@latest serve` locally and the page auto-connects to your local backend.
|
||||
|
||||
<img width="2550" height="1343" alt="gitnexus_img" src="https://github.com/user-attachments/assets/cc5d637d-e0e5-48e6-93ff-5bcfdb929285" />
|
||||
|
||||
Or run locally:
|
||||
Or run the frontend locally:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/abhigyanpatwari/gitnexus.git
|
||||
cd gitnexus/gitnexus-shared && npm install && npm run build
|
||||
cd ../gitnexus-web && npm install
|
||||
npm run dev
|
||||
# Then in another terminal, start the backend the frontend connects to:
|
||||
npx gitnexus@latest serve
|
||||
```
|
||||
|
||||
## 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 +409,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 +434,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):
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ services:
|
|||
- ${WORKSPACE_DIR:-./workspace}:/workspace:ro
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ['CMD', 'curl', '-fsS', 'http://localhost:4747/api/heartbeat']
|
||||
test: ['CMD', 'curl', '-fsSI', 'http://localhost:4747/api/heartbeat']
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
|
|
|||
|
|
@ -82,6 +82,9 @@ matching:
|
|||
bm25_threshold: 0.7
|
||||
embedding_threshold: 0.65
|
||||
max_candidates_per_step: 3
|
||||
# Exclude noisy paths from cross-link matching (contracts are still extracted)
|
||||
exclude_links_paths: [/ping, /health, /healthcheck]
|
||||
exclude_links_param_only_paths: true
|
||||
```
|
||||
|
||||
Field notes (schema in [`types.ts`](../../gitnexus/src/core/group/types.ts)):
|
||||
|
|
@ -91,7 +94,9 @@ Field notes (schema in [`types.ts`](../../gitnexus/src/core/group/types.ts)):
|
|||
- `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.
|
||||
- `matching` — thresholds for the matching cascade. The exact match is always run; other strategies depend on indexer state. Two optional fields reduce false-positive cross-links in large groups:
|
||||
- `exclude_links_paths` — list of HTTP paths to exclude from cross-link matching (default `[]`). Contracts at these paths are still extracted and visible in the registry, but they don't produce cross-repo links. Useful for health-check endpoints (`/ping`, `/health`) that every service exposes. Trailing slashes are normalized.
|
||||
- `exclude_links_param_only_paths` — when `true`, exclude routes where every segment is `{param}` (e.g. `/{param}`, `/{param}/{param}`) from cross-link matching (default `false`). Mixed routes like `/users/{param}` are not affected.
|
||||
|
||||
### 3. Sync the group
|
||||
|
||||
|
|
|
|||
|
|
@ -53,13 +53,13 @@ class MCPBridge:
|
|||
|
||||
try:
|
||||
# Find gitnexus binary
|
||||
gitnexus_bin = self._find_gitnexus()
|
||||
if not gitnexus_bin:
|
||||
gitnexus_cmd = self._find_gitnexus_command()
|
||||
if not gitnexus_cmd:
|
||||
logger.error("GitNexus not found. Install with: npm install -g gitnexus")
|
||||
return False
|
||||
|
||||
self.process = subprocess.Popen(
|
||||
[gitnexus_bin, "mcp"],
|
||||
[*gitnexus_cmd, "mcp"],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
|
|
@ -152,33 +152,34 @@ class MCPBridge:
|
|||
return contents[0].get("text", "")
|
||||
return None
|
||||
|
||||
def _find_gitnexus(self) -> str | None:
|
||||
"""Find the gitnexus CLI binary."""
|
||||
def _find_gitnexus_command(self) -> list[str] | None:
|
||||
"""Find the gitnexus CLI command prefix."""
|
||||
# Check if npx is available (preferred - uses local install)
|
||||
for cmd in ["npx"]:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[cmd, "gitnexus", "--version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=MCP_FIND_GITNEXUS_TIMEOUT_SECONDS,
|
||||
cwd=self.repo_path,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return cmd # Will use "npx gitnexus mcp"
|
||||
except Exception:
|
||||
continue
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["npx", "gitnexus", "--version"],
|
||||
stdin=subprocess.DEVNULL,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=MCP_FIND_GITNEXUS_TIMEOUT_SECONDS,
|
||||
cwd=self.repo_path,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return ["npx", "gitnexus"]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Check for global install
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["gitnexus", "--version"],
|
||||
stdin=subprocess.DEVNULL,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=MCP_FIND_GITNEXUS_FALLBACK_TIMEOUT_SECONDS,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return "gitnexus"
|
||||
return ["gitnexus"]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
|
|
|||
170
eval/tests/test_mcp_bridge.py
Normal file
170
eval/tests/test_mcp_bridge.py
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
"""Tests for MCPBridge._find_gitnexus_command() and subprocess spawn."""
|
||||
import subprocess
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, call, patch
|
||||
|
||||
|
||||
class TestFindGitnexusCommand(unittest.TestCase):
|
||||
"""Verify _find_gitnexus_command() returns the correct command prefix."""
|
||||
|
||||
def _make_bridge(self):
|
||||
from bridge.mcp_bridge import MCPBridge
|
||||
return MCPBridge(repo_path="/fake/repo")
|
||||
|
||||
def _success(self):
|
||||
r = MagicMock()
|
||||
r.returncode = 0
|
||||
return r
|
||||
|
||||
def _failure(self):
|
||||
r = MagicMock()
|
||||
r.returncode = 1
|
||||
return r
|
||||
|
||||
def test_npx_path_returns_npx_gitnexus(self):
|
||||
"""When npx probe succeeds, command prefix is ['npx', 'gitnexus']."""
|
||||
with patch("subprocess.run", return_value=self._success()) as mock_run:
|
||||
bridge = self._make_bridge()
|
||||
result = bridge._find_gitnexus_command()
|
||||
|
||||
self.assertEqual(result, ["npx", "gitnexus"])
|
||||
mock_run.assert_called_once()
|
||||
args = mock_run.call_args[0][0]
|
||||
self.assertEqual(args, ["npx", "gitnexus", "--version"])
|
||||
|
||||
def test_global_path_returns_gitnexus(self):
|
||||
"""When npx probe fails but global install exists, prefix is ['gitnexus']."""
|
||||
with patch("subprocess.run", side_effect=[self._failure(), self._success()]) as mock_run:
|
||||
bridge = self._make_bridge()
|
||||
result = bridge._find_gitnexus_command()
|
||||
|
||||
self.assertEqual(result, ["gitnexus"])
|
||||
self.assertEqual(mock_run.call_count, 2)
|
||||
|
||||
def test_both_fail_returns_none(self):
|
||||
"""When both probes fail, returns None."""
|
||||
with patch("subprocess.run", return_value=self._failure()):
|
||||
bridge = self._make_bridge()
|
||||
result = bridge._find_gitnexus_command()
|
||||
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_npx_exception_falls_back_to_global(self):
|
||||
"""When npx raises (not installed), falls back to global probe."""
|
||||
with patch("subprocess.run", side_effect=[FileNotFoundError, self._success()]):
|
||||
bridge = self._make_bridge()
|
||||
result = bridge._find_gitnexus_command()
|
||||
|
||||
self.assertEqual(result, ["gitnexus"])
|
||||
|
||||
def test_both_raise_returns_none(self):
|
||||
"""When both probes raise exceptions, returns None."""
|
||||
with patch("subprocess.run", side_effect=FileNotFoundError):
|
||||
bridge = self._make_bridge()
|
||||
result = bridge._find_gitnexus_command()
|
||||
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_stdin_devnull_on_npx_probe(self):
|
||||
"""npx probe must pass stdin=DEVNULL to prevent interactive blocking."""
|
||||
with patch("subprocess.run", return_value=self._success()) as mock_run:
|
||||
bridge = self._make_bridge()
|
||||
bridge._find_gitnexus_command()
|
||||
|
||||
kwargs = mock_run.call_args[1]
|
||||
self.assertEqual(kwargs.get("stdin"), subprocess.DEVNULL)
|
||||
|
||||
def test_stdin_devnull_on_global_probe(self):
|
||||
"""global probe must pass stdin=DEVNULL to prevent interactive blocking."""
|
||||
with patch("subprocess.run", side_effect=[self._failure(), self._success()]) as mock_run:
|
||||
bridge = self._make_bridge()
|
||||
bridge._find_gitnexus_command()
|
||||
|
||||
global_call_kwargs = mock_run.call_args_list[1][1]
|
||||
self.assertEqual(global_call_kwargs.get("stdin"), subprocess.DEVNULL)
|
||||
|
||||
|
||||
class TestStartSpawnCommand(unittest.TestCase):
|
||||
"""Verify start() spawns Popen with the correct argv."""
|
||||
|
||||
def _make_bridge(self):
|
||||
from bridge.mcp_bridge import MCPBridge
|
||||
return MCPBridge(repo_path="/fake/repo")
|
||||
|
||||
def test_npx_path_spawns_npx_gitnexus_mcp(self):
|
||||
"""When npx path found, Popen must receive ['npx', 'gitnexus', 'mcp']."""
|
||||
bridge = self._make_bridge()
|
||||
|
||||
with patch.object(bridge, "_find_gitnexus_command", return_value=["npx", "gitnexus"]), \
|
||||
patch("subprocess.Popen") as mock_popen, \
|
||||
patch.object(bridge, "_send_request", return_value={"protocolVersion": "2024-11-05"}), \
|
||||
patch.object(bridge, "_send_notification"):
|
||||
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.stdin = MagicMock()
|
||||
mock_proc.stdout = MagicMock()
|
||||
mock_proc.stderr = MagicMock()
|
||||
mock_popen.return_value = mock_proc
|
||||
|
||||
bridge.start()
|
||||
|
||||
mock_popen.assert_called_once()
|
||||
argv = mock_popen.call_args[0][0]
|
||||
self.assertEqual(argv, ["npx", "gitnexus", "mcp"])
|
||||
|
||||
def test_global_path_spawns_gitnexus_mcp(self):
|
||||
"""When global path found, Popen must receive ['gitnexus', 'mcp']."""
|
||||
bridge = self._make_bridge()
|
||||
|
||||
with patch.object(bridge, "_find_gitnexus_command", return_value=["gitnexus"]), \
|
||||
patch("subprocess.Popen") as mock_popen, \
|
||||
patch.object(bridge, "_send_request", return_value={"protocolVersion": "2024-11-05"}), \
|
||||
patch.object(bridge, "_send_notification"):
|
||||
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.stdin = MagicMock()
|
||||
mock_proc.stdout = MagicMock()
|
||||
mock_proc.stderr = MagicMock()
|
||||
mock_popen.return_value = mock_proc
|
||||
|
||||
bridge.start()
|
||||
|
||||
mock_popen.assert_called_once()
|
||||
argv = mock_popen.call_args[0][0]
|
||||
self.assertEqual(argv, ["gitnexus", "mcp"])
|
||||
|
||||
def test_no_shell_true(self):
|
||||
"""Popen must never be called with shell=True."""
|
||||
bridge = self._make_bridge()
|
||||
|
||||
with patch.object(bridge, "_find_gitnexus_command", return_value=["npx", "gitnexus"]), \
|
||||
patch("subprocess.Popen") as mock_popen, \
|
||||
patch.object(bridge, "_send_request", return_value={"protocolVersion": "2024-11-05"}), \
|
||||
patch.object(bridge, "_send_notification"):
|
||||
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.stdin = MagicMock()
|
||||
mock_proc.stdout = MagicMock()
|
||||
mock_proc.stderr = MagicMock()
|
||||
mock_popen.return_value = mock_proc
|
||||
|
||||
bridge.start()
|
||||
|
||||
kwargs = mock_popen.call_args[1]
|
||||
self.assertNotEqual(kwargs.get("shell"), True)
|
||||
|
||||
def test_gitnexus_not_found_returns_false(self):
|
||||
"""start() returns False and does not call Popen when gitnexus not found."""
|
||||
bridge = self._make_bridge()
|
||||
|
||||
with patch.object(bridge, "_find_gitnexus_command", return_value=None), \
|
||||
patch("subprocess.Popen") as mock_popen:
|
||||
|
||||
result = bridge.start()
|
||||
|
||||
self.assertFalse(result)
|
||||
mock_popen.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -31,11 +31,21 @@ function readInput() {
|
|||
* Find the .gitnexus directory by walking up from startDir.
|
||||
* Returns the path to .gitnexus/ or null if not found.
|
||||
*/
|
||||
function isGlobalRegistryDir(candidate) {
|
||||
if (fs.existsSync(path.join(candidate, 'meta.json'))) return false;
|
||||
return (
|
||||
fs.existsSync(path.join(candidate, 'registry.json')) ||
|
||||
fs.existsSync(path.join(candidate, 'repos'))
|
||||
);
|
||||
}
|
||||
|
||||
function findGitNexusDir(startDir) {
|
||||
let dir = startDir || process.cwd();
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const candidate = path.join(dir, '.gitnexus');
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
if (fs.existsSync(candidate)) {
|
||||
if (!isGlobalRegistryDir(candidate)) return candidate;
|
||||
}
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
8
gitnexus-shared/package-lock.json
generated
8
gitnexus-shared/package-lock.json
generated
|
|
@ -8,13 +8,13 @@
|
|||
"name": "gitnexus-shared",
|
||||
"version": "1.0.0",
|
||||
"devDependencies": {
|
||||
"typescript": "^6.0.2"
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz",
|
||||
"integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==",
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
||||
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
|
|
|
|||
|
|
@ -20,6 +20,6 @@
|
|||
"src"
|
||||
],
|
||||
"devDependencies": {
|
||||
"typescript": "^6.0.2"
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -134,7 +134,11 @@ export type {
|
|||
// Scope tree spine + position lookup (RFC §2.2 + §3.1; Ring 2 SHARED #912)
|
||||
export { makeScopeId, clearScopeIdInternPool } from './scope-resolution/scope-id.js';
|
||||
export type { ScopeIdInput } from './scope-resolution/scope-id.js';
|
||||
export { buildScopeTree, ScopeTreeInvariantError } from './scope-resolution/scope-tree.js';
|
||||
export {
|
||||
buildScopeTree,
|
||||
canParentScope,
|
||||
ScopeTreeInvariantError,
|
||||
} from './scope-resolution/scope-tree.js';
|
||||
export type { ScopeTree } from './scope-resolution/scope-tree.js';
|
||||
export { buildPositionIndex } from './scope-resolution/position-index.js';
|
||||
export type { PositionIndex } from './scope-resolution/position-index.js';
|
||||
|
|
|
|||
|
|
@ -26,9 +26,9 @@
|
|||
* (`resolveImportTarget`, `expandsWildcardTo`, `mergeBindings`) that
|
||||
* match the LanguageProvider surface from #911.
|
||||
*
|
||||
* **Dynamic imports rule.** `kind === 'dynamic-unresolved'` passes through
|
||||
* as an `ImportEdge { kind: 'dynamic-unresolved', targetFile: null }`
|
||||
* with no `BindingRef`. They are parse-time signals, not linkable targets.
|
||||
* **Non-binding imports rule.** `dynamic-unresolved` passes through with
|
||||
* `targetFile: null`; `dynamic-resolved` and `side-effect` resolve to
|
||||
* file-level `ImportEdge`s. None of these materialize `BindingRef`s.
|
||||
*/
|
||||
|
||||
import type { SymbolDefinition } from './symbol-definition.js';
|
||||
|
|
@ -45,20 +45,28 @@ export interface FinalizeFile {
|
|||
/**
|
||||
* Defs exported from this file — the "what other files can import by name"
|
||||
* surface. Typically those with `isExported: true` (the module's own
|
||||
* declarations) plus, for multi-hop re-export chains, the re-exported
|
||||
* names the parser chose to surface here.
|
||||
* declarations); parsers MAY also surface re-exported names here as a
|
||||
* shortcut, but it is no longer required for correctness.
|
||||
*
|
||||
* **Multi-hop re-export contract.** `finalize` resolves an edge
|
||||
* `A → B (importedName: 'X')` by looking up `X` in `B.localDefs`. If B
|
||||
* only has `export { X } from './C'` and the parser *does not* include
|
||||
* `X` in `B.localDefs`, A's edge hits the fixpoint cap and is marked
|
||||
* `linkStatus: 'unresolved'`. The fixpoint does NOT mutate `localDefs`
|
||||
* across iterations — it is static input.
|
||||
* `A → B (importedName: 'X')` by first looking up `X` in `B.localDefs`.
|
||||
* If `B` only has `export { X } from './C'` and does NOT surface `X` in
|
||||
* its own `localDefs`, `finalize` falls back to the precomputed
|
||||
* per-file re-export closure (`buildReexportClosures`), which encodes
|
||||
* every name reachable through `B`'s named and wildcard re-exports —
|
||||
* including transitively through cyclic SCCs. The lookup is O(1) and
|
||||
* inherits the upstream `targetDefId`, populating `transitiveVia` with
|
||||
* the file paths traversed to reach the leaf def.
|
||||
*
|
||||
* Parsers that want multi-hop re-export chains to settle end-to-end must
|
||||
* include re-exported names in the intermediate file's `localDefs` (with
|
||||
* the original `DefId` of the source symbol). This keeps the algorithm
|
||||
* O(1) per lookup and avoids graph-crawl during finalize.
|
||||
* Surfacing re-exported names in `localDefs` is still a valid (and
|
||||
* slightly cheaper) optimization: the direct lookup short-circuits the
|
||||
* closure consult. Parsers SHOULD prefer surfacing names they can resolve
|
||||
* statically (e.g., `export { X } from './c'` when `c.ts` is parsed in
|
||||
* the same workspace), and rely on the closure for the long tail of
|
||||
* barrel patterns.
|
||||
*
|
||||
* The fixpoint does NOT mutate `localDefs` across iterations — it is
|
||||
* static input.
|
||||
*/
|
||||
readonly localDefs: readonly SymbolDefinition[];
|
||||
}
|
||||
|
|
@ -186,7 +194,8 @@ export function finalize(input: FinalizeInput, hooks: FinalizeHooks): FinalizeOu
|
|||
graph.set(file.filePath, new Set());
|
||||
}
|
||||
for (const [fromFile, drafts] of edgeIndex) {
|
||||
const edges = graph.get(fromFile)!;
|
||||
const edges = graph.get(fromFile);
|
||||
if (edges === undefined) continue;
|
||||
for (const d of drafts) {
|
||||
if (d.targetFile !== null && byFilePath.has(d.targetFile)) {
|
||||
edges.add(d.targetFile);
|
||||
|
|
@ -197,6 +206,12 @@ export function finalize(input: FinalizeInput, hooks: FinalizeHooks): FinalizeOu
|
|||
// ── Phase 2: Tarjan SCC → reverse-topological list of SCCs.
|
||||
const sccs = tarjanSccs(graph);
|
||||
|
||||
// ── Phase 2.5: precompute the per-file re-export closure (iterative,
|
||||
// SCC-condensed). Eliminates the recursive crawl that the per-edge
|
||||
// `tryFinalize` call site used to do; lookups are O(1) afterwards.
|
||||
// See `buildReexportClosures` for the algorithm.
|
||||
const reexportClosures = buildReexportClosures(input.files, byFilePath, edgeIndex);
|
||||
|
||||
// ── Phase 3: process SCCs in reverse-topological order (leaves first).
|
||||
// Within each SCC, run a bounded fixpoint that resolves intra-SCC edges.
|
||||
// Edges leaving the SCC are already resolved (their target SCC is
|
||||
|
|
@ -217,10 +232,11 @@ export function finalize(input: FinalizeInput, hooks: FinalizeHooks): FinalizeOu
|
|||
progressed = false;
|
||||
iterations++;
|
||||
for (const filePath of scc.files) {
|
||||
const drafts = edgeIndex.get(filePath)!;
|
||||
const drafts = edgeIndex.get(filePath);
|
||||
if (drafts === undefined) continue;
|
||||
for (const draft of drafts) {
|
||||
if (draft.finalized !== null) continue;
|
||||
const finalized = tryFinalize(draft, byFilePath);
|
||||
const finalized = tryFinalize(draft, byFilePath, reexportClosures);
|
||||
if (finalized !== null) {
|
||||
draft.finalized = finalized;
|
||||
progressed = true;
|
||||
|
|
@ -231,7 +247,8 @@ export function finalize(input: FinalizeInput, hooks: FinalizeHooks): FinalizeOu
|
|||
|
||||
// Any drafts still not finalized within this SCC hit the cap → unresolved.
|
||||
for (const filePath of scc.files) {
|
||||
const drafts = edgeIndex.get(filePath)!;
|
||||
const drafts = edgeIndex.get(filePath);
|
||||
if (drafts === undefined) continue;
|
||||
for (const draft of drafts) {
|
||||
if (draft.finalized !== null) continue;
|
||||
draft.finalized = {
|
||||
|
|
@ -245,10 +262,14 @@ export function finalize(input: FinalizeInput, hooks: FinalizeHooks): FinalizeOu
|
|||
// ── Phase 4: collect finalized `ImportEdge[]` per module scope, preserving
|
||||
// input order within each file, and wildcard-expand where applicable.
|
||||
for (const file of input.files) {
|
||||
const drafts = edgeIndex.get(file.filePath)!;
|
||||
const drafts = edgeIndex.get(file.filePath);
|
||||
if (drafts === undefined) continue;
|
||||
const finalized: ImportEdge[] = [];
|
||||
for (const d of drafts) {
|
||||
const edge = d.finalized!;
|
||||
const edge = d.finalized;
|
||||
if (edge === null) {
|
||||
throw new Error(`Invariant violated: import edge was not finalized for ${file.filePath}`);
|
||||
}
|
||||
if (d.source.kind === 'wildcard' && edge.linkStatus !== 'unresolved') {
|
||||
// Produce one `wildcard-expanded` ImportEdge per exported name.
|
||||
const expanded = expandWildcard(edge, byFilePath, hooks, input.workspaceIndex);
|
||||
|
|
@ -327,14 +348,11 @@ function makeEdgeDraft(
|
|||
|
||||
// Edge is unresolvable at the file level — mark unresolved now.
|
||||
if (targetFile === null) {
|
||||
const edgeKind = parsed.kind === 'wildcard' ? 'wildcard-expanded' : parsed.kind;
|
||||
const localName = parsed.kind === 'wildcard' ? '' : parsed.localName;
|
||||
const targetExportedName = extractExportedName(parsed);
|
||||
const base: ImportEdge = {
|
||||
localName,
|
||||
localName: extractLocalName(parsed),
|
||||
targetFile: null,
|
||||
targetExportedName,
|
||||
kind: edgeKind,
|
||||
targetExportedName: extractExportedName(parsed),
|
||||
kind: edgeKindFor(parsed),
|
||||
linkStatus: 'unresolved',
|
||||
};
|
||||
return {
|
||||
|
|
@ -348,26 +366,43 @@ function makeEdgeDraft(
|
|||
}
|
||||
|
||||
// Resolvable at the file level; intra-SCC fixpoint may still fail to fill
|
||||
// in `targetDefId` (e.g., symbol not exported from target).
|
||||
const edgeKind = parsed.kind === 'wildcard' ? 'wildcard-expanded' : parsed.kind;
|
||||
const localName = parsed.kind === 'wildcard' ? '' : parsed.localName;
|
||||
const targetExportedName = extractExportedName(parsed);
|
||||
// in `targetDefId` (e.g., symbol not exported from target). Side-effect
|
||||
// and resolved-dynamic imports are terminal at the file level — no
|
||||
// `targetDefId` needed since they materialize no `BindingRef`. Pre-
|
||||
// finalize them here so the fixpoint loop skips them entirely.
|
||||
const base: ImportEdge = {
|
||||
localName,
|
||||
localName: extractLocalName(parsed),
|
||||
targetFile,
|
||||
targetExportedName,
|
||||
kind: edgeKind,
|
||||
targetExportedName: extractExportedName(parsed),
|
||||
kind: edgeKindFor(parsed),
|
||||
};
|
||||
const isFileLevelTerminal = parsed.kind === 'side-effect' || parsed.kind === 'dynamic-resolved';
|
||||
return {
|
||||
source: parsed,
|
||||
fromFile: file.filePath,
|
||||
fromScope: file.moduleScope,
|
||||
targetFile,
|
||||
base,
|
||||
finalized: null,
|
||||
finalized: isFileLevelTerminal ? base : null,
|
||||
};
|
||||
}
|
||||
|
||||
function edgeKindFor(parsed: ParsedImport): ImportEdge['kind'] {
|
||||
if (parsed.kind === 'wildcard') return 'wildcard-expanded';
|
||||
return parsed.kind;
|
||||
}
|
||||
|
||||
function extractLocalName(parsed: ParsedImport): string {
|
||||
switch (parsed.kind) {
|
||||
case 'wildcard':
|
||||
case 'side-effect':
|
||||
case 'dynamic-resolved':
|
||||
return '';
|
||||
default:
|
||||
return parsed.localName;
|
||||
}
|
||||
}
|
||||
|
||||
function extractExportedName(parsed: ParsedImport): string {
|
||||
switch (parsed.kind) {
|
||||
case 'named':
|
||||
|
|
@ -377,6 +412,8 @@ function extractExportedName(parsed: ParsedImport): string {
|
|||
return parsed.importedName;
|
||||
case 'wildcard':
|
||||
case 'dynamic-unresolved':
|
||||
case 'dynamic-resolved':
|
||||
case 'side-effect':
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
|
@ -386,6 +423,7 @@ function extractExportedName(parsed: ParsedImport): string {
|
|||
function tryFinalize(
|
||||
draft: ImportEdgeDraft,
|
||||
byFilePath: Map<string, FinalizeFile>,
|
||||
reexportClosures: ReadonlyMap<string, FileReexportClosure>,
|
||||
): ImportEdge | null {
|
||||
const targetFile = draft.targetFile;
|
||||
if (targetFile === null) return draft.base; // already terminal
|
||||
|
|
@ -423,22 +461,265 @@ function tryFinalize(
|
|||
const importedName = extractExportedName(draft.source);
|
||||
const exported = findExportByName(targetModule.localDefs, importedName);
|
||||
|
||||
if (exported === undefined) {
|
||||
if (exported !== undefined) {
|
||||
const transitiveVia =
|
||||
draft.source.kind === 'reexport' ? Object.freeze([targetFile]) : undefined;
|
||||
return {
|
||||
...draft.base,
|
||||
targetModuleScope: targetModule.moduleScope,
|
||||
targetDefId: exported.nodeId,
|
||||
...(transitiveVia !== undefined ? { transitiveVia } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
// Multi-hop re-export follow. Barrel modules like
|
||||
// // models.ts
|
||||
// export { User } from './base';
|
||||
// emit no local def for `User`; the name surfaces only via their own
|
||||
// `reexport` edge. The per-file re-export closure built in phase 2.5
|
||||
// already encodes every name reachable through that file's named and
|
||||
// wildcard re-exports — including transitively through cyclic SCCs —
|
||||
// so the lookup is O(1) and never recurses.
|
||||
const followed = lookupReexportedName(reexportClosures, targetFile, importedName);
|
||||
if (followed === null) {
|
||||
// Target resolvable but the name isn't exported — keep trying in case a
|
||||
// re-export inside the target's SCC surfaces it in a later iteration.
|
||||
return null;
|
||||
}
|
||||
|
||||
const transitiveVia = draft.source.kind === 'reexport' ? Object.freeze([targetFile]) : undefined;
|
||||
const viaFiles = [targetFile, ...followed.via];
|
||||
const transitiveVia =
|
||||
draft.source.kind === 'reexport' || viaFiles.length > 1 ? Object.freeze(viaFiles) : undefined;
|
||||
|
||||
return {
|
||||
...draft.base,
|
||||
targetModuleScope: targetModule.moduleScope,
|
||||
targetDefId: exported.nodeId,
|
||||
targetDefId: followed.def.nodeId,
|
||||
...(transitiveVia !== undefined ? { transitiveVia } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Internal: re-export closure (phase 2.5) ───────────────────────────────
|
||||
|
||||
/**
|
||||
* Per-file map of `name → terminal def + via path` — i.e. every name
|
||||
* importable from this file via its named/wildcard re-export chain
|
||||
* (excluding the file's own `localDefs`, which the caller checks first
|
||||
* via `findExportByName`). `via` is the ordered list of intermediate
|
||||
* files traversed to reach the def.
|
||||
*
|
||||
* Built once per finalize pass. Lookups are O(1).
|
||||
*/
|
||||
type ReexportClosureEntry = { readonly def: SymbolDefinition; readonly via: readonly string[] };
|
||||
type FileReexportClosure = ReadonlyMap<string, ReexportClosureEntry>;
|
||||
|
||||
/**
|
||||
* Build per-file re-export closures.
|
||||
*
|
||||
* **Algorithm.** Iterative SCC-condensed reverse-topological propagation,
|
||||
* structurally identical to how `finalize` itself processes the file-
|
||||
* level import graph. Replaces the legacy recursive
|
||||
* `followReexportChain` crawl with a bounded, stack-safe pass:
|
||||
*
|
||||
* 1. **Sub-graph.** Build a directed graph whose edges are
|
||||
* `reexport` and `wildcard` drafts only (regular imports do not
|
||||
* contribute to the export surface, and `namespace`/
|
||||
* `reexport-namespace` are terminal — their target def lives in
|
||||
* `localDefs`).
|
||||
* 2. **SCC condensation.** Run the same iterative `tarjanSccs` over
|
||||
* the sub-graph. Output is in reverse-topological order (leaves
|
||||
* first), so when we process an SCC every out-of-SCC neighbor
|
||||
* already has its closure populated.
|
||||
* 3. **Per-SCC propagation.**
|
||||
* * Acyclic singleton: one pass — read neighbors' (already
|
||||
* fully populated) closures.
|
||||
* * Cyclic SCC (cycle ≥ 2 files, or self-loop): bounded
|
||||
* fixpoint inside the SCC, capped at `|SCC| + 1` iterations
|
||||
* (each iteration propagates names one hop further around
|
||||
* the cycle; first-wins precedence keeps the map monotone
|
||||
* so the fixpoint converges in at most |SCC| hops).
|
||||
*
|
||||
* **Precedence semantics — preserved from the recursive crawl.**
|
||||
* * Named re-exports take precedence over wildcards.
|
||||
* * Within each kind, declaration order wins (first match for a
|
||||
* given exported name is kept; later drafts skip).
|
||||
*
|
||||
* **Complexity.**
|
||||
* * Pre-pass: O(V + E_re) for SCC, plus O(|SCC| × Σ drafts) per cyclic
|
||||
* SCC. For tree-shaped barrel graphs (the common case) it
|
||||
* collapses to O(E_re) total.
|
||||
* * Per-edge lookup at finalize time: O(1).
|
||||
* * `transitiveVia` preserves the exact file path chain for diagnostics
|
||||
* and graph provenance. Building those arrays copies the inherited path,
|
||||
* which is O(depth²) in a pathological single-name barrel chain; practical
|
||||
* TypeScript barrel chains are shallow enough that we keep exact paths
|
||||
* instead of capping or summarizing them.
|
||||
* * Pathological deep chains that previously needed
|
||||
* `MAX_REEXPORT_DEPTH=100` to bound stack growth now resolve
|
||||
* in full and are bounded only by available memory — the
|
||||
* iterative formulation has no call-stack ceiling.
|
||||
*/
|
||||
function buildReexportClosures(
|
||||
files: readonly FinalizeFile[],
|
||||
byFilePath: ReadonlyMap<string, FinalizeFile>,
|
||||
edgeIndex: ReadonlyMap<string, ImportEdgeDraft[]>,
|
||||
): ReadonlyMap<string, FileReexportClosure> {
|
||||
const closures = new Map<string, Map<string, ReexportClosureEntry>>();
|
||||
for (const file of files) closures.set(file.filePath, new Map());
|
||||
|
||||
// ── Step 1: build the re-export sub-graph (only resolvable
|
||||
// reexport/wildcard targets contribute edges).
|
||||
const subGraph = new Map<string, Set<string>>();
|
||||
for (const file of files) {
|
||||
const targets = new Set<string>();
|
||||
const drafts = edgeIndex.get(file.filePath);
|
||||
if (drafts !== undefined) {
|
||||
for (const d of drafts) {
|
||||
if (d.source.kind !== 'reexport' && d.source.kind !== 'wildcard') continue;
|
||||
if (d.targetFile === null) continue;
|
||||
if (!byFilePath.has(d.targetFile)) continue;
|
||||
targets.add(d.targetFile);
|
||||
}
|
||||
}
|
||||
subGraph.set(file.filePath, targets);
|
||||
}
|
||||
|
||||
// ── Step 2: SCC over the sub-graph. Reuses the same iterative Tarjan
|
||||
// implementation that drives the file-level finalize loop, so any
|
||||
// call-stack-safety guarantees there transfer here unchanged.
|
||||
const subSccs = tarjanSccs(subGraph);
|
||||
|
||||
// ── Step 3: process SCCs in reverse-topological order. Acyclic
|
||||
// singletons settle in one pass; cyclic SCCs run a bounded fixpoint.
|
||||
for (const scc of subSccs) {
|
||||
if (!scc.isCycle) {
|
||||
const filePath = scc.files[0];
|
||||
if (filePath !== undefined) {
|
||||
populateFileClosure(filePath, byFilePath, edgeIndex, closures);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Cap = |SCC| + 1. With first-wins precedence each name needs at
|
||||
// most |SCC| iterations to propagate fully around the cycle; the
|
||||
// extra iteration confirms no progress and breaks the loop.
|
||||
const cap = scc.files.length + 1;
|
||||
let progressed = true;
|
||||
let iter = 0;
|
||||
while (progressed && iter < cap) {
|
||||
progressed = false;
|
||||
iter++;
|
||||
for (const filePath of scc.files) {
|
||||
if (populateFileClosure(filePath, byFilePath, edgeIndex, closures)) {
|
||||
progressed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return closures;
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate one file's re-export closure for one pass. Returns `true`
|
||||
* iff the closure grew (signalling fixpoint progress to the caller).
|
||||
*
|
||||
* Walks the file's drafts in declaration order, named re-exports first
|
||||
* (precedence), then wildcards. For each draft, attempts:
|
||||
* 1. **Direct hit** — name exists in the target file's `localDefs`.
|
||||
* 2. **Inherited** — name exists in the target file's already-populated
|
||||
* closure (which encodes the target's own re-export chain).
|
||||
*
|
||||
* `closures.get(targetFile)` may itself still be empty for in-SCC
|
||||
* targets on the first iteration; the outer fixpoint loop handles
|
||||
* that by re-invoking this function.
|
||||
*/
|
||||
function populateFileClosure(
|
||||
filePath: string,
|
||||
byFilePath: ReadonlyMap<string, FinalizeFile>,
|
||||
edgeIndex: ReadonlyMap<string, ImportEdgeDraft[]>,
|
||||
closures: Map<string, Map<string, ReexportClosureEntry>>,
|
||||
): boolean {
|
||||
const myClosure = closures.get(filePath);
|
||||
if (myClosure === undefined) return false;
|
||||
const before = myClosure.size;
|
||||
const drafts = edgeIndex.get(filePath);
|
||||
if (drafts === undefined) return false;
|
||||
|
||||
// Named re-exports — precedence over wildcards, declaration order
|
||||
// first-wins for duplicates of the same exported name.
|
||||
for (const draft of drafts) {
|
||||
if (draft.source.kind !== 'reexport') continue;
|
||||
const targetFile = draft.targetFile;
|
||||
if (targetFile === null) continue;
|
||||
const targetModule = byFilePath.get(targetFile);
|
||||
if (targetModule === undefined) continue;
|
||||
|
||||
const localName = draft.source.localName;
|
||||
if (myClosure.has(localName)) continue;
|
||||
|
||||
const importedName = draft.source.importedName;
|
||||
const direct = findExportByName(targetModule.localDefs, importedName);
|
||||
if (direct !== undefined) {
|
||||
myClosure.set(localName, { def: direct, via: Object.freeze([targetFile]) });
|
||||
continue;
|
||||
}
|
||||
const inherited = closures.get(targetFile)?.get(importedName);
|
||||
if (inherited !== undefined) {
|
||||
myClosure.set(localName, {
|
||||
def: inherited.def,
|
||||
via: Object.freeze([targetFile, ...inherited.via]),
|
||||
});
|
||||
}
|
||||
// Else: target's closure is still empty (in-SCC, awaiting next
|
||||
// iteration). Outer loop will revisit.
|
||||
}
|
||||
|
||||
// Wildcard re-exports — fan out the target's own surface (localDefs
|
||||
// + transitive closure). `myClosure.has(name)` checks below preserve
|
||||
// the named-precedence and first-wins semantics from above.
|
||||
for (const draft of drafts) {
|
||||
if (draft.source.kind !== 'wildcard') continue;
|
||||
const targetFile = draft.targetFile;
|
||||
if (targetFile === null) continue;
|
||||
const targetModule = byFilePath.get(targetFile);
|
||||
if (targetModule === undefined) continue;
|
||||
|
||||
for (const def of targetModule.localDefs) {
|
||||
const name = deriveSimpleName(def);
|
||||
if (name === null || myClosure.has(name)) continue;
|
||||
myClosure.set(name, { def, via: Object.freeze([targetFile]) });
|
||||
}
|
||||
const targetClosure = closures.get(targetFile);
|
||||
if (targetClosure !== undefined) {
|
||||
for (const [name, entry] of targetClosure) {
|
||||
if (myClosure.has(name)) continue;
|
||||
myClosure.set(name, {
|
||||
def: entry.def,
|
||||
via: Object.freeze([targetFile, ...entry.via]),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return myClosure.size > before;
|
||||
}
|
||||
|
||||
/**
|
||||
* O(1) lookup into a precomputed re-export closure. Replaces the legacy
|
||||
* recursive `followReexportChain` traversal with a single map indexing.
|
||||
*/
|
||||
function lookupReexportedName(
|
||||
closures: ReadonlyMap<string, FileReexportClosure>,
|
||||
filePath: string,
|
||||
name: string,
|
||||
): { def: SymbolDefinition; via: readonly string[] } | null {
|
||||
const closure = closures.get(filePath);
|
||||
if (closure === undefined) return null;
|
||||
const entry = closure.get(name);
|
||||
if (entry === undefined) return null;
|
||||
return { def: entry.def, via: entry.via };
|
||||
}
|
||||
|
||||
/**
|
||||
* The "simple" (unqualified) name of a def, for import-name matching.
|
||||
*
|
||||
|
|
@ -522,6 +803,17 @@ function materializeBindings(
|
|||
): ReadonlyMap<ScopeId, ReadonlyMap<string, readonly BindingRef[]>> {
|
||||
const out = new Map<ScopeId, ReadonlyMap<string, readonly BindingRef[]>>();
|
||||
|
||||
// Build a `nodeId → SymbolDefinition` index once across all files
|
||||
// (O(N_files × D_defs)) so the per-edge lookup below is O(1) instead
|
||||
// of a full linear scan. At realistic TypeScript monorepo scale
|
||||
// (~5k files × ~50 defs × ~100k linked import edges) this is the
|
||||
// difference between ~25 s and a few ms inside finalize. The map
|
||||
// is local to this pass — no cross-pass state leaks.
|
||||
const defById = new Map<string, SymbolDefinition>();
|
||||
for (const f of files) {
|
||||
for (const d of f.localDefs) defById.set(d.nodeId, d);
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
const scopeBindings = new Map<string, readonly BindingRef[]>();
|
||||
|
||||
|
|
@ -538,10 +830,7 @@ function materializeBindings(
|
|||
const imports = linkedByScope.get(file.moduleScope) ?? [];
|
||||
for (const edge of imports) {
|
||||
if (edge.targetDefId === undefined || edge.linkStatus === 'unresolved') continue;
|
||||
// Every def the importing file needs to reach is in some other file's
|
||||
// `localDefs`; walk all files to find it. In practice we could index
|
||||
// this, but at finalize-time N(files) is small per workspace pass.
|
||||
const def = findDefById(files, edge.targetDefId);
|
||||
const def = defById.get(edge.targetDefId);
|
||||
if (def === undefined) continue;
|
||||
|
||||
const origin: BindingRef['origin'] =
|
||||
|
|
@ -571,15 +860,6 @@ function materializeBindings(
|
|||
return out;
|
||||
}
|
||||
|
||||
function findDefById(files: readonly FinalizeFile[], defId: string): SymbolDefinition | undefined {
|
||||
for (const f of files) {
|
||||
for (const d of f.localDefs) {
|
||||
if (d.nodeId === defId) return d;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ─── Internal: Tarjan SCC ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
|
@ -607,7 +887,8 @@ function tarjanSccs(graph: ReadonlyMap<string, ReadonlySet<string>>): FinalizedS
|
|||
entered: false,
|
||||
});
|
||||
while (iterStack.length > 0) {
|
||||
const frame = iterStack[iterStack.length - 1]!;
|
||||
const frame = iterStack[iterStack.length - 1];
|
||||
if (frame === undefined) break;
|
||||
|
||||
if (!frame.entered) {
|
||||
frame.entered = true;
|
||||
|
|
@ -625,7 +906,10 @@ function tarjanSccs(graph: ReadonlyMap<string, ReadonlySet<string>>): FinalizedS
|
|||
const scc: string[] = [];
|
||||
let selfInCycle = false;
|
||||
while (true) {
|
||||
const w = stack.pop()!;
|
||||
const w = stack.pop();
|
||||
if (w === undefined) {
|
||||
throw new Error(`Invariant violated: Tarjan stack exhausted at ${frame.node}`);
|
||||
}
|
||||
onStack.delete(w);
|
||||
scc.push(w);
|
||||
// A single-file self-loop counts as a cycle.
|
||||
|
|
@ -640,8 +924,16 @@ function tarjanSccs(graph: ReadonlyMap<string, ReadonlySet<string>>): FinalizedS
|
|||
iterStack.pop();
|
||||
// Propagate lowlink to parent.
|
||||
if (iterStack.length > 0) {
|
||||
const parent = iterStack[iterStack.length - 1]!;
|
||||
lowlink.set(parent.node, Math.min(lowlink.get(parent.node)!, lowlink.get(frame.node)!));
|
||||
const parent = iterStack[iterStack.length - 1];
|
||||
if (parent !== undefined) {
|
||||
lowlink.set(
|
||||
parent.node,
|
||||
Math.min(
|
||||
requiredNumber(lowlink, parent.node, 'lowlink'),
|
||||
requiredNumber(lowlink, frame.node, 'lowlink'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
|
@ -654,10 +946,24 @@ function tarjanSccs(graph: ReadonlyMap<string, ReadonlySet<string>>): FinalizedS
|
|||
entered: false,
|
||||
});
|
||||
} else if (onStack.has(child)) {
|
||||
lowlink.set(frame.node, Math.min(lowlink.get(frame.node)!, index.get(child)!));
|
||||
lowlink.set(
|
||||
frame.node,
|
||||
Math.min(
|
||||
requiredNumber(lowlink, frame.node, 'lowlink'),
|
||||
requiredNumber(index, child, 'index'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sccs;
|
||||
}
|
||||
|
||||
function requiredNumber(map: ReadonlyMap<string, number>, key: string, label: string): number {
|
||||
const value = map.get(key);
|
||||
if (value === undefined) {
|
||||
throw new Error(`Invariant violated: missing Tarjan ${label} for ${key}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,18 @@
|
|||
* `localDefs`. A `ParsedFile` is trivially convertible to a `FinalizeFile`
|
||||
* by picking those four fields, so the finalize orchestrator threads
|
||||
* ParsedFile through to the shared algorithm without shape-shifting.
|
||||
*
|
||||
* ## Source-of-truth invariant
|
||||
*
|
||||
* `ParsedFile` is the single semantic model consumed by both the legacy
|
||||
* DAG (`gitnexus/src/core/ingestion/` outside `scope-resolution/`) and
|
||||
* the scope-resolution pipeline (`gitnexus/src/core/ingestion/scope-resolution/`).
|
||||
* Downstream passes MUST NOT build a parallel parse representation; if
|
||||
* a pass needs AST-level facts that `ParsedFile` doesn't expose, it
|
||||
* should reuse the orchestrator's `treeCache` rather than re-invoke
|
||||
* `parser.parse(...)` on its own. See the
|
||||
* `ScopeResolver` contract (`gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts`)
|
||||
* for the full list of invariants downstream consumers rely on.
|
||||
*/
|
||||
|
||||
import type { Scope, ScopeId } from './types.js';
|
||||
|
|
|
|||
|
|
@ -71,4 +71,12 @@ export interface ReferenceSite {
|
|||
readonly explicitReceiver?: { readonly name: string };
|
||||
/** Argument count at the call site; used by `provider.arityCompatibility`. */
|
||||
readonly arity?: number;
|
||||
/**
|
||||
* Inferred argument types at the call site, one per argument. An
|
||||
* empty-string entry means "unknown" — consumers narrowing overload
|
||||
* candidates treat unknown as any-match. Populated by languages
|
||||
* that can derive types from literals / constructor expressions
|
||||
* (C#: `42` → `'int'`, `"alice"` → `'string'`).
|
||||
*/
|
||||
readonly argumentTypes?: readonly string[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -119,10 +119,10 @@ export function buildScopeTree(scopes: readonly Scope[]): ScopeTree {
|
|||
`Scope '${scope.id}' (${scope.filePath}) has parent '${parent.id}' in a different file (${parent.filePath}). Parent/child scopes must share filePath.`,
|
||||
);
|
||||
}
|
||||
if (!rangeStrictlyContains(parent.range, scope.range)) {
|
||||
if (!canParentScope(parent.range, scope.range, parent.kind, scope.kind)) {
|
||||
throw new ScopeTreeInvariantError(
|
||||
'parent-must-contain-child',
|
||||
`Parent scope '${parent.id}' at ${formatRange(parent.range)} does not strictly contain child '${scope.id}' at ${formatRange(scope.range)}.`,
|
||||
`Parent scope '${parent.id}' at ${formatRange(parent.range)} does not contain child '${scope.id}' at ${formatRange(scope.range)} (allowed: strict containment, or equal-range Module-as-parent).`,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -230,6 +230,47 @@ function rangeStrictlyContains(outer: Range, inner: Range): boolean {
|
|||
return outerStartsAtOrBefore && outerEndsAtOrAfter;
|
||||
}
|
||||
|
||||
function rangesEqual(a: Range, b: Range): boolean {
|
||||
return (
|
||||
a.startLine === b.startLine &&
|
||||
a.startCol === b.startCol &&
|
||||
a.endLine === b.endLine &&
|
||||
a.endCol === b.endCol
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `outer` (kind `outerKind`) is a valid parent for `inner` (kind
|
||||
* `innerKind`).
|
||||
*
|
||||
* Strict containment is the general rule. The single carve-out is the
|
||||
* `Module`/non-`Module` pair whose ranges are exactly equal — this happens
|
||||
* naturally when tree-sitter reports identical byte spans for the
|
||||
* `compilation_unit` (or equivalent file-root construct) and the file's
|
||||
* single top-level scope. Common shape: a C# file consisting of nothing
|
||||
* but `namespace X { ... }` with no leading or trailing trivia outside the
|
||||
* namespace's `{}` body — `compilation_unit` and `namespace_declaration`
|
||||
* both span exactly the same byte range. The `Module` is the universal
|
||||
* outer of any file-level scope by language semantics, so coincident
|
||||
* ranges should not break the parent chain.
|
||||
*
|
||||
* The carve-out is direction-asymmetric: only `Module`-as-outer parents a
|
||||
* same-range non-`Module`, never the reverse. This preserves the
|
||||
* acyclicity buildScopeTree relies on, and matches the corresponding
|
||||
* helper in `scope-extractor.ts` so `pass1BuildScopes` and the validator
|
||||
* agree on what a well-formed parent edge looks like.
|
||||
*/
|
||||
export function canParentScope(
|
||||
outer: Range,
|
||||
inner: Range,
|
||||
outerKind: Scope['kind'],
|
||||
innerKind: Scope['kind'],
|
||||
): boolean {
|
||||
if (rangeStrictlyContains(outer, inner)) return true;
|
||||
if (outerKind === 'Module' && innerKind !== 'Module' && rangesEqual(outer, inner)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Two ranges overlap when neither finishes before the other begins. Ranges
|
||||
* that merely touch at a single boundary point (`a.end === b.start`) do
|
||||
|
|
|
|||
|
|
@ -10,8 +10,16 @@
|
|||
* Lifecycle contract (RFC §2.8): scopes are **constructed during extraction,
|
||||
* linked during finalize, immutable after finalize**. All fields are
|
||||
* `readonly` at the type level; `Object.freeze` is applied at runtime in dev
|
||||
* builds. `ReferenceIndex` is the sole structure populated after freeze — by
|
||||
* resolution, before emission.
|
||||
* builds.
|
||||
*
|
||||
* Two structures are populated after freeze:
|
||||
* 1. `ReferenceIndex` — by resolution, before emission.
|
||||
* 2. `ScopeResolutionIndexes.bindingAugmentations` — the dedicated
|
||||
* append-only post-finalize binding channel (e.g. C# same-namespace
|
||||
* cross-file fanout). The companion `indexes.bindings` is the
|
||||
* finalize-output channel and is deep-frozen by `materializeBindings`;
|
||||
* walkers consult both via `lookupBindingsAt`. See `ScopeResolver`
|
||||
* Invariant I8 for the full lifecycle contract.
|
||||
*/
|
||||
|
||||
import type { NodeLabel } from '../graph/types.js';
|
||||
|
|
@ -182,6 +190,42 @@ export type ParsedImport =
|
|||
readonly localName: string;
|
||||
/** Source text of the unresolved expression when available; `null` otherwise. */
|
||||
readonly targetRaw: string | null;
|
||||
}
|
||||
/**
|
||||
* Lazy / dynamic import whose target IS a static string literal at parse
|
||||
* time, so it can be linked to a concrete `targetFile`. No local name
|
||||
* binding is materialized — `import('./m')` returns `Promise<Module>` and
|
||||
* any consumer-visible names appear via subsequent `.then(({ X }) => …)`
|
||||
* destructuring, which is outside the static-import surface. The edge
|
||||
* exists for module-reachability and impact analysis (so editing `./m`
|
||||
* still flags the dynamic importer as affected).
|
||||
*
|
||||
* Providers MUST only emit this kind when `targetRaw` is a literal
|
||||
* string they can hand to `resolveImportTarget`; expression arguments
|
||||
* stay `dynamic-unresolved`.
|
||||
*
|
||||
* Examples:
|
||||
* - JS `import('./feature')` → `{ kind: 'dynamic-resolved', targetRaw: './feature' }`
|
||||
* - JS `await import('@scope/pkg/sub')` → `{ kind: 'dynamic-resolved', targetRaw: '@scope/pkg/sub' }`
|
||||
*/
|
||||
| {
|
||||
readonly kind: 'dynamic-resolved';
|
||||
readonly targetRaw: string;
|
||||
}
|
||||
/**
|
||||
* Bare-source / side-effect import that introduces no local name binding
|
||||
* but still establishes a file-level dependency. Resolves to a concrete
|
||||
* `targetFile` via `resolveImportTarget` and produces a file→file
|
||||
* `ImportEdge` for module-reachability and impact analysis, with no
|
||||
* `BindingRef` materialized.
|
||||
*
|
||||
* Examples:
|
||||
* - JS / TS `import './polyfill'` → `{ kind: 'side-effect', targetRaw: './polyfill' }`
|
||||
* - Rust `use foo::bar as _` → side-effect (binding hidden under `_`)
|
||||
*/
|
||||
| {
|
||||
readonly kind: 'side-effect';
|
||||
readonly targetRaw: string;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -253,7 +297,9 @@ export interface ImportEdge {
|
|||
| 'namespace'
|
||||
| 'wildcard-expanded'
|
||||
| 'reexport'
|
||||
| 'dynamic-unresolved';
|
||||
| 'dynamic-unresolved'
|
||||
| 'dynamic-resolved'
|
||||
| 'side-effect';
|
||||
/** Re-export chain, for provenance (e.g., `['./y']` when re-exported via `./y`). */
|
||||
readonly transitiveVia?: readonly string[];
|
||||
/** Set to `'unresolved'` when the SCC fixpoint could not link this edge. */
|
||||
|
|
|
|||
2722
gitnexus-web/package-lock.json
generated
2722
gitnexus-web/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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.5",
|
||||
"@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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@ WORKDIR /app
|
|||
RUN apt-get -o Acquire::Check-Valid-Until=false -o Acquire::Check-Date=false update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
|
||||
COPY . .
|
||||
RUN npm ci --ignore-scripts \
|
||||
&& node scripts/patch-tree-sitter-swift.cjs \
|
||||
&& npm rebuild tree-sitter-swift 2>&1 \
|
||||
&& node -e "require('tree-sitter-swift')" \
|
||||
&& (npm rebuild 2>&1 || true) \
|
||||
&& cd node_modules/tree-sitter-kotlin && npx --yes node-gyp rebuild 2>&1
|
||||
CMD ["npx", "vitest", "run", "test/integration", "--reporter=verbose"]
|
||||
|
|
|
|||
|
|
@ -155,6 +155,8 @@ 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 analyze --worker-timeout 60 # Increase worker idle timeout for slow parses
|
||||
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
|
||||
|
|
@ -294,6 +296,25 @@ If `npm install -g gitnexus` fails on native modules:
|
|||
npm install -g gitnexus
|
||||
```
|
||||
|
||||
### Analyze warns about unavailable FTS or VECTOR extensions
|
||||
|
||||
GitNexus uses optional DuckDB extensions for BM25 and vector search. The `gitnexus serve` and MCP read paths only ever try to `LOAD` the extensions — they never block on a network install. The `analyze` command, by default, attempts one bounded out-of-process `INSTALL` if `LOAD` fails and proceeds even when that install times out, so the index is always written to disk; BM25/vector search degrade gracefully until the extensions become available.
|
||||
|
||||
Configure the behavior with two environment variables:
|
||||
|
||||
| Variable | Values | Default | Effect |
|
||||
|----------|--------|---------|--------|
|
||||
| `GITNEXUS_LBUG_EXTENSION_INSTALL` | `auto`, `load-only`, `never` | `auto` | `auto` runs one bounded INSTALL if LOAD fails. `load-only` only uses already-installed extensions (recommended for offline / firewalled environments). `never` skips optional extensions entirely. |
|
||||
| `GITNEXUS_LBUG_EXTENSION_INSTALL_TIMEOUT_MS` | positive integer | `15000` | Wall-clock budget for the out-of-process `INSTALL` child before it is killed. |
|
||||
|
||||
```bash
|
||||
# Offline/airgapped: never reach the network for extensions
|
||||
GITNEXUS_LBUG_EXTENSION_INSTALL=load-only npx gitnexus analyze
|
||||
|
||||
# Slow network: give extension downloads more time
|
||||
GITNEXUS_LBUG_EXTENSION_INSTALL_TIMEOUT_MS=30000 npx gitnexus analyze
|
||||
```
|
||||
|
||||
### Analysis runs out of memory
|
||||
|
||||
For very large repositories:
|
||||
|
|
@ -307,6 +328,36 @@ 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)`).
|
||||
|
||||
### Analyze reports a worker timeout
|
||||
|
||||
Worker parse timeouts are recoverable. GitNexus retries stalled worker jobs with backoff, splits large jobs to isolate slow files, and falls back to the sequential parser when needed. If a large repository needs more time per worker job, use either:
|
||||
|
||||
```bash
|
||||
# CLI flag, in seconds
|
||||
npx gitnexus analyze --worker-timeout 60
|
||||
|
||||
# Environment variable, in milliseconds
|
||||
export GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=60000
|
||||
npx gitnexus analyze
|
||||
```
|
||||
|
||||
For repositories with very large source files, `GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES` controls the worker job byte budget. The default is **8388608 bytes (8 MB)**.
|
||||
|
||||
## Privacy
|
||||
|
||||
- All processing happens locally on your machine
|
||||
|
|
|
|||
|
|
@ -31,11 +31,21 @@ function readInput() {
|
|||
* Find the .gitnexus directory by walking up from startDir.
|
||||
* Returns the path to .gitnexus/ or null if not found.
|
||||
*/
|
||||
function isGlobalRegistryDir(candidate) {
|
||||
if (fs.existsSync(path.join(candidate, 'meta.json'))) return false;
|
||||
return (
|
||||
fs.existsSync(path.join(candidate, 'registry.json')) ||
|
||||
fs.existsSync(path.join(candidate, 'repos'))
|
||||
);
|
||||
}
|
||||
|
||||
function findGitNexusDir(startDir) {
|
||||
let dir = startDir || process.cwd();
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const candidate = path.join(dir, '.gitnexus');
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
if (fs.existsSync(candidate)) {
|
||||
if (!isGlobalRegistryDir(candidate)) return candidate;
|
||||
}
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
|
|
|
|||
146
gitnexus/package-lock.json
generated
146
gitnexus/package-lock.json
generated
|
|
@ -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": {
|
||||
|
|
@ -24,6 +24,7 @@
|
|||
"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",
|
||||
|
|
@ -64,10 +65,10 @@
|
|||
"optionalDependencies": {
|
||||
"node-addon-api": "^8.0.0",
|
||||
"node-gyp-build": "^4.8.0",
|
||||
"tree-sitter-dart": "git+https://github.com/UserNobody14/tree-sitter-dart.git#80e23c07b64494f7e21090bb3450223ef0b192f4",
|
||||
"tree-sitter-dart": "file:./vendor/tree-sitter-dart",
|
||||
"tree-sitter-kotlin": "^0.3.8",
|
||||
"tree-sitter-proto": "file:./vendor/tree-sitter-proto",
|
||||
"tree-sitter-swift": "^0.6.0"
|
||||
"tree-sitter-swift": "file:./vendor/tree-sitter-swift"
|
||||
}
|
||||
},
|
||||
"../gitnexus-shared": {
|
||||
|
|
@ -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"
|
||||
}
|
||||
},
|
||||
|
|
@ -3617,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",
|
||||
|
|
@ -4230,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",
|
||||
|
|
@ -5058,20 +5065,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/tree-sitter-cli": {
|
||||
"version": "0.23.2",
|
||||
"resolved": "https://registry.npmjs.org/tree-sitter-cli/-/tree-sitter-cli-0.23.2.tgz",
|
||||
"integrity": "sha512-kPPXprOqREX+C/FgUp2Qpt9jd0vSwn+hOgjzVv/7hapdoWpa+VeWId53rf4oNNd29ikheF12BYtGD/W90feMbA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"bin": {
|
||||
"tree-sitter": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tree-sitter-cpp": {
|
||||
"version": "0.23.4",
|
||||
"resolved": "https://registry.npmjs.org/tree-sitter-cpp/-/tree-sitter-cpp-0.23.4.tgz",
|
||||
|
|
@ -5093,31 +5086,8 @@
|
|||
}
|
||||
},
|
||||
"node_modules/tree-sitter-dart": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "git+ssh://git@github.com/UserNobody14/tree-sitter-dart.git#80e23c07b64494f7e21090bb3450223ef0b192f4",
|
||||
"integrity": "sha512-Bs/1wAOIJ2akPEXlE/XVpuES19Oo3NqoSJRJ/0N2r38qAd9nTXdqmaGHQ44/JXnA6QHcbgD2YzCCc4wUc98cyQ==",
|
||||
"hasInstallScript": true,
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"node-addon-api": "^7.1.0",
|
||||
"node-gyp-build": "^4.8.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"tree-sitter": "^0.21.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"tree_sitter": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/tree-sitter-dart/node_modules/node-addon-api": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
|
||||
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"resolved": "vendor/tree-sitter-dart",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/tree-sitter-go": {
|
||||
"version": "0.23.4",
|
||||
|
|
@ -5284,49 +5254,8 @@
|
|||
}
|
||||
},
|
||||
"node_modules/tree-sitter-swift": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/tree-sitter-swift/-/tree-sitter-swift-0.6.0.tgz",
|
||||
"integrity": "sha512-9vOJZes4/UFjBr4COHtp6ZHVuZYwfChSQbpneXQog04dAstfx5px3ybVX2cN+ylvLqsvVpmXLpidxxgF2rDQ7A==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"node-addon-api": "^8.0.0",
|
||||
"node-gyp-build": "^4.8.0",
|
||||
"tree-sitter-cli": "^0.23",
|
||||
"which": "2.0.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"tree-sitter": "^0.21.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"tree_sitter": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/tree-sitter-swift/node_modules/isexe": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||
"license": "ISC",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/tree-sitter-swift/node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"isexe": "^2.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"node-which": "bin/node-which"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
"resolved": "vendor/tree-sitter-swift",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/tree-sitter-typescript": {
|
||||
"version": "0.23.2",
|
||||
|
|
@ -5761,6 +5690,19 @@
|
|||
"zod": "^3.25.28 || ^4"
|
||||
}
|
||||
},
|
||||
"vendor/tree-sitter-dart": {
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"peerDependencies": {
|
||||
"tree-sitter": "^0.21.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"tree_sitter": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"vendor/tree-sitter-proto": {
|
||||
"version": "0.4.1",
|
||||
"license": "MIT",
|
||||
|
|
@ -5768,6 +5710,24 @@
|
|||
"peerDependencies": {
|
||||
"tree-sitter": ">=0.21.0"
|
||||
}
|
||||
},
|
||||
"vendor/tree-sitter-swift": {
|
||||
"version": "0.7.1",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"node-addon-api": "^8.0.0",
|
||||
"node-gyp-build": "^4.8.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"tree-sitter": "^0.21.1 || ^0.22.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"tree-sitter": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
@ -35,7 +35,8 @@
|
|||
"hooks",
|
||||
"scripts",
|
||||
"skills",
|
||||
"vendor"
|
||||
"vendor",
|
||||
"web"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "node scripts/build.js",
|
||||
|
|
@ -46,7 +47,7 @@
|
|||
"test:integration": "vitest run test/integration",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"postinstall": "node scripts/patch-tree-sitter-swift.cjs && node scripts/build-tree-sitter-proto.cjs",
|
||||
"postinstall": "node scripts/build-tree-sitter-dart.cjs && node scripts/build-tree-sitter-proto.cjs",
|
||||
"prepare": "node scripts/build.js",
|
||||
"prepack": "node scripts/build.js"
|
||||
},
|
||||
|
|
@ -65,6 +66,7 @@
|
|||
"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",
|
||||
|
|
@ -86,10 +88,10 @@
|
|||
"optionalDependencies": {
|
||||
"node-addon-api": "^8.0.0",
|
||||
"node-gyp-build": "^4.8.0",
|
||||
"tree-sitter-dart": "git+https://github.com/UserNobody14/tree-sitter-dart.git#80e23c07b64494f7e21090bb3450223ef0b192f4",
|
||||
"tree-sitter-dart": "file:./vendor/tree-sitter-dart",
|
||||
"tree-sitter-kotlin": "^0.3.8",
|
||||
"tree-sitter-proto": "file:./vendor/tree-sitter-proto",
|
||||
"tree-sitter-swift": "^0.6.0"
|
||||
"tree-sitter-swift": "file:./vendor/tree-sitter-swift"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cli-progress": "^3.11.6",
|
||||
|
|
|
|||
42
gitnexus/scripts/build-tree-sitter-dart.cjs
Normal file
42
gitnexus/scripts/build-tree-sitter-dart.cjs
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
#!/usr/bin/env node
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
const dartDir = path.join(__dirname, '..', 'node_modules', 'tree-sitter-dart');
|
||||
const bindingGyp = path.join(dartDir, 'binding.gyp');
|
||||
const bindingNode = path.join(dartDir, 'build', 'Release', 'tree_sitter_dart_binding.node');
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(bindingGyp) || fs.existsSync(bindingNode)) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
try {
|
||||
require.resolve('node-addon-api');
|
||||
require.resolve('node-gyp-build');
|
||||
} catch (resolveErr) {
|
||||
console.warn(
|
||||
'[tree-sitter-dart] Skipping build: hoisted build deps not resolvable (%s).',
|
||||
resolveErr.message,
|
||||
);
|
||||
console.warn(
|
||||
'[tree-sitter-dart] Dart parsing will be unavailable. Install without --no-optional and with scripts enabled to build.',
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log('[tree-sitter-dart] Building native binding...');
|
||||
execSync('npx node-gyp rebuild', {
|
||||
cwd: dartDir,
|
||||
stdio: 'pipe',
|
||||
timeout: 180000,
|
||||
});
|
||||
console.log('[tree-sitter-dart] Native binding built successfully');
|
||||
} catch (err) {
|
||||
console.warn('[tree-sitter-dart] Could not build native binding:', err.message);
|
||||
console.warn(
|
||||
'[tree-sitter-dart] Dart parsing will be unavailable. Non-Dart functionality is unaffected.',
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
|
@ -26,7 +26,7 @@
|
|||
* `node_modules/tree-sitter-proto/build/Release/tree_sitter_proto_binding.node`
|
||||
* — under npm-managed territory, safe on upgrade.
|
||||
*
|
||||
* Mirrors scripts/patch-tree-sitter-swift.cjs. Best-effort: if any
|
||||
* Mirrors the tree-sitter-dart build helper. Best-effort: if any
|
||||
* precondition fails (optional dep absent, no toolchain, --ignore-scripts),
|
||||
* warn and exit 0 so gitnexus install still succeeds.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -21,11 +21,11 @@ const SHARED_DEST = path.join(DIST, '_shared');
|
|||
|
||||
// ── 1. Build gitnexus-shared ───────────────────────────────────────
|
||||
console.log('[build] compiling gitnexus-shared…');
|
||||
execSync('npx tsc', { cwd: SHARED_ROOT, stdio: 'inherit' });
|
||||
execSync('npx tsc', { cwd: SHARED_ROOT, stdio: 'inherit', timeout: 120_000 });
|
||||
|
||||
// ── 2. Build gitnexus ──────────────────────────────────────────────
|
||||
console.log('[build] compiling gitnexus…');
|
||||
execSync('npx tsc', { cwd: ROOT, stdio: 'inherit' });
|
||||
execSync('npx tsc', { cwd: ROOT, stdio: 'inherit', timeout: 120_000 });
|
||||
|
||||
// ── 3. Copy shared dist ────────────────────────────────────────────
|
||||
console.log('[build] copying shared module into dist/_shared…');
|
||||
|
|
@ -70,4 +70,24 @@ walk(DIST, ['.js', '.d.ts'], rewriteFile);
|
|||
const cliEntry = path.join(DIST, 'cli', 'index.js');
|
||||
if (fs.existsSync(cliEntry)) fs.chmodSync(cliEntry, 0o755);
|
||||
|
||||
// ── 6. Build & copy web UI ──────────────────────────────────────────
|
||||
const WEB_ROOT = path.resolve(ROOT, '..', 'gitnexus-web');
|
||||
const WEB_DEST = path.join(DIST, '..', 'web');
|
||||
|
||||
if (fs.existsSync(path.join(WEB_ROOT, 'package.json'))) {
|
||||
console.log('[build] building gitnexus-web…');
|
||||
if (!fs.existsSync(path.join(WEB_ROOT, 'node_modules'))) {
|
||||
console.log('[build] installing gitnexus-web dependencies…');
|
||||
execSync('npm ci', { cwd: WEB_ROOT, stdio: 'inherit', timeout: 120_000 });
|
||||
}
|
||||
execSync('npm run build', { cwd: WEB_ROOT, stdio: 'inherit', timeout: 120_000 });
|
||||
|
||||
// Copy dist → gitnexus/web/ (shipped in the npm package)
|
||||
fs.rmSync(WEB_DEST, { recursive: true, force: true });
|
||||
fs.cpSync(path.join(WEB_ROOT, 'dist'), WEB_DEST, { recursive: true });
|
||||
console.log('[build] copied web UI → gitnexus/web/');
|
||||
} else {
|
||||
console.log('[build] skipping web UI (gitnexus-web not found)');
|
||||
}
|
||||
|
||||
console.log(`[build] done — rewrote ${rewritten} files.`);
|
||||
|
|
|
|||
37
gitnexus/scripts/install-duckdb-extension.mjs
Normal file
37
gitnexus/scripts/install-duckdb-extension.mjs
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
#!/usr/bin/env node
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
const EXTENSION_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9_]*$/;
|
||||
|
||||
async function installDuckDbExtension(extensionName) {
|
||||
if (!extensionName || !EXTENSION_NAME_PATTERN.test(extensionName)) {
|
||||
throw new Error(`Invalid DuckDB extension name: ${extensionName ?? '<missing>'}`);
|
||||
}
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const lbugModule = require('@ladybugdb/core');
|
||||
const lbug = lbugModule.default ?? lbugModule;
|
||||
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-ext-install-'));
|
||||
const dbPath = path.join(tmpDir, 'install.lbug');
|
||||
let db;
|
||||
let conn;
|
||||
|
||||
try {
|
||||
db = new lbug.Database(dbPath);
|
||||
conn = new lbug.Connection(db);
|
||||
await conn.query(`INSTALL ${extensionName}`);
|
||||
} finally {
|
||||
if (conn) await conn.close().catch(() => {});
|
||||
if (db) await db.close().catch(() => {});
|
||||
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
installDuckDbExtension(process.argv[2] ?? process.env.GITNEXUS_LBUG_EXTENSION_NAME).catch((err) => {
|
||||
console.error(err instanceof Error ? (err.stack ?? err.message) : String(err));
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* WORKAROUND: tree-sitter-swift@0.6.0 binding.gyp build failure
|
||||
*
|
||||
* Background:
|
||||
* tree-sitter-swift@0.6.0's binding.gyp contains an "actions" array that
|
||||
* invokes `tree-sitter generate` to regenerate parser.c from grammar.js.
|
||||
* This is intended for grammar developers, but the published npm package
|
||||
* already ships pre-generated parser files (parser.c, scanner.c), so the
|
||||
* actions are unnecessary for consumers. Since consumers don't have
|
||||
* tree-sitter-cli installed, the actions always fail during `npm install`.
|
||||
*
|
||||
* Why we can't just upgrade:
|
||||
* tree-sitter-swift@0.7.1 fixes this (removes postinstall, ships prebuilds),
|
||||
* but it requires tree-sitter@^0.22.1. The upstream project pins tree-sitter
|
||||
* to ^0.21.0 and all other grammar packages depend on that version.
|
||||
* Upgrading tree-sitter would be a separate breaking change.
|
||||
*
|
||||
* How this workaround works:
|
||||
* 1. tree-sitter-swift's own postinstall fails (npm warns but continues)
|
||||
* 2. This script runs as gitnexus's postinstall
|
||||
* 3. It removes the "actions" array from binding.gyp
|
||||
* 4. It rebuilds the native binding with the cleaned binding.gyp
|
||||
*
|
||||
* TODO: Remove this script when tree-sitter is upgraded to ^0.22.x,
|
||||
* which allows using tree-sitter-swift@0.7.1+ directly.
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
const swiftDir = path.join(__dirname, '..', 'node_modules', 'tree-sitter-swift');
|
||||
const bindingPath = path.join(swiftDir, 'binding.gyp');
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(bindingPath)) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(bindingPath, 'utf8');
|
||||
let needsRebuild = false;
|
||||
|
||||
if (content.includes('"actions"')) {
|
||||
// Strip Python-style comments (#) and trailing commas before JSON parsing
|
||||
const cleaned = content
|
||||
.replace(/#[^\n]*/g, '') // Remove # comments
|
||||
.replace(/,(\s*[\]}])/g, '$1'); // Remove trailing commas before ] or }
|
||||
const gyp = JSON.parse(cleaned);
|
||||
|
||||
if (gyp.targets && gyp.targets[0] && gyp.targets[0].actions) {
|
||||
delete gyp.targets[0].actions;
|
||||
fs.writeFileSync(bindingPath, JSON.stringify(gyp, null, 2) + '\n');
|
||||
console.log('[tree-sitter-swift] Patched binding.gyp (removed actions array)');
|
||||
needsRebuild = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if native binding exists
|
||||
const bindingNode = path.join(swiftDir, 'build', 'Release', 'tree_sitter_swift_binding.node');
|
||||
if (!fs.existsSync(bindingNode)) {
|
||||
needsRebuild = true;
|
||||
}
|
||||
|
||||
if (needsRebuild) {
|
||||
console.log('[tree-sitter-swift] Rebuilding native binding...');
|
||||
execSync('npx node-gyp rebuild', {
|
||||
cwd: swiftDir,
|
||||
stdio: 'pipe',
|
||||
timeout: 120000,
|
||||
});
|
||||
console.log('[tree-sitter-swift] Native binding built successfully');
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[tree-sitter-swift] Could not build native binding:', err.message);
|
||||
console.warn(
|
||||
'[tree-sitter-swift] You may need to manually run: cd node_modules/tree-sitter-swift && npx node-gyp rebuild',
|
||||
);
|
||||
}
|
||||
|
|
@ -21,8 +21,9 @@ 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.
|
||||
**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 detects staleness after `git commit` and `git merge` and notifies the agent to run `analyze` — the hook does not run analyze itself, to avoid blocking the agent for up to 120s and risking KuzuDB corruption on timeout.
|
||||
|
||||
### status — Check index freshness
|
||||
|
||||
|
|
|
|||
|
|
@ -32,6 +32,33 @@ export interface AIContextOptions {
|
|||
const GITNEXUS_START_MARKER = '<!-- gitnexus:start -->';
|
||||
const GITNEXUS_END_MARKER = '<!-- gitnexus:end -->';
|
||||
|
||||
/**
|
||||
* Find the index of a section marker that occupies its own line.
|
||||
* Unlike `indexOf`, this rejects inline prose references like
|
||||
* `` See the `<!-- gitnexus:start -->` block `` that appear
|
||||
* mid-sentence (#1041). A marker counts as section-position only when:
|
||||
* - preceded by newline or start-of-file, AND
|
||||
* - followed by newline, `\r` (CRLF files), or end-of-file.
|
||||
* The generator always emits each marker alone on its line, so this
|
||||
* matches every legitimate section and none of the inline mentions.
|
||||
*
|
||||
* `startFrom` lets the end-marker lookup start after the already-found
|
||||
* start marker, avoiding a scan from 0 and guaranteeing we never pick
|
||||
* up an end marker that appears earlier in the file than the start.
|
||||
*/
|
||||
function findSectionMarkerIndex(content: string, marker: string, startFrom = 0): number {
|
||||
let idx = content.indexOf(marker, startFrom);
|
||||
while (idx !== -1) {
|
||||
const atLineStart = idx === 0 || content[idx - 1] === '\n';
|
||||
const endPos = idx + marker.length;
|
||||
const atLineEnd =
|
||||
endPos === content.length || content[endPos] === '\n' || content[endPos] === '\r';
|
||||
if (atLineStart && atLineEnd) return idx;
|
||||
idx = content.indexOf(marker, idx + 1);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the full GitNexus context content.
|
||||
*
|
||||
|
|
@ -163,9 +190,18 @@ async function upsertGitNexusSection(
|
|||
|
||||
const existingContent = await fs.readFile(filePath, 'utf-8');
|
||||
|
||||
// Check if GitNexus section already exists
|
||||
const startIdx = existingContent.indexOf(GITNEXUS_START_MARKER);
|
||||
const endIdx = existingContent.indexOf(GITNEXUS_END_MARKER);
|
||||
// Check if GitNexus section already exists. Matching is restricted
|
||||
// to markers that occupy their own line so that inline prose
|
||||
// references (e.g. `` See the `<!-- gitnexus:start -->` block `` in
|
||||
// the shipped CLAUDE.md) are NOT treated as section delimiters
|
||||
// (#1041). The end-marker scan starts after the start-marker so it
|
||||
// can never pick up an earlier end in the file.
|
||||
const startIdx = findSectionMarkerIndex(existingContent, GITNEXUS_START_MARKER);
|
||||
const endIdx = findSectionMarkerIndex(
|
||||
existingContent,
|
||||
GITNEXUS_END_MARKER,
|
||||
startIdx === -1 ? 0 : startIdx,
|
||||
);
|
||||
|
||||
if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
|
||||
// Replace existing section
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import {
|
|||
} from '../storage/repo-manager.js';
|
||||
import { getGitRoot, hasGitDir } from '../storage/git.js';
|
||||
import { runFullAnalysis } from '../core/run-analyze.js';
|
||||
import { getMaxFileSizeBannerMessage } from '../core/ingestion/utils/max-file-size.js';
|
||||
import fs from 'fs/promises';
|
||||
|
||||
const HEAP_MB = 8192;
|
||||
|
|
@ -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,18 @@ 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;
|
||||
/** Override worker sub-batch idle timeout in seconds. */
|
||||
workerTimeout?: string;
|
||||
embeddingThreads?: string;
|
||||
embeddingBatchSize?: string;
|
||||
embeddingSubBatchSize?: string;
|
||||
embeddingDevice?: string;
|
||||
}
|
||||
|
||||
export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOptions) => {
|
||||
|
|
@ -87,6 +106,68 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
|
|||
process.env.GITNEXUS_VERBOSE = '1';
|
||||
}
|
||||
|
||||
if (options?.maxFileSize) {
|
||||
process.env.GITNEXUS_MAX_FILE_SIZE = options.maxFileSize;
|
||||
}
|
||||
|
||||
if (options?.workerTimeout) {
|
||||
const workerTimeoutSeconds = Number(options.workerTimeout);
|
||||
if (!Number.isFinite(workerTimeoutSeconds) || workerTimeoutSeconds < 1) {
|
||||
console.error(' --worker-timeout must be at least 1 second.\n');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
process.env.GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS = String(
|
||||
Math.round(workerTimeoutSeconds * 1000),
|
||||
);
|
||||
}
|
||||
|
||||
const setPositiveEnv = (
|
||||
optionName: string,
|
||||
envName: string,
|
||||
value: string | undefined,
|
||||
): boolean => {
|
||||
if (value === undefined) return true;
|
||||
const parsed = Number(value);
|
||||
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||
console.error(` ${optionName} must be a positive integer.\n`);
|
||||
process.exitCode = 1;
|
||||
return false;
|
||||
}
|
||||
process.env[envName] = String(parsed);
|
||||
return true;
|
||||
};
|
||||
|
||||
if (
|
||||
!setPositiveEnv(
|
||||
'--embedding-threads',
|
||||
'GITNEXUS_EMBEDDING_THREADS',
|
||||
options?.embeddingThreads,
|
||||
) ||
|
||||
!setPositiveEnv(
|
||||
'--embedding-batch-size',
|
||||
'GITNEXUS_EMBEDDING_BATCH_SIZE',
|
||||
options?.embeddingBatchSize,
|
||||
) ||
|
||||
!setPositiveEnv(
|
||||
'--embedding-sub-batch-size',
|
||||
'GITNEXUS_EMBEDDING_SUB_BATCH_SIZE',
|
||||
options?.embeddingSubBatchSize,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (options?.embeddingDevice) {
|
||||
const allowed = new Set(['auto', 'cpu', 'dml', 'cuda', 'wasm']);
|
||||
if (!allowed.has(options.embeddingDevice)) {
|
||||
console.error(' --embedding-device must be one of: auto, cpu, dml, cuda, wasm.\n');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
process.env.GITNEXUS_EMBEDDING_DEVICE = options.embeddingDevice;
|
||||
}
|
||||
|
||||
console.log('\n GitNexus Analyzer\n');
|
||||
|
||||
let repoPath: string;
|
||||
|
|
@ -132,6 +213,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 +296,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,
|
||||
|
|
|
|||
32
gitnexus/src/cli/doctor.ts
Normal file
32
gitnexus/src/cli/doctor.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { getRuntimeCapabilities, getRuntimeFingerprint } from '../core/platform/capabilities.js';
|
||||
import { resolveEmbeddingConfig } from '../core/embeddings/config.js';
|
||||
import { isHttpMode } from '../core/embeddings/http-client.js';
|
||||
|
||||
export const doctorCommand = async () => {
|
||||
const fingerprint = getRuntimeFingerprint();
|
||||
const capabilities = getRuntimeCapabilities();
|
||||
const embeddingConfig = resolveEmbeddingConfig();
|
||||
|
||||
console.log('GitNexus Doctor\n');
|
||||
console.log('Runtime');
|
||||
console.log(` OS: ${fingerprint.platform}/${fingerprint.arch}`);
|
||||
console.log(` Node: ${fingerprint.node}`);
|
||||
console.log(` GitNexus: ${fingerprint.gitnexus}`);
|
||||
console.log(` LadybugDB: ${fingerprint.ladybugdb ?? 'unknown'}`);
|
||||
console.log(` ONNX: ${fingerprint.onnxruntime ?? 'unknown'}`);
|
||||
console.log('');
|
||||
console.log('Capabilities');
|
||||
console.log(` Graph store: ${capabilities.graph}`);
|
||||
console.log(` Full-text search:${capabilities.fts.padStart(10)}`);
|
||||
console.log(` VECTOR index: ${capabilities.vector}`);
|
||||
console.log(` Semantic mode: ${capabilities.semanticMode}`);
|
||||
console.log(` Exact scan limit:${String(capabilities.exactScanLimit).padStart(9)} chunks`);
|
||||
if (capabilities.reason) console.log(` Note: ${capabilities.reason}`);
|
||||
console.log('');
|
||||
console.log('Embeddings');
|
||||
console.log(` Backend: ${isHttpMode() ? 'http' : 'local'}`);
|
||||
console.log(` Device: ${embeddingConfig.device}`);
|
||||
console.log(` Threads: ${embeddingConfig.threads}`);
|
||||
console.log(` Batch: ${embeddingConfig.batchSize} nodes`);
|
||||
console.log(` Sub-batch: ${embeddingConfig.subBatchSize} chunks`);
|
||||
};
|
||||
|
|
@ -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,29 @@ 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).',
|
||||
)
|
||||
.option(
|
||||
'--worker-timeout <seconds>',
|
||||
'Worker sub-batch idle timeout before retry/fallback. Default: 30.',
|
||||
)
|
||||
.option('--embedding-threads <n>', 'Limit local ONNX embedding CPU threads')
|
||||
.option('--embedding-batch-size <n>', 'Number of nodes per embedding batch')
|
||||
.option('--embedding-sub-batch-size <n>', 'Number of chunks per embedding model call')
|
||||
.option('--embedding-device <device>', 'Embedding device: auto, cpu, dml, cuda, or wasm')
|
||||
.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' +
|
||||
' GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=N Worker idle timeout in milliseconds. Default 30000.\n' +
|
||||
' GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES=N Worker job byte budget. Default 8388608.\n' +
|
||||
' GITNEXUS_EMBEDDING_THREADS=N Limit local ONNX CPU threads for --embeddings.\n' +
|
||||
' GITNEXUS_SEMANTIC_EXACT_SCAN_LIMIT=N Max embedding chunks for exact-scan fallback. Default 10000.\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'));
|
||||
|
||||
|
|
@ -76,6 +101,11 @@ program
|
|||
.description('Show index status for current repo')
|
||||
.action(createLazyAction(() => import('./status.js'), 'statusCommand'));
|
||||
|
||||
program
|
||||
.command('doctor')
|
||||
.description('Show runtime platform capabilities and embedding configuration')
|
||||
.action(createLazyAction(() => import('./doctor.js'), 'doctorCommand'));
|
||||
|
||||
program
|
||||
.command('clean')
|
||||
.description('Delete GitNexus index for current repo')
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
|
|
@ -434,13 +581,13 @@ async function installCursorSkills(result: SetupResult): Promise<void> {
|
|||
}
|
||||
|
||||
/**
|
||||
* Install global OpenCode skills to ~/.config/opencode/skill/gitnexus/
|
||||
* Install global OpenCode skills to ~/.config/opencode/skills/gitnexus/
|
||||
*/
|
||||
async function installOpenCodeSkills(result: SetupResult): Promise<void> {
|
||||
const opencodeDir = path.join(os.homedir(), '.config', 'opencode');
|
||||
if (!(await dirExists(opencodeDir))) return;
|
||||
|
||||
const skillsDir = path.join(opencodeDir, 'skill');
|
||||
const skillsDir = path.join(opencodeDir, 'skills');
|
||||
try {
|
||||
const installed = await installSkillsTo(skillsDir);
|
||||
if (installed.length > 0) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
|
|
|
|||
54
gitnexus/src/core/embedding-mode.ts
Normal file
54
gitnexus/src/core/embedding-mode.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
54
gitnexus/src/core/embeddings/config.ts
Normal file
54
gitnexus/src/core/embeddings/config.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { defaultEmbeddingThreads } from '../platform/capabilities.js';
|
||||
import { DEFAULT_EMBEDDING_CONFIG, type EmbeddingConfig } from './types.js';
|
||||
|
||||
const parsePositiveInt = (name: string, value: string | undefined, fallback: number): number => {
|
||||
if (value === undefined) return fallback;
|
||||
const parsed = Number(value);
|
||||
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||
throw new Error(`${name} must be a positive integer, got "${value}"`);
|
||||
}
|
||||
return parsed;
|
||||
};
|
||||
|
||||
const parseDevice = (value: string | undefined): EmbeddingConfig['device'] | undefined => {
|
||||
if (value === undefined) return undefined;
|
||||
if (
|
||||
value === 'auto' ||
|
||||
value === 'dml' ||
|
||||
value === 'cuda' ||
|
||||
value === 'cpu' ||
|
||||
value === 'wasm'
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
throw new Error(`embedding device must be one of auto, dml, cuda, cpu, wasm; got "${value}"`);
|
||||
};
|
||||
|
||||
export const resolveEmbeddingConfig = (
|
||||
overrides: Partial<EmbeddingConfig> = {},
|
||||
): EmbeddingConfig => {
|
||||
const env = process.env;
|
||||
return {
|
||||
...DEFAULT_EMBEDDING_CONFIG,
|
||||
...overrides,
|
||||
batchSize: parsePositiveInt(
|
||||
'GITNEXUS_EMBEDDING_BATCH_SIZE',
|
||||
env.GITNEXUS_EMBEDDING_BATCH_SIZE,
|
||||
overrides.batchSize ?? DEFAULT_EMBEDDING_CONFIG.batchSize,
|
||||
),
|
||||
subBatchSize: parsePositiveInt(
|
||||
'GITNEXUS_EMBEDDING_SUB_BATCH_SIZE',
|
||||
env.GITNEXUS_EMBEDDING_SUB_BATCH_SIZE,
|
||||
overrides.subBatchSize ?? DEFAULT_EMBEDDING_CONFIG.subBatchSize,
|
||||
),
|
||||
threads: parsePositiveInt(
|
||||
'GITNEXUS_EMBEDDING_THREADS',
|
||||
env.GITNEXUS_EMBEDDING_THREADS,
|
||||
overrides.threads ?? defaultEmbeddingThreads(),
|
||||
),
|
||||
device:
|
||||
parseDevice(env.GITNEXUS_EMBEDDING_DEVICE) ??
|
||||
overrides.device ??
|
||||
DEFAULT_EMBEDDING_CONFIG.device,
|
||||
};
|
||||
};
|
||||
|
|
@ -15,12 +15,14 @@ if (!process.env.ORT_LOG_LEVEL) {
|
|||
}
|
||||
|
||||
import { pipeline, env, type FeatureExtractionPipeline } from '@huggingface/transformers';
|
||||
import os from 'os';
|
||||
import { existsSync } from 'fs';
|
||||
import { execFileSync } from 'child_process';
|
||||
import { join, dirname } from 'path';
|
||||
import { createRequire } from 'module';
|
||||
import { DEFAULT_EMBEDDING_CONFIG, type EmbeddingConfig, type ModelProgress } from './types.js';
|
||||
import { isHttpMode, getHttpDimensions, httpEmbed } from './http-client.js';
|
||||
import { resolveEmbeddingConfig } from './config.js';
|
||||
|
||||
/**
|
||||
* Check whether the onnxruntime-node package that @huggingface/transformers
|
||||
|
|
@ -143,13 +145,12 @@ export const initEmbedder = async (
|
|||
|
||||
isInitializing = true;
|
||||
|
||||
const finalConfig = { ...DEFAULT_EMBEDDING_CONFIG, ...config };
|
||||
// On Windows, use DirectML for GPU acceleration (via DirectX12)
|
||||
// CUDA is only available on Linux x64 with onnxruntime-node
|
||||
const finalConfig = resolveEmbeddingConfig(config);
|
||||
// CUDA is probe-gated because ONNX Runtime can crash in native code when
|
||||
// provider libraries are missing. DirectML stays opt-in for the same reason.
|
||||
// Probe for CUDA first — ONNX Runtime crashes (uncatchable native error)
|
||||
// if we attempt CUDA without the required shared libraries
|
||||
const isWindows = process.platform === 'win32';
|
||||
const gpuDevice = isWindows ? 'dml' : isCudaAvailable() ? 'cuda' : 'cpu';
|
||||
const gpuDevice = isCudaAvailable() ? 'cuda' : 'cpu';
|
||||
const requestedDevice =
|
||||
forceDevice || (finalConfig.device === 'auto' ? gpuDevice : finalConfig.device);
|
||||
|
||||
|
|
@ -161,7 +162,7 @@ export const initEmbedder = async (
|
|||
// ./node_modules/.cache inside its own install dir, which is unwritable
|
||||
// when gitnexus is installed globally (e.g. /usr/lib/node_modules/).
|
||||
// Respect HF_HOME if set, otherwise fall back to ~/.cache/huggingface.
|
||||
env.cacheDir = process.env.HF_HOME ?? `${process.env.HOME}/.cache/huggingface`;
|
||||
env.cacheDir = process.env.HF_HOME ?? join(os.homedir(), '.cache', 'huggingface');
|
||||
|
||||
const isDev = process.env.NODE_ENV === 'development';
|
||||
if (isDev) {
|
||||
|
|
@ -204,7 +205,12 @@ export const initEmbedder = async (
|
|||
device: device,
|
||||
dtype: 'fp32',
|
||||
progress_callback: progressCallback,
|
||||
session_options: { logSeverityLevel: 3 },
|
||||
session_options: {
|
||||
logSeverityLevel: 3,
|
||||
intraOpNumThreads: finalConfig.threads,
|
||||
interOpNumThreads: 1,
|
||||
executionMode: 'sequential',
|
||||
},
|
||||
});
|
||||
currentDevice = device;
|
||||
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@ import {
|
|||
type SemanticSearchResult,
|
||||
type ModelProgress,
|
||||
type EmbeddingContext,
|
||||
DEFAULT_EMBEDDING_CONFIG,
|
||||
EMBEDDABLE_LABELS,
|
||||
isShortLabel,
|
||||
LABEL_METHOD,
|
||||
|
|
@ -35,6 +34,8 @@ import {
|
|||
STRUCTURAL_LABELS,
|
||||
collectBestChunks,
|
||||
} from './types.js';
|
||||
import { resolveEmbeddingConfig } from './config.js';
|
||||
import { rankExactEmbeddingRows, type ExactEmbeddingRow } from './exact-search.js';
|
||||
import {
|
||||
EMBEDDING_TABLE_NAME,
|
||||
EMBEDDING_INDEX_NAME,
|
||||
|
|
@ -42,8 +43,20 @@ import {
|
|||
STALE_HASH_SENTINEL,
|
||||
} from '../lbug/schema.js';
|
||||
import { loadVectorExtension } from '../lbug/lbug-adapter.js';
|
||||
import { getExactScanLimit } from '../platform/capabilities.js';
|
||||
|
||||
const isDev = process.env.NODE_ENV === 'development';
|
||||
|
||||
const vectorUnavailableMessage =
|
||||
'VECTOR extension is unavailable for this LadybugDB runtime; semantic search will use exact scan when embeddings exist.';
|
||||
|
||||
const ensureVectorExtensionAvailable = async (): Promise<boolean> => {
|
||||
const vectorReady = await loadVectorExtension();
|
||||
if (!vectorReady) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
/**
|
||||
* Bump this when the embedding text template changes in a way that should
|
||||
* invalidate existing vectors, such as metadata/header shape changes,
|
||||
|
|
@ -192,19 +205,26 @@ export const batchInsertEmbeddings = async (
|
|||
*/
|
||||
const createVectorIndex = async (
|
||||
executeQuery: (cypher: string) => Promise<any[]>,
|
||||
): Promise<void> => {
|
||||
// Delegate to the adapter which tracks loaded state and handles DB reconnect resets
|
||||
await loadVectorExtension();
|
||||
|
||||
): Promise<boolean> => {
|
||||
if (!(await ensureVectorExtensionAvailable())) return false;
|
||||
try {
|
||||
await executeQuery(CREATE_VECTOR_INDEX_QUERY);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (isDev) {
|
||||
console.warn('Vector index creation warning:', error);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export interface EmbeddingPipelineResult {
|
||||
nodesProcessed: number;
|
||||
chunksProcessed: number;
|
||||
vectorIndexReady: boolean;
|
||||
semanticMode: 'vector-index' | 'exact-scan';
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the embedding pipeline
|
||||
*
|
||||
|
|
@ -230,10 +250,14 @@ export const runEmbeddingPipeline = async (
|
|||
skipNodeIds?: Set<string>,
|
||||
context?: EmbeddingContext,
|
||||
existingEmbeddings?: Map<string, string>,
|
||||
): Promise<void> => {
|
||||
const finalConfig = { ...DEFAULT_EMBEDDING_CONFIG, ...config };
|
||||
): Promise<EmbeddingPipelineResult> => {
|
||||
const finalConfig = resolveEmbeddingConfig(config);
|
||||
let totalChunks = 0;
|
||||
|
||||
try {
|
||||
const vectorAvailable = await ensureVectorExtensionAvailable();
|
||||
if (!vectorAvailable && isDev) console.warn(vectorUnavailableMessage);
|
||||
|
||||
// Phase 1: Load embedding model
|
||||
onProgress({
|
||||
phase: 'loading-model',
|
||||
|
|
@ -338,7 +362,7 @@ export const runEmbeddingPipeline = async (
|
|||
// Ensure the vector index exists even when no new nodes need embedding.
|
||||
// A prior crash or first-time incremental run may have left CodeEmbedding
|
||||
// rows without ever reaching index creation.
|
||||
await createVectorIndex(executeQuery);
|
||||
const vectorIndexReady = await createVectorIndex(executeQuery);
|
||||
|
||||
onProgress({
|
||||
phase: 'ready',
|
||||
|
|
@ -346,7 +370,12 @@ export const runEmbeddingPipeline = async (
|
|||
nodesProcessed: 0,
|
||||
totalNodes: 0,
|
||||
});
|
||||
return;
|
||||
return {
|
||||
nodesProcessed: 0,
|
||||
chunksProcessed: 0,
|
||||
vectorIndexReady,
|
||||
semanticMode: vectorIndexReady ? 'vector-index' : 'exact-scan',
|
||||
};
|
||||
}
|
||||
|
||||
// Phase 3: Chunk + embed nodes
|
||||
|
|
@ -354,7 +383,6 @@ export const runEmbeddingPipeline = async (
|
|||
const chunkSize = finalConfig.chunkSize;
|
||||
const overlap = finalConfig.overlap;
|
||||
let processedNodes = 0;
|
||||
let totalChunks = 0;
|
||||
|
||||
onProgress({
|
||||
phase: 'embedding',
|
||||
|
|
@ -445,7 +473,7 @@ export const runEmbeddingPipeline = async (
|
|||
}
|
||||
|
||||
// Embed chunk texts in sub-batches to control memory
|
||||
const EMBED_SUB_BATCH = 8;
|
||||
const EMBED_SUB_BATCH = finalConfig.subBatchSize;
|
||||
for (let si = 0; si < allTexts.length; si += EMBED_SUB_BATCH) {
|
||||
const subTexts = allTexts.slice(si, si + EMBED_SUB_BATCH);
|
||||
const subUpdates = allUpdates.slice(si, si + EMBED_SUB_BATCH);
|
||||
|
|
@ -495,7 +523,7 @@ export const runEmbeddingPipeline = async (
|
|||
console.log('📇 Creating vector index...');
|
||||
}
|
||||
|
||||
await createVectorIndex(executeQuery);
|
||||
const vectorIndexReady = await createVectorIndex(executeQuery);
|
||||
|
||||
onProgress({
|
||||
phase: 'ready',
|
||||
|
|
@ -509,6 +537,12 @@ export const runEmbeddingPipeline = async (
|
|||
`✅ Embedding pipeline complete! (${totalChunks} chunks from ${totalNodes} nodes)`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
nodesProcessed: totalNodes,
|
||||
chunksProcessed: totalChunks,
|
||||
vectorIndexReady,
|
||||
semanticMode: vectorIndexReady ? 'vector-index' : 'exact-scan',
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
|
|
@ -543,27 +577,71 @@ export const semanticSearch = async (
|
|||
const queryVec = embeddingToArray(queryEmbedding);
|
||||
const queryVecStr = `[${queryVec.join(',')}]`;
|
||||
|
||||
const bestChunks = await collectBestChunks(k, async (fetchLimit) => {
|
||||
const vectorQuery = `
|
||||
CALL QUERY_VECTOR_INDEX('${EMBEDDING_TABLE_NAME}', '${EMBEDDING_INDEX_NAME}',
|
||||
CAST(${queryVecStr} AS FLOAT[${queryVec.length}]), ${fetchLimit})
|
||||
YIELD node AS emb, distance
|
||||
WITH emb, distance
|
||||
WHERE distance < ${maxDistance}
|
||||
RETURN emb.nodeId AS nodeId, emb.chunkIndex AS chunkIndex,
|
||||
emb.startLine AS startLine, emb.endLine AS endLine, distance
|
||||
ORDER BY distance
|
||||
`;
|
||||
let bestChunks = new Map<
|
||||
string,
|
||||
{ distance: number; chunkIndex: number; startLine: number; endLine: number }
|
||||
>();
|
||||
if (await loadVectorExtension()) {
|
||||
try {
|
||||
bestChunks = await collectBestChunks(k, async (fetchLimit) => {
|
||||
const vectorQuery = `
|
||||
CALL QUERY_VECTOR_INDEX('${EMBEDDING_TABLE_NAME}', '${EMBEDDING_INDEX_NAME}',
|
||||
CAST(${queryVecStr} AS FLOAT[${queryVec.length}]), ${fetchLimit})
|
||||
YIELD node AS emb, distance
|
||||
WITH emb, distance
|
||||
WHERE distance < ${maxDistance}
|
||||
RETURN emb.nodeId AS nodeId, emb.chunkIndex AS chunkIndex,
|
||||
emb.startLine AS startLine, emb.endLine AS endLine, distance
|
||||
ORDER BY distance
|
||||
`;
|
||||
|
||||
const embResults = await executeQuery(vectorQuery);
|
||||
return embResults.map((row) => ({
|
||||
nodeId: row.nodeId ?? row[0],
|
||||
chunkIndex: row.chunkIndex ?? row[1] ?? 0,
|
||||
startLine: row.startLine ?? row[2] ?? 0,
|
||||
endLine: row.endLine ?? row[3] ?? 0,
|
||||
distance: row.distance ?? row[4],
|
||||
}));
|
||||
});
|
||||
const embResults = await executeQuery(vectorQuery);
|
||||
return embResults.map((row) => ({
|
||||
nodeId: row.nodeId ?? row[0],
|
||||
chunkIndex: row.chunkIndex ?? row[1] ?? 0,
|
||||
startLine: row.startLine ?? row[2] ?? 0,
|
||||
endLine: row.endLine ?? row[3] ?? 0,
|
||||
distance: row.distance ?? row[4],
|
||||
}));
|
||||
});
|
||||
} catch {
|
||||
bestChunks = new Map();
|
||||
}
|
||||
}
|
||||
|
||||
if (bestChunks.size === 0) {
|
||||
const countRows = await executeQuery(
|
||||
`MATCH (e:${EMBEDDING_TABLE_NAME}) RETURN count(e) AS cnt`,
|
||||
);
|
||||
const countRow = countRows[0];
|
||||
const embeddingCount = Number(countRow?.cnt ?? countRow?.[0] ?? 0);
|
||||
const exactLimit = getExactScanLimit();
|
||||
if (embeddingCount > 0 && embeddingCount <= exactLimit) {
|
||||
const rows = await executeQuery(`
|
||||
MATCH (e:${EMBEDDING_TABLE_NAME})
|
||||
RETURN e.nodeId AS nodeId, e.chunkIndex AS chunkIndex,
|
||||
e.startLine AS startLine, e.endLine AS endLine, e.embedding AS embedding
|
||||
`);
|
||||
const exactRows: ExactEmbeddingRow[] = rows.map((row) => ({
|
||||
nodeId: row.nodeId ?? row[0],
|
||||
chunkIndex: row.chunkIndex ?? row[1] ?? 0,
|
||||
startLine: row.startLine ?? row[2] ?? 0,
|
||||
endLine: row.endLine ?? row[3] ?? 0,
|
||||
embedding: row.embedding ?? row[4] ?? [],
|
||||
}));
|
||||
bestChunks = new Map(
|
||||
rankExactEmbeddingRows(exactRows, queryVec, k, maxDistance).map((row) => [
|
||||
row.nodeId,
|
||||
{
|
||||
distance: row.distance,
|
||||
chunkIndex: row.chunkIndex,
|
||||
startLine: row.startLine,
|
||||
endLine: row.endLine,
|
||||
},
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (bestChunks.size === 0) {
|
||||
return [];
|
||||
|
|
|
|||
49
gitnexus/src/core/embeddings/exact-search.ts
Normal file
49
gitnexus/src/core/embeddings/exact-search.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
export interface ExactEmbeddingRow {
|
||||
nodeId: string;
|
||||
chunkIndex: number;
|
||||
startLine: number;
|
||||
endLine: number;
|
||||
embedding: readonly number[];
|
||||
}
|
||||
|
||||
export interface ExactSearchChunk {
|
||||
nodeId: string;
|
||||
chunkIndex: number;
|
||||
startLine: number;
|
||||
endLine: number;
|
||||
distance: number;
|
||||
}
|
||||
|
||||
const cosineDistance = (a: readonly number[], b: readonly number[]): number => {
|
||||
let dot = 0;
|
||||
let aNorm = 0;
|
||||
let bNorm = 0;
|
||||
const len = Math.min(a.length, b.length);
|
||||
for (let i = 0; i < len; i++) {
|
||||
const av = a[i] ?? 0;
|
||||
const bv = b[i] ?? 0;
|
||||
dot += av * bv;
|
||||
aNorm += av * av;
|
||||
bNorm += bv * bv;
|
||||
}
|
||||
if (aNorm === 0 || bNorm === 0) return 1;
|
||||
return 1 - dot / (Math.sqrt(aNorm) * Math.sqrt(bNorm));
|
||||
};
|
||||
|
||||
export const rankExactEmbeddingRows = (
|
||||
rows: readonly ExactEmbeddingRow[],
|
||||
queryEmbedding: readonly number[],
|
||||
limit: number,
|
||||
maxDistance: number,
|
||||
): ExactSearchChunk[] =>
|
||||
rows
|
||||
.map((row) => ({
|
||||
nodeId: row.nodeId,
|
||||
chunkIndex: row.chunkIndex,
|
||||
startLine: row.startLine,
|
||||
endLine: row.endLine,
|
||||
distance: cosineDistance(row.embedding, queryEmbedding),
|
||||
}))
|
||||
.filter((row) => row.distance < maxDistance)
|
||||
.sort((a, b) => a.distance - b.distance)
|
||||
.slice(0, limit);
|
||||
|
|
@ -207,6 +207,10 @@ export interface EmbeddingConfig {
|
|||
modelId: string;
|
||||
/** Number of nodes to embed in each batch */
|
||||
batchSize: number;
|
||||
/** Number of chunks passed to one local/HTTP embedding call */
|
||||
subBatchSize: number;
|
||||
/** Maximum ONNX Runtime CPU threads for local inference */
|
||||
threads: number;
|
||||
/** Embedding vector dimensions */
|
||||
dimensions: number;
|
||||
/** Device to use for inference: 'auto' tries GPU first (DirectML on Windows, CUDA on Linux), falls back to CPU */
|
||||
|
|
@ -229,6 +233,8 @@ export interface EmbeddingConfig {
|
|||
export const DEFAULT_EMBEDDING_CONFIG: EmbeddingConfig = {
|
||||
modelId: 'Snowflake/snowflake-arctic-embed-xs',
|
||||
batchSize: 16,
|
||||
subBatchSize: 8,
|
||||
threads: 2,
|
||||
dimensions: 384,
|
||||
device: 'auto',
|
||||
maxSnippetLength: 500,
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ const DEFAULT_MATCHING = {
|
|||
bm25_threshold: 0.7,
|
||||
embedding_threshold: 0.65,
|
||||
max_candidates_per_step: 3,
|
||||
exclude_links_paths: [] as string[],
|
||||
exclude_links_param_only_paths: false,
|
||||
};
|
||||
|
||||
export function parseGroupConfig(yamlContent: string): GroupConfig {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { StoredContract, CrossLink } from './types.js';
|
||||
import type { StoredContract, CrossLink, MatchingConfig } from './types.js';
|
||||
|
||||
export interface MatchResult {
|
||||
matched: CrossLink[];
|
||||
|
|
@ -14,6 +14,43 @@ function isServiceWildcard(cid: string): boolean {
|
|||
return (cid.startsWith('grpc::') || cid.startsWith('thrift::')) && cid.endsWith('/*');
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect HTTP contracts that are too generic or infrastructure-level to
|
||||
* produce meaningful cross-repo links. These are still extracted (useful
|
||||
* for documentation / route maps) but excluded from cross-link matching.
|
||||
*
|
||||
* Two categories:
|
||||
* 1. Health-check / readiness endpoints — every service has one, matching
|
||||
* them produces N×M false links.
|
||||
* 2. Param-only paths — routes like `/{param}` or `/{param}/{param}` that
|
||||
* collapse to a single catch-all after normalization. These match any
|
||||
* service with a similar shape, producing false positives.
|
||||
*
|
||||
* Both are configurable via matching.exclude_links_paths and
|
||||
* matching.exclude_links_param_only_paths in group.yaml.
|
||||
*/
|
||||
function buildNoisyContractFilter(
|
||||
matchingConfig?: MatchingConfig,
|
||||
): (contractId: string) => boolean {
|
||||
const excludePaths = matchingConfig?.exclude_links_paths?.length
|
||||
? new Set(matchingConfig.exclude_links_paths.map((p) => p.replace(/\/+$/, '')))
|
||||
: new Set<string>();
|
||||
const excludeParamOnly = matchingConfig?.exclude_links_param_only_paths === true;
|
||||
|
||||
return function isNoisyHttpContract(contractId: string): boolean {
|
||||
if (!contractId.startsWith('http::')) return false;
|
||||
const parts = contractId.split('::');
|
||||
if (parts.length < 3) return false;
|
||||
const pathPart = parts.slice(2).join('::').replace(/\/+$/, '');
|
||||
if (excludePaths.has(pathPart)) return true;
|
||||
if (excludeParamOnly) {
|
||||
const segments = pathPart.split('/').filter(Boolean);
|
||||
if (segments.length > 0 && segments.every((s) => s === '{param}')) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeContractId(id: string): string {
|
||||
const colonIdx = id.indexOf('::');
|
||||
if (colonIdx === -1) return id;
|
||||
|
|
@ -119,8 +156,12 @@ function findMatchingKeys(contractId: string, index: Map<string, StoredContract[
|
|||
return [];
|
||||
}
|
||||
|
||||
export function buildProviderIndex(contracts: StoredContract[]): Map<string, StoredContract[]> {
|
||||
const providers = contracts.filter((c) => c.role === 'provider');
|
||||
export function buildProviderIndex(
|
||||
contracts: StoredContract[],
|
||||
matchingConfig?: MatchingConfig,
|
||||
): Map<string, StoredContract[]> {
|
||||
const isNoisy = buildNoisyContractFilter(matchingConfig);
|
||||
const providers = contracts.filter((c) => c.role === 'provider' && !isNoisy(c.contractId));
|
||||
const index = new Map<string, StoredContract[]>();
|
||||
for (const p of providers) {
|
||||
const key = normalizeContractId(p.contractId);
|
||||
|
|
@ -134,12 +175,14 @@ export function buildProviderIndex(contracts: StoredContract[]): Map<string, Sto
|
|||
export function runExactMatch(
|
||||
contracts: StoredContract[],
|
||||
providerIndex?: Map<string, StoredContract[]>,
|
||||
matchingConfig?: MatchingConfig,
|
||||
): MatchResult {
|
||||
const index = providerIndex ?? buildProviderIndex(contracts);
|
||||
const isNoisy = buildNoisyContractFilter(matchingConfig);
|
||||
const index = providerIndex ?? buildProviderIndex(contracts, matchingConfig);
|
||||
|
||||
// Skip service wildcard consumers — they go to wildcard pass only
|
||||
const consumers = contracts.filter(
|
||||
(c) => c.role === 'consumer' && !isServiceWildcard(c.contractId),
|
||||
(c) => c.role === 'consumer' && !isServiceWildcard(c.contractId) && !isNoisy(c.contractId),
|
||||
);
|
||||
|
||||
const matched: CrossLink[] = [];
|
||||
|
|
@ -185,6 +228,7 @@ export function runExactMatch(
|
|||
// normalUnmatched: contracts that weren't matched in exact pass
|
||||
const normalUnmatched = contracts.filter((c) => {
|
||||
if (isServiceWildcard(c.contractId)) return false; // excluded from exact, handled separately
|
||||
if (isNoisy(c.contractId)) return false; // excluded from matching — don't surface as unmatched
|
||||
const id = `${c.repo}::${c.contractId}`;
|
||||
return c.role === 'provider' ? !matchedProviderIds.has(id) : !matchedConsumerIds.has(id);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -103,6 +103,8 @@ matching:
|
|||
bm25_threshold: 0.7
|
||||
embedding_threshold: 0.65
|
||||
max_candidates_per_step: 3
|
||||
# exclude_links_paths: [/ping, /health, /healthcheck]
|
||||
# exclude_links_param_only_paths: false
|
||||
`;
|
||||
await fsp.writeFile(path.join(groupDir, 'group.yaml'), template, 'utf-8');
|
||||
return groupDir;
|
||||
|
|
|
|||
|
|
@ -221,8 +221,8 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
|
|||
}
|
||||
}
|
||||
|
||||
const providerIndex = buildProviderIndex(autoContracts);
|
||||
const { matched, unmatched } = runExactMatch(autoContracts, providerIndex);
|
||||
const providerIndex = buildProviderIndex(autoContracts, config.matching);
|
||||
const { matched, unmatched } = runExactMatch(autoContracts, providerIndex, config.matching);
|
||||
const wildcard = runWildcardMatch(unmatched, providerIndex);
|
||||
|
||||
// Dedupe cross-links. Manifest contracts participate in runExactMatch, so a
|
||||
|
|
|
|||
|
|
@ -34,6 +34,24 @@ export interface MatchingConfig {
|
|||
bm25_threshold: number;
|
||||
embedding_threshold: number;
|
||||
max_candidates_per_step: number;
|
||||
/**
|
||||
* HTTP paths to exclude from cross-link matching. Contracts at these paths
|
||||
* are still extracted and visible in the registry, but they don't produce
|
||||
* cross-repo links. Useful for health-check endpoints (`/ping`, `/health`)
|
||||
* that every service exposes and would otherwise create N×M false links.
|
||||
* Trailing slashes are normalized before comparison.
|
||||
* @default []
|
||||
*/
|
||||
exclude_links_paths?: string[];
|
||||
/**
|
||||
* When `true`, exclude HTTP routes where every path segment is `{param}`
|
||||
* (e.g. `/{param}`, `/{param}/{param}`) from cross-link matching. Mixed
|
||||
* routes like `/users/{param}` are not affected. These param-only routes
|
||||
* collapse to a single catch-all after normalization and produce false
|
||||
* positives across unrelated services.
|
||||
* @default false
|
||||
*/
|
||||
exclude_links_param_only_paths?: boolean;
|
||||
}
|
||||
|
||||
export interface SymbolRef {
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ type ReceiverSource = ReceiverEnriched['receiverSource'];
|
|||
* DAG stage 4 fallback: used when `selectDispatch` is absent or returns null.
|
||||
* Preserves pre-DAG dispatch semantics:
|
||||
* - 'constructor' → constructor branch
|
||||
* - 'free' → free branch (admits Swift/Kotlin class-target fast path)
|
||||
* - 'free' → free branch (admits class-target fast path)
|
||||
* - 'member' or undefined → owner-scoped branch
|
||||
*
|
||||
* `undefined` callForm MUST route through owner-scoped (not free) so bare
|
||||
|
|
@ -770,7 +770,7 @@ export const processCalls = async (
|
|||
if (!tree) {
|
||||
try {
|
||||
tree = parser.parse(file.content, undefined, {
|
||||
bufferSize: getTreeSitterBufferSize(file.content.length),
|
||||
bufferSize: getTreeSitterBufferSize(file.content),
|
||||
});
|
||||
} catch (parseError) {
|
||||
continue;
|
||||
|
|
@ -1595,41 +1595,30 @@ const disambiguateByOverloadOrArgTypes = (
|
|||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Collapse Swift-extension duplicate Class/Struct candidates to the primary
|
||||
* definition, preferring the shortest file path.
|
||||
*
|
||||
* Swift extensions (`extension User { ... }` in a separate file) create
|
||||
* multiple `Class` nodes sharing the same symbol name — one for the primary
|
||||
* declaration and one per extension file. When overload disambiguation and
|
||||
* receiver narrowing both fail to converge on a single candidate, this
|
||||
* heuristic picks the primary definition based on the assumption that it
|
||||
* lives at the shortest file path (e.g. `User.swift` over `UserExtensions.swift`).
|
||||
*
|
||||
* Intentionally narrower than {@link INSTANTIABLE_CLASS_TYPES}: only `Class`
|
||||
* and `Struct` are considered, not `Record`. Swift extensions only produce
|
||||
* `Class` duplicates in practice, and C#/Kotlin records do not exhibit the
|
||||
* same multi-file-definition pattern, so widening this set risks accidental
|
||||
* dedup of legitimately distinct record types.
|
||||
*
|
||||
* Returns a `ResolveResult` when the heuristic fires, `null` when the
|
||||
* candidate pool does not match the shape (mixed types, non-Class/Struct
|
||||
* kinds, or `length <= 1`). Callers should fall through to their own null
|
||||
* return when this helper returns `null`.
|
||||
*
|
||||
* Used by `resolveFreeCall`. Having a single source of truth prevents
|
||||
* duplication if the heuristic is ever tuned.
|
||||
*/
|
||||
const dedupSwiftExtensionCandidates = (
|
||||
const orderProviderSameNameTypeCandidates = (
|
||||
candidates: readonly SymbolDefinition[],
|
||||
typeName: string,
|
||||
filePath: string,
|
||||
): readonly SymbolDefinition[] | null => {
|
||||
const language = getLanguageFromFilename(filePath);
|
||||
if (language == null) return null;
|
||||
return (
|
||||
getProvider(language).orderSameNameTypeCandidates?.({
|
||||
typeName,
|
||||
callSiteFilePath: filePath,
|
||||
candidates,
|
||||
}) ?? null
|
||||
);
|
||||
};
|
||||
|
||||
const resolveProviderPrimaryTypeCandidate = (
|
||||
candidates: readonly SymbolDefinition[],
|
||||
tier: ResolutionTier,
|
||||
typeName: string,
|
||||
filePath: string,
|
||||
): ResolveResult | null => {
|
||||
if (candidates.length <= 1) return null;
|
||||
const allSameType = candidates.every((c) => c.type === candidates[0].type);
|
||||
if (!allSameType) return null;
|
||||
if (candidates[0].type !== 'Class' && candidates[0].type !== 'Struct') return null;
|
||||
const sorted = [...candidates].sort((a, b) => a.filePath.length - b.filePath.length);
|
||||
return toResolveResult(sorted[0], tier);
|
||||
const ordered = orderProviderSameNameTypeCandidates(candidates, typeName, filePath);
|
||||
return ordered && ordered.length > 0 ? toResolveResult(ordered[0], tier) : null;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -2223,6 +2212,35 @@ const resolveMethodByOwner = (
|
|||
}
|
||||
}
|
||||
|
||||
if (!firstDef && !ambiguous) {
|
||||
const orderedTypeCandidates = orderProviderSameNameTypeCandidates(
|
||||
ctx.model.types.lookupClassByName(receiverTypeName),
|
||||
receiverTypeName,
|
||||
filePath,
|
||||
);
|
||||
if (orderedTypeCandidates) {
|
||||
for (const candidate of orderedTypeCandidates) {
|
||||
const def = canWalkMRO
|
||||
? lookupMethodByOwnerWithMRO(
|
||||
candidate.nodeId,
|
||||
methodName,
|
||||
heritageMap,
|
||||
ctx.model,
|
||||
mroStrategy,
|
||||
argCount,
|
||||
)
|
||||
: ctx.model.methods.lookupMethodByOwner(candidate.nodeId, methodName, argCount);
|
||||
if (!def) continue;
|
||||
if (!firstDef) {
|
||||
firstDef = def;
|
||||
} else if (def.nodeId !== firstDef.nodeId) {
|
||||
ambiguous = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!firstDef || ambiguous) return undefined;
|
||||
return { def: firstDef, tier: typeResolved.tier };
|
||||
};
|
||||
|
|
@ -2290,9 +2308,9 @@ export const resolveMemberCall = (
|
|||
* resolution via `ctx.resolve()`.
|
||||
*
|
||||
* Used for `foo()`, `doStuff()` — unqualified calls with no receiver.
|
||||
* Also handles Swift/Kotlin implicit constructors (`User()` without `new`)
|
||||
* by delegating to {@link resolveStaticCall} when the tiered pool contains
|
||||
* class-like targets.
|
||||
* Also handles implicit constructors (`User()` without `new`) by delegating
|
||||
* to {@link resolveStaticCall} when the tiered pool contains class-like
|
||||
* targets.
|
||||
*
|
||||
* {@link resolveCallTarget} delegates here for `callForm === 'free'`.
|
||||
*
|
||||
|
|
@ -2324,33 +2342,30 @@ export const resolveFreeCall = (
|
|||
|
||||
let filteredCandidates = filterCallableCandidates(tiered.candidates, argCount, 'free');
|
||||
|
||||
// Class-target fast path: Swift/Kotlin `User()` — free-form call targeting a
|
||||
// class. Delegates to resolveStaticCall for O(1) class + constructor lookup.
|
||||
// Class-target fast path: free-form call targeting a class. Delegates to
|
||||
// resolveStaticCall for O(1) class + constructor lookup.
|
||||
// The `.some()` trigger must stay aligned with `INSTANTIABLE_CLASS_TYPES` —
|
||||
// any type admitted here that is not in that set will cause resolveStaticCall
|
||||
// to return null, wasting two lookup passes per call. `Enum` is deliberately
|
||||
// excluded; `Record` is included so C# records and Kotlin data classes reach
|
||||
// the fast path.
|
||||
// excluded; `Record` is included so record-like class targets reach the fast
|
||||
// path.
|
||||
// Align with INSTANTIABLE_CLASS_TYPES by reusing the set directly rather
|
||||
// than enumerating literal strings. This converts an invariant that was
|
||||
// previously enforced by a comment ("keep this list aligned with
|
||||
// INSTANTIABLE_CLASS_TYPES") into one enforced structurally — any future
|
||||
// extension of the set (e.g. Kotlin `object`) propagates here automatically.
|
||||
// The `dedupSwiftExtensionCandidates` helper used in the tail of this
|
||||
// function deliberately uses a narrower literal `'Class' | 'Struct'` check
|
||||
// — Swift extensions only produce Class duplicates in practice, so Record
|
||||
// is excluded there by design. Do not collapse that helper into
|
||||
// INSTANTIABLE_CLASS_TYPES.
|
||||
// extension of the set propagates here automatically.
|
||||
// Language providers can still choose a primary same-name type candidate in
|
||||
// the tail of this function when their grammars index one logical type
|
||||
// multiple times.
|
||||
const hasClassTarget =
|
||||
filteredCandidates.length === 0 &&
|
||||
tiered.candidates.some((c) => INSTANTIABLE_CLASS_TYPES.has(c.type));
|
||||
if (hasClassTarget) {
|
||||
const staticResult = resolveStaticCall(calledName, filePath, ctx, argCount, tiered);
|
||||
if (staticResult) return staticResult;
|
||||
// Retry with constructor form: Swift/Kotlin constructor calls look like
|
||||
// free function calls (no `new` keyword). If resolveStaticCall didn't
|
||||
// match, re-filter with constructor form so CONSTRUCTOR_TARGET_TYPES
|
||||
// applies.
|
||||
// Retry with constructor form for languages whose constructor calls look
|
||||
// like free function calls. If resolveStaticCall didn't match, re-filter
|
||||
// with constructor form so CONSTRUCTOR_TARGET_TYPES applies.
|
||||
//
|
||||
// The retry fires for every null return from `resolveStaticCall`, which
|
||||
// can happen for three distinct reasons — all three are handled below:
|
||||
|
|
@ -2364,9 +2379,8 @@ export const resolveFreeCall = (
|
|||
// (b) Homonym ambiguity — two or more instantiable class candidates
|
||||
// share the name (e.g. `User` in two files, same tier). The
|
||||
// retry repopulates `filteredCandidates` with both Classes and
|
||||
// they flow into `dedupSwiftExtensionCandidates` below, which
|
||||
// either picks the shortest-path primary or null-routes.
|
||||
// Covered by the R7 Swift-extension dedup test.
|
||||
// they flow into the provider same-name candidate hook below, which
|
||||
// can pick a primary definition or null-route.
|
||||
//
|
||||
// (c) `resolveStaticCall` step 4 bailed because the tiered pool
|
||||
// contains ownerless `Constructor` nodes (some extractors emit
|
||||
|
|
@ -2391,10 +2405,13 @@ export const resolveFreeCall = (
|
|||
}
|
||||
|
||||
if (filteredCandidates.length !== 1) {
|
||||
// See `dedupSwiftExtensionCandidates` — shared helper, single source of
|
||||
// truth for the Swift-extension same-name collision heuristic.
|
||||
const deduped = dedupSwiftExtensionCandidates(filteredCandidates, tiered.tier);
|
||||
if (deduped) return deduped;
|
||||
const primary = resolveProviderPrimaryTypeCandidate(
|
||||
filteredCandidates,
|
||||
tiered.tier,
|
||||
calledName,
|
||||
filePath,
|
||||
);
|
||||
if (primary) return primary;
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -2559,9 +2576,16 @@ export const resolveStaticCall = (
|
|||
// Interface / Trait / Impl). Null-route via the fall-through `return
|
||||
// null` — this is the dominant Codex-fix case.
|
||||
// length === 1 → a single instantiable candidate remains, return it.
|
||||
// length > 1 → two or more instantiable classes share the name (e.g.
|
||||
// homonym classes across files with no import narrowing). Fall through
|
||||
// to `return null` so the caller null-routes rather than guess.
|
||||
// length > 1 → let the call-site provider choose a primary when it can
|
||||
// prove the candidates are one logical type; otherwise null-route.
|
||||
const primary = resolveProviderPrimaryTypeCandidate(
|
||||
instantiableCandidates,
|
||||
typeResolved.tier,
|
||||
className,
|
||||
currentFile,
|
||||
);
|
||||
if (primary) return primary;
|
||||
|
||||
if (instantiableCandidates.length === 1) {
|
||||
return toResolveResult(instantiableCandidates[0], typeResolved.tier);
|
||||
}
|
||||
|
|
@ -3257,7 +3281,7 @@ export const extractFetchCallsFromFiles = async (
|
|||
if (!tree) {
|
||||
try {
|
||||
tree = parser.parse(file.content, undefined, {
|
||||
bufferSize: getTreeSitterBufferSize(file.content.length),
|
||||
bufferSize: getTreeSitterBufferSize(file.content),
|
||||
});
|
||||
} catch {
|
||||
continue;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { Buffer } from 'node:buffer';
|
||||
|
||||
/**
|
||||
* Default minimum buffer size for tree-sitter parsing (512 KB).
|
||||
* tree-sitter requires bufferSize >= file size in bytes.
|
||||
|
|
@ -12,8 +14,13 @@ export const TREE_SITTER_MAX_BUFFER = 32 * 1024 * 1024;
|
|||
|
||||
/**
|
||||
* Compute adaptive buffer size for tree-sitter parsing.
|
||||
* Uses 2× file size, clamped between 512 KB and 32 MB.
|
||||
* Previous 256 KB fixed limit silently skipped files > ~200 KB (e.g., imgui.h at 411 KB).
|
||||
* Uses 2x UTF-8 byte size, clamped between 512 KB and 32 MB.
|
||||
* Keeps tree-sitter's byte-sized buffer above large ASCII and multibyte sources.
|
||||
*/
|
||||
export const getTreeSitterBufferSize = (contentLength: number): number =>
|
||||
Math.min(Math.max(contentLength * 2, TREE_SITTER_BUFFER_SIZE), TREE_SITTER_MAX_BUFFER);
|
||||
export const getTreeSitterContentByteLength = (sourceText: string): number =>
|
||||
Buffer.byteLength(sourceText, 'utf8');
|
||||
|
||||
export const getTreeSitterBufferSize = (sourceText: string): number => {
|
||||
const byteLength = getTreeSitterContentByteLength(sourceText);
|
||||
return Math.min(Math.max(byteLength * 2, TREE_SITTER_BUFFER_SIZE), TREE_SITTER_MAX_BUFFER);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -106,15 +106,13 @@ export function finalizeScopeModel(
|
|||
const allScopes: Scope[] = [];
|
||||
const allDefs: SymbolDefinition[] = [];
|
||||
const moduleEntries: { filePath: string; moduleScopeId: ScopeId }[] = [];
|
||||
const allReferenceSites = [] as ReturnType<typeof collectReferenceSites>;
|
||||
const allReferenceSites = collectReferenceSites(parsedFiles);
|
||||
|
||||
for (const file of parsedFiles) {
|
||||
for (const s of file.scopes) allScopes.push(s);
|
||||
for (const d of file.localDefs) allDefs.push(d);
|
||||
moduleEntries.push({ filePath: file.filePath, moduleScopeId: file.moduleScope });
|
||||
}
|
||||
// References kept out of the loop above to centralize list-init.
|
||||
allReferenceSites.push(...collectReferenceSites(parsedFiles));
|
||||
|
||||
const scopeTree = buildScopeTree(allScopes);
|
||||
const defs = buildDefIndex(allDefs);
|
||||
|
|
@ -141,6 +139,11 @@ export function finalizeScopeModel(
|
|||
methodDispatch,
|
||||
imports: finalizeOut.imports,
|
||||
bindings: finalizeOut.bindings,
|
||||
// Empty post-finalize augmentation channel. Populated (if at all)
|
||||
// by language hooks like `populateCsharpNamespaceSiblings` running
|
||||
// AFTER `finalizeScopeModel` returns, before `resolveReferenceSites`
|
||||
// consumes the bundle. Most languages leave it empty.
|
||||
bindingAugmentations: new Map(),
|
||||
referenceSites: Object.freeze([...allReferenceSites]),
|
||||
sccs: finalizeOut.sccs,
|
||||
stats: finalizeOut.stats,
|
||||
|
|
|
|||
|
|
@ -220,7 +220,7 @@ export const processHeritage = async (
|
|||
// Use larger bufferSize for files > 32KB
|
||||
try {
|
||||
tree = parser.parse(file.content, undefined, {
|
||||
bufferSize: getTreeSitterBufferSize(file.content.length),
|
||||
bufferSize: getTreeSitterBufferSize(file.content),
|
||||
});
|
||||
} catch (parseError) {
|
||||
// Skip files that can't be parsed
|
||||
|
|
@ -414,7 +414,7 @@ export async function extractExtractedHeritageFromFiles(
|
|||
if (!tree) {
|
||||
try {
|
||||
tree = parser.parse(file.content, undefined, {
|
||||
bufferSize: getTreeSitterBufferSize(file.content.length),
|
||||
bufferSize: getTreeSitterBufferSize(file.content),
|
||||
});
|
||||
} catch {
|
||||
continue;
|
||||
|
|
|
|||
|
|
@ -306,7 +306,7 @@ export const processImports = async (
|
|||
if (!tree) {
|
||||
try {
|
||||
tree = parser.parse(file.content, undefined, {
|
||||
bufferSize: getTreeSitterBufferSize(file.content.length),
|
||||
bufferSize: getTreeSitterBufferSize(file.content),
|
||||
});
|
||||
} catch (parseError) {
|
||||
continue;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -497,6 +498,15 @@ interface LanguageProviderConfig {
|
|||
|
||||
// ── Resolution phase (RFC §4v2) ────────────────────────────────────
|
||||
|
||||
/** Order same-name type candidates when a language can index multiple
|
||||
* definitions for one logical type. Return null to keep shared ambiguity
|
||||
* handling. */
|
||||
readonly orderSameNameTypeCandidates?: (params: {
|
||||
readonly typeName: string;
|
||||
readonly callSiteFilePath: string;
|
||||
readonly candidates: readonly SymbolDefinition[];
|
||||
}) => readonly SymbolDefinition[] | null;
|
||||
|
||||
/**
|
||||
* Is this callable definition compatible with the given call-site arity?
|
||||
* Language-specific rules: Python `*args`/`**kwargs`/defaults, JS default
|
||||
|
|
|
|||
|
|
@ -25,6 +25,17 @@ import { csharpMethodConfig } from '../method-extractors/configs/csharp.js';
|
|||
import { createVariableExtractor } from '../variable-extractors/generic.js';
|
||||
import { csharpVariableConfig } from '../variable-extractors/configs/csharp.js';
|
||||
import { createHeritageExtractor } from '../heritage-extractors/generic.js';
|
||||
import {
|
||||
emitCsharpScopeCaptures,
|
||||
interpretCsharpImport,
|
||||
interpretCsharpTypeBinding,
|
||||
csharpBindingScopeFor,
|
||||
csharpImportOwningScope,
|
||||
csharpMergeBindings,
|
||||
csharpReceiverBinding,
|
||||
csharpArityCompatibility,
|
||||
resolveCsharpImportTarget,
|
||||
} from './csharp/index.js';
|
||||
|
||||
const BUILT_INS: ReadonlySet<string> = new Set([
|
||||
'Console',
|
||||
|
|
@ -138,4 +149,18 @@ export const csharpProvider = defineLanguage({
|
|||
classExtractor: createClassExtractor(csharpClassConfig),
|
||||
heritageExtractor: createHeritageExtractor(SupportedLanguages.CSharp),
|
||||
builtInNames: BUILT_INS,
|
||||
|
||||
// ── RFC #909 Ring 3: scope-based resolution hooks (RFC §5) ──────────
|
||||
// C# is the second migration after Python. See ./csharp/index.ts for
|
||||
// the full per-hook rationale and the canonical capture vocabulary
|
||||
// in ./csharp/query.ts (CSHARP_SCOPE_QUERY constant).
|
||||
emitScopeCaptures: emitCsharpScopeCaptures,
|
||||
interpretImport: interpretCsharpImport,
|
||||
interpretTypeBinding: interpretCsharpTypeBinding,
|
||||
bindingScopeFor: csharpBindingScopeFor,
|
||||
importOwningScope: csharpImportOwningScope,
|
||||
mergeBindings: (_scope, bindings) => csharpMergeBindings(bindings),
|
||||
receiverBinding: csharpReceiverBinding,
|
||||
arityCompatibility: csharpArityCompatibility,
|
||||
resolveImportTarget: resolveCsharpImportTarget,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,57 @@
|
|||
/**
|
||||
* C# collection-accessor unwrapping.
|
||||
*
|
||||
* When the compound-receiver resolver encounters a trailing
|
||||
* `.Values` / `.Keys` on a dotted member-access chain, it calls the
|
||||
* provider's `unwrapCollectionAccessor` hook to find the element
|
||||
* type. This module supplies the C# implementation — recognizing
|
||||
* Dictionary-family generics and returning the value or key type.
|
||||
*
|
||||
* Other languages (Python, Java, TypeScript) use method-call syntax
|
||||
* for the same access (`.values()` / `.keys()`), which the compound-
|
||||
* receiver's call-expression branch already handles; they leave this
|
||||
* hook undefined.
|
||||
*/
|
||||
|
||||
/** Extract (K, V) from `Dictionary<K, V>` / `IDictionary<K, V>` /
|
||||
* `IReadOnlyDictionary<K, V>` / `SortedDictionary<K, V>` /
|
||||
* `ConcurrentDictionary<K, V>` / `ImmutableDictionary<K, V>`.
|
||||
* Returns undefined if the type name doesn't match or the argument
|
||||
* list isn't exactly two top-level args. */
|
||||
function extractDictionaryArgs(rawName: string): { key: string; value: string } | undefined {
|
||||
const match = rawName.match(
|
||||
/^(?:[A-Za-z_][A-Za-z0-9_.]*\.)?(?:Dictionary|IDictionary|IReadOnlyDictionary|SortedDictionary|ConcurrentDictionary|ImmutableDictionary)<(.+)>$/,
|
||||
);
|
||||
if (match === null) return undefined;
|
||||
const inner = match[1]!;
|
||||
// Split on the top-level comma (tolerate nested `<...>`).
|
||||
let depth = 0;
|
||||
let commaIdx = -1;
|
||||
for (let i = 0; i < inner.length; i++) {
|
||||
const ch = inner[i];
|
||||
if (ch === '<') depth++;
|
||||
else if (ch === '>') depth--;
|
||||
else if (ch === ',' && depth === 0) {
|
||||
commaIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (commaIdx === -1) return undefined;
|
||||
return { key: inner.slice(0, commaIdx).trim(), value: inner.slice(commaIdx + 1).trim() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve `data.Values` / `data.Keys` on a Dictionary-like receiver
|
||||
* to its element-type simple name. Returns `undefined` for any
|
||||
* receiver / accessor combination we don't recognize, letting the
|
||||
* compound-receiver pass fall through to the regular field walk.
|
||||
*/
|
||||
export function unwrapCsharpCollectionAccessor(
|
||||
receiverType: string,
|
||||
accessor: string,
|
||||
): string | undefined {
|
||||
if (accessor !== 'Values' && accessor !== 'Keys') return undefined;
|
||||
const args = extractDictionaryArgs(receiverType);
|
||||
if (args === undefined) return undefined;
|
||||
return accessor === 'Values' ? args.value : args.key;
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
/**
|
||||
* Extract C# arity metadata from a method-like tree-sitter node —
|
||||
* `method_declaration`, `constructor_declaration`, `destructor_declaration`,
|
||||
* `operator_declaration`, `conversion_operator_declaration`, or
|
||||
* `local_function_statement`.
|
||||
*
|
||||
* Reuses `csharpMethodConfig.extractParameters` so scope-extracted defs
|
||||
* carry the same arity semantics as the legacy parse-worker path:
|
||||
* - `params` variadic collapses `parameterCount` to `undefined`,
|
||||
* which `csharpArityCompatibility` then treats as "max unknown" —
|
||||
* the candidate stays eligible at `argCount >= required`.
|
||||
* - Defaulted parameters (`= expr`) contribute to `optionalCount`;
|
||||
* `requiredParameterCount = total − optionalCount`.
|
||||
* - `parameterTypes` collects declared type names (with `ref`/`out`/
|
||||
* `in` prefix) for overload narrowing; a literal `'params'` marker
|
||||
* is appended for variadic methods so `csharpArityCompatibility`
|
||||
* can detect them without re-reading the AST.
|
||||
*/
|
||||
|
||||
import type { SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
import { csharpMethodConfig } from '../../method-extractors/configs/csharp.js';
|
||||
|
||||
interface CsharpArityMetadata {
|
||||
readonly parameterCount: number | undefined;
|
||||
readonly requiredParameterCount: number | undefined;
|
||||
readonly parameterTypes: readonly string[] | undefined;
|
||||
}
|
||||
|
||||
export function computeCsharpArityMetadata(fnNode: SyntaxNode): CsharpArityMetadata {
|
||||
const params = csharpMethodConfig.extractParameters?.(fnNode) ?? [];
|
||||
|
||||
let hasVariadic = false;
|
||||
let optionalCount = 0;
|
||||
const types: string[] = [];
|
||||
for (const p of params) {
|
||||
if (p.isVariadic) hasVariadic = true;
|
||||
else if (p.isOptional) optionalCount++;
|
||||
if (p.type !== null) types.push(p.type);
|
||||
}
|
||||
if (hasVariadic) types.push('params');
|
||||
|
||||
const total = params.length;
|
||||
// `params int[] args` declares one formal param but accepts any arg
|
||||
// count ≥ required — mirror Python's treatment of `*args` and leave
|
||||
// `parameterCount` undefined so the registry treats max as unknown.
|
||||
const parameterCount = hasVariadic ? undefined : total;
|
||||
const requiredParameterCount = hasVariadic ? undefined : total - optionalCount;
|
||||
|
||||
return {
|
||||
parameterCount,
|
||||
requiredParameterCount,
|
||||
parameterTypes: types.length > 0 ? types : undefined,
|
||||
};
|
||||
}
|
||||
44
gitnexus/src/core/ingestion/languages/csharp/arity.ts
Normal file
44
gitnexus/src/core/ingestion/languages/csharp/arity.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
/**
|
||||
* C# arity check, accommodating `params` variadic and default parameters.
|
||||
*
|
||||
* The `def` metadata we care about (synthesized by `arity-metadata.ts`):
|
||||
* - `parameterCount` — total formal parameters; `undefined`
|
||||
* when the method has `params T[]` variadic.
|
||||
* - `requiredParameterCount` — min required (excludes defaulted params
|
||||
* and `params` variadic).
|
||||
* - `parameterTypes` — declared type strings; contains the
|
||||
* literal `'params'` when the method is
|
||||
* variadic.
|
||||
*
|
||||
* Verdicts:
|
||||
* - `'compatible'` — `requiredParameterCount <= argCount <= parameterCount`,
|
||||
* OR the def takes `params` (then any `argCount >= required`).
|
||||
* - `'incompatible'` — argCount is below required, OR above max with no variadic.
|
||||
* - `'unknown'` — metadata is absent / incomplete.
|
||||
*
|
||||
* `'incompatible'` is a soft signal in `Registry.lookup` (penalized but
|
||||
* still considered when no compatible candidate exists), per RFC §4.
|
||||
*/
|
||||
|
||||
import type { Callsite, SymbolDefinition } from 'gitnexus-shared';
|
||||
|
||||
export function csharpArityCompatibility(
|
||||
def: SymbolDefinition,
|
||||
callsite: Callsite,
|
||||
): 'compatible' | 'unknown' | 'incompatible' {
|
||||
const max = def.parameterCount;
|
||||
const min = def.requiredParameterCount;
|
||||
if (max === undefined && min === undefined) return 'unknown';
|
||||
|
||||
const argCount = callsite.arity;
|
||||
if (!Number.isFinite(argCount) || argCount < 0) return 'unknown';
|
||||
|
||||
const hasVarArgs =
|
||||
def.parameterTypes !== undefined &&
|
||||
def.parameterTypes.some((t) => t === 'params' || t.startsWith('params '));
|
||||
|
||||
if (min !== undefined && argCount < min) return 'incompatible';
|
||||
if (max !== undefined && argCount > max && !hasVarArgs) return 'incompatible';
|
||||
|
||||
return 'compatible';
|
||||
}
|
||||
30
gitnexus/src/core/ingestion/languages/csharp/cache-stats.ts
Normal file
30
gitnexus/src/core/ingestion/languages/csharp/cache-stats.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
/**
|
||||
* Dev-mode counters for the cross-phase scope-captures parse cache
|
||||
* (C# mirror of `languages/python/cache-stats.ts`).
|
||||
*
|
||||
* Gated by `PROF_SCOPE_RESOLUTION=1`. Production builds fold every
|
||||
* increment into dead code via the module-level `PROF` constant, so
|
||||
* the hot path in `captures.ts` stays branch-free.
|
||||
*/
|
||||
|
||||
const PROF = process.env.PROF_SCOPE_RESOLUTION === '1';
|
||||
|
||||
let CACHE_HITS = 0;
|
||||
let CACHE_MISSES = 0;
|
||||
|
||||
export function recordCacheHit(): void {
|
||||
if (PROF) CACHE_HITS++;
|
||||
}
|
||||
|
||||
export function recordCacheMiss(): void {
|
||||
if (PROF) CACHE_MISSES++;
|
||||
}
|
||||
|
||||
export function getCsharpCaptureCacheStats(): { hits: number; misses: number } {
|
||||
return { hits: CACHE_HITS, misses: CACHE_MISSES };
|
||||
}
|
||||
|
||||
export function resetCsharpCaptureCacheStats(): void {
|
||||
CACHE_HITS = 0;
|
||||
CACHE_MISSES = 0;
|
||||
}
|
||||
308
gitnexus/src/core/ingestion/languages/csharp/captures.ts
Normal file
308
gitnexus/src/core/ingestion/languages/csharp/captures.ts
Normal file
|
|
@ -0,0 +1,308 @@
|
|||
/**
|
||||
* `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';
|
||||
import { getTreeSitterBufferSize } from '../../constants.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, undefined, {
|
||||
bufferSize: getTreeSitterBufferSize(sourceText),
|
||||
});
|
||||
recordCacheMiss();
|
||||
} else {
|
||||
recordCacheHit();
|
||||
}
|
||||
|
||||
const rawMatches = getCsharpScopeQuery().matches(tree.rootNode);
|
||||
const out: CaptureMatch[] = [];
|
||||
|
||||
for (const m of rawMatches) {
|
||||
// Group captures by their tag name. Tree-sitter strips the leading
|
||||
// `@`; we put it back so the central extractor's prefix lookups
|
||||
// (`@scope.`, `@declaration.`, …) work.
|
||||
const grouped: Record<string, Capture> = {};
|
||||
for (const c of m.captures) {
|
||||
const tag = '@' + c.name;
|
||||
grouped[tag] = nodeToCapture(tag, c.node);
|
||||
}
|
||||
if (Object.keys(grouped).length === 0) continue;
|
||||
|
||||
// Decompose each `using_directive` so `interpretCsharpImport` sees
|
||||
// the kind/source/name/alias markers it consumes. Raw query match
|
||||
// only carries the @import.statement anchor.
|
||||
if (grouped['@import.statement'] !== undefined) {
|
||||
const stmtCapture = grouped['@import.statement'];
|
||||
const stmtNode = findNodeAtRange(tree.rootNode, stmtCapture.range, 'using_directive');
|
||||
if (stmtNode !== null) {
|
||||
const decomposed = splitUsingDirective(stmtNode);
|
||||
if (decomposed !== null) {
|
||||
out.push(decomposed);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Defensive fallback: emit the raw match so the extractor at
|
||||
// least sees an anchor, even without markers.
|
||||
out.push(grouped);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Synthesize `this` / `base` receiver type-bindings on every
|
||||
// instance method-like. Tree-sitter can't cleanly express "the
|
||||
// implicit receiver of a non-static member of a class/struct/
|
||||
// record/interface" via a static `.scm` pattern, so we walk up
|
||||
// the AST in code. Mirrors Python's `self`/`cls` synthesis on
|
||||
// `@scope.function` matches.
|
||||
if (grouped['@scope.function'] !== undefined) {
|
||||
out.push(grouped);
|
||||
const anchor = grouped['@scope.function']!;
|
||||
const fnNode = findFunctionNode(tree.rootNode, anchor.range);
|
||||
if (fnNode !== null) {
|
||||
for (const synth of synthesizeCsharpReceiverBinding(fnNode)) {
|
||||
out.push(synth);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Synthesize arity metadata on function-like declarations so the
|
||||
// registry can narrow overloads (C# relies heavily on this). Mirrors
|
||||
// Python's captures.ts pattern — one anchor per match, so we find
|
||||
// the first tag that matches.
|
||||
const declTag = FUNCTION_DECL_TAGS.find((t) => grouped[t] !== undefined);
|
||||
if (declTag !== undefined) {
|
||||
const anchor = grouped[declTag]!;
|
||||
const fnNode = findFunctionNode(tree.rootNode, anchor.range);
|
||||
if (fnNode !== null) {
|
||||
const arity = computeCsharpArityMetadata(fnNode);
|
||||
if (arity.parameterCount !== undefined) {
|
||||
grouped['@declaration.parameter-count'] = syntheticCapture(
|
||||
'@declaration.parameter-count',
|
||||
fnNode,
|
||||
String(arity.parameterCount),
|
||||
);
|
||||
}
|
||||
if (arity.requiredParameterCount !== undefined) {
|
||||
grouped['@declaration.required-parameter-count'] = syntheticCapture(
|
||||
'@declaration.required-parameter-count',
|
||||
fnNode,
|
||||
String(arity.requiredParameterCount),
|
||||
);
|
||||
}
|
||||
if (arity.parameterTypes !== undefined) {
|
||||
grouped['@declaration.parameter-types'] = syntheticCapture(
|
||||
'@declaration.parameter-types',
|
||||
fnNode,
|
||||
JSON.stringify(arity.parameterTypes),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Synthesize `@reference.arity` on every callsite so the
|
||||
// registry's arity filter can narrow overloads. Count the
|
||||
// `argument` named children of the backing `argument_list`.
|
||||
// Python doesn't synthesize this today; C# needs it because the
|
||||
// language has method overloading and the suite asserts overload
|
||||
// resolution.
|
||||
const callTag = (
|
||||
['@reference.call.free', '@reference.call.member', '@reference.call.constructor'] as const
|
||||
).find((t) => grouped[t] !== undefined);
|
||||
if (callTag !== undefined && grouped['@reference.arity'] === undefined) {
|
||||
const anchor = grouped[callTag]!;
|
||||
const callNode =
|
||||
findNodeAtRange(tree.rootNode, anchor.range, 'invocation_expression') ??
|
||||
findNodeAtRange(tree.rootNode, anchor.range, 'object_creation_expression');
|
||||
if (callNode !== null) {
|
||||
const argList = callNode.childForFieldName('arguments');
|
||||
const args =
|
||||
argList === null
|
||||
? []
|
||||
: argList.namedChildren.filter((c) => c !== null && c.type === 'argument');
|
||||
grouped['@reference.arity'] = syntheticCapture(
|
||||
'@reference.arity',
|
||||
callNode,
|
||||
String(args.length),
|
||||
);
|
||||
|
||||
// Infer argument types from literal nodes so overload
|
||||
// disambiguation can narrow same-arity candidates by param
|
||||
// type. Non-literal arguments emit empty string to indicate
|
||||
// "unknown" — consumers treat unknown as any-match.
|
||||
const argTypes = args.map((arg) => inferArgType(arg!));
|
||||
grouped['@reference.parameter-types'] = syntheticCapture(
|
||||
'@reference.parameter-types',
|
||||
callNode,
|
||||
JSON.stringify(argTypes),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
out.push(grouped);
|
||||
|
||||
// Synthesize primary-constructor declarations on class/record
|
||||
// declarations that carry a `parameter_list` child (C# 12 syntax
|
||||
// `public class User(string name, int age) { ... }` or
|
||||
// `public record Person(string FirstName, string LastName)`).
|
||||
// Legacy `csharpMethodConfig.extractPrimaryConstructor` runs via
|
||||
// the parse phase; the scope-resolution path needs its own emit so
|
||||
// `new User(...)` resolves to a Constructor def in memberByOwner.
|
||||
if (
|
||||
grouped['@declaration.class'] !== undefined ||
|
||||
grouped['@declaration.record'] !== undefined
|
||||
) {
|
||||
const anchor = grouped['@declaration.class'] ?? grouped['@declaration.record']!;
|
||||
const typeNode =
|
||||
findNodeAtRange(tree.rootNode, anchor.range, 'class_declaration') ??
|
||||
findNodeAtRange(tree.rootNode, anchor.range, 'record_declaration');
|
||||
if (typeNode !== null) {
|
||||
const synth = synthesizePrimaryConstructor(typeNode);
|
||||
if (synth !== null) out.push(synth);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/** C# 12 primary constructor: `class X(a, b) { }` / `record X(a, b)`.
|
||||
* The parameters are a bare `parameter_list` named child of the type
|
||||
* declaration (no `constructor_declaration` node). Emit a synthetic
|
||||
* @declaration.constructor match so the extractor creates a
|
||||
* Constructor def in memberByOwner — free-call-fallback's
|
||||
* `pickConstructorOrClass` then targets it for `new X(...)` calls. */
|
||||
function synthesizePrimaryConstructor(typeNode: SyntaxNode): CaptureMatch | null {
|
||||
// Skip types with an explicit constructor_declaration — that would
|
||||
// create duplicate defs.
|
||||
const body = typeNode.childForFieldName('body');
|
||||
if (body !== null) {
|
||||
for (let i = 0; i < body.namedChildCount; i++) {
|
||||
const child = body.namedChild(i);
|
||||
if (child !== null && child.type === 'constructor_declaration') return null;
|
||||
}
|
||||
}
|
||||
let paramList: SyntaxNode | null = null;
|
||||
for (let i = 0; i < typeNode.namedChildCount; i++) {
|
||||
const child = typeNode.namedChild(i);
|
||||
if (child !== null && child.type === 'parameter_list') {
|
||||
paramList = child;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (paramList === null) return null;
|
||||
|
||||
const nameNode = typeNode.childForFieldName('name');
|
||||
if (nameNode === null) return null;
|
||||
|
||||
const paramCount = paramList.namedChildren.filter(
|
||||
(c) => c !== null && c.type === 'parameter',
|
||||
).length;
|
||||
|
||||
const m: Record<string, Capture> = {
|
||||
'@declaration.constructor': nodeToCapture('@declaration.constructor', paramList),
|
||||
'@declaration.name': syntheticCapture('@declaration.name', nameNode, nameNode.text),
|
||||
'@declaration.parameter-count': syntheticCapture(
|
||||
'@declaration.parameter-count',
|
||||
paramList,
|
||||
String(paramCount),
|
||||
),
|
||||
'@declaration.required-parameter-count': syntheticCapture(
|
||||
'@declaration.required-parameter-count',
|
||||
paramList,
|
||||
String(paramCount),
|
||||
),
|
||||
};
|
||||
return m;
|
||||
}
|
||||
|
||||
type SyntaxNode = ReturnType<ReturnType<typeof getCsharpParser>['parse']>['rootNode'];
|
||||
|
||||
/** Infer a C# argument's static type from literal / constructor
|
||||
* patterns. Returns `''` when the arg has no statically-derivable
|
||||
* type (e.g. identifier — would require full type inference). */
|
||||
function inferArgType(argNode: SyntaxNode): string {
|
||||
// `argument > expression` — tree-sitter-c-sharp wraps the value.
|
||||
const expr = argNode.namedChild(0);
|
||||
if (expr === null) return '';
|
||||
switch (expr.type) {
|
||||
case 'integer_literal':
|
||||
return 'int';
|
||||
case 'real_literal':
|
||||
return 'double';
|
||||
case 'string_literal':
|
||||
case 'verbatim_string_literal':
|
||||
case 'interpolated_string_expression':
|
||||
case 'raw_string_literal':
|
||||
return 'string';
|
||||
case 'character_literal':
|
||||
return 'char';
|
||||
case 'boolean_literal':
|
||||
return 'bool';
|
||||
case 'null_literal':
|
||||
return 'null';
|
||||
case 'object_creation_expression': {
|
||||
const typeNode = expr.childForFieldName('type');
|
||||
return typeNode?.text ?? '';
|
||||
}
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/** Find the first C# function-like node at the given range. The
|
||||
* declaration anchor range covers the whole method/constructor/etc.
|
||||
* node, but the tag alone doesn't tell us which node type. */
|
||||
function findFunctionNode(rootNode: SyntaxNode, range: Capture['range']): SyntaxNode | null {
|
||||
for (const nodeType of FUNCTION_NODE_TYPES) {
|
||||
const n = findNodeAtRange(rootNode, range, nodeType);
|
||||
if (n !== null) return n as SyntaxNode;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
/**
|
||||
* Decompose a C# `using_directive` into a `CaptureMatch` carrying the
|
||||
* synthesized markers `@import.kind` / `@import.source` / `@import.name`
|
||||
* / `@import.alias` that `interpretCsharpImport` consumes.
|
||||
*
|
||||
* Unlike Python's decomposer this is 1:1 — each `using` produces exactly
|
||||
* one import. The split layer exists to expose the kind (namespace vs
|
||||
* alias vs static) without pushing raw-text parsing into `interpret.ts`.
|
||||
*
|
||||
* using System; → namespace
|
||||
* using System.Collections.Generic; → namespace
|
||||
* using Foo = System.Bar; → alias
|
||||
* using static System.Math; → static
|
||||
* global using System.IO; → namespace (treated as file-scoped)
|
||||
* using global::System.IO; → namespace (global:: alias stripped)
|
||||
*/
|
||||
|
||||
import type { Capture, CaptureMatch } from 'gitnexus-shared';
|
||||
import { nodeToCapture, syntheticCapture, type SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
|
||||
type ImportKind = 'namespace' | 'alias' | 'static';
|
||||
|
||||
interface ImportSpec {
|
||||
readonly kind: ImportKind;
|
||||
/** Full dotted path (generics stripped): `System.Collections.Generic`. */
|
||||
readonly source: string;
|
||||
/** Local binding name — last source segment for namespace/static,
|
||||
* the alias for alias imports. */
|
||||
readonly name: string;
|
||||
/** Present iff `kind === 'alias'`. */
|
||||
readonly alias?: string;
|
||||
/** Node to anchor the synthesized captures (range-wise). */
|
||||
readonly atNode: SyntaxNode;
|
||||
}
|
||||
|
||||
export function splitUsingDirective(stmtNode: SyntaxNode): CaptureMatch | null {
|
||||
if (stmtNode.type !== 'using_directive') return null;
|
||||
const spec = parseUsingDirective(stmtNode);
|
||||
if (spec === null) return null;
|
||||
return buildImportMatch(stmtNode, spec);
|
||||
}
|
||||
|
||||
function parseUsingDirective(node: SyntaxNode): ImportSpec | null {
|
||||
// tree-sitter-c-sharp's using_directive exposes named children
|
||||
// corresponding to the parts of the directive but omits keyword tokens
|
||||
// (`using`, `static`, `global`) from the named-child list. We inspect
|
||||
// the raw source text to detect the flavor — the grammar doesn't give
|
||||
// us a cleaner signal.
|
||||
const raw = node.text;
|
||||
|
||||
// Named child layout:
|
||||
// namespace form: [pathNode]
|
||||
// alias form: [aliasIdNode, pathNode] (name field = aliasId)
|
||||
// static form: [pathNode] (same as namespace)
|
||||
// global using: [pathNode] (same as namespace)
|
||||
const aliasField = node.childForFieldName('name');
|
||||
const children = node.namedChildren;
|
||||
if (children.length === 0) return null;
|
||||
|
||||
// Alias form — the `name:` field is the alias identifier; the
|
||||
// remaining named child is the type/namespace path.
|
||||
if (aliasField !== null) {
|
||||
const pathNode = children.find((c) => c !== null && c.startIndex !== aliasField.startIndex) as
|
||||
| SyntaxNode
|
||||
| undefined;
|
||||
if (pathNode === undefined) return null;
|
||||
return {
|
||||
kind: 'alias',
|
||||
source: stripGenericArgs(unwrapGlobalAlias(pathNode.text)),
|
||||
name: aliasField.text,
|
||||
alias: aliasField.text,
|
||||
atNode: node,
|
||||
};
|
||||
}
|
||||
|
||||
const pathNode = children[0];
|
||||
if (pathNode === null) return null;
|
||||
const source = stripGenericArgs(unwrapGlobalAlias(pathNode.text));
|
||||
if (source === '') return null;
|
||||
const lastSegment = source.split('.').pop() ?? source;
|
||||
|
||||
// `using static X.Y;` — detect by scanning the raw text before the path.
|
||||
// `global using` behaves semantically as a file-scoped using for our
|
||||
// purposes, so it isn't a separate kind here.
|
||||
if (/^\s*(?:global\s+)?using\s+static\s/.test(raw)) {
|
||||
return { kind: 'static', source, name: '*', atNode: node };
|
||||
}
|
||||
|
||||
return { kind: 'namespace', source, name: lastSegment, atNode: node };
|
||||
}
|
||||
|
||||
/** Strip `global::` prefix — `global::System.IO` → `System.IO`. */
|
||||
function unwrapGlobalAlias(text: string): string {
|
||||
return text.replace(/^global::/, '');
|
||||
}
|
||||
|
||||
/** Strip generic type arguments — `Dictionary<string, int>` → `Dictionary`. */
|
||||
function stripGenericArgs(text: string): string {
|
||||
const lt = text.indexOf('<');
|
||||
if (lt === -1) return text;
|
||||
return text.slice(0, lt);
|
||||
}
|
||||
|
||||
function buildImportMatch(stmtNode: SyntaxNode, spec: ImportSpec): CaptureMatch {
|
||||
const m: Record<string, Capture> = {
|
||||
'@import.statement': nodeToCapture('@import.statement', stmtNode),
|
||||
'@import.kind': syntheticCapture('@import.kind', spec.atNode, spec.kind),
|
||||
'@import.source': syntheticCapture('@import.source', spec.atNode, spec.source),
|
||||
'@import.name': syntheticCapture('@import.name', spec.atNode, spec.name),
|
||||
};
|
||||
if (spec.alias !== undefined) {
|
||||
m['@import.alias'] = syntheticCapture('@import.alias', spec.atNode, spec.alias);
|
||||
}
|
||||
return m;
|
||||
}
|
||||
130
gitnexus/src/core/ingestion/languages/csharp/import-target.ts
Normal file
130
gitnexus/src/core/ingestion/languages/csharp/import-target.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
/**
|
||||
* Adapter from `(ParsedImport, WorkspaceIndex)` → concrete file path.
|
||||
*
|
||||
* Unit 2 shape: suffix-match against the repo's `.cs` files. Each
|
||||
* `using System.Collections.Generic;` could legally expand to multiple
|
||||
* files (every `.cs` that declares `namespace System.Collections.Generic`
|
||||
* — partial classes, assembly-wide namespaces). The scope-resolver
|
||||
* contract returns a single primary target, so we pick the first
|
||||
* match. Cross-file partial-class aggregation runs at graph-bridge
|
||||
* time (Unit 6) via `populateOwners`.
|
||||
*
|
||||
* The legacy csproj-based `resolveCSharpImportInternal` needs config
|
||||
* objects the scope-resolver doesn't carry; the Unit 7 parity gate
|
||||
* will surface cases where the suffix-match diverges from the
|
||||
* namespace-based resolver and we'll adjust the contract if needed.
|
||||
*
|
||||
* Returning `null` lets the finalize algorithm mark the edge as
|
||||
* `linkStatus: 'unresolved'`.
|
||||
*/
|
||||
|
||||
import type { ParsedImport, WorkspaceIndex } from 'gitnexus-shared';
|
||||
|
||||
export interface CsharpResolveContext {
|
||||
readonly fromFile: string;
|
||||
readonly allFilePaths: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
export function resolveCsharpImportTarget(
|
||||
parsedImport: ParsedImport,
|
||||
workspaceIndex: WorkspaceIndex,
|
||||
): string | null {
|
||||
// WorkspaceIndex is `unknown` in the shared contract (Ring 1
|
||||
// placeholder). The scope-resolution orchestrator hands us a
|
||||
// CsharpResolveContext-shaped object; narrow structurally rather
|
||||
// than via a cast chain so unexpected shapes return null cleanly.
|
||||
const ctx = workspaceIndex as CsharpResolveContext | undefined;
|
||||
if (
|
||||
ctx === undefined ||
|
||||
typeof (ctx as { fromFile?: unknown }).fromFile !== 'string' ||
|
||||
!((ctx as { allFilePaths?: unknown }).allFilePaths instanceof Set)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (parsedImport.kind === 'dynamic-unresolved') return null;
|
||||
if (parsedImport.targetRaw === null || parsedImport.targetRaw === '') return null;
|
||||
|
||||
// Namespace path: `System.Collections.Generic` → `System/Collections/Generic`.
|
||||
const pathLike = parsedImport.targetRaw.replace(/\./g, '/');
|
||||
const suffix = `/${pathLike}`;
|
||||
|
||||
// Exact file match: `System/Collections/Generic.cs` (rare but legal).
|
||||
// Suffix match for nested layouts: `src/lib/System/Collections/Generic.cs`.
|
||||
// Directory match: first `.cs` file directly inside the namespace dir
|
||||
// (e.g. `System/Collections/Generic/List.cs` matches namespace Generic).
|
||||
let exactFile: string | null = null;
|
||||
let suffixFile: string | null = null;
|
||||
let directoryChild: string | null = null;
|
||||
const dirPrefix = `${pathLike}/`;
|
||||
const suffixDirPrefix = `/${dirPrefix}`;
|
||||
|
||||
for (const raw of ctx.allFilePaths) {
|
||||
const f = raw.replace(/\\/g, '/');
|
||||
if (!f.endsWith('.cs')) continue;
|
||||
if (f === `${pathLike}.cs`) {
|
||||
exactFile = raw;
|
||||
break;
|
||||
}
|
||||
if (suffixFile === null && f.endsWith(`${suffix}.cs`)) {
|
||||
suffixFile = raw;
|
||||
}
|
||||
if (directoryChild === null) {
|
||||
// Namespace-to-directory match: pick the first `.cs` directly in
|
||||
// the namespace dir (not nested deeper). Legacy resolver emits
|
||||
// all of them; we take one so the scope-resolver contract stays
|
||||
// single-target.
|
||||
const atRoot = f.startsWith(dirPrefix);
|
||||
const atNested = f.includes(suffixDirPrefix);
|
||||
if (atRoot || atNested) {
|
||||
const idx = atRoot ? 0 : f.indexOf(suffixDirPrefix) + 1;
|
||||
const after = f.slice(idx + dirPrefix.length);
|
||||
if (after.length > 0 && !after.includes('/')) {
|
||||
directoryChild = raw;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (exactFile !== null) return exactFile;
|
||||
if (suffixFile !== null) return suffixFile;
|
||||
if (directoryChild !== null) return directoryChild;
|
||||
|
||||
// Progressive prefix stripping — mirrors csproj's root-namespace
|
||||
// mapping without the csproj. `using CrossFile.Models;` in a repo
|
||||
// laid out `Models/User.cs` (no `CrossFile/` prefix) works because
|
||||
// the legacy resolver consults csproj; the scope-resolver layer
|
||||
// doesn't have csproj, so we try each suffix of the namespace path
|
||||
// against `.cs` files and directories.
|
||||
//
|
||||
// Also handles `using static CrossFile.Models.UserFactory;` —
|
||||
// strip the leading segment, try `Models/UserFactory.cs`; strip
|
||||
// two, try `UserFactory.cs`.
|
||||
const segments = pathLike.split('/').filter(Boolean);
|
||||
for (let skip = 1; skip < segments.length; skip++) {
|
||||
const tail = segments.slice(skip).join('/');
|
||||
if (tail === '') continue;
|
||||
const tailFile = `${tail}.cs`;
|
||||
const tailSuffix = `/${tailFile}`;
|
||||
const tailDir = `${tail}/`;
|
||||
const tailSuffixDir = `/${tailDir}`;
|
||||
let tailDirectChild: string | null = null;
|
||||
for (const raw of ctx.allFilePaths) {
|
||||
const f = raw.replace(/\\/g, '/');
|
||||
if (!f.endsWith('.cs')) continue;
|
||||
if (f === tailFile) return raw;
|
||||
if (f.endsWith(tailSuffix)) return raw;
|
||||
if (tailDirectChild === null) {
|
||||
const atRoot = f.startsWith(tailDir);
|
||||
const atNested = f.includes(tailSuffixDir);
|
||||
if (atRoot || atNested) {
|
||||
const idx = atRoot ? 0 : f.indexOf(tailSuffixDir) + 1;
|
||||
const after = f.slice(idx + tailDir.length);
|
||||
if (after.length > 0 && !after.includes('/')) tailDirectChild = raw;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (tailDirectChild !== null) return tailDirectChild;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
87
gitnexus/src/core/ingestion/languages/csharp/index.ts
Normal file
87
gitnexus/src/core/ingestion/languages/csharp/index.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
/**
|
||||
* C# scope-resolution hooks (RFC #909 Ring 3, RFC §5).
|
||||
*
|
||||
* Public API barrel. Consumers should import from this file rather than
|
||||
* the individual modules.
|
||||
*
|
||||
* Module layout (each file is a single concern):
|
||||
*
|
||||
* - `query.ts` — tree-sitter query + lazy parser/query singletons
|
||||
* - `captures.ts` — `emitCsharpScopeCaptures` orchestrator
|
||||
* - `import-decomposer.ts` — each `using` → ParsedImport-shaped captures
|
||||
* - `interpret.ts` — capture-match → `ParsedImport` / `ParsedTypeBinding`
|
||||
* - `simple-hooks.ts` — small/no-op hooks made explicit
|
||||
* - `receiver-binding.ts` — synthesize `this`/`base` type-bindings on
|
||||
* instance-method entry
|
||||
* - `merge-bindings.ts` — C# `using` precedence
|
||||
* - `arity.ts` — C# arity compatibility (`params`, default values)
|
||||
* - `arity-metadata.ts` — synthesize arity metadata from declarations
|
||||
* - `accessor-unwrap.ts` — `.Values` / `.Keys` receiver-type unwrap for
|
||||
* `Dictionary<K,V>` chains
|
||||
* - `namespace-siblings.ts` — AST-driven cross-file implicit-namespace
|
||||
* visibility (file/namespace attribution, no
|
||||
* regex; reuses orchestrator's treeCache)
|
||||
* - `import-target.ts` — `(ParsedImport, WorkspaceIndex) → file path` adapter
|
||||
* - `scope-resolver.ts` — `ScopeResolver` registered in `SCOPE_RESOLVERS`
|
||||
* - `cache-stats.ts` — PROF_SCOPE_RESOLUTION cache hit/miss counters
|
||||
*
|
||||
* ## Known limitations
|
||||
*
|
||||
* The C# registry-primary path intentionally does NOT resolve the
|
||||
* following. Each is a conscious trade-off at migration time.
|
||||
*
|
||||
* 1. **csproj-driven namespace resolution** — the legacy path
|
||||
* consults `csharpConfigs` (the parsed .csproj workspace) to map
|
||||
* `using X.Y;` back to the exact files declaring `namespace X.Y`.
|
||||
* The scope-resolver contract passes only `allFilePaths`, so we
|
||||
* fall back to suffix matching on `.cs` files. Unit 7's parity
|
||||
* gate flags any divergence.
|
||||
* 2. **Multi-file namespace expansion** — a single `using X.Y;` in
|
||||
* the legacy path can emit multiple IMPORTS edges (every file
|
||||
* declaring that namespace). The scope-resolver contract returns
|
||||
* a single target, so we pick the first match; partial-class
|
||||
* aggregation runs at graph-bridge time.
|
||||
* 3. **Overload resolution by parameter type** — arity narrowing is
|
||||
* wired (`arity.ts` + `arity-metadata.ts`), but type-based
|
||||
* disambiguation (`F(int)` vs `F(string)` at a call with a typed
|
||||
* argument) is left to the registry's type-binding layer.
|
||||
* 4. **Generic type parameter resolution** — `List<User>` binds the
|
||||
* bound name to `User` via the single-arg-generic stripper;
|
||||
* nested generics (`Dictionary<K, List<V>>`) fall through the
|
||||
* receiver-type heuristic.
|
||||
* 5. **`dynamic` typed expressions** — runtime dispatch through
|
||||
* `dynamic` is not followed.
|
||||
* 6. **Preprocessor-conditional code** — `#if DEBUG` blocks parse
|
||||
* as usual; branch selection is ignored, so both arms contribute
|
||||
* bindings.
|
||||
* 7. **Global using propagation across files** — treated as a
|
||||
* file-scoped using for the declaring file. Unit 7 parity gate
|
||||
* will flag cases where this matters.
|
||||
* 8. **Expression-bodied `=>` members** — handled by the method
|
||||
* extractor, but receiver synthesis for `=> this.Field` shortcuts
|
||||
* follows the same path as block-bodied methods.
|
||||
* 9. **Multi-namespace file attribution** — when a single file
|
||||
* declares two namespaces (rare), all top-level classes are
|
||||
* attributed to the first declared namespace via a `first-wins`
|
||||
* rule in `namespace-siblings.ts`. Namespace detection itself is
|
||||
* AST-driven (tree-sitter), so `global using static`, aliased
|
||||
* `using static X = Y.Z;`, attributes, and preprocessor-gated
|
||||
* declarations are all recognized correctly.
|
||||
*
|
||||
* Shadow-harness corpus parity is the authoritative signal for which
|
||||
* of these matter in practice. The CI parity gate blocks any PR that
|
||||
* regresses either the legacy or registry-primary run of
|
||||
* `test/integration/resolvers/csharp.test.ts`.
|
||||
*/
|
||||
|
||||
export { emitCsharpScopeCaptures } from './captures.js';
|
||||
export { getCsharpCaptureCacheStats, resetCsharpCaptureCacheStats } from './cache-stats.js';
|
||||
export { interpretCsharpImport, interpretCsharpTypeBinding } from './interpret.js';
|
||||
export { csharpMergeBindings } from './merge-bindings.js';
|
||||
export { csharpArityCompatibility } from './arity.js';
|
||||
export { resolveCsharpImportTarget, type CsharpResolveContext } from './import-target.js';
|
||||
export {
|
||||
csharpBindingScopeFor,
|
||||
csharpImportOwningScope,
|
||||
csharpReceiverBinding,
|
||||
} from './simple-hooks.js';
|
||||
137
gitnexus/src/core/ingestion/languages/csharp/interpret.ts
Normal file
137
gitnexus/src/core/ingestion/languages/csharp/interpret.ts
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
/**
|
||||
* Capture-match → semantic-shape interpreters for C#.
|
||||
*
|
||||
* - `interpretCsharpImport` → `ParsedImport`
|
||||
* - `interpretCsharpTypeBinding` → `ParsedTypeBinding`
|
||||
*
|
||||
* The using-directive matches arrive pre-decomposed by
|
||||
* `emitCsharpScopeCaptures` (one import per match, with synthesized
|
||||
* `@import.kind/source/name/alias` markers). Type-binding matches arrive
|
||||
* from the raw query captures — each `@type-binding.*` anchor carries
|
||||
* `@type-binding.name` + `@type-binding.type`.
|
||||
*/
|
||||
|
||||
import type { CaptureMatch, ParsedImport, ParsedTypeBinding, TypeRef } from 'gitnexus-shared';
|
||||
|
||||
// ─── interpretImport ──────────────────────────────────────────────────────
|
||||
|
||||
export function interpretCsharpImport(captures: CaptureMatch): ParsedImport | null {
|
||||
const kindCap = captures['@import.kind'];
|
||||
const sourceCap = captures['@import.source'];
|
||||
const nameCap = captures['@import.name'];
|
||||
const aliasCap = captures['@import.alias'];
|
||||
|
||||
const kind = kindCap?.text;
|
||||
if (kind === undefined || sourceCap === undefined) return null;
|
||||
|
||||
switch (kind) {
|
||||
case 'namespace': {
|
||||
// `using System;` / `using System.Collections.Generic;`
|
||||
// Bind the last segment as the local name so `Generic.Foo`-style
|
||||
// qualifier references resolve. Full path is the resolution target.
|
||||
return {
|
||||
kind: 'namespace',
|
||||
localName: nameCap?.text ?? sourceCap.text.split('.').pop() ?? sourceCap.text,
|
||||
importedName: sourceCap.text,
|
||||
targetRaw: sourceCap.text,
|
||||
};
|
||||
}
|
||||
case 'alias': {
|
||||
// `using Dict = System.Collections.Generic.Dictionary<string, int>;`
|
||||
// The decomposer already stripped generic args from source.
|
||||
if (aliasCap === undefined) return null;
|
||||
const importedName = sourceCap.text.split('.').pop() ?? sourceCap.text;
|
||||
return {
|
||||
kind: 'alias',
|
||||
localName: aliasCap.text,
|
||||
importedName,
|
||||
alias: aliasCap.text,
|
||||
targetRaw: sourceCap.text,
|
||||
};
|
||||
}
|
||||
case 'static': {
|
||||
// `using static System.Math;` — brings static members of Math into
|
||||
// unqualified scope. Semantically closest to a wildcard, but we
|
||||
// map to `namespace` here so finalize emits the File→File IMPORTS
|
||||
// edge without requiring `expandsWildcardTo` (which would list
|
||||
// every exported member). Static-member unqualified-access is a
|
||||
// deferred limitation; the usual cross-file lookup via
|
||||
// namespace-siblings covers `Target.Member` calls.
|
||||
const lastSegment = sourceCap.text.split('.').pop() ?? sourceCap.text;
|
||||
return {
|
||||
kind: 'namespace',
|
||||
localName: lastSegment,
|
||||
importedName: sourceCap.text,
|
||||
targetRaw: sourceCap.text,
|
||||
};
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── interpretTypeBinding ─────────────────────────────────────────────────
|
||||
|
||||
export function interpretCsharpTypeBinding(captures: CaptureMatch): ParsedTypeBinding | null {
|
||||
const nameCap = captures['@type-binding.name'];
|
||||
const typeCap = captures['@type-binding.type'];
|
||||
if (nameCap === undefined || typeCap === undefined) return null;
|
||||
|
||||
// Strip nullable suffix (`User?` → `User`), single-arg generic wrapper
|
||||
// (`List<User>` → `User`), and qualifier (`System.User` → `User`) so
|
||||
// receiver-typed resolution treats these identically.
|
||||
const rawType = stripQualifier(stripGeneric(stripNullable(typeCap.text.trim())));
|
||||
|
||||
// Anchor captures distinguish the source of the binding. Order
|
||||
// matters: more-specific anchors take precedence.
|
||||
let source: TypeRef['source'] = 'parameter-annotation';
|
||||
if (captures['@type-binding.self'] !== undefined) source = 'self';
|
||||
else if (captures['@type-binding.constructor'] !== undefined) source = 'constructor-inferred';
|
||||
else if (captures['@type-binding.annotation'] !== undefined) source = 'annotation';
|
||||
else if (captures['@type-binding.alias'] !== undefined) source = 'assignment-inferred';
|
||||
else if (captures['@type-binding.return'] !== undefined) source = 'return-annotation';
|
||||
|
||||
return { boundName: nameCap.text, rawTypeName: rawType, source };
|
||||
}
|
||||
|
||||
/** Member accesses we want to preserve through qualifier stripping.
|
||||
* Dictionary/collection views (`data.Values`, `data.Keys`) survive
|
||||
* so the compound-receiver pass can unwrap the receiver's generic
|
||||
* type (Dictionary<K,V>) based on the suffix. */
|
||||
const COLLECTION_ACCESSOR_SUFFIXES = new Set(['Values', 'Keys']);
|
||||
|
||||
/** `User?` → `User`. */
|
||||
function stripNullable(text: string): string {
|
||||
if (text.endsWith('?')) return text.slice(0, -1).trim();
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unwrap a single-arg generic collection wrapper — `List<User>`,
|
||||
* `IEnumerable<User>`, `Task<User>` — to its element type. Mirrors
|
||||
* Python's `stripGeneric` behavior so for-loop and chain propagation
|
||||
* work on the element type.
|
||||
*
|
||||
* Multi-arg generics (`Dictionary<string, User>`, `Func<int, User>`)
|
||||
* are left alone — element semantics aren't unambiguous.
|
||||
*/
|
||||
function stripGeneric(text: string): string {
|
||||
const single = text.match(
|
||||
/^(?:[A-Za-z_][A-Za-z0-9_.]*\.)?(?:List|IList|IEnumerable|ICollection|IReadOnlyList|IReadOnlyCollection|HashSet|ISet|Task|ValueTask|Nullable|IAsyncEnumerable)<([^,<>]+)>$/,
|
||||
);
|
||||
if (single !== null) return single[1].trim();
|
||||
return text;
|
||||
}
|
||||
|
||||
/** `System.Collections.User` → `User`. Preserves dotted paths whose
|
||||
* final segment is a known Dictionary/collection accessor (`.Values`,
|
||||
* `.Keys`, `.Count`, etc.) so downstream resolvers can unwrap the
|
||||
* receiver's generic type based on the suffix — `data.Values` →
|
||||
* element type of `data`'s Dictionary<K,V>. */
|
||||
function stripQualifier(text: string): string {
|
||||
const lastDot = text.lastIndexOf('.');
|
||||
if (lastDot === -1) return text;
|
||||
const tail = text.slice(lastDot + 1);
|
||||
if (COLLECTION_ACCESSOR_SUFFIXES.has(tail)) return text;
|
||||
return tail;
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
/**
|
||||
* C# shadowing precedence for the `mergeBindings` hook.
|
||||
*
|
||||
* Tier ranking (lower wins in shadowing):
|
||||
*
|
||||
* - 0: `local` — a class member, method, local variable, or parameter
|
||||
* declared in this scope.
|
||||
* - 1: `import` / `namespace` / `reexport` — `using System;`,
|
||||
* `using System.Collections.Generic;`, `using Alias = Foo;`.
|
||||
* All three using flavors that introduce a name at this scope
|
||||
* tier together; the compiler resolves ambiguity by requiring
|
||||
* an explicit qualifier when two `using`s collide, but for
|
||||
* receiver-typed dispatch we treat them as equivalent tiers.
|
||||
* - 2: `wildcard` — `using static System.Math;` brings static
|
||||
* members in; any local or `using` with the same simple name
|
||||
* shadows.
|
||||
*
|
||||
* Explicit interface implementations (`void IFoo.Bar() { }`) bind under
|
||||
* the qualified name in the extractor layer, so they never collide with
|
||||
* a plain `Bar` at this layer.
|
||||
*
|
||||
* Within a surviving tier we de-dup by `DefId`, last-write-wins so a
|
||||
* `using` re-declared further down the file cleanly replaces the
|
||||
* earlier binding.
|
||||
*/
|
||||
|
||||
import type { BindingRef } from 'gitnexus-shared';
|
||||
|
||||
const TIER_LOCAL = 0;
|
||||
const TIER_IMPORT = 1;
|
||||
const TIER_WILDCARD = 2;
|
||||
const TIER_UNKNOWN = 3;
|
||||
|
||||
function tierOf(b: BindingRef): number {
|
||||
switch (b.origin) {
|
||||
case 'local':
|
||||
return TIER_LOCAL;
|
||||
case 'reexport':
|
||||
case 'import':
|
||||
case 'namespace':
|
||||
return TIER_IMPORT;
|
||||
case 'wildcard':
|
||||
return TIER_WILDCARD;
|
||||
default:
|
||||
return TIER_UNKNOWN;
|
||||
}
|
||||
}
|
||||
|
||||
export function csharpMergeBindings(bindings: readonly BindingRef[]): readonly BindingRef[] {
|
||||
if (bindings.length === 0) return bindings;
|
||||
|
||||
let bestTier = Number.POSITIVE_INFINITY;
|
||||
for (const b of bindings) bestTier = Math.min(bestTier, tierOf(b));
|
||||
const survivors = bindings.filter((b) => tierOf(b) === bestTier);
|
||||
|
||||
const seen = new Map<string, BindingRef>();
|
||||
for (const b of survivors) seen.set(b.def.nodeId, b);
|
||||
return [...seen.values()];
|
||||
}
|
||||
|
|
@ -0,0 +1,419 @@
|
|||
/**
|
||||
* 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 immutable
|
||||
* `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 append cross-file sibling classes
|
||||
* into each Namespace scope's `bindingAugmentations` bucket with
|
||||
* `origin: 'namespace'`. Finalized bindings remain first in
|
||||
* `lookupBindingsAt`, and local lexical `Scope.bindings` remains the
|
||||
* first-tier shadowing channel.
|
||||
*
|
||||
* 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';
|
||||
import { getTreeSitterBufferSize } from '../../constants.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, undefined, {
|
||||
bufferSize: getTreeSitterBufferSize(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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Append cross-file sibling class defs to each Namespace scope's
|
||||
* `bindingAugmentations` bucket. 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
|
||||
// post-finalize augmentation channel (per I8). The
|
||||
// `indexes.bindingAugmentations` map is the dedicated mutable
|
||||
// append-only buffer for post-finalize hooks: inner `BindingRef[]`
|
||||
// arrays here are NEVER frozen (unlike `indexes.bindings`, which
|
||||
// `materializeBindings` freezes). Walkers consult both channels
|
||||
// via `lookupBindingsAt`; we never need to consult or mutate
|
||||
// `indexes.bindings`.
|
||||
const augmentations = indexes.bindingAugmentations 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;
|
||||
|
||||
// Append to the augmentation bucket for the importer's module
|
||||
// scope. `findCallableBindingInScope` reads via
|
||||
// `lookupBindingsAt`, which fans out across `bindings` +
|
||||
// `bindingAugmentations`.
|
||||
const bucketArr = getAugmentationBucket(augmentations, moduleScope.id, simpleName);
|
||||
if (bucketArr.some((b) => b.def.nodeId === memberDef.nodeId)) continue;
|
||||
bucketArr.push({ def: memberDef, origin: 'import' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
const bucketArr = getAugmentationBucket(augmentations, moduleScope.id, simpleName);
|
||||
if (bucketArr.some((b) => b.def.nodeId === def.nodeId)) continue;
|
||||
bucketArr.push({ def, origin: 'namespace' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
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;
|
||||
|
||||
let bucketArr: BindingRef[] | null = null;
|
||||
for (const def of defs) {
|
||||
if (def.filePath === filePath) continue; // don't self-reference
|
||||
if (bucketArr === null) bucketArr = getAugmentationBucket(augmentations, scopeId, name);
|
||||
if (bucketArr.some((b) => b.def.nodeId === def.nodeId)) continue;
|
||||
bucketArr.push({ def, origin: 'namespace' });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Get-or-create a mutable inner bucket inside the `bindingAugmentations`
|
||||
* channel. The inner arrays here are mutable by contract (see
|
||||
* `ScopeResolutionIndexes.bindingAugmentations` doc + scope-resolver I8);
|
||||
* callers may `push` directly. Allocating the outer/inner Maps lazily
|
||||
* keeps the augmentation footprint zero for files with no cross-file
|
||||
* fanout. */
|
||||
function getAugmentationBucket(
|
||||
augmentations: Map<ScopeId, Map<string, BindingRef[]>>,
|
||||
scopeId: ScopeId,
|
||||
name: string,
|
||||
): BindingRef[] {
|
||||
let scopeBindings = augmentations.get(scopeId);
|
||||
if (scopeBindings === undefined) {
|
||||
scopeBindings = new Map<string, BindingRef[]>();
|
||||
augmentations.set(scopeId, scopeBindings);
|
||||
}
|
||||
let bucketArr = scopeBindings.get(name);
|
||||
if (bucketArr === undefined) {
|
||||
bucketArr = [];
|
||||
scopeBindings.set(name, bucketArr);
|
||||
}
|
||||
return bucketArr;
|
||||
}
|
||||
|
||||
function isTypeDef(def: SymbolDefinition): boolean {
|
||||
return (
|
||||
def.type === 'Class' ||
|
||||
def.type === 'Interface' ||
|
||||
def.type === 'Struct' ||
|
||||
def.type === 'Record' ||
|
||||
def.type === 'Enum'
|
||||
);
|
||||
}
|
||||
520
gitnexus/src/core/ingestion/languages/csharp/query.ts
Normal file
520
gitnexus/src/core/ingestion/languages/csharp/query.ts
Normal file
|
|
@ -0,0 +1,520 @@
|
|||
/**
|
||||
* Tree-sitter query for C# scope captures (RFC §5.1).
|
||||
*
|
||||
* Captures the structural skeleton the generic scope-resolution
|
||||
* pipeline consumes: scopes (module/namespace/class/function),
|
||||
* declarations (class-likes, method-likes, properties, variables,
|
||||
* local functions), imports (using directives), type bindings
|
||||
* (parameter annotations, variable annotations, constructor
|
||||
* inference), and references (call sites, member writes).
|
||||
*
|
||||
* C# specifics that shape this query:
|
||||
*
|
||||
* - Both block-scoped (`namespace X { }`) and file-scoped
|
||||
* (`namespace X;`) namespaces. tree-sitter-c-sharp emits them
|
||||
* under distinct node types (`namespace_declaration` vs
|
||||
* `file_scoped_namespace_declaration`); both map to
|
||||
* `@scope.namespace` since the scope semantics are identical.
|
||||
* - `partial class X` splits a Class def across files. Each file
|
||||
* emits its own `@declaration.class`; cross-file resolution is
|
||||
* handled at the graph-bridge layer via the qualified-name key.
|
||||
* - `using X = Y;` aliases and `using static X;` are interpreted in
|
||||
* `interpret.ts` via the `@import.*` captures. All three using
|
||||
* flavors share the same anchor (`@import.statement`).
|
||||
* - Explicit interface implementations (`void IFoo.Bar() { }`)
|
||||
* expose the qualified name via the existing `@declaration.name`
|
||||
* — the extractor's `csharpMethodConfig.extractQualifiedName`
|
||||
* picks up the explicit qualifier from the method declaration
|
||||
* node.
|
||||
*
|
||||
* Exposes lazy `Parser` and `Query` singletons so callers don't pay
|
||||
* tree-sitter init cost per file.
|
||||
*/
|
||||
|
||||
import Parser from 'tree-sitter';
|
||||
import CSharp from 'tree-sitter-c-sharp';
|
||||
|
||||
const CSHARP_SCOPE_QUERY = `
|
||||
;; Scopes
|
||||
(compilation_unit) @scope.module
|
||||
|
||||
(namespace_declaration) @scope.namespace
|
||||
(file_scoped_namespace_declaration) @scope.namespace
|
||||
|
||||
(class_declaration) @scope.class
|
||||
(interface_declaration) @scope.class
|
||||
(struct_declaration) @scope.class
|
||||
(record_declaration) @scope.class
|
||||
(enum_declaration) @scope.class
|
||||
|
||||
(method_declaration) @scope.function
|
||||
(constructor_declaration) @scope.function
|
||||
(destructor_declaration) @scope.function
|
||||
(local_function_statement) @scope.function
|
||||
(operator_declaration) @scope.function
|
||||
(conversion_operator_declaration) @scope.function
|
||||
;; Property accessors are blocks within a property; not scoped here.
|
||||
;; Anonymous methods / lambdas are not scoped — out of scope per plan.
|
||||
|
||||
;; Declarations — types
|
||||
(class_declaration
|
||||
name: (identifier) @declaration.name) @declaration.class
|
||||
|
||||
(interface_declaration
|
||||
name: (identifier) @declaration.name) @declaration.interface
|
||||
|
||||
(struct_declaration
|
||||
name: (identifier) @declaration.name) @declaration.struct
|
||||
|
||||
(record_declaration
|
||||
name: (identifier) @declaration.name) @declaration.record
|
||||
|
||||
(enum_declaration
|
||||
name: (identifier) @declaration.name) @declaration.enum
|
||||
|
||||
;; Declarations — methods / constructors / properties
|
||||
(method_declaration
|
||||
name: (identifier) @declaration.name) @declaration.method
|
||||
|
||||
(constructor_declaration
|
||||
name: (identifier) @declaration.name) @declaration.constructor
|
||||
|
||||
(destructor_declaration
|
||||
name: (identifier) @declaration.name) @declaration.method
|
||||
|
||||
(local_function_statement
|
||||
name: (identifier) @declaration.name) @declaration.function
|
||||
|
||||
;; Operator declarations — \`public static T operator +(T a, T b)\`.
|
||||
;; tree-sitter-c-sharp exposes the operator token under the \`operator:\`
|
||||
;; field (an anonymous node like \`+\`, \`-\`, \`==\`). Capture the whole
|
||||
;; node under @declaration.name so the extractor reads the operator
|
||||
;; symbol as the declared name; downstream csharpMethodConfig can
|
||||
;; normalize it (e.g. to \`op_Addition\`) when it runs.
|
||||
(operator_declaration
|
||||
operator: _ @declaration.name) @declaration.method
|
||||
|
||||
;; Conversion operators — \`public static explicit operator int(T x)\`.
|
||||
;; No operator token; the target type (\`int\`) identifies the conversion
|
||||
;; and serves as the name anchor.
|
||||
(conversion_operator_declaration
|
||||
type: _ @declaration.name) @declaration.method
|
||||
|
||||
(property_declaration
|
||||
name: (identifier) @declaration.name) @declaration.property
|
||||
|
||||
(indexer_declaration) @declaration.property
|
||||
|
||||
;; Fields — \`int x;\` at class scope. variable_declarator inside
|
||||
;; field_declaration carries the name.
|
||||
(field_declaration
|
||||
(variable_declaration
|
||||
(variable_declarator
|
||||
name: (identifier) @declaration.name))) @declaration.variable
|
||||
|
||||
;; Local variables — \`int x = 1;\` inside a method body
|
||||
(local_declaration_statement
|
||||
(variable_declaration
|
||||
(variable_declarator
|
||||
name: (identifier) @declaration.name))) @declaration.variable
|
||||
|
||||
;; Imports — single anchor per directive; interpretCsharpImport classifies
|
||||
(using_directive) @import.statement
|
||||
|
||||
;; Type bindings — parameter annotations: \`void F(User u)\`
|
||||
(parameter
|
||||
type: (identifier) @type-binding.type
|
||||
name: (identifier) @type-binding.name) @type-binding.parameter
|
||||
|
||||
(parameter
|
||||
type: (generic_name) @type-binding.type
|
||||
name: (identifier) @type-binding.name) @type-binding.parameter
|
||||
|
||||
(parameter
|
||||
type: (qualified_name) @type-binding.type
|
||||
name: (identifier) @type-binding.name) @type-binding.parameter
|
||||
|
||||
(parameter
|
||||
type: (nullable_type) @type-binding.type
|
||||
name: (identifier) @type-binding.name) @type-binding.parameter
|
||||
|
||||
;; Type bindings — local variable annotations: \`User u = new User();\`
|
||||
;; Typed local with identifier type + \`new X()\` initializer — shape
|
||||
;; matters so \`u\` binds to \`X\` (the constructor call's type), not the
|
||||
;; declared type alias (which is usually the same, but \`new DerivedUser()\`
|
||||
;; would be distinct).
|
||||
(local_declaration_statement
|
||||
(variable_declaration
|
||||
type: (identifier) @type-binding.type
|
||||
(variable_declarator
|
||||
name: (identifier) @type-binding.name))) @type-binding.annotation
|
||||
|
||||
(local_declaration_statement
|
||||
(variable_declaration
|
||||
type: (generic_name) @type-binding.type
|
||||
(variable_declarator
|
||||
name: (identifier) @type-binding.name))) @type-binding.annotation
|
||||
|
||||
(local_declaration_statement
|
||||
(variable_declaration
|
||||
type: (qualified_name) @type-binding.type
|
||||
(variable_declarator
|
||||
name: (identifier) @type-binding.name))) @type-binding.annotation
|
||||
|
||||
;; Type bindings — \`var u = new User();\` — constructor-inferred.
|
||||
;; Captures object_creation_expression's type as the binding type.
|
||||
;; variable_declarator wraps the \`= <expr>\` directly; tree-sitter-c-sharp
|
||||
;; does not surface an equals_value_clause wrapper here.
|
||||
(local_declaration_statement
|
||||
(variable_declaration
|
||||
(variable_declarator
|
||||
name: (identifier) @type-binding.name
|
||||
(object_creation_expression
|
||||
type: (identifier) @type-binding.type)))) @type-binding.constructor
|
||||
|
||||
(local_declaration_statement
|
||||
(variable_declaration
|
||||
(variable_declarator
|
||||
name: (identifier) @type-binding.name
|
||||
(object_creation_expression
|
||||
type: (generic_name) @type-binding.type)))) @type-binding.constructor
|
||||
|
||||
(local_declaration_statement
|
||||
(variable_declaration
|
||||
(variable_declarator
|
||||
name: (identifier) @type-binding.name
|
||||
(object_creation_expression
|
||||
type: (qualified_name) @type-binding.type)))) @type-binding.constructor
|
||||
|
||||
;; Type bindings — \`var u = factory();\` alias (chain-follow picks up
|
||||
;; factory's return type via propagateImportedReturnTypes)
|
||||
(local_declaration_statement
|
||||
(variable_declaration
|
||||
(variable_declarator
|
||||
name: (identifier) @type-binding.name
|
||||
(invocation_expression
|
||||
function: (identifier) @type-binding.type)))) @type-binding.alias
|
||||
|
||||
;; Type bindings — identifier-to-identifier alias: \`var alias = u;\`.
|
||||
;; The resolver's chain-follow walks from \`alias\` → \`u\` → u's
|
||||
;; declared type, so we only need to tag the rename here.
|
||||
(local_declaration_statement
|
||||
(variable_declaration
|
||||
type: (implicit_type)
|
||||
(variable_declarator
|
||||
name: (identifier) @type-binding.name
|
||||
(identifier) @type-binding.type))) @type-binding.alias
|
||||
|
||||
;; Type bindings — chained method-call alias: \`var u = svc.GetUser();\`.
|
||||
;; The chain-follow then walks GetUser's return-type binding.
|
||||
(local_declaration_statement
|
||||
(variable_declaration
|
||||
type: (implicit_type)
|
||||
(variable_declarator
|
||||
name: (identifier) @type-binding.name
|
||||
(invocation_expression
|
||||
function: (member_access_expression
|
||||
name: (identifier) @type-binding.type))))) @type-binding.alias
|
||||
|
||||
;; Type bindings — \`await\` propagation: \`var u = await Factory();\`.
|
||||
;; Strip the await wrapper to get the underlying invocation; interpret
|
||||
;; layer's stripGeneric handles Task<T> / ValueTask<T>.
|
||||
(local_declaration_statement
|
||||
(variable_declaration
|
||||
type: (implicit_type)
|
||||
(variable_declarator
|
||||
name: (identifier) @type-binding.name
|
||||
(await_expression
|
||||
(invocation_expression
|
||||
function: (identifier) @type-binding.type))))) @type-binding.alias
|
||||
|
||||
(local_declaration_statement
|
||||
(variable_declaration
|
||||
type: (implicit_type)
|
||||
(variable_declarator
|
||||
name: (identifier) @type-binding.name
|
||||
(await_expression
|
||||
(invocation_expression
|
||||
function: (member_access_expression
|
||||
name: (identifier) @type-binding.type)))))) @type-binding.alias
|
||||
|
||||
;; Type bindings — identifier-to-identifier assignment rebind:
|
||||
;; \`alias = u;\` — aliases the rhs identifier's current type.
|
||||
(assignment_expression
|
||||
left: (identifier) @type-binding.name
|
||||
right: (identifier) @type-binding.type) @type-binding.alias
|
||||
|
||||
;; Type bindings — method return type: \`public User GetUser() { ... }\`.
|
||||
;; Anchor on the method_declaration so bindingScopeFor can hoist the
|
||||
;; binding from function scope to the enclosing class/module scope
|
||||
;; (callers, not the function body, look up the return type by the
|
||||
;; function's name). Required for cross-file return-type propagation
|
||||
;; via propagateImportedReturnTypes.
|
||||
(method_declaration
|
||||
returns: (identifier) @type-binding.type
|
||||
name: (identifier) @type-binding.name) @type-binding.return
|
||||
|
||||
(method_declaration
|
||||
returns: (generic_name) @type-binding.type
|
||||
name: (identifier) @type-binding.name) @type-binding.return
|
||||
|
||||
(method_declaration
|
||||
returns: (qualified_name) @type-binding.type
|
||||
name: (identifier) @type-binding.name) @type-binding.return
|
||||
|
||||
(method_declaration
|
||||
returns: (nullable_type) @type-binding.type
|
||||
name: (identifier) @type-binding.name) @type-binding.return
|
||||
|
||||
;; Type bindings — field declaration: \`private City _city;\`. Attaches
|
||||
;; to the enclosing class scope via positionIndex, so \`this._city.X\`
|
||||
;; can look up _city's type on the class.
|
||||
(field_declaration
|
||||
(variable_declaration
|
||||
type: (identifier) @type-binding.type
|
||||
(variable_declarator
|
||||
name: (identifier) @type-binding.name))) @type-binding.annotation
|
||||
|
||||
(field_declaration
|
||||
(variable_declaration
|
||||
type: (generic_name) @type-binding.type
|
||||
(variable_declarator
|
||||
name: (identifier) @type-binding.name))) @type-binding.annotation
|
||||
|
||||
(field_declaration
|
||||
(variable_declaration
|
||||
type: (qualified_name) @type-binding.type
|
||||
(variable_declarator
|
||||
name: (identifier) @type-binding.name))) @type-binding.annotation
|
||||
|
||||
;; Type bindings — property declaration: \`public User Owner { get; set; }\`.
|
||||
(property_declaration
|
||||
type: (identifier) @type-binding.type
|
||||
name: (identifier) @type-binding.name) @type-binding.annotation
|
||||
|
||||
(property_declaration
|
||||
type: (generic_name) @type-binding.type
|
||||
name: (identifier) @type-binding.name) @type-binding.annotation
|
||||
|
||||
(property_declaration
|
||||
type: (qualified_name) @type-binding.type
|
||||
name: (identifier) @type-binding.name) @type-binding.annotation
|
||||
|
||||
(property_declaration
|
||||
type: (nullable_type) @type-binding.type
|
||||
name: (identifier) @type-binding.name) @type-binding.annotation
|
||||
|
||||
;; Type bindings — assignment rebind: \`alias = Factory();\` where
|
||||
;; \`alias\` was previously declared. Same alias shape as \`var x = F();\`.
|
||||
(assignment_expression
|
||||
left: (identifier) @type-binding.name
|
||||
right: (invocation_expression
|
||||
function: (identifier) @type-binding.type)) @type-binding.alias
|
||||
|
||||
;; Type bindings — assignment with constructor: \`alias = new User();\`.
|
||||
(assignment_expression
|
||||
left: (identifier) @type-binding.name
|
||||
right: (object_creation_expression
|
||||
type: (identifier) @type-binding.type)) @type-binding.constructor
|
||||
|
||||
(assignment_expression
|
||||
left: (identifier) @type-binding.name
|
||||
right: (object_creation_expression
|
||||
type: (generic_name) @type-binding.type)) @type-binding.constructor
|
||||
|
||||
;; Type bindings — \`is\` pattern: \`if (obj is User u) { u.Save(); }\`.
|
||||
;; The declaration_pattern carries both the matched type and the
|
||||
;; binding name; scope narrowing to the guarded branch is simplified
|
||||
;; to function-scope (matches Python's match-case treatment) since we
|
||||
;; don't emit @scope.block.
|
||||
(is_pattern_expression
|
||||
pattern: (declaration_pattern
|
||||
type: (identifier) @type-binding.type
|
||||
name: (identifier) @type-binding.name)) @type-binding.annotation
|
||||
|
||||
(is_pattern_expression
|
||||
pattern: (declaration_pattern
|
||||
type: (generic_name) @type-binding.type
|
||||
name: (identifier) @type-binding.name)) @type-binding.annotation
|
||||
|
||||
(is_pattern_expression
|
||||
pattern: (declaration_pattern
|
||||
type: (qualified_name) @type-binding.type
|
||||
name: (identifier) @type-binding.name)) @type-binding.annotation
|
||||
|
||||
;; Type bindings — \`case User u:\` inside a switch section.
|
||||
;; tree-sitter-c-sharp's switch_section directly contains the
|
||||
;; declaration_pattern / recursive_pattern (no case_pattern_switch_label
|
||||
;; wrapper as in other C# grammars).
|
||||
(switch_section
|
||||
(declaration_pattern
|
||||
type: (identifier) @type-binding.type
|
||||
name: (identifier) @type-binding.name)) @type-binding.annotation
|
||||
|
||||
(switch_section
|
||||
(declaration_pattern
|
||||
type: (generic_name) @type-binding.type
|
||||
name: (identifier) @type-binding.name)) @type-binding.annotation
|
||||
|
||||
;; Type bindings — recursive_pattern with named binding:
|
||||
;; \`x is User { Age: 1 } u\` / \`case User { Age: 1 } u:\`. type + name
|
||||
;; are named fields on recursive_pattern; inner property/positional
|
||||
;; clauses don't affect the binding.
|
||||
(is_pattern_expression
|
||||
pattern: (recursive_pattern
|
||||
type: (identifier) @type-binding.type
|
||||
name: (identifier) @type-binding.name)) @type-binding.annotation
|
||||
|
||||
(switch_section
|
||||
(recursive_pattern
|
||||
type: (identifier) @type-binding.type
|
||||
name: (identifier) @type-binding.name)) @type-binding.annotation
|
||||
|
||||
;; Type bindings — switch-expression arms: \`obj switch { User u => …, Repo { Name: "x" } r => … }\`.
|
||||
;; Distinct from the \`switch_statement\` shape above — expression-switch
|
||||
;; uses \`switch_expression_arm\` nodes.
|
||||
(switch_expression_arm
|
||||
(declaration_pattern
|
||||
type: (identifier) @type-binding.type
|
||||
name: (identifier) @type-binding.name)) @type-binding.annotation
|
||||
|
||||
(switch_expression_arm
|
||||
(declaration_pattern
|
||||
type: (generic_name) @type-binding.type
|
||||
name: (identifier) @type-binding.name)) @type-binding.annotation
|
||||
|
||||
(switch_expression_arm
|
||||
(recursive_pattern
|
||||
type: (identifier) @type-binding.type
|
||||
name: (identifier) @type-binding.name)) @type-binding.annotation
|
||||
|
||||
;; Type bindings — typed foreach: \`foreach (User u in xs)\`.
|
||||
;; Shape parity with \`User u = …;\` — left binds to the declared type.
|
||||
(foreach_statement
|
||||
type: (identifier) @type-binding.type
|
||||
left: (identifier) @type-binding.name) @type-binding.annotation
|
||||
|
||||
(foreach_statement
|
||||
type: (generic_name) @type-binding.type
|
||||
left: (identifier) @type-binding.name) @type-binding.annotation
|
||||
|
||||
(foreach_statement
|
||||
type: (qualified_name) @type-binding.type
|
||||
left: (identifier) @type-binding.name) @type-binding.annotation
|
||||
|
||||
(foreach_statement
|
||||
type: (nullable_type) @type-binding.type
|
||||
left: (identifier) @type-binding.name) @type-binding.annotation
|
||||
|
||||
;; Type bindings — \`var\` foreach: \`foreach (var u in xs)\`. Alias to
|
||||
;; the iterable's identifier / chain so chain-follow unwraps
|
||||
;; \`List<User>\` / \`Dictionary<K,V>.Values\` to the element type via
|
||||
;; the generic-stripper in interpret.ts. Mirrors Python's for-loop
|
||||
;; alias patterns.
|
||||
(foreach_statement
|
||||
type: (implicit_type)
|
||||
left: (identifier) @type-binding.name
|
||||
right: (identifier) @type-binding.type) @type-binding.alias
|
||||
|
||||
(foreach_statement
|
||||
type: (implicit_type)
|
||||
left: (identifier) @type-binding.name
|
||||
right: (member_access_expression) @type-binding.type) @type-binding.alias
|
||||
|
||||
(foreach_statement
|
||||
type: (implicit_type)
|
||||
left: (identifier) @type-binding.name
|
||||
right: (invocation_expression
|
||||
function: (identifier) @type-binding.type)) @type-binding.alias
|
||||
|
||||
;; Return-type captures on method_declaration / property_declaration /
|
||||
;; field_declaration are deferred — tree-sitter-c-sharp does not expose
|
||||
;; the return/field type under a simple named field that pattern-matches
|
||||
;; cleanly. When Unit 7's parity gate surfaces a gap requiring these
|
||||
;; bindings, revisit with a positional pattern or a post-hoc lookup via
|
||||
;; csharpMethodConfig.extractReturnType / csharpFieldConfig.extractType.
|
||||
|
||||
;; References — free calls: \`Foo()\`
|
||||
(invocation_expression
|
||||
function: (identifier) @reference.name) @reference.call.free
|
||||
|
||||
;; References — member calls: \`obj.Method()\`
|
||||
;; \`(_)\` matches only named nodes in tree-sitter queries. \`this\` and
|
||||
;; \`base\` are anonymous tokens in tree-sitter-c-sharp (unlike Python's
|
||||
;; \`self\` which is a regular identifier), so they need explicit
|
||||
;; patterns to emit a receiver capture.
|
||||
(invocation_expression
|
||||
function: (member_access_expression
|
||||
expression: (_) @reference.receiver
|
||||
name: (identifier) @reference.name)) @reference.call.member
|
||||
|
||||
(invocation_expression
|
||||
function: (member_access_expression
|
||||
expression: "this" @reference.receiver
|
||||
name: (identifier) @reference.name)) @reference.call.member
|
||||
|
||||
(invocation_expression
|
||||
function: (member_access_expression
|
||||
expression: "base" @reference.receiver
|
||||
name: (identifier) @reference.name)) @reference.call.member
|
||||
|
||||
;; References — null-conditional member calls: \`obj?.Method()\`
|
||||
;; conditional_access_expression wraps a receiver followed by a
|
||||
;; member_binding_expression. Capture the receiver explicitly so
|
||||
;; receiver-bound resolution doesn't silently downgrade the call to
|
||||
;; a free-call (which would misresolve to an imported \`Save\`).
|
||||
;; tree-sitter-c-sharp doesn't expose named fields here, so use
|
||||
;; positional wildcards.
|
||||
(invocation_expression
|
||||
function: (conditional_access_expression
|
||||
(_) @reference.receiver
|
||||
(member_binding_expression
|
||||
(identifier) @reference.name))) @reference.call.member
|
||||
|
||||
;; References — constructor calls: \`new User(...)\`
|
||||
(object_creation_expression
|
||||
type: (identifier) @reference.name) @reference.call.constructor
|
||||
|
||||
(object_creation_expression
|
||||
type: (generic_name
|
||||
(identifier) @reference.name)) @reference.call.constructor
|
||||
|
||||
(object_creation_expression
|
||||
type: (qualified_name) @reference.call.constructor.qualified) @reference.call.constructor
|
||||
|
||||
;; References — field/property writes: \`obj.Name = "x"\` emits a write
|
||||
;; ACCESSES edge from the enclosing method to the field/property on
|
||||
;; obj's class.
|
||||
(assignment_expression
|
||||
left: (member_access_expression
|
||||
expression: (_) @reference.receiver
|
||||
name: (identifier) @reference.name)) @reference.write.member
|
||||
|
||||
(assignment_expression
|
||||
left: (member_access_expression
|
||||
expression: "this" @reference.receiver
|
||||
name: (identifier) @reference.name)) @reference.write.member
|
||||
|
||||
(assignment_expression
|
||||
left: (member_access_expression
|
||||
expression: "base" @reference.receiver
|
||||
name: (identifier) @reference.name)) @reference.write.member
|
||||
`;
|
||||
|
||||
let _parser: Parser | null = null;
|
||||
let _query: Parser.Query | null = null;
|
||||
|
||||
export function getCsharpParser(): Parser {
|
||||
if (_parser === null) {
|
||||
_parser = new Parser();
|
||||
_parser.setLanguage(CSharp as Parameters<Parser['setLanguage']>[0]);
|
||||
}
|
||||
return _parser;
|
||||
}
|
||||
|
||||
export function getCsharpScopeQuery(): Parser.Query {
|
||||
if (_query === null) {
|
||||
_query = new Parser.Query(CSharp as Parameters<Parser['setLanguage']>[0], CSHARP_SCOPE_QUERY);
|
||||
}
|
||||
return _query;
|
||||
}
|
||||
142
gitnexus/src/core/ingestion/languages/csharp/receiver-binding.ts
Normal file
142
gitnexus/src/core/ingestion/languages/csharp/receiver-binding.ts
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
/**
|
||||
* Synthesize `@type-binding.self` captures for C# instance methods —
|
||||
* one for `this` (always on non-static methods inside a type
|
||||
* declaration) and optionally one for `base` (only on class methods
|
||||
* when the enclosing class has an explicit base in its `base_list`).
|
||||
*
|
||||
* Mirrors `languages/python/receiver-binding.ts` in structure. The
|
||||
* tree-sitter-c-sharp grammar doesn't give us a clean `.scm` pattern
|
||||
* for "this-receiver on every instance method inside an enclosing
|
||||
* type" because the binding isn't a parameter — it's an implicit
|
||||
* receiver. Synthesis in code is the same approach Python uses for
|
||||
* `self` / `cls`.
|
||||
*/
|
||||
|
||||
import type { Capture, CaptureMatch } from 'gitnexus-shared';
|
||||
import { nodeToCapture, syntheticCapture, type SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
|
||||
const TYPE_DECL_NODE_TYPES = new Set([
|
||||
'class_declaration',
|
||||
'struct_declaration',
|
||||
'record_declaration',
|
||||
'interface_declaration',
|
||||
]);
|
||||
|
||||
const FUNCTION_NODE_TYPES = new Set([
|
||||
'method_declaration',
|
||||
'constructor_declaration',
|
||||
'destructor_declaration',
|
||||
'operator_declaration',
|
||||
'conversion_operator_declaration',
|
||||
'local_function_statement',
|
||||
]);
|
||||
|
||||
/** Walk up to the enclosing type declaration, stopping at any other
|
||||
* function-like node (nested local functions shouldn't leak `this`
|
||||
* from an outer class to an inner closure). */
|
||||
function findEnclosingTypeDeclaration(node: SyntaxNode): SyntaxNode | null {
|
||||
let cur: SyntaxNode | null = node.parent;
|
||||
while (cur !== null) {
|
||||
if (TYPE_DECL_NODE_TYPES.has(cur.type)) return cur;
|
||||
// A local function nested inside another method still sees `this`
|
||||
// from the enclosing class — don't break on function-like nodes.
|
||||
cur = cur.parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function typeName(typeNode: SyntaxNode): string | null {
|
||||
return typeNode.childForFieldName('name')?.text ?? null;
|
||||
}
|
||||
|
||||
/** First entry in the type's `base_list`, read as raw text. C# allows
|
||||
* generic and qualified bases (`Foo<T>`, `N.M.Base`); we keep the raw
|
||||
* form so downstream interpret layer can strip generics/qualifiers
|
||||
* the same way as other type-binding captures. Returns null when the
|
||||
* type has no base (or an empty base_list). */
|
||||
function firstBaseText(typeNode: SyntaxNode): string | null {
|
||||
for (let i = 0; i < typeNode.namedChildCount; i++) {
|
||||
const child = typeNode.namedChild(i);
|
||||
if (child === null || child.type !== 'base_list') continue;
|
||||
const firstBase = child.namedChild(0);
|
||||
if (firstBase === null) return null;
|
||||
return firstBase.text;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isStaticMethod(fnNode: SyntaxNode): boolean {
|
||||
// A `static` modifier appears as a named `modifier` child whose text
|
||||
// is exactly "static". collectModifierTexts in field-extractors
|
||||
// handles this, but duplicating the tiny scan here keeps the
|
||||
// receiver-binding module dependency-free.
|
||||
for (let i = 0; i < fnNode.namedChildCount; i++) {
|
||||
const child = fnNode.namedChild(i);
|
||||
if (child !== null && child.type === 'modifier' && child.text.trim() === 'static') return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build zero, one, or two `@type-binding.self` matches for `fnNode`:
|
||||
*
|
||||
* - Returns `null` if the function is free (no enclosing type),
|
||||
* static, or the enclosing type has no resolvable name.
|
||||
* - Returns one match (`this`) for non-static methods inside a
|
||||
* class/struct/record/interface.
|
||||
* - Returns two matches (`this` + `base`) only when the function
|
||||
* lives in a `class_declaration` (or `record_declaration`) that has
|
||||
* at least one base entry. Structs cannot inherit classes;
|
||||
* interfaces cannot call `base.X`.
|
||||
*
|
||||
* The caller is responsible for guaranteeing
|
||||
* `FUNCTION_NODE_TYPES.has(fnNode.type)`.
|
||||
*/
|
||||
export function synthesizeCsharpReceiverBinding(fnNode: SyntaxNode): CaptureMatch[] {
|
||||
if (!FUNCTION_NODE_TYPES.has(fnNode.type)) return [];
|
||||
if (isStaticMethod(fnNode)) return [];
|
||||
|
||||
const enclosingType = findEnclosingTypeDeclaration(fnNode);
|
||||
if (enclosingType === null) return [];
|
||||
|
||||
const enclosingName = typeName(enclosingType);
|
||||
if (enclosingName === null) return [];
|
||||
|
||||
// Anchor the synthesized captures to a node clearly *inside* the
|
||||
// function's scope (not at the method's start position, which maps
|
||||
// to the enclosing class scope via positionIndex). The method's
|
||||
// `body` field is the block statement — its range is guaranteed to
|
||||
// be inside the function scope. If the method has no body (interface
|
||||
// declaration, `abstract`), skip — there's no function scope to
|
||||
// attach the binding to.
|
||||
const anchorNode = fnNode.childForFieldName('body');
|
||||
if (anchorNode === null) return [];
|
||||
|
||||
const out: CaptureMatch[] = [];
|
||||
out.push(buildReceiverMatch(anchorNode, 'this', enclosingName));
|
||||
|
||||
// `base` applies only to class / record methods with an explicit
|
||||
// base class. `struct` can't inherit a class; `interface` can't
|
||||
// call `base.X`. The first entry of `base_list` is the base class
|
||||
// (interfaces follow); we can't statically distinguish the two here,
|
||||
// but `base.X` only compiles when the first entry IS a class, so we
|
||||
// trust the source — if the user wrote `base.X` in a class with
|
||||
// interface-only bases, their code wouldn't compile anyway.
|
||||
if (enclosingType.type === 'class_declaration' || enclosingType.type === 'record_declaration') {
|
||||
const baseText = firstBaseText(enclosingType);
|
||||
if (baseText !== null) {
|
||||
out.push(buildReceiverMatch(anchorNode, 'base', baseText));
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function buildReceiverMatch(anchorNode: SyntaxNode, name: string, typeText: string): CaptureMatch {
|
||||
const m: Record<string, Capture> = {
|
||||
'@type-binding.self': nodeToCapture('@type-binding.self', anchorNode),
|
||||
'@type-binding.name': syntheticCapture('@type-binding.name', anchorNode, name),
|
||||
'@type-binding.type': syntheticCapture('@type-binding.type', anchorNode, typeText),
|
||||
};
|
||||
return m;
|
||||
}
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
/**
|
||||
* C# `ScopeResolver` registered in `SCOPE_RESOLVERS` and consumed by
|
||||
* the generic `runScopeResolution` orchestrator (RFC #909 Ring 3).
|
||||
*
|
||||
* Second migration after Python — see `pythonScopeResolver` for the
|
||||
* canonical shape.
|
||||
*/
|
||||
|
||||
import type { ParsedFile } from 'gitnexus-shared';
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js';
|
||||
import { populateClassOwnedMembers } from '../../scope-resolution/scope/walkers.js';
|
||||
import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js';
|
||||
import { csharpProvider } from '../csharp.js';
|
||||
import {
|
||||
csharpArityCompatibility,
|
||||
csharpMergeBindings,
|
||||
resolveCsharpImportTarget,
|
||||
type CsharpResolveContext,
|
||||
} from './index.js';
|
||||
import { populateCsharpNamespaceSiblings } from './namespace-siblings.js';
|
||||
import { unwrapCsharpCollectionAccessor } from './accessor-unwrap.js';
|
||||
|
||||
const csharpScopeResolver: ScopeResolver = {
|
||||
language: SupportedLanguages.CSharp,
|
||||
languageProvider: csharpProvider,
|
||||
importEdgeReason: 'csharp-scope: using',
|
||||
|
||||
resolveImportTarget: (targetRaw, fromFile, allFilePaths) => {
|
||||
const ws: CsharpResolveContext = { fromFile, allFilePaths };
|
||||
// `WorkspaceIndex` is an opaque `unknown` placeholder in the
|
||||
// shared contract, so `ws` passes structurally without a cast.
|
||||
return resolveCsharpImportTarget(
|
||||
{ kind: 'namespace', localName: '_', importedName: '_', targetRaw },
|
||||
ws,
|
||||
);
|
||||
},
|
||||
|
||||
// C# shadowing: local > using > using static. The per-scope id is
|
||||
// unused by the C# implementation (shadowing is computed purely
|
||||
// from the binding tier), so we don't need to synthesize a Scope.
|
||||
mergeBindings: (existing, incoming) => [...csharpMergeBindings([...existing, ...incoming])],
|
||||
|
||||
// Adapter: csharpArityCompatibility uses (def, callsite); the
|
||||
// contract is (callsite, def).
|
||||
arityCompatibility: (callsite, def) => csharpArityCompatibility(def, callsite),
|
||||
|
||||
buildMro: (graph, parsedFiles, nodeLookup) =>
|
||||
buildMro(graph, parsedFiles, nodeLookup, defaultLinearize),
|
||||
|
||||
populateOwners: (parsed: ParsedFile) => populateClassOwnedMembers(parsed),
|
||||
|
||||
// C# uses `base` for super-class dispatch, not `super`. Match as a
|
||||
// plain identifier (no `()` call like Python's `super(...)`) — `base`
|
||||
// is a keyword-like receiver, not a callable.
|
||||
isSuperReceiver: (text) => text.trim() === 'base',
|
||||
|
||||
// Same-namespace cross-file visibility — C# makes every type
|
||||
// declared in `namespace X` visible to other files declaring the
|
||||
// same namespace, without any `using` directive. See
|
||||
// `namespace-siblings.ts` for the implementation.
|
||||
populateNamespaceSiblings: populateCsharpNamespaceSiblings,
|
||||
|
||||
// C# is statically typed — type information is reliable. Field-
|
||||
// fallback heuristic stays off (the type-binding layer already
|
||||
// produces precise owner types); return-type propagation on is fine
|
||||
// since signatures are authoritative.
|
||||
fieldFallbackOnMethodLookup: false,
|
||||
propagatesReturnTypesAcrossImports: true,
|
||||
|
||||
// `data.Values` / `data.Keys` on Dictionary-like receivers unwrap
|
||||
// to the value / key element type. Other languages use method-call
|
||||
// syntax for the same access and leave this hook undefined.
|
||||
unwrapCollectionAccessor: unwrapCsharpCollectionAccessor,
|
||||
|
||||
// C# matches legacy DAG by collapsing member-call CALLS edges to
|
||||
// `(caller, target)` — multiple `g.Greet(...)` sites from Main
|
||||
// yield ONE edge, not one per site.
|
||||
collapseMemberCallsByCallerTarget: true,
|
||||
|
||||
// C# hoists method return-type bindings to the enclosing Module
|
||||
// scope so `propagateImportedReturnTypes` can mirror them across
|
||||
// files. The compound-receiver walker needs to walk up from the
|
||||
// class scope to find them; see the contract field for rationale.
|
||||
hoistTypeBindingsToModule: true,
|
||||
};
|
||||
|
||||
export { csharpScopeResolver };
|
||||
96
gitnexus/src/core/ingestion/languages/csharp/simple-hooks.ts
Normal file
96
gitnexus/src/core/ingestion/languages/csharp/simple-hooks.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
/**
|
||||
* Trivial / no-op-ish hooks for the C# provider. Kept together because
|
||||
* each is a few lines and they share a common theme: they make the
|
||||
* provider's choice explicit rather than relying on "absence == default"
|
||||
* so reviewers don't have to re-derive the analysis.
|
||||
*/
|
||||
|
||||
import type {
|
||||
CaptureMatch,
|
||||
ParsedImport,
|
||||
Scope,
|
||||
ScopeId,
|
||||
ScopeTree,
|
||||
TypeRef,
|
||||
} from 'gitnexus-shared';
|
||||
|
||||
// ─── bindingScopeFor ──────────────────────────────────────────────────────
|
||||
|
||||
/** C# has block scope, but the central extractor's "innermost enclosing
|
||||
* scope" default already handles it correctly: class-body declarations
|
||||
* attach to the innermost Class scope, method-body declarations attach
|
||||
* to the innermost Function scope, and namespace-body declarations
|
||||
* attach to the innermost Namespace scope (which the scope query emits
|
||||
* for both `namespace X { }` and `namespace X;` forms).
|
||||
*
|
||||
* Exception: **method return-type bindings** (`@type-binding.return`)
|
||||
* must hoist all the way to the Module scope. The default auto-hoist
|
||||
* in the central extractor only promotes one level (Function → its
|
||||
* parent). For C# methods the parent is always a Class, so without
|
||||
* this override the return binding gets stuck at the Class scope,
|
||||
* where it's invisible to:
|
||||
* - chain-follow's parent-chain walk in `followChainPostFinalize`
|
||||
* (tests: `var u = GetUser(); u.Save()` single-file);
|
||||
* - cross-file `propagateImportedReturnTypes`, which reads only
|
||||
* `sourceModule.typeBindings`.
|
||||
* Walking to Module restores both paths. */
|
||||
export function csharpBindingScopeFor(
|
||||
decl: CaptureMatch,
|
||||
innermost: Scope,
|
||||
tree: ScopeTree,
|
||||
): ScopeId | null {
|
||||
if (decl['@type-binding.return'] !== undefined) {
|
||||
let cur: Scope | undefined = innermost;
|
||||
while (cur !== undefined && cur.kind !== 'Module') {
|
||||
const parentId: ScopeId | null = cur.parent ?? null;
|
||||
if (parentId === null) break;
|
||||
cur = tree.getScope(parentId);
|
||||
}
|
||||
if (cur !== undefined && cur.kind === 'Module') return cur.id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── importOwningScope ────────────────────────────────────────────────────
|
||||
|
||||
/** `using` inside `namespace X { }` binds to that namespace's scope (its
|
||||
* types are visible only within that namespace's members). File-level
|
||||
* `using` delegates to the module default. Class-body `using` is not
|
||||
* legal C# — defensively handle it by attaching to the class if it
|
||||
* ever slips through.
|
||||
*
|
||||
* `global using X;` (C# 10+) at the compilation_unit level is treated
|
||||
* as a file-scoped using for Unit 2's purposes; cross-file propagation
|
||||
* will be addressed if Unit 7's parity gate flags it. */
|
||||
export function csharpImportOwningScope(
|
||||
_imp: ParsedImport,
|
||||
innermost: Scope,
|
||||
_tree: ScopeTree,
|
||||
): ScopeId | null {
|
||||
if (innermost.kind === 'Namespace' || innermost.kind === 'Class' || innermost.kind === 'Function')
|
||||
return innermost.id;
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── receiverBinding ──────────────────────────────────────────────────────
|
||||
|
||||
/** Look up `this` or `base` in the function scope's type bindings.
|
||||
*
|
||||
* `this` and `base` are synthesized as type bindings on instance
|
||||
* methods during capture emission (`receiver-binding.ts`) — `this`
|
||||
* for every method inside a class/struct/record/interface body, and
|
||||
* `base` additionally for methods of a class-like type with an
|
||||
* explicit `base_list`. This hook therefore returns a non-null
|
||||
* `TypeRef` for instance-method bodies.
|
||||
*
|
||||
* Returns `null` for:
|
||||
* - static methods (no `this` synthesized)
|
||||
* - free functions / module-level code (no enclosing class)
|
||||
* - non-Function scopes
|
||||
*
|
||||
* Matches `pythonReceiverBinding`'s shape so the two provider
|
||||
* wirings stay symmetric. */
|
||||
export function csharpReceiverBinding(functionScope: Scope): TypeRef | null {
|
||||
if (functionScope.kind !== 'Function') return null;
|
||||
return functionScope.typeBindings.get('this') ?? functionScope.typeBindings.get('base') ?? null;
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@
|
|||
* - namedBindingExtractor: present (from X import Y)
|
||||
*/
|
||||
|
||||
import type { NodeLabel } from 'gitnexus-shared';
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import { createClassExtractor } from '../class-extractors/generic.js';
|
||||
import { pythonClassConfig } from '../class-extractors/configs/python.js';
|
||||
|
|
@ -29,8 +30,11 @@ 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 type { CaptureMap } from '../language-provider.js';
|
||||
import type { SyntaxNode } from '../utils/ast-helpers.js';
|
||||
import {
|
||||
emitPythonScopeCaptures,
|
||||
pythonFunctionDefinitionLabel,
|
||||
interpretPythonImport,
|
||||
interpretPythonTypeBinding,
|
||||
pythonArityCompatibility,
|
||||
|
|
@ -71,6 +75,34 @@ const BUILT_INS: ReadonlySet<string> = new Set([
|
|||
'abs',
|
||||
]);
|
||||
|
||||
function pythonDescriptionExtractor(
|
||||
nodeLabel: NodeLabel,
|
||||
_nodeName: string,
|
||||
captureMap: CaptureMap,
|
||||
): string | undefined {
|
||||
if (nodeLabel !== 'Function' && nodeLabel !== 'Method') return undefined;
|
||||
const functionNode = captureMap['definition.function'] ?? captureMap['definition.method'];
|
||||
if (functionNode === undefined) return undefined;
|
||||
return extractPythonDocstring(functionNode);
|
||||
}
|
||||
|
||||
function extractPythonDocstring(functionNode: SyntaxNode): string | undefined {
|
||||
const body = functionNode.childForFieldName('body');
|
||||
const firstStatement = body?.namedChild(0);
|
||||
if (firstStatement?.type !== 'expression_statement') return undefined;
|
||||
|
||||
const literal = firstStatement.namedChild(0);
|
||||
if (literal?.type !== 'string') return undefined;
|
||||
return normalizePythonStringLiteral(literal.text);
|
||||
}
|
||||
|
||||
function normalizePythonStringLiteral(text: string): string | undefined {
|
||||
const match = text.match(/^[rRuUbBfF]*("""|'''|"|')([\s\S]*)\1$/);
|
||||
const raw = match?.[2]?.trim();
|
||||
if (!raw) return undefined;
|
||||
return raw.replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
export const pythonProvider = defineLanguage({
|
||||
id: SupportedLanguages.Python,
|
||||
extensions: ['.py'],
|
||||
|
|
@ -87,18 +119,20 @@ export const pythonProvider = defineLanguage({
|
|||
variableExtractor: createVariableExtractor(pythonVariableConfig),
|
||||
classExtractor: createClassExtractor(pythonClassConfig),
|
||||
heritageExtractor: createHeritageExtractor(SupportedLanguages.Python),
|
||||
descriptionExtractor: pythonDescriptionExtractor,
|
||||
builtInNames: BUILT_INS,
|
||||
labelOverride: pythonFunctionDefinitionLabel,
|
||||
|
||||
// ── 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/scopes.scm.
|
||||
// ./python/query.ts (PYTHON_SCOPE_QUERY constant).
|
||||
emitScopeCaptures: emitPythonScopeCaptures,
|
||||
interpretImport: interpretPythonImport,
|
||||
interpretTypeBinding: interpretPythonTypeBinding,
|
||||
bindingScopeFor: pythonBindingScopeFor,
|
||||
importOwningScope: pythonImportOwningScope,
|
||||
mergeBindings: pythonMergeBindings,
|
||||
mergeBindings: (_scope, bindings) => pythonMergeBindings(bindings),
|
||||
receiverBinding: pythonReceiverBinding,
|
||||
arityCompatibility: pythonArityCompatibility,
|
||||
resolveImportTarget: resolvePythonImportTarget,
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ 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';
|
||||
import { getTreeSitterBufferSize } from '../../constants.js';
|
||||
import { pythonFunctionDefinitionLabel } from './simple-hooks.js';
|
||||
|
||||
export function emitPythonScopeCaptures(
|
||||
sourceText: string,
|
||||
|
|
@ -36,7 +38,9 @@ export function emitPythonScopeCaptures(
|
|||
// here at the use site.
|
||||
let tree = cachedTree as ReturnType<ReturnType<typeof getPythonParser>['parse']> | undefined;
|
||||
if (tree === undefined) {
|
||||
tree = getPythonParser().parse(sourceText);
|
||||
tree = getPythonParser().parse(sourceText, undefined, {
|
||||
bufferSize: getTreeSitterBufferSize(sourceText),
|
||||
});
|
||||
recordCacheMiss();
|
||||
} else {
|
||||
recordCacheHit();
|
||||
|
|
@ -95,6 +99,10 @@ export function emitPythonScopeCaptures(
|
|||
const anchorCap = grouped['@declaration.function']!;
|
||||
const fnNode = findNodeAtRange(tree.rootNode, anchorCap.range, 'function_definition');
|
||||
if (fnNode !== null) {
|
||||
if (pythonFunctionDefinitionLabel(fnNode, 'Function') === 'Method') {
|
||||
delete grouped['@declaration.function'];
|
||||
grouped['@declaration.method'] = { ...anchorCap, name: '@declaration.method' };
|
||||
}
|
||||
const arity = computePythonArityMetadata(fnNode);
|
||||
if (arity.parameterCount !== undefined) {
|
||||
grouped['@declaration.parameter-count'] = syntheticCapture(
|
||||
|
|
|
|||
|
|
@ -15,6 +15,10 @@ 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>;
|
||||
}
|
||||
|
||||
|
|
@ -22,6 +26,10 @@ 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 ||
|
||||
|
|
|
|||
|
|
@ -80,6 +80,7 @@ export { pythonArityCompatibility } from './arity.js';
|
|||
export { resolvePythonImportTarget, type PythonResolveContext } from './import-target.js';
|
||||
export {
|
||||
pythonBindingScopeFor,
|
||||
pythonFunctionDefinitionLabel,
|
||||
pythonImportOwningScope,
|
||||
pythonReceiverBinding,
|
||||
} from './simple-hooks.js';
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
* purposes).
|
||||
*/
|
||||
|
||||
import type { BindingRef, Scope } from 'gitnexus-shared';
|
||||
import type { BindingRef } from 'gitnexus-shared';
|
||||
|
||||
const TIER_LOCAL = 0;
|
||||
const TIER_IMPORT = 1;
|
||||
|
|
@ -35,10 +35,7 @@ function tierOf(b: BindingRef): number {
|
|||
}
|
||||
}
|
||||
|
||||
export function pythonMergeBindings(
|
||||
_scope: Scope,
|
||||
bindings: readonly BindingRef[],
|
||||
): readonly BindingRef[] {
|
||||
export function pythonMergeBindings(bindings: readonly BindingRef[]): readonly BindingRef[] {
|
||||
if (bindings.length === 0) return bindings;
|
||||
|
||||
let bestTier = Number.POSITIVE_INFINITY;
|
||||
|
|
|
|||
|
|
@ -1,12 +1,7 @@
|
|||
/**
|
||||
* Tree-sitter query for Python scope captures (RFC §5.1).
|
||||
*
|
||||
* The `.scm` sibling file is the human-readable spec; this module
|
||||
* mirrors it at runtime. **Edit both together** — the unit/integration
|
||||
* tests reference the embedded constant, and the file documents the
|
||||
* contract.
|
||||
*
|
||||
* Also exposes lazy `Parser` and `Query` singletons so callers don't
|
||||
* Exposes lazy `Parser` and `Query` singletons so callers don't
|
||||
* pay tree-sitter init cost per file.
|
||||
*/
|
||||
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@
|
|||
*
|
||||
* 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 `.scm` file declarative and lets us
|
||||
* encode the `@classmethod` / `@staticmethod` decorator awareness that
|
||||
* Python's runtime depends on.
|
||||
* 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';
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
* the 2 booleans, and register in `scope-resolution/pipeline/registry.ts`.
|
||||
*/
|
||||
|
||||
import type { ParsedFile, Scope, WorkspaceIndex } from 'gitnexus-shared';
|
||||
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';
|
||||
|
|
@ -31,30 +31,30 @@ const pythonScopeResolver: ScopeResolver = {
|
|||
importEdgeReason: 'python-scope: import',
|
||||
|
||||
resolveImportTarget: (targetRaw, fromFile, allFilePaths) => {
|
||||
// PythonResolveContext expects a mutable Set; orchestrator hands us
|
||||
// a ReadonlySet — safe to widen since the resolver only reads.
|
||||
const ws: PythonResolveContext = {
|
||||
fromFile,
|
||||
allFilePaths: allFilePaths as Set<string>,
|
||||
};
|
||||
// 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 as unknown as WorkspaceIndex,
|
||||
ws,
|
||||
);
|
||||
},
|
||||
|
||||
// Python LEGB precedence: local > import/namespace/reexport > wildcard.
|
||||
mergeBindings: (existing, incoming, scopeId) => {
|
||||
// pythonMergeBindings(scope, bindings) only consults BindingRef.origin
|
||||
// for tier ordering, not scope.kind. A shape-stub satisfies the type
|
||||
// contract without falsifying behavior. Widen the readonly result
|
||||
// to a mutable BindingRef[] for the orchestrator's hook signature.
|
||||
const fakeScope = { id: scopeId } as unknown as Scope;
|
||||
return [...pythonMergeBindings(fakeScope, [...existing, ...incoming])];
|
||||
},
|
||||
// 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). Contract is (callsite, def).
|
||||
// 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) =>
|
||||
|
|
|
|||
|
|
@ -1,266 +0,0 @@
|
|||
; Tree-sitter Python query — RFC §5.1 captures for scope-based resolution
|
||||
; (RFC #909 Ring 3, language: Python — first migration).
|
||||
;
|
||||
; Capture vocabulary consumed by the central `ScopeExtractor`:
|
||||
;
|
||||
; @scope.module — file root
|
||||
; @scope.class — class body
|
||||
; @scope.function — def / async def body (functions, methods, lambdas)
|
||||
;
|
||||
; @declaration.class + @declaration.name
|
||||
; @declaration.function + @declaration.name
|
||||
; @declaration.method + @declaration.name (functions inside class bodies)
|
||||
; @declaration.variable + @declaration.name (module/class/function-level assignments)
|
||||
;
|
||||
; @import.statement — anchor for `interpretImport`. The hook reads
|
||||
; the captured source text and tokenizes it into
|
||||
; a `ParsedImport`. We do NOT decompose
|
||||
; `import X, Y` here at query time — `interpretImport`
|
||||
; splits multi-target statements into N matches.
|
||||
;
|
||||
; @type-binding.parameter + @type-binding.name + @type-binding.type
|
||||
;
|
||||
; @reference.call.free + @reference.name (e.g. `print(x)`)
|
||||
; @reference.call.member + @reference.name + @reference.receiver
|
||||
; (e.g. `obj.save()`)
|
||||
;
|
||||
; Python has NO block scope: `if`, `for`, `while`, `try`, `with`, `match`
|
||||
; bodies do NOT introduce a new lexical scope (PEP 8 / language reference).
|
||||
; We therefore do NOT emit `@scope.block` captures for those constructs;
|
||||
; their contained declarations land in the enclosing function/class/module
|
||||
; scope automatically (RFC §5.1 "transparent block" behavior).
|
||||
;
|
||||
; `@reference.call.constructor` is intentionally absent: Python has no
|
||||
; `new` keyword. A call to a class is syntactically identical to a call to
|
||||
; a free function; the registry decides constructor-vs-call by inspecting
|
||||
; the resolved `def.type`. Stays out of the parser to avoid duplicating
|
||||
; that logic in tree-sitter.
|
||||
|
||||
; ─── Scopes ────────────────────────────────────────────────────────────────
|
||||
|
||||
(module) @scope.module
|
||||
|
||||
(class_definition) @scope.class
|
||||
|
||||
(function_definition) @scope.function
|
||||
|
||||
; ─── Declarations: class / function ────────────────────────────────────────
|
||||
|
||||
(class_definition
|
||||
name: (identifier) @declaration.name) @declaration.class
|
||||
|
||||
(function_definition
|
||||
name: (identifier) @declaration.name) @declaration.function
|
||||
|
||||
; ─── Declarations: assignments (module-, class-, function-level variables)
|
||||
;
|
||||
; Note: tree-sitter-python parses both annotated and plain assignments as
|
||||
; `(assignment left: ...)` — typed and untyped both surface here. The
|
||||
; central extractor de-dupes by `nodeId` (file#line:col:type:name).
|
||||
|
||||
(assignment
|
||||
left: (identifier) @declaration.name) @declaration.variable
|
||||
|
||||
; for-loop target — Python `for` does NOT introduce a new scope; the
|
||||
; loop variable binds in the enclosing function/module scope.
|
||||
(for_statement
|
||||
left: (identifier) @declaration.name) @declaration.variable
|
||||
|
||||
; ─── Imports ───────────────────────────────────────────────────────────────
|
||||
;
|
||||
; The whole statement is the anchor — `interpretImport` decomposes it.
|
||||
; We tag both shapes so the hook sees a single capture name (`@import.statement`).
|
||||
|
||||
(import_statement) @import.statement
|
||||
|
||||
(import_from_statement) @import.statement
|
||||
|
||||
; ─── Type bindings: parameter annotations ──────────────────────────────────
|
||||
;
|
||||
; `def f(x: User)` — `x` is bound to `User` in `f`'s scope.
|
||||
;
|
||||
; `interpretTypeBinding` reads `@type-binding.name` (the parameter name)
|
||||
; and `@type-binding.type` (the annotation source text) to produce a
|
||||
; `ParsedTypeBinding { boundName, rawTypeName, source: 'parameter-annotation' }`.
|
||||
|
||||
(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 assignments ───────────────────────
|
||||
;
|
||||
; `u = User("alice")` — `u`'s type is inferred from the RHS call's target.
|
||||
; Python has no `new` keyword, so the pattern matches any `assignment`
|
||||
; whose RHS is a `call` with a bare-identifier function (constructor-
|
||||
; shaped). The registry resolves the raw name through the scope chain at
|
||||
; lookup time, so imported classes, local classes, and aliased imports
|
||||
; all work without query-time knowledge.
|
||||
;
|
||||
; Emits `source: 'constructor-inferred'`.
|
||||
;
|
||||
; Listed BEFORE the annotation pattern so `u: User = find()` — which
|
||||
; matches both patterns — has the annotation (later-processed match)
|
||||
; overwrite the constructor-inferred guess. Explicit user intent wins.
|
||||
|
||||
(assignment
|
||||
left: (identifier) @type-binding.name
|
||||
right: (call
|
||||
function: (identifier) @type-binding.type)) @type-binding.constructor
|
||||
|
||||
; ─── Type bindings: variable annotations ───────────────────────────────────
|
||||
;
|
||||
; `u: User` or `u: User = some_value` — `u` is explicitly annotated. Both
|
||||
; forms parse under tree-sitter-python as `(assignment left: type:)` with
|
||||
; an optional `right:`. Module-, class-, and function-scope annotations
|
||||
; all land here; scope attachment is handled by the central extractor
|
||||
; via the anchor's innermost-containing scope.
|
||||
;
|
||||
; Emits `source: 'annotation'`.
|
||||
|
||||
(assignment
|
||||
left: (identifier) @type-binding.name
|
||||
type: (type) @type-binding.type) @type-binding.annotation
|
||||
|
||||
; For-loop iterable of a free-call result: `for u in get_users()` —
|
||||
; binds `u → get_users` so the chain post-pass follows it through
|
||||
; `get_users`'s return-type annotation (cross-file via
|
||||
; `propagateImportedReturnTypes`).
|
||||
(for_statement
|
||||
left: (identifier) @type-binding.name
|
||||
right: (call
|
||||
function: (identifier) @type-binding.type)) @type-binding.alias
|
||||
|
||||
; for (i, u) in enumerate(X) and for i, u in enumerate(X) — bind the
|
||||
; second tuple element to X. enumerate yields (int, X-element); the
|
||||
; chain-follow unwraps X via generic-strip when X is an annotated
|
||||
; collection.
|
||||
(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_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.
|
||||
(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.
|
||||
(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.
|
||||
(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 resolves X via the enclosing method's parameter
|
||||
; typeBinding. Supports fixtures that reference `self.X` as a stand-in
|
||||
; for a parameter X (matches legacy DAG fallback 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: function return-type annotations ─────────────────────
|
||||
;
|
||||
; `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. 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 ─────────────────────────────────────────────────────
|
||||
;
|
||||
; Free call: `print(x)` — function is a bare identifier
|
||||
; Member call: `obj.save()` — function is an attribute access
|
||||
|
||||
(call
|
||||
function: (identifier) @reference.name) @reference.call.free
|
||||
|
||||
(call
|
||||
function: (attribute
|
||||
object: (_) @reference.receiver
|
||||
attribute: (identifier) @reference.name)) @reference.call.member
|
||||
|
||||
; Attribute write: `obj.name = "x"` — emits ACCESSES (write) edge from
|
||||
; the enclosing function to the field on obj's class.
|
||||
(assignment
|
||||
left: (attribute
|
||||
object: (_) @reference.receiver
|
||||
attribute: (identifier) @reference.name)) @reference.write.member
|
||||
|
|
@ -8,12 +8,30 @@
|
|||
|
||||
import type {
|
||||
CaptureMatch,
|
||||
NodeLabel,
|
||||
ParsedImport,
|
||||
Scope,
|
||||
ScopeId,
|
||||
ScopeTree,
|
||||
TypeRef,
|
||||
} from 'gitnexus-shared';
|
||||
import type { SyntaxNode } from 'tree-sitter';
|
||||
import { findAncestorBeforeBoundary, FUNCTION_NODE_TYPES } from '../../utils/ast-helpers.js';
|
||||
|
||||
const PYTHON_METHOD_CONTAINER_TYPES: ReadonlySet<string> = new Set(['class_definition']);
|
||||
|
||||
export function pythonFunctionDefinitionLabel(
|
||||
functionNode: SyntaxNode,
|
||||
defaultLabel: NodeLabel,
|
||||
): NodeLabel {
|
||||
if (defaultLabel !== 'Function') return defaultLabel;
|
||||
const ancestor = findAncestorBeforeBoundary(
|
||||
functionNode,
|
||||
PYTHON_METHOD_CONTAINER_TYPES,
|
||||
FUNCTION_NODE_TYPES,
|
||||
);
|
||||
return ancestor === null ? 'Function' : 'Method';
|
||||
}
|
||||
|
||||
// ─── bindingScopeFor ──────────────────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
*/
|
||||
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import type { NodeLabel } from 'gitnexus-shared';
|
||||
import type { NodeLabel, SymbolDefinition } from 'gitnexus-shared';
|
||||
import { createClassExtractor } from '../class-extractors/generic.js';
|
||||
import { swiftClassConfig } from '../class-extractors/configs/swift.js';
|
||||
import { defineLanguage } from '../language-provider.js';
|
||||
|
|
@ -128,6 +128,24 @@ const swiftExtractFunctionName = (
|
|||
return null; // fall through to generic
|
||||
};
|
||||
|
||||
const orderSwiftSameNameTypeCandidates = ({
|
||||
callSiteFilePath,
|
||||
candidates,
|
||||
}: {
|
||||
readonly typeName: string;
|
||||
readonly callSiteFilePath: string;
|
||||
readonly candidates: readonly SymbolDefinition[];
|
||||
}): readonly SymbolDefinition[] | null => {
|
||||
if (!callSiteFilePath.endsWith('.swift')) return null;
|
||||
if (candidates.length <= 1) return null;
|
||||
if (!candidates.every((c) => c.type === candidates[0].type)) return null;
|
||||
if (candidates[0].type !== 'Class' && candidates[0].type !== 'Struct') return null;
|
||||
if (!candidates.every((c) => c.filePath.endsWith('.swift'))) return null;
|
||||
return [...candidates].sort(
|
||||
(a, b) => a.filePath.length - b.filePath.length || a.filePath.localeCompare(b.filePath),
|
||||
);
|
||||
};
|
||||
|
||||
const BUILT_INS: ReadonlySet<string> = new Set([
|
||||
'print',
|
||||
'debugPrint',
|
||||
|
|
@ -257,5 +275,6 @@ export const swiftProvider = defineLanguage({
|
|||
classExtractor: createClassExtractor(swiftClassConfig),
|
||||
heritageExtractor: createHeritageExtractor(SupportedLanguages.Swift),
|
||||
implicitImportWirer: wireSwiftImplicitImports,
|
||||
orderSameNameTypeCandidates: orderSwiftSameNameTypeCandidates,
|
||||
builtInNames: BUILT_INS,
|
||||
});
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue