Merge branch 'main' into feat/unified-deployment-enhancement

# Conflicts:
#	README.md
#	gitnexus/README.md
#	gitnexus/test/unit/cli-index-help.test.ts
This commit is contained in:
weiyf 2026-07-17 14:31:56 +08:00
commit c0d8afbbe9
423 changed files with 37471 additions and 4225 deletions

View file

@ -0,0 +1,21 @@
{
"name": "gitnexus-marketplace",
"interface": {
"displayName": "GitNexus"
},
"plugins": [
{
"name": "gitnexus",
"version": "1.6.9",
"source": {
"source": "local",
"path": "./gitnexus-claude-plugin"
},
"policy": {
"installation": "AVAILABLE",
"authentication": "ON_INSTALL"
},
"category": "Developer Tools"
}
]
}

View file

@ -11,7 +11,7 @@
"plugins": [
{
"name": "gitnexus",
"version": "1.6.8",
"version": "1.6.9",
"source": "./gitnexus-claude-plugin",
"description": "Code intelligence powered by a knowledge graph. Provides execution flow tracing, blast radius analysis, and augmented search across your codebase."
}

View file

@ -14,7 +14,7 @@ Canonical agent instructions: **[AGENTS.md](../AGENTS.md)** (GitNexus MCP rules,
- NEVER rename symbols with find-and-replace — use `gitnexus_rename`.
- NEVER commit without running `gitnexus_detect_changes()`.
- NEVER ignore HIGH/CRITICAL risk warnings from impact analysis.
- NEVER run `npx gitnexus analyze` without `--embeddings` if `.gitnexus/meta.json` shows stored embeddings.
- NEVER run `npx gitnexus analyze` without `--embeddings` if the index metadata (`.gitnexus/gitnexus.json` / legacy `meta.json`) shows stored embeddings.
Full rules: **[AGENTS.md](../AGENTS.md)** (`gitnexus:start` block, Cursor Cloud section).

View file

@ -10,6 +10,8 @@ A cross-platform Dev Container that pre-installs Claude Code, OpenAI Codex CLI,
>
> The trade-off of the copy model: host and container config **diverge after first create.** A skill or plugin you add on the host later won't appear in the container until you wipe the config volume and rebuild (see [§ Rebuild / reset](#rebuild--reset)). Edits you make inside the container persist across rebuilds but never reach the host.
**Contents:** [Quick start](#quick-start) · [Windows 11 setup](#windows-11-setup) · [macOS](#macos) · [Linux](#linux) · [How CLI state flows from your host](#how-cli-state-flows-from-your-host) · [Session resume](#session-resume-across-container-recreation) · [Trust boundary](#trust-boundary-concretely) · [First-time CLI authentication](#first-time-cli-authentication) · [API key auth](#alternative-api-key-authentication-ci--headless) · [Port forwarding](#port-forwarding) · [Known gotchas](#known-gotchas) · [Rebuild / reset](#rebuild--reset) · [Bumping CLI versions](#bumping-cli-versions) · [What's not included (yet)](#whats-not-included-yet) · [Troubleshooting](#troubleshooting)
## Quick start
1. Install [Docker Desktop](https://docs.docker.com/desktop/) (Windows/macOS) or Docker Engine (Linux).

3
.github/CODEOWNERS vendored
View file

@ -1,4 +1,5 @@
# Code owners
* @Arvuno
* @abhigyanpatwari
* @magyargergo
* @azizur100389

View file

@ -4,7 +4,7 @@ description: Setup Node.js 22, build gitnexus-shared, install web dependencies
runs:
using: composite
steps:
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
# Vite 7 requires Node ^20.19.0 || >=22.12.0 (require(esm) support).
node-version: 22

View file

@ -10,7 +10,7 @@ inputs:
runs:
using: composite
steps:
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
cache: npm

View file

@ -358,7 +358,7 @@ jobs:
- name: Ensure Python (arm64 Windows only)
if: matrix.platform_arch == 'win32-arm64'
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: '3.12'
@ -561,7 +561,7 @@ jobs:
NODE
- name: Attest build provenance (SLSA)
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
with:
subject-path: 'gitnexus/vendor/tree-sitter-*/prebuilds/**/*.node'

View file

@ -17,7 +17,7 @@ jobs:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v3
- uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v3
id: filter
with:
filters: |

View file

@ -7,18 +7,28 @@ permissions:
contents: read
jobs:
# Ubuntu full-suite coverage, sharded. Each shard writes a vitest blob report
# (carrying its slice of V8 coverage) with thresholds forced OFF — a single
# shard's partial coverage can't meet the gate. The coverage-merge job below
# reduces the blobs and enforces the real thresholds on the combined coverage.
# FTS self-installs per shard (test/helpers/fts-availability.ts), so sharding
# the full suite across fresh runners is safe. Shard count: shard-plan.cov_total.
tests:
name: ubuntu / coverage
name: ubuntu / coverage ${{ matrix.shard }}/${{ needs.shard-plan.outputs.cov_total }}
needs: shard-plan
runs-on: ubuntu-latest
timeout-minutes: 25
strategy:
fail-fast: false
matrix:
shard: ${{ fromJSON(needs.shard-plan.outputs.cov_shards) }}
# Fail loudly (don't silently skip) if the FTS extension is unavailable, so
# FTS-dependent lbug integration suites are guaranteed to run in CI.
env:
GITNEXUS_REQUIRE_FTS: '1'
steps:
# persist-credentials: false — this job runs tests and uploads a
# test-reports artifact (if: always()). The default-persisted token in
# .git/config must not be capturable through that upload (zizmor
# persist-credentials: false — runs tests + uploads a blob artifact; the
# default-persisted token must not be capturable through it (zizmor
# credential-persistence / artipacked audit). The job never pushes.
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
@ -26,10 +36,78 @@ jobs:
- uses: ./.github/actions/setup-gitnexus
with:
build: 'true'
- name: Run all tests with coverage
# Warm-cache the FTS extension (same per-OS key as the cross-platform job)
# and install it up front, so every coverage shard has FTS in ~/.lbdb before
# any test module loads. The file-path FTS gate (extension-binary-real)
# resolves the extension at module load and can't self-install, so sharding
# could otherwise drop it into a shard with no installer sibling.
- name: Cache LadybugDB FTS extension
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v5
with:
path: ~/.lbdb/extension
key: lbug-fts-${{ runner.os }}-${{ hashFiles('gitnexus/package-lock.json') }}
- name: Ensure FTS extension installed
run: npx tsx scripts/ensure-fts.ts
working-directory: gitnexus
- name: Run sharded tests with coverage (blob)
# Shard via env var (not `${{ }}` inlined into the shell) so it isn't a
# template-injection sink; shell: bash makes "$SHARD" expand uniformly.
# Thresholds forced to 0 — the merge job enforces the real gate on the
# MERGED coverage; a single shard's partial coverage would always fail.
shell: bash
env:
SHARD: ${{ matrix.shard }}/${{ needs.shard-plan.outputs.cov_total }}
run: >-
npx vitest run
--shard="$SHARD"
--reporter=default
--reporter=blob
--coverage
--coverage.thresholds.lines=0
--coverage.thresholds.functions=0
--coverage.thresholds.branches=0
--coverage.thresholds.statements=0
working-directory: gitnexus
- name: Upload coverage blob
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: coverage-blob-${{ matrix.shard }}
path: gitnexus/.vitest-reports/
# .vitest-reports is a dotdir; upload-artifact excludes hidden files by
# default, which would upload an empty artifact and break the merge.
include-hidden-files: true
retention-days: 5
# Merge the sharded coverage blobs into one report and enforce the real
# thresholds on the combined ('new') coverage — `vitest --mergeReports` re-runs
# nothing, it just reduces the stored blobs. Also emits the merged
# test-results.json and runs the (unsharded) web + docker suites, so the
# `test-reports` artifact keeps the exact shape ci-report.yml consumes for its
# base-branch ('baseline') vs new coverage delta.
coverage-merge:
name: ubuntu / coverage merge
needs: tests
runs-on: ubuntu-latest
timeout-minutes: 15
env:
GITNEXUS_REQUIRE_FTS: '1'
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- uses: ./.github/actions/setup-gitnexus
with:
build: 'true'
- name: Download coverage blobs
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
pattern: coverage-blob-*
path: gitnexus/.vitest-reports
merge-multiple: true
- name: Merge coverage + enforce thresholds
run: >-
npx vitest --mergeReports
--reporter=default
--reporter=json
--outputFile=test-results.json
@ -38,14 +116,11 @@ jobs:
--coverage.reporter=json
--coverage.reporter=text
--coverage.thresholdAutoUpdate=false
--coverage.reportOnFailure=true
working-directory: gitnexus
# gitnexus-shared already built by setup-gitnexus action above
# gitnexus-shared already built by setup-gitnexus above
- name: Install gitnexus-web dependencies
run: npm ci
working-directory: gitnexus-web
- name: Run gitnexus-web unit tests
run: >-
npx vitest run
@ -53,10 +128,8 @@ jobs:
--reporter=json
--outputFile=web-test-results.json
working-directory: gitnexus-web
- name: Run docker-server integration tests
run: node --test docker-server.test.mjs
- name: Upload test reports
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
@ -69,22 +142,74 @@ jobs:
gitnexus-web/web-test-results.json
retention-days: 5
# Single source of truth for the platform-sensitive shard count. TOTAL below
# generates both the shard index list (the matrix) and the /N denominator (job
# name + --shard arg), so they can't drift — bump the shard count by editing
# TOTAL alone. Checkout-free (ubuntu ships jq), so no credential surface.
shard-plan:
runs-on: ubuntu-latest
outputs:
shards: ${{ steps.gen.outputs.shards }}
total: ${{ steps.gen.outputs.total }}
cov_shards: ${{ steps.gen.outputs.cov_shards }}
cov_total: ${{ steps.gen.outputs.cov_total }}
steps:
- id: gen
run: |
TOTAL=3 # cross-platform (windows/macOS) shards per OS
COV_TOTAL=3 # ubuntu coverage shards (merged before thresholds)
if [ "$TOTAL" -lt 1 ] || [ "$COV_TOTAL" -lt 1 ]; then
echo "shard totals must be >= 1" >&2; exit 1
fi
{
echo "shards=$(jq -nc --argjson n "$TOTAL" '[range(1; $n + 1)]')"
echo "total=$TOTAL"
echo "cov_shards=$(jq -nc --argjson n "$COV_TOTAL" '[range(1; $n + 1)]')"
echo "cov_total=$COV_TOTAL"
} >> "$GITHUB_OUTPUT"
# Platform-sensitive subset only — the full suite runs on Ubuntu above.
# See gitnexus/scripts/cross-platform-tests.ts for the file list and
# rationale for each included test.
cross-platform:
name: ${{ matrix.os }} (platform-sensitive)
name: ${{ matrix.os }} (platform-sensitive) ${{ matrix.shard }}/${{ needs.shard-plan.outputs.total }}
needs: shard-plan
strategy:
fail-fast: false
matrix:
# Ubuntu already covered by the coverage job above
os: [windows-latest, macos-latest]
# Shard the fixed file list across N runners per OS (N = TOTAL in the
# shard-plan job). The suite is dominated by ~50 CLI/worker process
# spawns and Windows is ~5x slower than macOS at those, so the unsharded
# run crept past the 15-min watchdog in run-cross-platform.ts. vitest
# shards by file COUNT, not runtime, so the heaviest spawn suites can
# cluster on one shard. The busiest Windows shard has grown to the old
# 15-minute watchdog (14m57s on the v1.6.10-rc.19 green run, one
# observed timeout since — #2449), so the job env below raises the
# per-shard watchdog to 20 minutes, still bounded by timeout-minutes.
# Shard indices come from the shard-plan job (single source of truth):
# its TOTAL drives this list and the /N in the job name + --shard arg.
shard: ${{ fromJSON(needs.shard-plan.outputs.shards) }}
runs-on: ${{ matrix.os }}
timeout-minutes: 20
timeout-minutes: 25
# Same guarantee on the platform-sensitive runners: FTS-dependent suites in
# the cross-platform subset must run, not silently skip.
#
# GITNEXUS_E2E_CLI=dist: the e2e suites spawn the CLI ~50 times; each spawn via
# `node --import tsx src/cli/index.ts` re-transpiles the whole CLI, and Windows
# is ~5x slower at process startup. `build: true` below produces a fresh dist
# before tests, so opting these runners into the built CLI removes that
# per-spawn transpile (see test/helpers/cli-entry.ts). Deliberately scoped to
# THIS job: the Ubuntu coverage job leaves it unset, so it keeps exercising the
# tsx-on-source path in CI (both entry points stay covered).
env:
GITNEXUS_REQUIRE_FTS: '1'
GITNEXUS_E2E_CLI: dist
# #2449: hosted Windows runners intermittently push the busiest shard past
# the default 15-minute watchdog. 20 minutes restores real headroom while
# the 25-minute job timeout above still bounds a genuine hang.
GITNEXUS_CROSS_PLATFORM_TIMEOUT_MINUTES: '20'
steps:
# persist-credentials: false — runs tests only, never pushes (zizmor
# credential-persistence / artipacked audit).
@ -94,8 +219,30 @@ jobs:
- uses: ./.github/actions/setup-gitnexus
with:
build: 'true'
# Warm-cache the installed LadybugDB FTS extension (~/.lbdb/extension) per
# OS + lockfile so a warm run skips the network install entirely, and the
# parallel shards share one download across runs. Pure reliability/speed:
# on a cache miss the tests self-install FTS on demand (see
# test/helpers/fts-availability.ts), so a miss just falls back to install —
# never a correctness dependency. Keyed by lockfile hash so a LadybugDB
# version bump re-installs; per-OS because the extension is a native binary.
- name: Cache LadybugDB FTS extension
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v5
with:
path: ~/.lbdb/extension
key: lbug-fts-${{ runner.os }}-${{ hashFiles('gitnexus/package-lock.json') }}
- name: Ensure FTS extension installed
run: npx tsx scripts/ensure-fts.ts
working-directory: gitnexus
- name: Run platform-sensitive tests
run: npx tsx scripts/run-cross-platform.ts
# Pass the shard through an env var (not `${{ }}` inlined into the shell)
# so it isn't a template-injection sink (zizmor). shell: bash makes the
# `"$SHARD"` expansion uniform across the windows + macOS matrix (the
# default run shell is pwsh on Windows, where `$SHARD` would be empty).
shell: bash
env:
SHARD: ${{ matrix.shard }}/${{ needs.shard-plan.outputs.total }}
run: npx tsx scripts/run-cross-platform.ts --shard="$SHARD"
working-directory: gitnexus
# Tree-sitter ABI gate (#1922). Two halves, both blocking:
@ -231,6 +378,64 @@ jobs:
"$PREFIX/bin/gitnexus" --version
fi
# Node engines-floor gate (#2372). The embedding resolvers statically named
# `module.registerHooks`, which only exists on Node >= 22.15 / >= 23.5, so on
# the supported floor (engines: >=22.0.0) those ESM modules failed to LINK —
# a class vitest/tsx transforms structurally mask, and the default
# `node-version: 22` (resolves to latest) never hits. Build the dist on 22.x,
# then import-link every module R1 names as a load surface on a pinned 22.14
# so a regression fails here instead of shipping to users on that Node range.
node-floor-compat:
name: node floor compat (22.14)
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
# persist-credentials: false — builds and import-links only, never pushes
# (zizmor credential-persistence / artipacked audit).
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '22'
cache: npm
cache-dependency-path: gitnexus/package-lock.json
- name: Build gitnexus-shared
run: npm install && npm run build
working-directory: gitnexus-shared
- name: Install and build gitnexus
shell: bash
run: |
set -euo pipefail
npm ci
npm run build
working-directory: gitnexus
# Switch to the engines-floor Node AFTER building — native deps built on
# 22.x load across the whole 22.x ABI line, and nothing installs after this
# (so no package-manager cache is needed).
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '22.14.0'
package-manager-cache: false
- name: Import-link the built dist on Node 22.14
shell: bash
run: |
set -euo pipefail
node --version
node --version | grep -q '^v22\.14\.' || { echo "expected Node 22.14.x" >&2; exit 1; }
for m in \
core/embeddings/runtime-install \
core/embeddings/onnxruntime-node-resolver \
core/embeddings/onnxruntime-common-resolver \
cli/embeddings \
cli/analyze \
cli/doctor \
mcp/core/embedder; do
echo "import dist/$m.js"
node --input-type=module -e "await import('./dist/$m.js')"
done
working-directory: gitnexus
# ── Dedicated benchmark gate ─────────────────────────────────────
# The cross-language `*-pipeline-benchmark.test.ts` suites are gated behind
# GITNEXUS_BENCH (they generate synthetic codebases at scale), so the main

View file

@ -138,17 +138,17 @@ jobs:
# Required for multi-platform (linux/arm64) emulation.
- name: Set up QEMU
uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Install Cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
- name: Log in to GitHub Container Registry
if: ${{ github.event_name != 'pull_request' && !inputs.dry_run }}
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@ -163,7 +163,7 @@ jobs:
# `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@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
@ -256,7 +256,7 @@ jobs:
# 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
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
with:
subject-name: ghcr.io/${{ github.repository_owner }}/${{ matrix.image.slug }}
subject-digest: ${{ steps.build.outputs.digest }}
@ -264,7 +264,7 @@ jobs:
- 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
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
with:
subject-name: docker.io/akonlabs/${{ matrix.image.slug }}
subject-digest: ${{ steps.build.outputs.digest }}

View file

@ -108,7 +108,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@ed4bc48ec97379be2258e7b7ac2624a3e26ab809 # v7.4.0
- uses: release-drafter/release-drafter@4d75298e00d9e34c483e5ff8c68d0ea1c1940c1e # v7.5.1
with:
config-name: release-drafter.yml
dry-run: true

View file

@ -423,6 +423,10 @@ jobs:
echo "::error::Tag version (v$TAG_VERSION) does not match package.json version ($PKG_VERSION)"
exit 1
fi
# Stable releases carry their version bump on main via the release
# PR, so the manifest surfaces must already be in sync — refuse to
# publish a stable whose manifests drifted (#2445).
node scripts/sync-plugin-manifests.mjs --check
echo "Version verified: $PKG_VERSION"
# ── RC-only: compute the next rc version against the live registry ──
@ -584,6 +588,17 @@ jobs:
npm version "${{ steps.rc-version.outputs.rc_version }}" \
--no-git-tag-version --allow-same-version
# ── Verify the plugin manifest surfaces synced (#2445) ───────────────
# The npm `version` lifecycle script in gitnexus/package.json syncs all
# four manifest surfaces whenever `npm version` runs (the step above,
# and a maintainer's laptop alike). This step only verifies fail-closed
# so a future removal of that wiring cannot ship a drifted RC again.
- name: Verify plugin manifests (rc)
if: needs.route.outputs.mode == 'rc'
shell: bash
working-directory: gitnexus
run: node scripts/sync-plugin-manifests.mjs --check
- name: Build gitnexus
run: npm run build
working-directory: gitnexus
@ -669,6 +684,12 @@ jobs:
# pristine, but the v-tag's tree matches the published package
# exactly (release-integrity).
git add package.json package-lock.json 2>/dev/null || git add package.json
# The synced manifest surfaces (#2445) belong in the same detached
# release commit so the tag's tree passes its own version contract.
git add ../gitnexus-claude-plugin/.claude-plugin/plugin.json \
../.claude-plugin/marketplace.json \
../gitnexus-claude-plugin/.codex-plugin/plugin.json \
../.agents/plugins/marketplace.json
git commit -m "release: ${VTAG}" --allow-empty
RELEASE_SHA="$(git rev-parse HEAD)"
echo "Detached release commit: $RELEASE_SHA"
@ -807,7 +828,7 @@ jobs:
fi
- name: Create GitHub Release
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v2
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v2
with:
tag_name: ${{ steps.vtag-gate.outputs.vtag }}
name: >-

View file

@ -66,7 +66,7 @@ jobs:
fetch-depth: 1
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
with:
python-version: '3.12'
cache: pip
@ -76,7 +76,7 @@ jobs:
run: pip install -r .github/scripts/triage/requirements.txt
- name: Cache FastEmbed model weights
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v5
with:
path: ${{ github.workspace }}/.fastembed_cache
key: fastembed-bge-small-en-v1.5

View file

@ -50,10 +50,10 @@ jobs:
persist-credentials: false
- name: Setup Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Build image (load locally for scan)
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
file: ${{ matrix.image.dockerfile }}

View file

@ -40,7 +40,7 @@ jobs:
# The action wraps the upstream `rhysd/actionlint` binary and emits
# GitHub-annotation-formatted findings on PRs.
- name: Run actionlint
uses: raven-actions/actionlint@205b530c5d9fa8f44ae9ed59f341a0db994aa6f8 # v2.1.2
uses: raven-actions/actionlint@3d39aea434753780c3b3d4a1a31c854b4dbf49d7 # v2.2.0
with:
fail-on-error: true
@ -58,7 +58,7 @@ jobs:
persist-credentials: false
- name: Setup Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
with:
python-version: '3.12'

13
.gitignore vendored
View file

@ -99,6 +99,12 @@ gitnexus/vendor/**/node_modules/
.claude/helpers
.claude/skills/*
!.claude/skills/gitnexus/
!.claude/skills/gitnexus-cli/
!.claude/skills/gitnexus-debugging/
!.claude/skills/gitnexus-exploring/
!.claude/skills/gitnexus-guide/
!.claude/skills/gitnexus-impact-analysis/
!.claude/skills/gitnexus-refactoring/
!.claude/skills/gitnexus-pr-swarm-review/
.history/
@ -108,8 +114,13 @@ gitnexus/vendor/**/node_modules/
local_docs/
# Local agent scratch / review prompts (never commit)
# (.agents/plugins/marketplace.json is the checked-in Codex plugin
# marketplace registry — the rest of .agents/ stays local scratch.)
.tmp/
.agents/
.agents/*
!.agents/plugins/
.agents/plugins/*
!.agents/plugins/marketplace.json
.context/
gitnexus/web/
/log/

2
.gitleaksignore Normal file
View file

@ -0,0 +1,2 @@
# Deleted README placeholder from PR #2458; no credential was present.
c9fdab17f25ebaf332fba6e6ba55ee328f20fe66:README.md:curl-auth-header:348

View file

@ -41,7 +41,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 & inheritance resolution (RFC #909 Ring 3):** See ARCHITECTURE.md § Scope-Resolution Pipeline. All languages resolve calls and inheritance through the scope-resolution pipeline (`Registry.lookup`, `preEmitInheritanceEdges`, `emitHeritageEdges`, `buildMro` → `MethodDispatchIndex`). **Shared code in `gitnexus/src/core/ingestion/` must not name languages** — plug language behavior in via `LanguageProvider` / `ScopeResolver` hooks. A language plugs in by implementing `ScopeResolver` (`scope-resolution/contract/scope-resolver.ts`) and registering it in `SCOPE_RESOLVERS`. (The legacy call-resolution DAG + `@heritage` capture path were removed in RING4-1 #942.)
- **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.
- **GitNexus:** standard skills in `.claude/skills/gitnexus-*/`; MCP rules in `gitnexus:start` block below.
## PR Swarm Review (cross-CLI)
@ -106,32 +106,32 @@ This project is indexed by GitNexus as **GitNexus** (26675 symbols, 35395 relati
| Task | Read this skill file |
|------|---------------------|
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` |
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` |
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` |
| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |
| Work in the Ingestion area (239 symbols) | `.claude/skills/generated/ingestion/SKILL.md` |
| Work in the Extractors area (135 symbols) | `.claude/skills/generated/extractors/SKILL.md` |
| Work in the Components area (112 symbols) | `.claude/skills/generated/components/SKILL.md` |
| Work in the Lbug area (96 symbols) | `.claude/skills/generated/lbug/SKILL.md` |
| Work in the Group area (94 symbols) | `.claude/skills/generated/group/SKILL.md` |
| Work in the Cli area (92 symbols) | `.claude/skills/generated/cli/SKILL.md` |
| Work in the Configs area (92 symbols) | `.claude/skills/generated/configs/SKILL.md` |
| Work in the Type-extractors area (90 symbols) | `.claude/skills/generated/type-extractors/SKILL.md` |
| Work in the Hooks area (88 symbols) | `.claude/skills/generated/hooks/SKILL.md` |
| Work in the Unit area (80 symbols) | `.claude/skills/generated/unit/SKILL.md` |
| Work in the Cpp area (73 symbols) | `.claude/skills/generated/cpp/SKILL.md` |
| Work in the Scope-resolution area (72 symbols) | `.claude/skills/generated/scope-resolution/SKILL.md` |
| Work in the Server area (66 symbols) | `.claude/skills/generated/server/SKILL.md` |
| Work in the Local area (61 symbols) | `.claude/skills/generated/local/SKILL.md` |
| Work in the Wiki area (60 symbols) | `.claude/skills/generated/wiki/SKILL.md` |
| Work in the Workers area (57 symbols) | `.claude/skills/generated/workers/SKILL.md` |
| Work in the Embeddings area (56 symbols) | `.claude/skills/generated/embeddings/SKILL.md` |
| Work in the Typescript area (53 symbols) | `.claude/skills/generated/typescript/SKILL.md` |
| Work in the Storage area (51 symbols) | `.claude/skills/generated/storage/SKILL.md` |
| Work in the Php area (48 symbols) | `.claude/skills/generated/php/SKILL.md` |
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus-exploring/SKILL.md` |
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus-impact-analysis/SKILL.md` |
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus-debugging/SKILL.md` |
| Rename / extract / split / refactor | `.claude/skills/gitnexus-refactoring/SKILL.md` |
| Tools, resources, schema reference | `.claude/skills/gitnexus-guide/SKILL.md` |
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus-cli/SKILL.md` |
| Work in the Ingestion area (239 symbols) | `.claude/skills/gitnexus-area-ingestion/SKILL.md` |
| Work in the Extractors area (135 symbols) | `.claude/skills/gitnexus-area-extractors/SKILL.md` |
| Work in the Components area (112 symbols) | `.claude/skills/gitnexus-area-components/SKILL.md` |
| Work in the Lbug area (96 symbols) | `.claude/skills/gitnexus-area-lbug/SKILL.md` |
| Work in the Group area (94 symbols) | `.claude/skills/gitnexus-area-group/SKILL.md` |
| Work in the Cli area (92 symbols) | `.claude/skills/gitnexus-area-cli/SKILL.md` |
| Work in the Configs area (92 symbols) | `.claude/skills/gitnexus-area-configs/SKILL.md` |
| Work in the Type-extractors area (90 symbols) | `.claude/skills/gitnexus-area-type-extractors/SKILL.md` |
| Work in the Hooks area (88 symbols) | `.claude/skills/gitnexus-area-hooks/SKILL.md` |
| Work in the Unit area (80 symbols) | `.claude/skills/gitnexus-area-unit/SKILL.md` |
| Work in the Cpp area (73 symbols) | `.claude/skills/gitnexus-area-cpp/SKILL.md` |
| Work in the Scope-resolution area (72 symbols) | `.claude/skills/gitnexus-area-scope-resolution/SKILL.md` |
| Work in the Server area (66 symbols) | `.claude/skills/gitnexus-area-server/SKILL.md` |
| Work in the Local area (61 symbols) | `.claude/skills/gitnexus-area-local/SKILL.md` |
| Work in the Wiki area (60 symbols) | `.claude/skills/gitnexus-area-wiki/SKILL.md` |
| Work in the Workers area (57 symbols) | `.claude/skills/gitnexus-area-workers/SKILL.md` |
| Work in the Embeddings area (56 symbols) | `.claude/skills/gitnexus-area-embeddings/SKILL.md` |
| Work in the Typescript area (53 symbols) | `.claude/skills/gitnexus-area-typescript/SKILL.md` |
| Work in the Storage area (51 symbols) | `.claude/skills/gitnexus-area-storage/SKILL.md` |
| Work in the Php area (48 symbols) | `.claude/skills/gitnexus-area-php/SKILL.md` |
<!-- gitnexus:end -->

View file

@ -15,9 +15,9 @@ Monorepo: **CLI/MCP** (`gitnexus/`) + **browser UI** (`gitnexus-web/`).
## End-to-end flow: index → graph → tools
1. **Ingestion** — `analyze.ts` → `runFullAnalysis` (`run-analyze.ts`) → `runPipelineFromRepo` (`pipeline.ts`). DAG of 14 phases builds a `KnowledgeGraph` in memory, then loads into LadybugDB under `.gitnexus/`. Repo registered in `~/.gitnexus/registry.json` for MCP discovery.
1. **Ingestion** — `analyze.ts` → `runFullAnalysis` (`run-analyze.ts`) → `runPipelineFromRepo` (`pipeline.ts`). DAG of 15 phases builds a `KnowledgeGraph` in memory, then loads into LadybugDB under `.gitnexus/`. Repo registered in `~/.gitnexus/registry.json` for MCP discovery.
2. **Persistence** — `repo-manager.ts` (paths, registry, KuzuDB cleanup). `lbug-adapter.ts` (graph load, queries, embedding batches).
2. **Persistence** — `repo-manager.ts` (paths, registry, LadybugDB cleanup). `lbug-adapter.ts` (graph load, queries, embedding batches).
3. **Query layer** — three interfaces to the same backend:
- **MCP (stdio):** `mcp.ts` → `LocalBackend` → tools (`tools.ts`) + resources (`resources.ts`)
@ -82,11 +82,11 @@ Group-mode `trace` (`gitnexus/src/core/group/cross-trace.ts`) stitches a path th
## Pipeline Phase DAG
14 phases defined in `gitnexus/src/core/ingestion/pipeline-phases/`, each with explicit `deps` and typed output.
15 phases defined in `gitnexus/src/core/ingestion/pipeline-phases/`, each with explicit `deps` and typed output.
```
scan → structure → [markdown, cobol] → parse → [routes, tools, orm]
→ crossFile → scopeResolution → pruneLocalSymbols → mro → communities → processes
→ crossFile → scopeResolution → pruneLocalSymbols → mro → di → communities → processes
```
| Phase | File | Deps | Output |
@ -103,6 +103,7 @@ scan → structure → [markdown, cobol] → parse → [routes, tools, orm]
| `scopeResolution` | `scope-resolution/pipeline/phase.ts` | `parse`, `crossFile`, `structure` | Binding/reference + inheritance edges; disposes BindingAccumulator |
| `pruneLocalSymbols` | `prune-local-symbols.ts` | `scopeResolution` | Drops inert block-local `Const`/`Variable`/`Static` nodes (only a `File→DEFINES` edge) post-resolution |
| `mro` | `mro.ts` | `crossFile`, `scopeResolution`, `pruneLocalSymbols`, `structure` | METHOD_OVERRIDES + METHOD_IMPLEMENTS edges |
| `di` | `di.ts` | `mro` | INJECTS edges (framework-neutral DI resolution; per-language matchers registered in `di-extractors/`) |
| `communities` | `communities.ts` | `mro`, `pruneLocalSymbols`, `structure` | Community nodes + MEMBER_OF edges (Leiden algorithm) |
| `processes` | `processes.ts` | `communities`, `routes`, `tools`, `pruneLocalSymbols`, `structure` | Process nodes + STEP_IN_PROCESS edges |
@ -126,7 +127,7 @@ scan → structure → [markdown, cobol] → parse → [routes, tools, orm]
- **Single graph accumulator** — all phases mutate the same `KnowledgeGraph` in `ctx`; the graph is the primary output.
- **Typed phase access** — `getPhaseOutput<T>(deps, 'name')` for type-safe upstream results.
- **Binding accumulator lifecycle** — created in `parse`, disposed by `crossFile` (in `finally`). No other phase should take ownership.
- **Skippable phases** — `skipGraphPhases` omits MRO/communities/processes (faster tests); `pruneLocalSymbols` still runs (it is graph cleanup, not analysis). `skipWorkers` is no longer a sequential escape hatch — it (like `--workers 0` / `GITNEXUS_WORKER_POOL_SIZE=0`) is rejected with an actionable error, since the worker pool is the sole parse path (§ Chunked parse-and-resolve).
- **Skippable phases** — `skipGraphPhases` omits MRO/di/communities/processes (faster tests); `pruneLocalSymbols` still runs (it is graph cleanup, not analysis). `skipWorkers` is no longer a sequential escape hatch — it (like `--workers 0` / `GITNEXUS_WORKER_POOL_SIZE=0`) is rejected with an actionable error, since the worker pool is the sole parse path (§ Chunked parse-and-resolve).
- **Local-symbol pruning** — `pruneLocalSymbols` removes inert block-local value symbols after scope resolution has consumed them. Opt out per-call with `PipelineOptions.keepLocalValueSymbols` or globally with the `GITNEXUS_KEEP_LOCAL_VALUE_SYMBOLS` env var.
### How to add a new phase
@ -381,8 +382,11 @@ CLI (analyze.ts) → runFullAnalysis(repoPath, options, callbacks)
<repo>/.gitnexus/
├── lbug # LadybugDB database
├── lbug.wal # Write-ahead log
├── lbug.shadow # Shadow sidecar (checkpoint staging)
├── lbug.lock # Single-writer lock
└── meta.json # lastCommit, indexedAt, stats
├── lbug.{wal,shadow}.dirty-recovery # parked sidecars from a crashed run; safe to delete
├── gitnexus.json # lastCommit, indexedAt, stats (primary metadata file)
└── meta.json # legacy mirror of gitnexus.json, kept in sync (see MIGRATION.md)
~/.gitnexus/
└── registry.json # Global repo registry (MCP discovery)

View file

@ -36,7 +36,7 @@ If always-on instructions grow, load deep conventions via conditional reads (e.g
- **This repository:** [AGENTS.md](AGENTS.md) (Cursor + monorepo notes), [ARCHITECTURE.md](ARCHITECTURE.md), [CONTRIBUTING.md](CONTRIBUTING.md), [GUARDRAILS.md](GUARDRAILS.md).
- **Call & inheritance resolution:** See ARCHITECTURE.md § Scope-Resolution Pipeline. Shared pipeline code in `gitnexus/src/core/ingestion/` must not name languages — use `LanguageProvider` / `ScopeResolver` hooks instead (see AGENTS.md). (The legacy call-resolution DAG was removed in #942.)
- **GitNexus:** `.claude/skills/gitnexus/`; MCP and indexed-repo rules live only in [AGENTS.md](AGENTS.md) (`gitnexus:start` … `gitnexus:end`). See **GitNexus rules** below.
- **GitNexus:** standard skills in `.claude/skills/gitnexus-*/`; MCP and indexed-repo rules live only in [AGENTS.md](AGENTS.md) (`gitnexus:start` … `gitnexus:end`). See **GitNexus rules** below.
## Changelog
@ -88,31 +88,31 @@ This project is indexed by GitNexus as **GitNexus** (26675 symbols, 35395 relati
| Task | Read this skill file |
|------|---------------------|
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` |
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` |
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` |
| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |
| Work in the Ingestion area (239 symbols) | `.claude/skills/generated/ingestion/SKILL.md` |
| Work in the Extractors area (135 symbols) | `.claude/skills/generated/extractors/SKILL.md` |
| Work in the Components area (112 symbols) | `.claude/skills/generated/components/SKILL.md` |
| Work in the Lbug area (96 symbols) | `.claude/skills/generated/lbug/SKILL.md` |
| Work in the Group area (94 symbols) | `.claude/skills/generated/group/SKILL.md` |
| Work in the Cli area (92 symbols) | `.claude/skills/generated/cli/SKILL.md` |
| Work in the Configs area (92 symbols) | `.claude/skills/generated/configs/SKILL.md` |
| Work in the Type-extractors area (90 symbols) | `.claude/skills/generated/type-extractors/SKILL.md` |
| Work in the Hooks area (88 symbols) | `.claude/skills/generated/hooks/SKILL.md` |
| Work in the Unit area (80 symbols) | `.claude/skills/generated/unit/SKILL.md` |
| Work in the Cpp area (73 symbols) | `.claude/skills/generated/cpp/SKILL.md` |
| Work in the Scope-resolution area (72 symbols) | `.claude/skills/generated/scope-resolution/SKILL.md` |
| Work in the Server area (66 symbols) | `.claude/skills/generated/server/SKILL.md` |
| Work in the Local area (61 symbols) | `.claude/skills/generated/local/SKILL.md` |
| Work in the Wiki area (60 symbols) | `.claude/skills/generated/wiki/SKILL.md` |
| Work in the Workers area (57 symbols) | `.claude/skills/generated/workers/SKILL.md` |
| Work in the Embeddings area (56 symbols) | `.claude/skills/generated/embeddings/SKILL.md` |
| Work in the Typescript area (53 symbols) | `.claude/skills/generated/typescript/SKILL.md` |
| Work in the Storage area (51 symbols) | `.claude/skills/generated/storage/SKILL.md` |
| Work in the Php area (48 symbols) | `.claude/skills/generated/php/SKILL.md` |
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus-exploring/SKILL.md` |
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus-impact-analysis/SKILL.md` |
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus-debugging/SKILL.md` |
| Rename / extract / split / refactor | `.claude/skills/gitnexus-refactoring/SKILL.md` |
| Tools, resources, schema reference | `.claude/skills/gitnexus-guide/SKILL.md` |
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus-cli/SKILL.md` |
| Work in the Ingestion area (239 symbols) | `.claude/skills/gitnexus-area-ingestion/SKILL.md` |
| Work in the Extractors area (135 symbols) | `.claude/skills/gitnexus-area-extractors/SKILL.md` |
| Work in the Components area (112 symbols) | `.claude/skills/gitnexus-area-components/SKILL.md` |
| Work in the Lbug area (96 symbols) | `.claude/skills/gitnexus-area-lbug/SKILL.md` |
| Work in the Group area (94 symbols) | `.claude/skills/gitnexus-area-group/SKILL.md` |
| Work in the Cli area (92 symbols) | `.claude/skills/gitnexus-area-cli/SKILL.md` |
| Work in the Configs area (92 symbols) | `.claude/skills/gitnexus-area-configs/SKILL.md` |
| Work in the Type-extractors area (90 symbols) | `.claude/skills/gitnexus-area-type-extractors/SKILL.md` |
| Work in the Hooks area (88 symbols) | `.claude/skills/gitnexus-area-hooks/SKILL.md` |
| Work in the Unit area (80 symbols) | `.claude/skills/gitnexus-area-unit/SKILL.md` |
| Work in the Cpp area (73 symbols) | `.claude/skills/gitnexus-area-cpp/SKILL.md` |
| Work in the Scope-resolution area (72 symbols) | `.claude/skills/gitnexus-area-scope-resolution/SKILL.md` |
| Work in the Server area (66 symbols) | `.claude/skills/gitnexus-area-server/SKILL.md` |
| Work in the Local area (61 symbols) | `.claude/skills/gitnexus-area-local/SKILL.md` |
| Work in the Wiki area (60 symbols) | `.claude/skills/gitnexus-area-wiki/SKILL.md` |
| Work in the Workers area (57 symbols) | `.claude/skills/gitnexus-area-workers/SKILL.md` |
| Work in the Embeddings area (56 symbols) | `.claude/skills/gitnexus-area-embeddings/SKILL.md` |
| Work in the Typescript area (53 symbols) | `.claude/skills/gitnexus-area-typescript/SKILL.md` |
| Work in the Storage area (51 symbols) | `.claude/skills/gitnexus-area-storage/SKILL.md` |
| Work in the Php area (48 symbols) | `.claude/skills/gitnexus-area-php/SKILL.md` |
<!-- gitnexus:end -->

View file

@ -16,9 +16,14 @@ This project uses the [PolyForm Noncommercial License 1.0.0](https://polyformpro
**Prerequisites:** Node.js — `gitnexus/` requires `>=22.0.0` and `gitnexus-web/` requires `^20.19.0 || >=22.12.0` (enforced via the `engines` field in each package). Use `nvm install` to match the local version.
1. Clone the repository.
2. **CLI / MCP package:** `cd gitnexus && npm install && npm run build`
3. **Web UI (if needed):** `cd gitnexus-web && npm install`
4. Run tests as described in [TESTING.md](TESTING.md).
2. **Shared package:** `cd gitnexus-shared && npm install && npm run build`
3. **CLI / MCP package:** `cd ../gitnexus && npm install && npm run build`
4. **Web UI (if needed):** `cd ../gitnexus-web && npm install`
5. Run tests as described in [TESTING.md](TESTING.md).
The CLI build imports `gitnexus-shared`, so a fresh clone must install and build
the shared package before running `npm install` in `gitnexus/`. This is the same
order used by the repository's `setup-gitnexus` CI action.
### Containerized development (optional)
@ -169,7 +174,9 @@ routes between two modes based on the triggering event:
not enforce branch reachability. No Docker build (RC-only). Before cutting a
stable release, keep `gitnexus/package.json`,
`gitnexus-claude-plugin/.claude-plugin/plugin.json`,
`.claude-plugin/marketplace.json`, and the matching `CHANGELOG.md` entry in
`.claude-plugin/marketplace.json`,
`gitnexus-claude-plugin/.codex-plugin/plugin.json`,
`.agents/plugins/marketplace.json`, and the matching `CHANGELOG.md` entry in
lockstep — the always-on `gitnexus` unit suite now fails if those manifest
versions drift.
- **Release-candidate mode** — runs on every push to `main` (typically a

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

View file

@ -0,0 +1,76 @@
# Connect GitNexus to Kilo Code via MCP
This guide shows how to connect GitNexus to the Kilo Code VS Code extension using Kilo’s MCP support, based on a setup that has been tested successfully.
## Prerequisites
GitNexus should already be installed globally and working on the target repository, and the repository should be indexed successfully with `gitnexus analyze` before testing inside Kilo.
## Tested Versions
| Component | Version |
| --- | --- |
| VS Code | 1.125.1 (user setup) |
| Node.js | 24.15.0 |
| Kilo Code | 7.3.50 |
| OS | Windows 11 25H2 / Windows_NT x64 10.0.26200 |
| GitNexus | 1.6.7 |
## Where Kilo Stores MCP Config
Kilo Code stores MCP server configuration in its main config file. For the VS Code extension, config can be stored at either the global or project level.
| Scope | Config path |
| --- | --- |
| Global | `~/.config/kilo/kilo.jsonc` |
| Project | `kilo.jsonc` or `.kilo/kilo.jsonc` in the project root |
Check latest path : https://kilo.ai/docs/automate/mcp/using-in-kilo-code
## Add GitNexus as an MCP Server
Kilo supports local MCP servers through STDIO, and GitNexus should be added as a local server under the `mcp` key in `kilo.jsonc`. Use this configuration:
```jsonc
{
"mcp": {
"gitnexus": {
"type": "local",
"command": ["npx", "-y", "gitnexus@latest", "mcp"],
"enabled": true,
"timeout": 10000
}
}
}
```
## Check It Through the Kilo UI
1. restart kilo code extension or vs code
2. open kilo code settings
3. select mcp server section
#### From there, Kilo allows adding, editing, enabling, disabling, and deleting MCP servers, and it writes changes directly to the appropriate config file.
![alt text](docs-asset/kilo-code-mcp.png)
## Test the Connection
After configuration, Kilo automatically detects the tools exposed by the MCP server and can use them from chat once the server is available.
A practical test flow is:
1. Open the indexed repository in VS Code.
2. Confirm `gitnexus analyze`completed successfully.
3. Open Kilo chat and ask: `Use GitNexus and explain What does index.php do?`.
4. Approve the MCP tool call if prompted.
#### Full Support will be added Soon 😎
## Troubleshooting
1. If the server shows `failed`, check the CLI output and confirm the command and paths are correct.
2. If no tools appear, confirm the MCP server is enabled and GitNexus is exposing the expected tools.
3. If Kilo does not automatically select GitNexus, note the exact settings you changed and mark them as an observed workaround.

View file

@ -19,7 +19,7 @@ Maintainer may widen scope per task.
2. **Never rename with find-and-replace** in GitNexus-indexed projects — use `rename` MCP tool with `dry_run: true` first, review `graph` vs `text_search` edits. No separate `gitnexus rename` CLI exists.
3. **Run impact analysis before editing shared symbols** — `impact` (upstream) for functions/classes/methods others call. Do not ignore HIGH/CRITICAL without maintainer sign-off.
4. **Run `detect_changes` before commit** — confirm diffs map to expected symbols/processes when the graph is available.
5. **Preserve embeddings** — 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).
5. **Preserve embeddings** — plain `npx gitnexus analyze` now preserves any embeddings recorded in the index metadata (`.gitnexus/gitnexus.json`, mirrored to the legacy `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).
---
@ -30,20 +30,20 @@ Format: **Trigger → Instruction → Reason**. Append new Signs when the same m
### Stale graph after edits
- **Trigger:** MCP warns index is behind `HEAD`, or search doesn't match latest commit.
- **Do:** `npx gitnexus analyze` (plus `--embeddings` if used). Runs incrementally by default — the pipeline parses every file every run (cross-file resolution requires it), but tree-sitter dispatch is skipped for unchanged file chunks via the content-addressed cache, and only changed-file rows (plus their importers, transitively) are rewritten in LadybugDB.
- **Do:** `npx gitnexus analyze` (plus `--embeddings` if used). Runs incrementally by default — the pipeline parses every file every run (cross-file resolution requires it), but tree-sitter dispatch is skipped for unchanged file chunks via the content-addressed cache, and only changed-file rows (plus their importers, transitively) are rewritten in LadybugDB. When the effective write set exceeds ~50% of the repo's files (minimum 50 files), the run transparently switches to the full wipe + bulk-COPY write plan and logs "switching to a full DB write" — expected behavior, not a bug, and file-level bookkeeping stays incremental.
- **Why:** Tools query LadybugDB from last analyze; git changes are invisible until re-indexed.
### Index seems corrupt or "incremental" is misbehaving
- **Trigger:** `analyze` produces unexpected results, or `meta.json.incrementalInProgress` is set, or the index is in a half-state after a crash.
- **Do:** `npx gitnexus analyze --force` to rebuild from scratch. The dirty-flag check forces this automatically when a previous incremental run didn't complete cleanly, but `--force` is the manual escape hatch. Safe to delete the `.gitnexus/parse-cache/` directory (and any legacy `.gitnexus/parse-cache.json`) at any time — content-addressed, will be regenerated.
- **Trigger:** `analyze` produces unexpected results, or `incrementalInProgress` is set in the index metadata (`.gitnexus/gitnexus.json` / legacy `meta.json`), or the index is in a half-state after a crash.
- **Do:** `npx gitnexus analyze --force` to rebuild from scratch. The dirty-flag check forces this automatically when a previous incremental run didn't complete cleanly, but `--force` is the manual escape hatch. A dirty-flag recovery rebuild parks the interrupted run's sidecars beside the DB as `lbug.wal.dirty-recovery` / `lbug.shadow.dirty-recovery` for post-mortem debugging — harmless, and removable with `npx gitnexus clean --lbug-sidecars`. Safe to delete the `.gitnexus/parse-cache/` directory (and any legacy `.gitnexus/parse-cache.json`) at any time — content-addressed, will be regenerated.
- **Why:** Incremental writeback is selective DB row replacement; if the on-disk state is inconsistent for any reason, a full rebuild is the cheapest path back to a known-good index.
### Embeddings vanished after analyze
- **Trigger:** Semantic search quality drops; `stats.embeddings` in `meta.json` is 0 after refresh.
- **Trigger:** Semantic search quality drops; `stats.embeddings` in the index metadata (`gitnexus.json` / legacy `meta.json`) is 0 after refresh.
- **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.
- **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. A dirty-recovery run that cannot move the crashed WAL aside now either discards it (logged: forensics lost, embeddings still preserved) or fails fast with a lock error naming the holder — it never silently zeroes embeddings.
### MCP lists no repos
@ -61,7 +61,7 @@ Format: **Trigger → Instruction → Reason**. Append new Signs when the same m
- **Trigger:** Errors opening `.gitnexus/lbug` while MCP and analyze both run.
- **Do:** Stop overlapping processes (one writer at a time). Retry analyze or restart MCP.
- **Why:** Embedded DB expects single-process ownership.
- **Why:** Embedded DB expects single-process ownership. `@ladybugdb/core` 0.18.0 also reports this contention as `"Only one write transaction at a time is allowed in the system."` — our busy/lock retry matcher (`isDbBusyError` in `src/core/lbug/lbug-config.ts`) recognizes this exact string too, so it's auto-retried the same as any other lock error. If you see that exact message, it's the same "one writer at a time" issue above, not a new failure mode.
---

View file

@ -69,3 +69,44 @@ normal full re-index.
The `OVERRIDES` compat alias will remain until a future major version. Removal
will be announced in this file and in the changelog before it happens.
## meta.json → gitnexus.json (PR #2363)
The per-repo index metadata file's primary name changed from
`.gitnexus/meta.json` to `.gitnexus/gitnexus.json` (and from
`branches/<slug>/meta.json` to `branches/<slug>/gitnexus.json` for
multi-branch indexes). This is purely a filename change — the JSON content
and every field in it are identical.
### Do I need to migrate?
**No.** Backward compatibility is handled automatically at runtime:
- `saveMeta` dual-writes both filenames on every analyze, so `meta.json`
keeps existing and staying current. Older GitNexus binaries, still-running
MCP servers, and the shipped editor hooks that read `meta.json` continue
to work unchanged.
- `loadMeta` reads `gitnexus.json` first and falls back to `meta.json` when
the primary file is absent, so a repo indexed by an older version works
without re-analysis.
- Each `analyze` run also reconciles the two files (the fresher `indexedAt`
wins and is written to both), so even a repo written by a mix of old and
new versions converges. Nothing is ever deleted.
### What happens on re-index?
Running `npx gitnexus analyze` writes both `gitnexus.json` and `meta.json`
with identical content. A pre-existing repo that only has `meta.json` gets
`gitnexus.json` bootstrapped from it on the first run.
### What about rollback?
Downgrading to an older GitNexus version is safe: `meta.json` is always
present and current, so the older binary sees the existing index (including
the `incrementalInProgress` crash-recovery flag) instead of treating the
repo as never analyzed.
### When will the legacy mirror be removed?
The `meta.json` mirror will remain until a future major version. Removal
will be announced in this file and in the changelog before it happens.

1106
README.md

File diff suppressed because it is too large Load diff

View file

@ -56,7 +56,7 @@ npx gitnexus list
npx gitnexus analyze --embeddings
```
**Important:** If you already had embeddings, **always** pass `--embeddings` on later analyzes, or they can be dropped. See `stats.embeddings` in `.gitnexus/meta.json` (0 means none).
**Important:** If you already had embeddings, **always** pass `--embeddings` on later analyzes, or they can be dropped. See `stats.embeddings` in `.gitnexus/gitnexus.json` (or its legacy `meta.json` mirror; 0 means none).
**Large repos:** Analyze may skip or limit embedding work when node counts are very high; watch CLI output.
@ -152,7 +152,9 @@ Analyze re-execs Node with a **large old-space heap** when needed (`analyze.ts`)
## LadybugDB / lock errors
Only one process should open a repo’s `.gitnexus/lbug` store at a time. If MCP and a second `analyze` run conflict, stop one process, then retry `analyze` or restart MCP.
Only one process should open a repo's `.gitnexus/lbug` store at a time. If MCP and a second `analyze` run conflict, stop one process, then retry `analyze` or restart MCP.
If the error text is `"Only one write transaction at a time is allowed in the system."` instead of a lock/busy message, it's the same underlying conflict — our retry matcher (`isDbBusyError` in `src/core/lbug/lbug-config.ts`) recognizes this exact string and auto-retries it. The fix if it still surfaces after retries is the same: stop the overlapping process.
---

View file

@ -0,0 +1,207 @@
---
title: Claude Skill Discovery Paths - Plan
type: fix
date: 2026-07-11
artifact_contract: ce-unified-plan/v1
artifact_readiness: implementation-ready
product_contract_source: ce-plan-bootstrap
execution: code
---
# Claude Skill Discovery Paths - Plan
## Goal Capsule
- **Objective:** Make every Claude Code skill written by `gitnexus analyze` discoverable from the project skill root while preserving skip flags, repeat-run stability, and unrelated user skills.
- **Authority:** GitHub issue #2433 and Claude Code's documented project-skill layout are the behavioral contract; repository guardrails and existing CLI conventions govern implementation.
- **Execution profile:** Standard, test-first bug fix in `gitnexus/`; no dependency, schema, or public MCP changes.
- **Stop conditions:** Stop if the fix requires deleting unrecognized user-owned skill directories, changes `--skip-skills` semantics, or impact analysis reports HIGH/CRITICAL risk without maintainer approval.
- **Tail ownership:** LFG owns simplification, review, commits, PR creation, and CI follow-through after the implementation units pass verification.
---
## Product Contract
### Summary
Install standard and repo-generated Claude Code skills as direct children of `.claude/skills/`, update all generated references and CLI messages to those paths, and migrate known legacy GitNexus outputs without touching unrelated project skills.
### Problem Frame
`gitnexus analyze` currently writes standard skills below `.claude/skills/gitnexus/` and community skills below `.claude/skills/generated/`.
Claude Code treats `.claude/skills/<skill-name>/SKILL.md` as the project-skill shape; nested `.claude/skills/` directories elsewhere in a monorepo are separate discovery roots, not grouping directories inside a skill root.
The current installer therefore reports success and writes managed instructions that point to files, but the skills are not registered for invocation.
### Requirements
**Standard skills**
- R1. Each bundled `gitnexus-*` standard skill is written to `.claude/skills/<skill-name>/SKILL.md`.
- R2. Generated AGENTS.md and CLAUDE.md routing rows reference the same direct standard-skill paths.
**Community skills**
- R3. Each `--skills` community skill is written directly below `.claude/skills/` with a GitNexus-owned name that cannot collide with the six standard skills or ordinary unprefixed project skills.
- R4. Community skill frontmatter, returned metadata, console output, and generated routing rows use one consistent discoverable name and path.
**Migration and compatibility**
- R5. A repeat analyze removes or replaces only legacy directories GitNexus can identify as its own output and preserves unrelated `.claude/skills/` entries.
- R6. `--skip-skills` continues to suppress only the six standard skills, while `--skills` community generation remains independent; `--index-only` continues to suppress all context-file injection.
- R7. CLI help and localized help text describe the corrected paths without changing flag behavior.
- R8. This repository's checked-in copies of the six standard skills and its managed AGENTS.md/CLAUDE.md routing rows use the corrected direct layout when the fix lands.
### Acceptance Examples
- AE1. Given a clean repository, a normal analyze creates `.claude/skills/gitnexus-exploring/SKILL.md`, does not create `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md`, and emits the direct path in AGENTS.md and CLAUDE.md.
- AE2. Given `analyze --skills`, each generated community skill has a direct, namespaced directory below `.claude/skills/`, and the context-file table points to that exact file.
- AE3. Given existing unrelated project skills plus legacy GitNexus grouping directories, rerunning analyze preserves the unrelated skills, produces the direct GitNexus skills, and leaves no managed reference pointing at a legacy grouped path.
- AE4. Given `--skip-skills`, no standard `gitnexus-*` skill is installed or referenced, while generated community skill behavior remains available when `--skills` is also requested.
### Success Criteria
- All standard and generated skill files use Claude Code's documented direct-child layout.
- Generated context, return messages, help text, and tests contain no active references to `.claude/skills/gitnexus/` or `.claude/skills/generated/`.
- The canonical repository no longer ships the six standard skills or managed routing rows in the broken grouped layout.
- Repeated runs are deterministic and do not delete unrelated user skills.
### Scope Boundaries
- **In scope:** project-local Claude Code skill installation performed by `analyze`, repo-generated community skills, managed context paths, the repository's checked-in copies of the six standard skills, CLI/help copy, migration of known legacy outputs, and regression coverage.
- **Out of scope:** global `gitnexus setup` targets, plugin skill layouts, changing the six bundled skill bodies, or changing Claude Code itself.
- **Deferred to follow-up work:** relocating this repository's three extra hand-maintained nested `.claude/skills/gitnexus/` skills that are not installed by `analyze`; those are workspace configuration rather than the issue's six standard installer outputs.
### Sources
- GitHub issue #2433: `https://github.com/abhigyanpatwari/GitNexus/issues/2433`
- Claude Code skills documentation: `https://code.claude.com/docs/en/slash-commands`
- Related path-contract regressions: GitHub issues #1098 and #1381.
---
## Planning Contract
### Key Technical Decisions
- KTD1. Treat `.claude/skills/` as the installation root and make each skill directory its direct child. This matches the official project-skill contract and avoids relying on recursive discovery that Claude Code does not document.
- KTD2. Keep the six standard names unchanged because they are already `gitnexus-*` namespaced. This preserves their intended invocation names while correcting only the filesystem layout.
- KTD3. Reserve a separate GitNexus-owned prefix for generated community skill names before writing them flat. This prevents a community such as `Cli` from overwriting `gitnexus-cli` and prevents common labels such as `auth` from replacing user skills.
- KTD4. Replace grouped-directory cleanup with ownership-bounded cleanup. Remove known standard legacy children and generated legacy output, or direct generated directories carrying the reserved prefix, but never recursively clear `.claude/skills/` itself.
- KTD5. Keep generation and documentation derived from the same `GeneratedSkillInfo.name` value so disk paths, frontmatter names, managed routing rows, and repeat-run cleanup cannot drift.
### Assumptions
- Issue #2433's request to check community skills includes fixing them in this PR rather than filing a separate follow-up.
- Legacy `.claude/skills/generated/` is GitNexus-owned because current code already deletes and recreates it on every `--skills` run; unknown siblings under `.claude/skills/` remain user-owned.
- Standard legacy cleanup is limited to the six bundled names under `.claude/skills/gitnexus/`; unknown children in that grouping directory are preserved.
- The exact generated-skill prefix may be refined during implementation, but it must be stable, GitNexus-owned, direct-child compatible, and non-conflicting with standard skill names.
### Existing Patterns to Follow
- `gitnexus/src/cli/setup.ts` installs globally scoped Claude skills directly under the target skill root and provides a path-contract precedent.
- `gitnexus/src/cli/ai-context.ts` already centralizes standard skill definitions, context table generation, skip semantics, and best-effort filesystem handling.
- `gitnexus/src/cli/skill-gen.ts` already owns community-name normalization, deterministic collision suffixes, output cleanup, frontmatter rendering, and returned path metadata.
- `gitnexus/test/unit/ai-context.test.ts` uses temporary repositories to prove file layout and skip-mode behavior.
- `gitnexus/test/unit/skill-gen.test.ts` and `gitnexus/test/integration/skills-e2e.test.ts` cover generated skill metadata, file contents, idempotency, and end-to-end context references.
### System-Wide Impact
The change affects the user-visible filesystem contract of `gitnexus analyze`, generated AGENTS.md/CLAUDE.md content, CLI help output, and the invocation names of previously inert community skills.
It does not alter indexing, graph storage, MCP APIs, global setup targets, or runtime analysis behavior.
### Risks and Mitigations
- **Accidental user-skill deletion:** Scope cleanup to known standard names, the prior generated output directory, and the new reserved prefix; add preservation tests with unrelated directories.
- **Standard/community collision:** Use distinct namespaces and assert representative `Cli`/common-label cases.
- **Path drift across surfaces:** Derive context rows from returned generated names and assert exact disk-to-doc parity.
- **Skip-mode regression:** Retain focused tests for normal, `--skip-skills`, `--skills`, and `--index-only` combinations.
---
## Implementation Units
### U1. Flatten standard skill installation and managed references
- **Goal:** Install the six bundled skills as direct project skills and migrate only their known legacy copies.
- **Requirements:** R1, R2, R5, R6; AE1, AE3, AE4.
- **Dependencies:** None.
- **Files:** `gitnexus/src/cli/ai-context.ts`, `gitnexus/test/unit/ai-context.test.ts`.
- **Approach:** Change the standard install root and routing-table templates together; preserve `skipSkills` behavior and result reporting; add bounded cleanup for the six known legacy child directories while preserving unknown siblings and unrelated direct skills.
- **Execution note:** Start with failing temporary-repository assertions for the direct path, absence of the legacy path, preservation of unrelated skills, and repeated-run behavior.
- **Patterns to follow:** Existing `installSkills`, `generateGitNexusContent`, and temporary-directory tests in `ai-context.test.ts`.
- **Test scenarios:**
- Covers AE1. A default run writes all six direct skill files and emits the same direct paths in both context files.
- Covers AE3. A run with an unrelated direct skill and an unknown legacy-group child preserves both while replacing known legacy standard children.
- Covers AE4. `skipSkills` writes no standard direct skill, emits no standard routing row, and reports the corrected skipped location.
- A second default run produces the same six skills without duplicate directories or context rows.
- **Verification:** Focused AI-context tests prove the filesystem, managed-document, migration, and skip contracts.
### U2. Flatten and namespace generated community skills
- **Goal:** Make `--skills` outputs discoverable without colliding with standard or user-authored skills.
- **Requirements:** R3, R4, R5, R6; AE2, AE3, AE4.
- **Dependencies:** U1 establishes the shared direct-root convention.
- **Files:** `gitnexus/src/cli/skill-gen.ts`, `gitnexus/test/unit/skill-gen.test.ts`, `gitnexus/test/integration/skills-e2e.test.ts`, `gitnexus/test/unit/analyze-no-stats-bridge.test.ts`, `gitnexus/test/unit/analyze-gitnexusrc.test.ts`.
- **Approach:** Generate reserved, deterministic community names; write each directory directly under `.claude/skills/`; clean only legacy generated output and stale directories in the reserved namespace; return and render the direct path consistently; update mocked path fixtures that model the output contract.
- **Execution note:** Characterize existing name normalization and idempotency first, then add red tests for a community label that would collide with a standard or common user skill.
- **Patterns to follow:** `toKebabName`, `renderSkillMarkdown`, and existing repeat-run tests.
- **Test scenarios:**
- Covers AE2. A representative community produces a direct namespaced directory whose basename equals frontmatter `name` and returned metadata `name`.
- A `Cli` community does not overwrite the standard `gitnexus-cli` skill.
- A pre-existing unrelated `.claude/skills/auth/SKILL.md` survives generation of an Auth community.
- Covers AE3. A repeat run removes stale GitNexus-generated community directories and the legacy `generated/` output while preserving unrelated direct skills.
- The end-to-end `analyze --skills` fixture finds generated files at direct paths and context tables point to those exact paths on first and second runs.
- **Verification:** Unit and integration tests prove collision resistance, ownership-bounded cleanup, path/frontmatter parity, and deterministic regeneration.
### U3. Align CLI help and path-contract assertions
- **Goal:** Remove stale user-facing descriptions of grouped skill directories and lock the corrected contract into CLI coverage.
- **Requirements:** R7 and the active-reference portion of R2/R4.
- **Dependencies:** U1 and U2 determine the final standard and generated naming conventions.
- **Files:** `gitnexus/src/cli/index.ts`, `gitnexus/src/cli/i18n/zh-CN.ts`, `gitnexus/test/unit/skip-git-cli.test.ts`, `gitnexus/test/unit/ai-context.test.ts`, `gitnexus/test/integration/skills-e2e.test.ts`.
- **Approach:** Update English and Chinese help copy and strengthen existing help/context assertions so legacy grouped paths fail tests if reintroduced.
- **Patterns to follow:** Existing Commander option descriptions, `help.option.analyze.*` translation keys, and `skip-git-cli.test.ts` help assertions.
- **Test scenarios:**
- `gitnexus analyze --help` names the direct standard location and the reserved direct community naming convention.
- Generated AGENTS.md and CLAUDE.md contain no active `.claude/skills/gitnexus/` or `.claude/skills/generated/` routing entries.
- Chinese help retains the same flag semantics while naming corrected locations.
- **Verification:** Focused CLI/help tests and repository search confirm stale active path copy is gone from changed runtime and test surfaces.
### U4. Align the repository's checked-in standard skills
- **Goal:** Ensure the canonical GitNexus checkout demonstrates the same discoverable layout the corrected analyzer produces.
- **Requirements:** R8 and the repository-facing portion of R2.
- **Dependencies:** U1 establishes the standard direct paths.
- **Files:** `.claude/skills/gitnexus-exploring/SKILL.md`, `.claude/skills/gitnexus-debugging/SKILL.md`, `.claude/skills/gitnexus-impact-analysis/SKILL.md`, `.claude/skills/gitnexus-refactoring/SKILL.md`, `.claude/skills/gitnexus-guide/SKILL.md`, `.claude/skills/gitnexus-cli/SKILL.md`, `AGENTS.md`, `CLAUDE.md`.
- **Approach:** Relocate exactly the six analyzer-installed standard skill directories from the grouped path to direct children and update only their managed routing rows; preserve the extra hand-maintained nested skills unchanged.
- **Patterns to follow:** The direct paths produced by U1 and the existing GitNexus-managed block markers in AGENTS.md and CLAUDE.md.
- **Test scenarios:** Test expectation: none -- this unit relocates checked-in skill assets without changing their bodies; repository search and the focused path-contract tests cover their discoverability contract.
- **Verification:** Each of the six direct files exists with unchanged content, the six legacy grouped copies are absent, the three extra nested skill directories remain, and both managed tables point to the direct files.
---
## Verification Contract
| Gate | Command | Proves |
|---|---|---|
| Focused standard installer | `cd gitnexus && npx vitest run test/unit/ai-context.test.ts` | Direct standard paths, managed rows, migration safety, skip flags |
| Focused community generator | `cd gitnexus && npx vitest run test/unit/skill-gen.test.ts` | Namespacing, collision handling, cleanup, metadata/frontmatter parity |
| CLI help | `cd gitnexus && npx vitest run test/unit/skip-git-cli.test.ts` | User-facing flag path contract |
| Community end to end | `cd gitnexus && npx vitest run test/integration/skills-e2e.test.ts` | Real analyze output and repeat-run references across fixtures |
| CLI/Core regression | `cd gitnexus && npm test` | Full package behavior |
| Type safety | `cd gitnexus && npx tsc --noEmit` | TypeScript contract integrity |
| Change scope | GitNexus `detect_changes` before each commit | Only expected CLI skill-generation symbols and flows are affected |
---
## Definition of Done
- U1-U3 requirements and test scenarios pass.
- U4's checked-in relocation and managed-row verification pass.
- Standard and community skills are direct children of `.claude/skills/` and discoverable by documented Claude Code rules.
- No runtime or generated-document surface points to the two legacy grouping layouts.
- Unrelated user-authored skills and unknown legacy-group children are preserved by regression tests.
- `--skip-skills`, `--skills`, and `--index-only` retain their documented independence.
- Full `gitnexus` tests and typecheck pass, or any environment-only exception is documented with focused proof.
- GitNexus change detection reports only the expected CLI generation and test scope.
- Abandoned experimental code and temporary artifacts from implementation are absent from the final diff.

View file

@ -0,0 +1,228 @@
# GitNexus Engineering Plan
> Task: Fix #2432 — `analyze` aborts with `Napi::Error` SIGABRT on triton-lang/triton: pathological C++ capture extraction triggers worker timeouts, then worker termination lands mid-native-call.
> Evidence verified at commit 737a8cdb; GitNexus index refreshed this session (`node .gitnexus/run.cjs analyze --index-only --pdg`, 230,253 nodes / 487,362 edges). Deepened same session: assumption A1 empirically refuted (see §5a/§12); §6-C redesigned accordingly. (Note: the MCP context resource still displays a stale banner after refresh — tools serve the refreshed data; PDG queries succeed. Cosmetic cache issue, recorded in §12.)
## 1. Objective
`gitnexus analyze` on triton (repro: `GITNEXUS_MAX_FILE_SIZE=5120 … analyze --worker-timeout 60`) must complete without SIGABRT. Two stacked defects, both fixed:
1. **Perf (trigger):** C++ scope-capture extraction is O(calls × args × treeSize) per file — `lib/Dialect/TritonInstrument/IR/FunctionBuilder.cpp` (194 KB, parses in 46 ms) burns **151 s** in the worker; `hip_prof_str.h` (`.h` → cpp provider) burns 116 s. Measured via `--cpu-prof` on the real dist worker `[verified]`.
2. **Crash (abort):** terminating a worker thread that is inside an N-API call — whether via pool shutdown, breaker trip, **or plain process exit** — makes the pending `Napi::Error` escape as an uncaught C++ exception → `std::terminate` → SIGABRT kills the whole CLI (workers are `worker_threads`, shared process). Reproduced 2/2 on origin/main, exit 134 `[verified]`; process-exit variant reproduced directly `[verified]` (§5a).
Acceptance criteria: triton repro completes (exit 0); `FunctionBuilder.cpp` extraction drops from ~151 s to sub-second; no worker-pool or cpp-resolver test regressions.
## 2. Current Behaviour
Per-file worker flow (`parse-worker.ts` `processFileGroup`): parse → `query.matches` → `extractParsedFile` → provider `emitScopeCaptures` (`emitCppScopeCaptures` for `.cpp`/`.h` — `c-cpp.ts:435` maps both) `[verified]`.
For every call-expression capture, `inferCppCallArgTypeClasses` (`cpp/captures.ts:929`, driven from `:325`) classifies each identifier argument via:
- `lookupDeclaredTypeClassForIdentifier` (`:1133`) — linear scan of the enclosing scope's children per identifier `[verified]`;
- `lookupFunctionParameterTypeClass` (`:1182`) + `findEnclosingFunctionParameter` (`:1200`) — walk up + param scan per identifier `[verified]`;
- **`isKnownEnumName` (`:1248`) — walks to the AST root and full-tree DFS for `enum_specifier`, per identifier argument** `[verified]`. CPU profile: 87.7 s of 149.9 s inside it; self-time dominated by tree-sitter N-API accessors (`child`/`childCount`/`type`/`unmarshalNode`) `[verified]`.
Crash path: idle-timeout/give-up paths all pass `'retire'` → `retireWorkerAfterTimeout` (`worker-pool.ts:1188`) defers terminate until the worker posts `sub-batch-done`/`result`/`error` (the #1848 fix) `[verified]`. But:
- `parse-impl.ts:1123-1125` `finally { await workerPool?.terminate() }` → pool `terminate` (`worker-pool.ts:2025` awaits) → `terminateTrackedWorkers` (`:971-980`) — terminates every live AND retired worker unconditionally `[verified]`.
- `tripBreaker` (`:1303`) fire-and-forgets the same (`void terminateTrackedWorkers`, `:1312`) on breaker trip — including live workers that may be mid-native-parse `[verified]`.
- These are the ONLY two callers `[verified]` (grep; matches the graph's d=1).
- **Existing test `worker-pool-timeout-retire.test.ts:97` asserts the crash-causing contract**: a never-safe retired mock worker gets `terminateCalls === 1` after `pool.terminate()` (`:114-118`); `:155` asserts the same for breaker trips `[verified]`. Both flip intentionally under this fix.
- Run evidence: process died 12 s after the last retire log with no error-path output; the 2-file mini corpus (retired workers finish before shutdown) exits 0 `[verified]`.
## 3. Relevant Architecture
- Shared ingestion pipeline is language-agnostic (AGENTS.md): the fix stays inside the cpp language module (`languages/cpp/captures.ts`) and the generic worker pool; no `LanguageProvider` interface change.
- Precedent for exactly this bug class: Go scope-capture re-walk fix (#1848/#1915), Python (#1918), C++ ADL once-built index (#1990) — `languages/c/captures.ts:39-42` documents the pattern `[verified]`.
- The C provider has its own thin `emitCScopeCaptures` (164 lines, no arg-type-class inference) — not affected `[verified]`.
- Workers cluster (76 symbols, 65% cohesion) is self-contained; depth-3 upstream impact of the shutdown change stays entirely inside it `[graph]`.
- `parse-worker.ts:1362-1369` documents the group-catch trap: a throw escaping per-file processing makes the language-group catch drop every remaining file — any new bail path must be caught per-file, never thrown outward `[verified]`.
## 4. GitNexus Findings
- `impact {target: emitCppScopeCaptures, direction: upstream, maxDepth: 2}` → 0 dependents, LOW `[graph]`. **Graph/source discrepancy:** the real consumer is the provider-hook indirection (`c-cpp.ts:495 emitScopeCaptures: emitCppScopeCaptures` → `scope-extractor-bridge.ts:41 extractParsedFile`) which the call graph doesn't model. Source wins; internal signature changes are still safe (all hot functions are file-private).
- `impact {target: terminateTrackedWorkers, direction: upstream}` at depth 2 → d=1: `tripBreaker`, `terminate`; deepened at `maxDepth: 3, summaryOnly` → 13 symbols total (d1:2, d2:7, d3:4), risk LOW, all in the Workers module. Key output: `"direct": 2` — both d=1 dependents modified deliberately in §6-C and source-confirmed `[verified]`.
- Related tests located and read: `test/unit/worker-pool-timeout-retire.test.ts` (mock `TimeoutThenHealthyWorker` harness with `terminateCalls`/`unrefCalls` counters and a `delayed-safe-return` mode — supports the new scenarios without factory changes `[verified]`), `test/integration/resolvers/cpp.test.ts` + `c.test.ts` (golden equivalence gate), `test/integration/cpp-adl-benchmark.test.ts` (GITNEXUS_BENCH-gated; template for the new benchmark) `[verified]`.
## 5. Statement-Level PDG Findings
- `pdg_query {mode: controls, target: isKnownEnumName}` (28 edges): the DFS body (`:1253-1262`) is control-dependent only on the trivial `typeName === ''` guard (`:1249`, guard:true) and its own loop conditions — **no memoization or early-exit gate exists**; the full-tree walk runs unconditionally on every call `[graph]`, consistent with source `[verified]`.
- `pdg_query {mode: controls, target: terminateTrackedWorkers}` → 0 edges: straight-line, unconditional termination of both worker lists `[graph]`. The crash fix is precisely "add the missing control dependency" (safe-point gate).
- Performance-mode scan: the hot loop's N-API fan-out (`cur.child(i)` per node per DFS per identifier) is the marshalling hotspot (`unmarshalNode` 15.5 s incl.) `[verified via profile]`.
- Ordering constraint: `lookupDeclaredTypeClassForIdentifier` returns the **first** matching `declaration` in scope-child order, with no position filtering relative to the identifier — the replacement index must preserve first-declaration-wins and must NOT introduce use-before-decl filtering `[verified]`.
## 5a. Deepen finding — A1 refuted empirically
Driver test (`exit-with-busy-worker.mjs`, kept in scratchpad): main thread `process.exit(0)` five seconds into the real dist worker's extraction of `FunctionBuilder.cpp`, worker `unref()`d → **process aborts: `terminate called after throwing an instance of 'Napi::Error'`, exit 134** `[verified]`. Node tears down worker environments on process exit through the same terminate path. Consequence: "skip terminating unsafe workers and let the process exit" merely relocates the abort. The shutdown design must instead guarantee workers reach a JS safe point in bounded time before the process exits — this promotes the previously-deferred cooperative extraction deadline into scope (§6-D).
## 6. Proposed Changes
**A. Perf root-cause — per-file lookup index in `gitnexus/src/core/ingestion/languages/cpp/captures.ts`.**
Introduce a lazily-built, per-invocation index object created at the top of `emitCppScopeCaptures` and threaded through `inferCppCallArgTypes` / `inferCppCallArgTypeClasses` → the lookup helpers (all file-private; no exported API change):
- `enumNames: Set<string>` — built by ONE root DFS on first `isKnownEnumName` query (lazy: files with no identifier args pay nothing). `isKnownEnumName` becomes a Set lookup. Behavior-identical: current code matches any `enum_specifier` name anywhere in the translation unit.
- `scopeDecls: Map<number /* scope.id */, Map<string, {typeNode, nameChild, stmt}>>` — per-scope declaration map built on first lookup in that scope by one pass over `scope` children, first-declaration-wins (skip existing keys). Replaces the per-identifier linear scans in `lookupDeclaredTypeClassForIdentifier` / `lookupDeclaredTypeForIdentifier` (`:1090-1131`).
- `fnParams: Map<number /* function node.id */, Map<string, param>>` — same treatment for `findEnclosingFunctionParameter`.
Complexity: O(treeSize + identifiers) per file. Expected: 151 s → sub-second (parse itself is 46 ms). `classifyCppParameterType` / `normalizeCppTypeText` stay per-hit (cheap; memoizing them changes nothing observable).
**B. New benchmark test — `gitnexus/test/integration/cpp-captures-typeclass-benchmark.test.ts`** modeled exactly on `cpp-adl-benchmark.test.ts` (`describe.skipIf(!GITNEXUS_BENCH)`): synthetic C++ file scaling call-sites × enums, asserts sub-quadratic scaling of the capture-emit phase.
**C. Crash fix — safe-point-gated shutdown with bounded drain, `gitnexus/src/core/ingestion/workers/worker-pool.ts`.** (Redesigned after §5a.)
- **C1 (gate):** extend `RetiredWorkerRecord` with `safeToTerminate`, set exactly where `terminateWhenBackInJs` fires today (`onRetiredMessage` for `sub-batch-done`/`result`/`error`, and `messageerror`). `terminateTrackedWorkers` terminates retired records only when safe; unsafe records keep their armed at-safe-point terminate listener.
- **C2 (bounded drain):** pool `terminate()` awaits unsafe retired records' safe-point terminate up to a cap (`GITNEXUS_WORKER_SHUTDOWN_DRAIN_MS`, default ≈ 30 s — comfortably above D's per-file deadline so the drain converges for the known class). On cap expiry: log a clear diagnostic naming the wedged worker + in-flight file and proceed (residual abort risk at process exit remains for truly-wedged native code, now rare and diagnosed). The breaker path (`tripBreaker:1312`) stays fire-and-forget — it must never block the dispatch rejection; its unsafe records drain when the pipeline's `finally` runs pool `terminate()`.
- **C3 (breaker-path live workers):** on breaker trip, live workers in `busySlots` (`:1154` `[verified]`) are routed through `retireWorkerAfterTimeout` instead of direct `terminate()` — same mid-native abort risk, same cure. Idle live workers terminate directly (parked in the JS event loop; safe). The normal post-parse `terminate()` still direct-terminates live workers — all idle by construction (jobs drained).
**D. Cooperative extraction deadline (promoted from deferred Q2 by §5a) — `cpp/captures.ts`.**
Bound per-file wall time inside `emitCppScopeCaptures`'s match loop: check `Date.now()` against a soft budget (`GITNEXUS_CPP_CAPTURE_BUDGET_MS`, default ≈ 20 s; post-A one iteration is microseconds, so check granularity of every N=64 matches is ample). On breach: **return** partial captures accumulated so far + `reportWarning` naming the file — never throw (the group-catch trap, §3). This guarantees cpp extraction returns to JS in bounded time, which is what makes C2's drain converge and process exit safe. Generic all-language budget remains a deferred follow-up (§12).
## 7. Implementation Sequence
1. **cpp captures index (A).** Build the index type + lazy constructors; convert `isKnownEnumName`, `lookupDeclaredType{Class}ForIdentifier`, `lookupFunctionParameterType{Class}`, `findEnclosingFunctionParameter`; thread from `emitCppScopeCaptures`. Gate: `npx vitest run test/integration/resolvers/cpp.test.ts test/integration/resolvers/c.test.ts` passes unchanged.
2. **Benchmark (B).** Add the GITNEXUS_BENCH-gated benchmark; record before/after in the PR body (before: 151 s / 116 s from this plan).
3. **Extraction deadline (D).** Budget check + partial-return + warning; unit test with a tiny budget forcing the bail (assert warning emitted, remaining files in group still processed).
4. **Worker-pool shutdown safety (C1–C3).** Gate + drain + breaker routing. Update `worker-pool-timeout-retire.test.ts:97` and `:155` (both currently assert the buggy contract) and add: (i) `pool.terminate()` with a never-safe retired worker + tiny drain cap → resolves after cap, `terminateCalls === 0`, diagnostic logged; (ii) retired worker signals safe during drain → terminated, `terminate()` resolves promptly; (iii) breaker trip with busy live worker → retired, not direct-terminated.
5. **End-to-end validation.** Rebuild (`npm run build`); re-run the triton repro → exits 0, `FunctionBuilder.cpp` indexed (not quarantined); mini 2-file corpus completes in seconds; re-run the §5a exit-with-busy-worker driver against the built worker with D's budget lowered → clean exit.
Steps 1–2 alone de-trigger #2432; 3–4 close the abort class. Each step leaves the tree green.
## 8. Test Strategy
- **Update:** `worker-pool-timeout-retire.test.ts:97` + `:155` — expectations flip to the new contract (unsafe ⇒ not terminated at shutdown; terminated at safe point). The mock harness supports this as-is `[verified]`.
- **Add:** benchmark (§6-B); three shutdown cases (§7-4); deadline-bail unit test (§7-3).
- **Regression:** resolver goldens `cpp.test.ts`/`c.test.ts` unchanged (equivalence gate); full `npm run test:unit`; `npm run test:integration` (carries its build via `pretest:integration`).
- **Edge cases:** file with enums but no calls (lazy index never built); duplicate declaration in one scope (first-wins preserved); use-before-decl in scope (still resolved — no position filter); anonymous enums (name-less `enum_specifier` excluded, same as today); breaker trip with mixed busy/idle live workers; drain cap = 0 (immediate proceed); deadline breach mid-file (partial captures kept, group continues).
- **Failure paths:** shutdown never hangs (drain is capped); deadline bail is a warning, never a group-dropping throw (§3 trap).
- **Verification commands (verified to exist):** `npm run build`, `npm run test:unit`, `npm run test:integration`, `GITNEXUS_BENCH=1 npx vitest run test/integration/cpp-captures-typeclass-benchmark.test.ts` — all from `gitnexus/`.
## 9. Risk and Impact Analysis
- **d=1 dependents of `terminateTrackedWorkers`** — `tripBreaker` (`:1312`), `terminate` (`:2025`): both modified deliberately; no other callers `[verified]`. Depth-3 radius stays pool-internal (13 symbols, Workers module) `[graph]`.
- **Behavioral-equivalence risk (A):** first-declaration-wins + position-free matching must be preserved (§5). Mitigation: resolver goldens + explicit edge cases.
- **Node identity:** key maps by `SyntaxNode.id` (stable within a tree); wrapper object identity is NOT usable (wrappers are recreated per access — a `WeakMap` would silently fail).
- **Drain-cap tuning (C2 vs D):** drain cap must exceed D's budget or the drain can expire while a worker is legitimately finishing its bailed file — defaults 30 s vs 20 s encode that; both env-tunable, relation asserted in a unit test comment.
- **Residual abort window:** a worker wedged in native code longer than the drain cap still aborts at process exit — now requires non-cpp pathological input (D bounds cpp) and is logged with the culprit file before it can happen. Accepted; full elimination needs child-process workers (out of scope, §12).
- **`emitCppScopeCaptures` consumers:** provider hook only; signature unchanged (D's budget read from env inside the module) — zero external surface.
- **Deadline false positives (D):** 20 s default is ~3 orders of magnitude above post-A extraction cost of the worst observed file; breach ⇒ degraded coverage for that file (warning), never a failed run.
- **Coverage change:** triton's `FunctionBuilder.cpp` was previously quarantined; post-fix it indexes — strictly an improvement.
- **Concurrency:** the new index and deadline state are function-scoped per invocation (per file, per worker thread) — no shared state, no `clearCaches()` interaction.
## 10. Files Expected to Change
| File | Symbols | Reason |
|---|---|---|
| `gitnexus/src/core/ingestion/languages/cpp/captures.ts` | `emitCppScopeCaptures`, `inferCppCallArgTypes`, `inferCppCallArgTypeClasses`, `lookupDeclaredType{Class}ForIdentifier`, `lookupFunctionParameterType{Class}`, `findEnclosingFunctionParameter`, `isKnownEnumName` (+ index type, + deadline) | A: O(n²)→O(n) index; D: bounded extraction |
| `gitnexus/src/core/ingestion/workers/worker-pool.ts` | `RetiredWorkerRecord`, `retireWorkerAfterTimeout`, `terminateTrackedWorkers`, `terminate`, `tripBreaker` | C1–C3: safe-point gate + bounded drain + breaker routing |
| `gitnexus/test/unit/worker-pool-timeout-retire.test.ts` | `:97`, `:155` + 3 new cases | New shutdown contract |
| `gitnexus/test/integration/cpp-captures-typeclass-benchmark.test.ts` | new | Scaling regression gate |
| `gitnexus/test/unit/` (new file) | cpp capture deadline-bail | D coverage |
## 11. Reusable Implementation Context
```yaml
implementation_context:
task_summary: >
Fix #2432 (SIGABRT on triton analyze): (A) replace per-identifier full-tree/
per-scope AST re-walks in cpp capture extraction with a lazily-built per-file
index; (C) gate worker terminate on a JS-safe-point flag with a bounded
shutdown drain and breaker-path retire routing; (D) bound cpp capture
extraction wall-time per file (partial-return + warning, never throw).
acceptance_criteria:
- "Triton repro (avoid[4] artifacts) exits 0, no Napi::Error abort"
- "FunctionBuilder.cpp capture extraction sub-second (was 151s)"
- "resolvers/cpp.test.ts + worker-pool suites green"
- "exit-with-busy-worker driver (avoid[4]) exits cleanly against built worker"
primary_symbols:
- { symbol: isKnownEnumName, file: gitnexus/src/core/ingestion/languages/cpp/captures.ts, lines: "1248-1265", role: "full-tree DFS per identifier — replace with per-file enum-name Set" }
- { symbol: lookupDeclaredTypeClassForIdentifier, file: gitnexus/src/core/ingestion/languages/cpp/captures.ts, lines: "1133-1172", role: "per-identifier scope scan — replace with per-scope decl map; preserve first-wins, position-free" }
- { symbol: lookupFunctionParameterTypeClass, file: gitnexus/src/core/ingestion/languages/cpp/captures.ts, lines: "1182-1224", role: "per-identifier param walk — memoize per function node.id" }
- { symbol: inferCppCallArgTypeClasses, file: gitnexus/src/core/ingestion/languages/cpp/captures.ts, lines: "929-1010", role: "per-call driver — threads the index down; call sites at 311/325" }
- { symbol: emitCppScopeCaptures, file: gitnexus/src/core/ingestion/languages/cpp/captures.ts, lines: "15-", role: "per-file entry — owns index lifetime + D deadline checks in its match loop" }
- { symbol: terminateTrackedWorkers, file: gitnexus/src/core/ingestion/workers/worker-pool.ts, lines: "971-980", role: "add safeToTerminate gate (C1); callers: tripBreaker :1312 (void), terminate :2025 (await) — the only two" }
- { symbol: retireWorkerAfterTimeout, file: gitnexus/src/core/ingestion/workers/worker-pool.ts, lines: "1188-1245", role: "set safeToTerminate where terminateWhenBackInJs fires; unref already at :1240" }
- { symbol: tripBreaker, file: gitnexus/src/core/ingestion/workers/worker-pool.ts, lines: "1303-1315", role: "C3: retire busySlots members instead of direct terminate; stays fire-and-forget" }
- { symbol: "pool terminate", file: gitnexus/src/core/ingestion/workers/worker-pool.ts, lines: "~2010-2027", role: "C2: bounded drain of unsafe records before/instead of force terminate" }
related_symbols:
- { symbol: extractParsedFile, relationship: "CALLS emitScopeCaptures via provider hook", relevance: "graph-invisible consumer; signature unchanged" }
- { symbol: "parse-impl.ts:1124 finally", relationship: CALLS, relevance: "the shutdown trigger; C2 drain runs under this await" }
- { symbol: "c-cpp.ts:435 extensions", relationship: config, relevance: ".h routes to cpp provider — hip_prof_str.h covered by A+D" }
- { symbol: busySlots, relationship: "state read by C3", relevance: "worker-pool.ts:1154; add/delete sites verified at :1631/:1646/:1676/:1687/:1720/:1814" }
execution_path:
- "worker: parse file → query.matches → extractParsedFile → emitCppScopeCaptures"
- "per call capture: inferCppCallArgTypeClasses → per identifier: scope scan + full-tree enum DFS (hot)"
- "worker exceeds idle timeout → retire (no terminate) → parse ends → parse-impl finally → pool.terminate → terminateTrackedWorkers → terminate mid-N-API → SIGABRT"
- "ALSO: process exit with native-busy unref'd worker → same abort (verified) — why C2+D exist"
pdg_constraints:
- description: "isKnownEnumName full-tree DFS gated only by typeName!=='' (guard, line 1249); no memo gate exists"
affected_statements: ["gitnexus/src/core/ingestion/languages/cpp/captures.ts:1253-1262"]
implementation_consequence: "Set lookup is behavior-identical; keep the empty/'unknown' early-out"
- description: "terminateTrackedWorkers is straight-line (0 CDG edges) — terminates unconditionally"
affected_statements: ["gitnexus/src/core/ingestion/workers/worker-pool.ts:975-977"]
implementation_consequence: "add safeToTerminate control dependency; drain bounded, never unbounded await"
- description: "lookupDeclaredTypeClassForIdentifier: first-declaration-wins, position-free scope match"
affected_statements: ["gitnexus/src/core/ingestion/languages/cpp/captures.ts:1148-1170"]
implementation_consequence: "build per-scope map in child order, skip existing keys, no use-before-decl filtering"
architectural_patterns:
- { pattern: "once-built per-file index over repeated AST walks", example_location: "gitnexus/src/core/ingestion/languages/c/captures.ts:39-42 (comment citing go #1848 / python #1918); ADL index #1990", usage_guidance: "thread an index object; key node maps by SyntaxNode.id, never object identity" }
- { pattern: "GITNEXUS_BENCH-gated scaling benchmark", example_location: "gitnexus/test/integration/cpp-adl-benchmark.test.ts", usage_guidance: "copy harness shape incl. skipIf + table output" }
- { pattern: "mock-Worker retire harness", example_location: "gitnexus/test/unit/worker-pool-timeout-retire.test.ts:12-64", usage_guidance: "TimeoutThenHealthyWorker: terminateCalls/unrefCalls counters + 'delayed-safe-return' mode cover all new cases; no factory change needed" }
- { pattern: "per-file bail must not throw", example_location: "gitnexus/src/core/ingestion/workers/parse-worker.ts:1362-1369 (CFG isolation comment)", usage_guidance: "D returns partial captures + reportWarning; a throw drops the whole language group" }
files_to_modify:
- { file: gitnexus/src/core/ingestion/languages/cpp/captures.ts, symbols: [see primary], intended_change: "A index + D deadline" }
- { file: gitnexus/src/core/ingestion/workers/worker-pool.ts, symbols: [see primary], intended_change: "C1 gate, C2 drain, C3 breaker routing" }
- { file: gitnexus/test/unit/worker-pool-timeout-retire.test.ts, symbols: [], intended_change: "flip :97/:155 + 3 new cases" }
- { file: gitnexus/test/integration/cpp-captures-typeclass-benchmark.test.ts, symbols: [], intended_change: "new benchmark" }
tests:
- file: gitnexus/test/unit/worker-pool-timeout-retire.test.ts
scenarios:
- "never-safe retired worker + tiny drain cap → pool.terminate() resolves after cap, terminateCalls === 0, diagnostic logged"
- "retired worker signals safe during drain → terminated, terminate() resolves promptly"
- "breaker trip with busy live worker → routed through retire, not direct terminate"
- "UPDATED :97/:155 — unsafe workers not terminated at shutdown (was: terminated)"
- file: gitnexus/test/integration/cpp-captures-typeclass-benchmark.test.ts
scenarios: ["N call-sites × M enums synthetic file → capture emit scales sub-quadratically"]
- file: "gitnexus/test/unit/ (new: cpp capture deadline test)"
scenarios: ["GITNEXUS_CPP_CAPTURE_BUDGET_MS=1 on a many-call file → partial captures returned, warning emitted, no throw"]
- file: gitnexus/test/integration/resolvers/cpp.test.ts
scenarios: ["existing golden behavior unchanged (equivalence gate — run, don't modify)"]
verification_commands:
- "cd gitnexus && npm run build"
- "cd gitnexus && npm run test:unit"
- "cd gitnexus && npm run test:integration"
- "cd gitnexus && GITNEXUS_BENCH=1 npx vitest run test/integration/cpp-captures-typeclass-benchmark.test.ts"
risks:
- "equivalence break in decl ordering (first-wins) → resolver goldens catch"
- "drain cap must exceed D budget (30s > 20s) or drains expire on legitimately-bailing workers"
- "SyntaxNode object identity is NOT stable — key by node.id"
- "residual abort: non-cpp native wedge longer than drain cap still aborts at exit — logged, accepted (child-process workers out of scope)"
assumptions:
- "D's env-read (GITNEXUS_CPP_CAPTURE_BUDGET_MS) is visible in worker threads — CHECK: workers inherit process.env by default; confirm no env filtering in spawnWorker (worker-pool.ts:909-925 sets only workerData/resourceLimits — none seen)"
open_questions:
- "Q2 (narrowed): generic all-language extraction budget — deferred follow-up issue after cpp-only D lands"
avoid:
- "Do not repeat full repository discovery — symbols and line ranges verified at 737a8cdb"
- "Do not change LanguageProvider or emitScopeCaptures signatures — provider hook consumers are graph-invisible"
- "Do not add position/use-before-decl filtering to scope lookups — changes resolution behavior"
- "Repro artifacts in scratchpad: repro-2432-wt (worktree), triton/, mini-2432/, repro-run{1,2}.log, profiles/CPU.*.cpuprofile, profile-worker.mjs, prof-top.mjs, exit-with-busy-worker.mjs — reuse for §7-5, do not re-derive"
- "Do not let a D bail throw out of emitCppScopeCaptures — the language-group catch drops all remaining files (parse-worker.ts:1362-1369)"
- "Do not edit CHANGELOG (release-time owned)"
```
## 12. Assumptions and Open Questions
- **A1 — RESOLVED (refuted):** process exit with a native-busy unref'd worker DOES abort (§5a, empirical). Design consequence absorbed into §6-C2/§6-D.
- **A2 — RESOLVED:** mock harness supports all new shutdown cases without factory changes (test file read in full).
- **A3 (new, minor):** worker threads see `process.env` for D's budget knob — spawn options set only `workerData`/`resourceLimits`, so default env inheritance applies; executor re-verifies in one line.
- **Q2 (narrowed):** generic per-language extraction budget — file as follow-up issue once cpp-only D proves the shape.
- **Deferred:** `lookupDeclaredTypeForIdentifier` (`:1090`) gets the same index for consistency (cheap, in A) though not hot (0.4 s incl.).
- **Graph/source discrepancies recorded:** (i) provider-hook edges invisible to `impact`; (ii) MCP `context` resource staleness banner not refreshed after `--index-only --pdg` while tools serve fresh data — both worth separate GitNexus issues, not this fix.
## 13. Definition of Done
1. `GITNEXUS_HOME=<fresh> GITNEXUS_LBUG_EXTENSION_INSTALL=never GITNEXUS_MAX_FILE_SIZE=5120 node gitnexus/dist/cli/index.js analyze --worker-timeout 60` on triton-lang/triton exits 0 with no `Napi::Error`/SIGABRT, and `lib/Dialect/TritonInstrument/IR/FunctionBuilder.cpp` appears in the index (not quarantined).
2. Mini 2-file corpus (FunctionBuilder.cpp + hip_prof_str.h) analyzes in seconds (was 318.9 s).
3. The §5a exit-with-busy-worker driver, run against the rebuilt worker, exits cleanly.
4. Updated + new worker-pool unit tests green (including flipped `:97`/`:155` contract); resolver goldens (`cpp.test.ts`, `c.test.ts`) green unchanged; `npm run test:unit` and `npm run test:integration` green in `gitnexus/`.
5. Benchmark demonstrates sub-quadratic capture-emit scaling behind `GITNEXUS_BENCH=1`; deadline-bail test proves partial-return-not-throw.
6. No `LanguageProvider`/public API signature changes; no CHANGELOG edits.

View file

@ -16,18 +16,19 @@ Evaluate whether GitNexus code intelligence improves AI agent performance on rea
> **Recommended**: Use `native_augment` mode. It mirrors the Claude Code model — the agent gets both explicit GitNexus tools (fast bash commands) AND automatic enrichment of grep results with callers, callees, and execution flows. The agent decides when to use explicit tools vs rely on enriched search output.
**Models supported:**
**Models supported** (see `configs/models/` for the current list):
- Claude 3.5 Haiku, Claude Sonnet 4, Claude Opus 4
- MiniMax M1 2.5
- Claude Haiku 4.5, Claude Sonnet 4, Claude Opus 4
- MiniMax M1 2.5, MiniMax M2.5
- GLM 4.7, GLM 5
- DeepSeek
- Any model supported by litellm (add a YAML config)
## Prerequisites
- Python 3.11+
- Docker (for SWE-bench containers)
- Node.js 18+ (for GitNexus)
- Node.js 22+ (for GitNexus)
- API keys for your chosen models
## Setup

View file

@ -1,7 +1,7 @@
{
"name": "gitnexus",
"description": "Code intelligence powered by a knowledge graph. Provides execution flow tracing, blast radius analysis, and augmented search across your codebase.",
"version": "1.6.8",
"version": "1.6.9",
"author": {
"name": "GitNexus"
},

View file

@ -0,0 +1,25 @@
{
"name": "gitnexus",
"description": "Code intelligence powered by a knowledge graph. Provides execution flow tracing, blast radius analysis, and augmented search across your codebase.",
"version": "1.6.9",
"skills": "./skills",
"mcpServers": "./.mcp.json",
"hooks": "./hooks/hooks.json",
"interface": {
"displayName": "GitNexus",
"category": "Developer Tools",
"capabilities": [
"code-exploration",
"impact-analysis",
"debugging",
"refactoring",
"code-review"
]
},
"author": {
"name": "GitNexus"
},
"homepage": "https://github.com/abhigyanpatwari/GitNexus",
"repository": "https://github.com/abhigyanpatwari/GitNexus",
"keywords": ["code-intelligence", "knowledge-graph", "mcp", "static-analysis"]
}

View file

@ -38,13 +38,35 @@ function readInput() {
* Returns the path to .gitnexus/ or null if not found.
*/
function isGlobalRegistryDir(candidate) {
if (fs.existsSync(path.join(candidate, 'meta.json'))) return false;
if (
fs.existsSync(path.join(candidate, 'gitnexus.json')) ||
fs.existsSync(path.join(candidate, 'meta.json'))
) {
return false;
}
return (
fs.existsSync(path.join(candidate, 'registry.json')) ||
fs.existsSync(path.join(candidate, 'repos'))
);
}
/**
* Read the index metadata file, preferring `gitnexus.json` (current format)
* and falling back to the legacy `meta.json` mirror. Returns `null` if
* neither exists or parses.
*/
function readIndexMeta(gitNexusDir) {
try {
return JSON.parse(fs.readFileSync(path.join(gitNexusDir, 'gitnexus.json'), 'utf-8'));
} catch {
try {
return JSON.parse(fs.readFileSync(path.join(gitNexusDir, 'meta.json'), 'utf-8'));
} catch {
return null;
}
}
}
/**
* Walk up from `startDir` looking for a non-registry `.gitnexus/` folder.
* Returns the path to `.gitnexus/` or null if not found within 5 levels.
@ -359,6 +381,49 @@ function sendHookResponse(hookEventName, message) {
);
}
/**
* Fallback augmentation for the #2396 path: when a GitNexus process holds the
* lbug DB write lock the CLI `augment` can't run, so point the agent at the MCP
* `query` tool instead. Phrased conditionally ("if the MCP tools are live") so it
* stays truthful on every owner path — a confirmed MCP owner, a `serve` owner, or
* a fail-closed probe where no server is actually confirmed. `pattern` is embedded
* verbatim; the caller (sendHookResponse) JSON-escapes it structurally.
*/
function buildMcpQueryHint(pattern) {
return (
`[GitNexus] Local augment is unavailable (the graph DB is held by another ` +
`GitNexus process). If the GitNexus MCP tools are live in this session, call ` +
`the GitNexus \`query\` MCP tool (e.g. mcp__gitnexus__query) with ` +
`search_query "${pattern}".`
);
}
/**
* #2396 throttle: emit the MCP-query hint at most once per repo per window, so an
* owner-locked session isn't nudged on every search. Window (ms) via
* GITNEXUS_MCP_HINT_THROTTLE_MS (default 10min; 0/invalid disables). Best-effort —
* any fs error falls back to emitting.
* ponytail: per-repo mtime marker, shared across concurrent sessions on the same
* repo; add per-session dedup only if that sharing becomes a problem.
*/
function shouldEmitMcpHint(gitNexusDir) {
const raw = process.env.GITNEXUS_MCP_HINT_THROTTLE_MS;
const windowMs = raw === undefined || raw === '' ? 600000 : Number(raw);
if (!Number.isFinite(windowMs) || windowMs <= 0) return true;
const marker = path.join(gitNexusDir, '.mcp-hint-shown');
try {
if (Date.now() - fs.statSync(marker).mtimeMs < windowMs) return false;
} catch {
/* marker missing/unreadable → emit */
}
try {
fs.writeFileSync(marker, '');
} catch {
/* best-effort; still emit */
}
return true;
}
/**
* PreToolUse handler — augment searches with graph context.
*/
@ -395,17 +460,24 @@ function handlePreToolUse(input) {
let result = '';
try {
if (hasGitNexusServerOwner(gitNexusDir)) {
// Normal skip path: the MCP server owns the DB, so the CLI augment would
// contend on the lock. Stay silent for strict hook runners (issue #1913);
// surface the reason only when diagnostics are explicitly requested.
// #2396: the MCP server holds the DB write lock, so a competing CLI
// `augment` would only contend on it (LadybugDB is single-writer). But the
// session that triggered this hook has the GitNexus MCP tools live — route
// the augmentation to the agent via additionalContext instead of silently
// doing nothing. Mirror the skip reason to stderr only under GITNEXUS_DEBUG
// (strict-runner contract, #1913); the hint itself rides the sanctioned
// additionalContext stdout channel the successful augment already uses.
if (isDebugEnabled()) {
process.stderr.write('[GitNexus] augment skipped: MCP server owns DB\n');
}
return;
}
const child = runGitNexusCli(['augment', '--', pattern], cwd, 7000);
if (!child.error && child.status === 0) {
result = extractAugmentContext(child.stderr || '');
if (shouldEmitMcpHint(gitNexusDir)) {
result = buildMcpQueryHint(pattern);
}
} else {
const child = runGitNexusCli(['augment', '--', pattern], cwd, 7000);
if (!child.error && child.status === 0) {
result = extractAugmentContext(child.stderr || '');
}
}
} catch {
/* graceful failure */
@ -462,12 +534,10 @@ function handlePostToolUse(input) {
let lastCommit = '';
let hadEmbeddings = false;
try {
const meta = JSON.parse(fs.readFileSync(path.join(gitNexusDir, 'meta.json'), 'utf-8'));
const meta = readIndexMeta(gitNexusDir);
if (meta) {
lastCommit = meta.lastCommit || '';
hadEmbeddings = meta.stats && meta.stats.embeddings > 0;
} catch {
/* no meta — treat as stale */
}
// If HEAD matches last indexed commit, no reindex needed

View file

@ -6,7 +6,7 @@
"hooks": [
{
"type": "command",
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/gitnexus-hook.js",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gitnexus-hook.js\"",
"timeout": 10,
"statusMessage": "Enriching with GitNexus graph context..."
}
@ -19,7 +19,7 @@
"hooks": [
{
"type": "command",
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/gitnexus-hook.js",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gitnexus-hook.js\"",
"timeout": 10,
"statusMessage": "Checking GitNexus index freshness..."
}

View file

@ -24,6 +24,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. |
| `--pdg` | Build the program-dependence layers used by `explain` and `pdg_query` (taint, CDG, and REACHING_DEF). |
**When to run:** First time in a project, after major code changes, or when `gitnexus://repo/{name}/context` reports the index is stale.

View file

@ -42,6 +42,12 @@ For any task involving code understanding, debugging, impact analysis, or refact
| `explain` | Persisted taint findings — source→sink data flows (needs `analyze --pdg`) |
| `pdg_query` | Control/data dependence — what gates X (CDG) / where Y flows (REACHING_DEF); needs `analyze --pdg` |
| `check` | Check graph invariants such as circular imports |
| `route_map` | API route map — which components/hooks fetch which endpoints, and the handler files that serve them |
| `shape_check` | Response-shape drift — keys each route returns vs keys its consumers access (flags MISMATCH) |
| `api_impact` | Pre-change report for an API route — consumers, middleware, shape mismatches, risk level |
| `tool_map` | MCP/RPC tool definitions and the files that handle them |
| `group_list` | List configured multi-repo groups, or one group's config |
| `group_sync` | Rebuild a group's Contract Registry (cross-repo HTTP contract links); run after `group.yaml` changes or member re-index |
| `list_repos` | Discover indexed repos (paginated — `limit`/`offset`) |
### Paginating `list_repos`
@ -77,13 +83,13 @@ Notes: `offset` ≥ `total` returns an empty page (with `total` still reported).
### Taint findings (`explain`)
`explain` returns intra-procedural taint findings (`TAINTED` edges) recorded by `gitnexus analyze --pdg` — each with a sink category (command-injection, code-injection, path-traversal, sql-injection, xss), source/sink lines, and the ordered hop path with the variable carried on each hop.
`explain` returns taint findings recorded by `gitnexus analyze --pdg` — intra-procedural `TAINTED` edges plus cross-function `TAINT_PATH` hops where the interprocedural taint phase found a function-level source→sink chain. Each finding includes a sink category (command-injection, code-injection, path-traversal, sql-injection, xss), source/sink lines, and the ordered hop path with the variable carried on each hop.
- `explain {}` — enumerate all findings for the repo (bounded by `limit`, deterministic order)
- `explain { target: "src/vuln.ts" }` — findings in a file (suffix path match accepted)
- `explain { target: "runUserCommand" }` — findings in a function (resolved like `context`; ambiguous names return ranked candidates)
A repo indexed without `--pdg` returns a clear "no taint layer" note. Caveats: findings are intra-procedural only — cross-function, closure/callback, property/field, and implicit flows are not modeled, so the absence of a finding is **not** proof of safety. `SANITIZES` (sanitizer-kill) edges are queryable via `cypher`.
A repo indexed without `--pdg` returns a clear "no taint layer" note. Caveats: closure/callback, property/field, and implicit flows are not modeled, and interprocedural findings are function-level `TAINT_PATH` hops rather than statement-level path proof, so the absence of a finding is **not** proof of safety. `SANITIZES` (sanitizer-kill) edges are queryable via `cypher`.
### Control & data dependence (`pdg_query`)
@ -104,6 +110,8 @@ A repo indexed without `--pdg` returns a "no PDG layer" note (or "status unknown
Returns ordered `hops` (each `{ name, filePath, startLine }`) and an aligned `edges[]` of `{ relType, confidence }`, so call hops and containment (`HAS_METHOD`) hops stay distinguishable. When no path exists it reports the **furthest** reachable node (where the chain breaks) and sets `truncated: true` if a traversal cap was hit first. Every result carries a `status`: `ok` / `no_path` / `ambiguous` / `not_found` / `error`.
Cross-repo (experimental): pass `repo: "@groupName"` to trace across a group's member repos — the path may cross **one** `ContractLink` boundary (reported as a `CONTRACT_LINK` hop with the bridged contract in `crossings[]`). Omit `to` entirely to follow `from`'s outgoing HTTP call to whatever provider endpoint it lands on. Groups are configured via `group_list` / `group_sync`.
## Resources Reference
Lightweight reads (~100-500 tokens) for navigation:
@ -119,8 +127,10 @@ Lightweight reads (~100-500 tokens) for navigation:
## Graph Schema
**Nodes:** File, Function, Class, Interface, Method, Community, Process
**Edges (via CodeRelation.type):** CALLS, IMPORTS, EXTENDS, IMPLEMENTS, DEFINES, MEMBER_OF, STEP_IN_PROCESS
**Nodes:** File, Folder, Function, Class, Interface, Method, CodeElement, Community, Process, Route, Tool, plus language-specific types (Struct, Enum, Trait, Impl, Namespace, Module, …) and BasicBlock (`--pdg` indexes only). The full node list lives in `gitnexus://repo/{name}/schema`.
**Edges (via CodeRelation.type):** CALLS, IMPORTS, EXTENDS, IMPLEMENTS, DEFINES, CONTAINS, MEMBER_OF, HAS_METHOD, HAS_PROPERTY, ACCESSES, METHOD_OVERRIDES, METHOD_IMPLEMENTS, STEP_IN_PROCESS, HANDLES_ROUTE, FETCHES, HANDLES_TOOL, ENTRY_POINT_OF, WRAPS, QUERIES, INJECTS, plus `--pdg`-only types (CFG, REACHING_DEF, TAINTED, SANITIZES, TAINT_PATH, CDG — zero rows on a default index).
Read `gitnexus://repo/{name}/schema` before writing Cypher — it is the authoritative schema for the indexed repo.
```cypher
MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(f:Function {name: "myFunc"})

View file

@ -36,7 +36,7 @@ All three are `BasicBlock → BasicBlock` edges in the single `CodeRelation` tab
- `pdg_query({ mode: 'controls', target })` — CDG. For the anchored function,
each edge: controlling predicate block → dependent block + branch sense in
`label` (`'T'` = predicate's true/taken arm, `'F'` = false/fall-through). An
`reason` (`'T'` = predicate's true/taken arm, `'F'` = false/fall-through). An
edge into an early-return/throw block is flagged `guard: true`.
- `pdg_query({ mode: 'flows', target, variable? })` — REACHING_DEF def→use
edges; `variable` filters to one binding.

View file

@ -30,7 +30,7 @@ description: "Use when the user wants to rename, extract, split, move, or restru
```
- [ ] rename({symbol_name: "oldName", new_name: "newName", dry_run: true}) — preview all edits
- [ ] Review graph edits (high confidence) and ast_search edits (review carefully)
- [ ] Review graph edits (high confidence) and text_search edits (review carefully)
- [ ] If satisfied: rename({..., dry_run: false}) — apply edits
- [ ] detect_changes() — verify only expected files changed
- [ ] Run tests for affected processes
@ -66,7 +66,7 @@ description: "Use when the user wants to rename, extract, split, move, or restru
```
rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true})
→ 12 edits across 8 files
→ 10 graph edits (high confidence), 2 ast_search edits (review)
→ 10 graph edits (high confidence), 2 text_search edits (review)
→ Changes: [{file_path, edits: [{line, old_text, new_text, confidence}]}]
```
@ -107,10 +107,10 @@ RETURN caller.name, caller.filePath ORDER BY caller.filePath
```
1. rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true})
→ 12 edits: 10 graph (safe), 2 ast_search (review)
→ 12 edits: 10 graph (safe), 2 text_search (review)
→ Files: validator.ts, login.ts, middleware.ts, config.json...
2. Review ast_search edits (config.json: dynamic reference!)
2. Review text_search edits (config.json: dynamic reference!)
3. rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: false})
→ Applied 12 edits across 8 files

View file

@ -8,8 +8,8 @@ Static config that adds GitNexus knowledge-graph augmentation and skill files to
| Layer | What it does | How it's installed |
| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
| **MCP** | `gitnexus` MCP server with 16 tools (`query`, `context`, `impact`, `detect_changes`, `rename`, …) | `npx gitnexus setup` writes `~/.cursor/mcp.json` automatically. |
| **Skills** | `/gitnexus-exploring`, `/gitnexus-debugging`, `/gitnexus-impact-analysis`, `/gitnexus-refactoring`, `/gitnexus-pr-review` markdown skills | `npx gitnexus setup` copies them to `~/.cursor/skills/gitnexus/`. |
| **MCP** | `gitnexus` MCP server with 17 tools (`query`, `context`, `impact`, `detect_changes`, `rename`, …) | `npx gitnexus setup` writes `~/.cursor/mcp.json` automatically. |
| **Skills** | All bundled markdown skills (`/gitnexus-exploring`, `/gitnexus-debugging`, `/gitnexus-impact-analysis`, `/gitnexus-refactoring`, `/gitnexus-guide`, `/gitnexus-cli`, `/gitnexus-pr-review`, `/gitnexus-pdg-query`, `/gitnexus-taint-analysis`) | `npx gitnexus setup` copies them to `~/.cursor/skills/gitnexus/`. |
| **Hooks** _(this README)_ | `postToolUse` hook that enriches `Shell` / `Read` / `Grep` tool calls with graph context — same augmentation Claude Code gets | **Manual** — copy the files described below into your project's `.cursor/`. |
## Hook install

View file

@ -30,7 +30,12 @@ function readInput() {
}
function isGlobalRegistryDir(candidate) {
if (fs.existsSync(path.join(candidate, 'meta.json'))) return false;
if (
fs.existsSync(path.join(candidate, 'gitnexus.json')) ||
fs.existsSync(path.join(candidate, 'meta.json'))
) {
return false;
}
return (
fs.existsSync(path.join(candidate, 'registry.json')) ||
fs.existsSync(path.join(candidate, 'repos'))

View file

@ -28,7 +28,7 @@ description: Plan safe refactors using blast radius and dependency mapping
### Rename Symbol
```
- [ ] rename({symbol_name: "oldName", new_name: "newName", dry_run: true}) — preview all edits
- [ ] Review graph edits (high confidence) and ast_search edits (review carefully)
- [ ] Review graph edits (high confidence) and text_search edits (review carefully)
- [ ] If satisfied: rename({..., dry_run: false}) — apply edits
- [ ] detect_changes() — verify only expected files changed
- [ ] Run tests for affected processes
@ -61,7 +61,7 @@ description: Plan safe refactors using blast radius and dependency mapping
```
rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true})
→ 12 edits across 8 files
→ 10 graph edits (high confidence), 2 ast_search edits (review)
→ 10 graph edits (high confidence), 2 text_search edits (review)
→ Changes: [{file_path, edits: [{line, old_text, new_text, confidence}]}]
```
@ -99,10 +99,10 @@ RETURN caller.name, caller.filePath ORDER BY caller.filePath
```
1. rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true})
→ 12 edits: 10 graph (safe), 2 ast_search (review)
→ 12 edits: 10 graph (safe), 2 text_search (review)
→ Files: validator.ts, login.ts, middleware.ts, config.json...
2. Review ast_search edits (config.json: dynamic reference!)
2. Review text_search edits (config.json: dynamic reference!)
3. rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: false})
→ Applied 12 edits across 8 files

View file

@ -78,6 +78,9 @@ export type NodeProperties = {
level?: number;
returnType?: string;
declaredType?: string;
/** Verbatim declared-type source text with generics preserved
* (e.g. `List<Shape>` where `declaredType` is the stripped `List`). */
rawDeclaredType?: string;
visibility?: string;
isStatic?: boolean;
isReadonly?: boolean;
@ -124,6 +127,19 @@ export type RelationshipType =
| 'ENTRY_POINT_OF'
| 'WRAPS'
| 'QUERIES'
/** Dependency-injection edge: a consumer class receives every implementer
* of interface `T` via a container-injected collection-typed field
* (`List<T>`, `Set<T>`, `Collection<T>`, or `Map<K,T>`). Precondition: the
* field carries an injection annotation recognized by a per-language
* matcher registered in `di-extractors/` (Java/Spring today: `@Autowired`
* or `@Inject`; `@Resource` is excluded — by-name-first semantics).
* Source = the consumer Class node (the one owning the field).
* Target = an implementing Class node.
* Framework specifics live in the `reason` payload (e.g.
* `Spring DI: @Autowired List<T>`), not in this type contract.
* Lets Cypher queries trace which beans the container injects into a given
* consumer, complementing the structural `IMPLEMENTS` heritage edges. */
| 'INJECTS'
/** Vue component event system: a handler function in a parent component is
* bound to an event emitted by a child component (`@event="handlerFn"`).
* Source = handler Function/Method node in the parent.

View file

@ -33,6 +33,8 @@ export interface ResilientFetchOptions {
breakerOptions?: CircuitBreakerOptions;
/** Tuning knobs for the retry helper. */
retry?: Partial<Pick<RetryOptions, 'maxAttempts' | 'baseDelayMs' | 'capDelayMs'>> & {
/** Upper bound on a single Retry-After wait. Defaults to RETRY_AFTER_CAP_MS. */
retryAfterCapMs?: number;
sleep?: RetryOptions['sleep'];
random?: RetryOptions['random'];
};
@ -83,6 +85,7 @@ type Outcome =
export function classifyOutcome(
result: { kind: 'error'; err: unknown } | { kind: 'response'; resp: Response },
now: () => number,
retryAfterCapMs = RETRY_AFTER_CAP_MS,
): Outcome {
if (result.kind === 'error') {
// Both timer-fired aborts (`AbortSignal.timeout()` → `TimeoutError`)
@ -111,7 +114,7 @@ export function classifyOutcome(
return {
kind: 'retryable-status',
resp,
afterMs: parsed !== null ? Math.min(parsed, RETRY_AFTER_CAP_MS) : undefined,
afterMs: parsed !== null ? Math.min(parsed, retryAfterCapMs) : undefined,
};
}
if (resp.status >= 500) return { kind: 'retryable-status', resp, afterMs: undefined };
@ -176,6 +179,7 @@ export async function resilientFetch(
maxAttempts: opts.retry?.maxAttempts ?? DEFAULT_RETRY.maxAttempts,
baseDelayMs: opts.retry?.baseDelayMs ?? DEFAULT_RETRY.baseDelayMs,
capDelayMs: opts.retry?.capDelayMs ?? DEFAULT_RETRY.capDelayMs,
retryAfterCapMs: opts.retry?.retryAfterCapMs ?? RETRY_AFTER_CAP_MS,
};
const sleep = opts.retry?.sleep ?? defaultSleep;
const random = opts.retry?.random ?? Math.random;
@ -202,7 +206,7 @@ export async function resilientFetch(
result = { kind: 'error', err };
}
const outcome = classifyOutcome(result, now);
const outcome = classifyOutcome(result, now, retryConfig.retryAfterCapMs);
switch (outcome.kind) {
case 'success':

View file

@ -69,6 +69,7 @@ export const REL_TYPES = [
'ENTRY_POINT_OF',
'WRAPS',
'QUERIES',
'INJECTS',
// Taint/PDG substrate (issue #2080) — reserved edge types, emitted by no
// phase yet (CFG → M1, REACHING_DEF → M2, TAINTED/SANITIZES/TAINT_PATH →
// M3/M4). REACHING_DEF's variable name rides the relation's `reason` column.

View file

@ -93,6 +93,7 @@ export interface FinalizeHooks {
targetRaw: string,
fromFile: string,
workspaceIndex: WorkspaceIndex,
parsedImport?: ParsedImport,
): string | readonly string[] | null;
/**
@ -348,7 +349,12 @@ function makeEdgeDrafts(
];
}
const targetFile = hooks.resolveImportTarget(parsed.targetRaw ?? '', file.filePath, workspace);
const targetFile = hooks.resolveImportTarget(
parsed.targetRaw ?? '',
file.filePath,
workspace,
parsed,
);
// Edge is unresolvable at the file level — mark unresolved now.
if (targetFile === null) {

View file

@ -105,6 +105,9 @@ export type ParsedImport =
readonly localName: string;
readonly importedName: string;
readonly targetRaw: string;
/** Provider-specific imported symbol category when module and symbol
* namespaces have distinct resolution rules (for example PHP). */
readonly importedSymbolKind?: 'type' | 'function' | 'const';
/**
* Set by providers when `targetRaw` already names the imported symbol
* rather than only its containing module. Consumers that compose
@ -127,6 +130,8 @@ export type ParsedImport =
readonly alias: string;
readonly targetRaw: string;
/** See the same field on the `named` variant. */
readonly importedSymbolKind?: 'type' | 'function' | 'const';
/** See the same field on the `named` variant. */
readonly targetIncludesImportedName?: boolean;
}
/**

View file

@ -0,0 +1,472 @@
import { test, expect, type Page } from '@playwright/test';
import { spawn, type ChildProcess } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
/**
* E2E tests for repo *path* identity with duplicate display names (#2419).
*
* Unlike the sibling specs, this file runs WRITE operations (analyze,
* re-analyze, delete), so it spawns its OWN backend on a dedicated port with
* an isolated GITNEXUS_HOME instead of sharing the suite-wide server: a force
* re-analysis rewrites LadybugDB files under a live server, and doing that on
* the shared instance while parallel workers hold connections has taken the
* whole backend down in CI (every later test in every file died with
* ECONNRESET). Isolation also makes the registry hermetic — exactly the two
* duplicates exist, and nothing here can perturb the other suites.
*
* Two repos with the SAME basename (`pr2419-dupe`) under different parent
* directories are provisioned against that backend via POST /api/analyze.
* Each contains a uniquely named marker file so the tests can assert which
* repo's graph is actually on screen — the whole point of #2419 is that name
* alone cannot distinguish them.
*
* Covers each ambiguity from the issue's "Actual behavior" list, end to end
* through a real browser:
* - duplicate rows render, and the ACTIVE one is identifiable (active-state
* comparison must not use `repo.name === projectName`)
* - switching between duplicates swaps the loaded graph (switching must not
* pass `repo.name` into onSwitchRepo)
* - re-analyze targets the clicked duplicate's exact path, tracks progress
* on that row only, and reconnects to that same duplicate on completion
* - delete removes exactly the chosen duplicate, not its sibling
* - backend HTTP repo resolution treats ?repo= as a path: landing selection
* loads the exact repo, ?repo= survives F5, and a stale path fails closed
* to the repo picker instead of silently retargeting the sibling
*/
const FRONTEND_URL = process.env.FRONTEND_URL ?? 'http://localhost:5173';
// Spec-owned backend (spawned in beforeAll) — deliberately NOT the shared
// suite server; see the header comment. 127.0.0.1 (not localhost) because the
// availability probes here run in Node, whose fetch resolves localhost to an
// address the server may not be bound to.
const BACKEND_PORT = 4799;
const BACKEND_URL = `http://127.0.0.1:${BACKEND_PORT}`;
// Playwright's cwd is gitnexus-web (the config dir).
const CLI_PATH = path.resolve(process.cwd(), '..', 'gitnexus', 'dist', 'cli', 'index.js');
const DUPE_NAME = 'pr2419-dupe';
const READY_TIMEOUT_MS = 45_000;
interface AnalyzeJobResponse {
jobId: string;
}
interface AnalyzeJobStatus {
status: string;
error?: string;
}
interface RepoListEntry {
name: string;
repoPath?: string;
path?: string;
}
let tempRoot = '';
let gitnexusHome = '';
let server: ChildProcess | undefined;
let serverLog = '';
let serverExited: number | null | undefined;
/** Duplicate repo paths in registry (= card/switcher-row) order. */
let dupePaths: string[] = [];
/** Spawn the spec-owned backend and wait until it serves /api/repos. */
async function startBackend(): Promise<void> {
gitnexusHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-dupe-home-'));
server = spawn(
process.execPath,
[CLI_PATH, 'serve', '--port', String(BACKEND_PORT), '--host', '127.0.0.1'],
{
env: { ...process.env, GITNEXUS_HOME: gitnexusHome },
stdio: ['ignore', 'pipe', 'pipe'],
},
);
const capture = (chunk: Buffer) => {
serverLog = (serverLog + chunk.toString()).slice(-8_192);
};
server.stdout?.on('data', capture);
server.stderr?.on('data', capture);
server.on('exit', (code) => {
serverExited = code;
});
const deadline = Date.now() + 30_000;
for (;;) {
if (serverExited !== undefined) {
throw new Error(`spec backend exited early (code ${serverExited}):\n${serverLog}`);
}
const ok = await fetch(`${BACKEND_URL}/api/repos`)
.then((r) => r.ok)
.catch(() => false);
if (ok) return;
if (Date.now() > deadline) {
throw new Error(`spec backend did not become ready on ${BACKEND_URL}:\n${serverLog}`);
}
await new Promise((r) => setTimeout(r, 250));
}
}
/**
* The marker file proving which duplicate's graph is on screen. Keyed off the
* team-a/team-b path segment (not exact path equality) so macOS
* `/var` → `/private/var` realpath drift can't break the mapping.
*/
function markerFile(repoPath: string): string {
return repoPath.includes(`${path.sep}team-a${path.sep}`)
? 'team-a-marker.ts'
: 'team-b-marker.ts';
}
async function analyzeAndWait(repoPath: string): Promise<void> {
const res = await fetch(`${BACKEND_URL}/api/analyze`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: repoPath, force: true }),
});
if (!res.ok) throw new Error(`POST /api/analyze for ${repoPath} → HTTP ${res.status}`);
const { jobId } = (await res.json()) as AnalyzeJobResponse;
const deadline = Date.now() + 120_000;
for (;;) {
const poll = await fetch(`${BACKEND_URL}/api/analyze/${jobId}`);
const job = (await poll.json()) as AnalyzeJobStatus;
if (job.status === 'complete' || job.status === 'completed') return;
if (job.status === 'failed') throw new Error(`analyze ${repoPath} failed: ${job.error}`);
if (Date.now() > deadline) throw new Error(`analyze ${repoPath} timed out`);
await new Promise((r) => setTimeout(r, 1_000));
}
}
async function deleteRepoByPath(repoPath: string): Promise<void> {
await fetch(`${BACKEND_URL}/api/repo?repo=${encodeURIComponent(repoPath)}`, {
method: 'DELETE',
}).catch(() => undefined);
}
async function listDupes(): Promise<string[]> {
const res = await fetch(`${BACKEND_URL}/api/repos`);
const repos = (await res.json()) as RepoListEntry[];
return repos.filter((r) => r.name === DUPE_NAME).map((r) => r.repoPath ?? r.path ?? '');
}
test.beforeAll(async () => {
// Backend spawn + two sequential live analyses can exceed the default budget.
test.setTimeout(300_000);
// Local runs skip gracefully when prerequisites are missing; under E2E=1
// (CI) a missing prerequisite is an infra failure and must fail loudly.
if (!process.env.E2E) {
const frontendUp = await fetch(FRONTEND_URL)
.then((r) => r.ok)
.catch(() => false);
if (!frontendUp) {
test.skip(true, 'Vite dev server not available');
return;
}
if (!fs.existsSync(CLI_PATH)) {
test.skip(true, `backend CLI not built (${CLI_PATH})`);
return;
}
}
await startBackend();
// Provision two repos with the SAME basename under different parents.
// The registry (fresh GITNEXUS_HOME) is hermetic by construction.
tempRoot = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gn-dupe-e2e-')));
const pathA = path.join(tempRoot, 'team-a', DUPE_NAME);
const pathB = path.join(tempRoot, 'team-b', DUPE_NAME);
fs.mkdirSync(pathA, { recursive: true });
fs.mkdirSync(pathB, { recursive: true });
fs.writeFileSync(
path.join(pathA, 'team-a-marker.ts'),
'export function teamAOnly(): string {\n return "team-a";\n}\n',
);
fs.writeFileSync(
path.join(pathB, 'team-b-marker.ts'),
'export function teamBOnly(): string {\n return "team-b";\n}\n',
);
// Sequential on purpose — concurrent analyses contend for the repo lock.
await analyzeAndWait(pathA);
await analyzeAndWait(pathB);
dupePaths = await listDupes();
if (dupePaths.length !== 2) {
throw new Error(`expected 2 registered "${DUPE_NAME}" repos, got ${dupePaths.length}`);
}
});
// Every page in this file must talk to the spec-owned backend: both the
// probe-driven landing flow and the ?server= auto-connect read the backend
// URL from useBackend, which honors this supported localStorage override.
test.beforeEach(async ({ page }) => {
await page.addInitScript((backendUrl) => {
window.localStorage.setItem('gitnexus-backend-url', backendUrl);
}, BACKEND_URL);
});
test.afterAll(async () => {
if (server && serverExited !== undefined) {
// The backend crashed mid-run — surface its output, which CI otherwise loses.
console.error(`spec backend exited (code ${serverExited}); last output:\n${serverLog}`);
}
if (server && serverExited === undefined) {
// Wait for the process to actually exit before removing its storage,
// otherwise the rm races the server's final writes.
const exited = new Promise<void>((resolve) => server?.once('exit', () => resolve()));
server.kill('SIGTERM');
await Promise.race([exited, new Promise((r) => setTimeout(r, 5_000))]);
}
try {
if (tempRoot) fs.rmSync(tempRoot, { recursive: true, force: true });
if (gitnexusHome) fs.rmSync(gitnexusHome, { recursive: true, force: true });
} catch {
/* best-effort cleanup of temp dirs */
}
});
/** Loads a specific duplicate directly via URL params and waits for Ready. */
async function connectTo(page: Page, repoPath: string): Promise<void> {
await page.goto(
`/?server=${encodeURIComponent(BACKEND_URL)}&project=${encodeURIComponent(DUPE_NAME)}&repo=${encodeURIComponent(repoPath)}`,
);
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({
timeout: READY_TIMEOUT_MS,
});
}
/** The explorer file entry proving which duplicate's graph is on screen. */
function marker(page: Page, repoPath: string) {
return page.getByText(markerFile(repoPath)).first();
}
// ── 1. Landing selection targets the exact path, not the first name match ────
test('landing lists both duplicates and selecting the second loads its exact path', async ({
page,
}) => {
// Plain `/` (no ?server= — that param auto-connects and skips the landing);
// the beforeEach localStorage override points the probe at the spec backend.
await page.goto('/');
const dupeCards = page
.locator('[data-testid="landing-repo-card"]')
.filter({ hasText: DUPE_NAME });
await expect(dupeCards).toHaveCount(2, { timeout: 20_000 });
// The second card is the second registry entry — the repo a name-keyed
// lookup would NEVER reach (it always resolves the first match, #2419).
await dupeCards.nth(1).click();
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({
timeout: READY_TIMEOUT_MS,
});
const url = new URL(page.url());
expect(url.searchParams.get('repo')).toBe(dupePaths[1]);
expect(url.searchParams.get('project')).toBe(DUPE_NAME);
await expect(marker(page, dupePaths[1])).toBeVisible();
await expect(marker(page, dupePaths[0])).toBeHidden();
});
// ── 2. Header switcher swaps between same-named repos by path ────────────────
test('header switcher switches between duplicates and swaps the loaded graph', async ({ page }) => {
await connectTo(page, dupePaths[0]);
await expect(marker(page, dupePaths[0])).toBeVisible();
await page.locator('[data-testid="repo-switcher-trigger"]').click();
const rows = page.locator('[data-testid="repo-switcher-row"]').filter({ hasText: DUPE_NAME });
await expect(rows).toHaveCount(2);
// Issue step 4: "Try to identify the active repository" — exactly one
// duplicate is marked active, and it is the one the URL points at
// (rows render in registry order, so row 0 ↔ dupePaths[0]).
await expect(rows.nth(0)).toHaveAttribute('data-active', 'true');
await expect(rows.nth(1)).toHaveAttribute('data-active', 'false');
const inactiveRow = page
.locator('[data-testid="repo-switcher-row"][data-active="false"]')
.filter({ hasText: DUPE_NAME });
await inactiveRow.locator('button').first().click();
await page.waitForURL((u) => u.searchParams.get('repo') === dupePaths[1], {
timeout: READY_TIMEOUT_MS,
});
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({
timeout: READY_TIMEOUT_MS,
});
await expect(marker(page, dupePaths[1])).toBeVisible();
await expect(marker(page, dupePaths[0])).toBeHidden();
// Re-open the switcher: the active marker must have followed the switch.
await page.locator('[data-testid="repo-switcher-trigger"]').click();
await expect(rows.nth(0)).toHaveAttribute('data-active', 'false');
await expect(rows.nth(1)).toHaveAttribute('data-active', 'true');
});
// ── 3. ?repo= path identity survives reload ──────────────────────────────────
test('?repo= path identity survives F5 reload', async ({ page }) => {
test.slow(); // two sequential connects (initial + reload)
await connectTo(page, dupePaths[1]);
await page.reload();
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({
timeout: READY_TIMEOUT_MS,
});
const url = new URL(page.url());
expect(url.searchParams.get('repo')).toBe(dupePaths[1]);
await expect(marker(page, dupePaths[1])).toBeVisible();
});
// ── 4. Stale ?repo= fails closed instead of retargeting the sibling ──────────
test('stale ?repo= path falls back to the repo picker, never a same-named sibling', async ({
page,
}) => {
const stalePath = path.join(tempRoot, 'ghost', DUPE_NAME);
await page.goto(
`/?server=${encodeURIComponent(BACKEND_URL)}&project=${encodeURIComponent(DUPE_NAME)}&repo=${encodeURIComponent(stalePath)}`,
);
// Fail-closed: the app must not silently load whichever sibling matches by
// name. The exact recovery surface can be either the error/onboarding path or
// the repo picker while the server probe settles, so assert the identity
// contract instead of overfitting the transient UI phase.
await expect(page.locator('[data-testid="status-ready"]')).toHaveCount(0, {
timeout: 20_000,
});
await expect(marker(page, dupePaths[0])).toHaveCount(0);
await expect(marker(page, dupePaths[1])).toHaveCount(0);
expect(new URL(page.url()).searchParams.get('repo')).toBe(stalePath);
});
// ── 5. Re-analyze targets the exact duplicate, not whatever matches by name ──
test('re-analyzing a duplicate targets its exact path throughout the flow', async ({ page }) => {
// Live re-index can exceed the default budget.
test.setTimeout(240_000);
await connectTo(page, dupePaths[0]);
// Track which repo every subsequent connect-shaped request targets. Attached
// while the app idles on dupePaths[0], so everything recorded from here on
// is driven by the re-analyze flow.
const connectTargets: string[] = [];
page.on('request', (req) => {
const u = new URL(req.url());
if (u.pathname === '/api/repo' || u.pathname === '/api/graph') {
const target = u.searchParams.get('repo');
if (target) connectTargets.push(target);
}
});
await page.locator('[data-testid="repo-switcher-trigger"]').click();
const activeRow = page
.locator('[data-testid="repo-switcher-row"][data-active="true"]')
.filter({ hasText: DUPE_NAME });
const inactiveRow = page
.locator('[data-testid="repo-switcher-row"][data-active="false"]')
.filter({ hasText: DUPE_NAME });
await expect(inactiveRow).toHaveCount(1);
// Re-analyze the INACTIVE duplicate — the repo a name-keyed flow would
// confuse with its sibling at every step.
const analyzeRequest = page.waitForRequest(
(req) => req.method() === 'POST' && req.url().includes('/api/analyze'),
);
await inactiveRow.hover();
await inactiveRow.locator('[data-testid="repo-switcher-reanalyze"]').click();
// The analyze POST must carry the clicked duplicate's path.
const analyzePost = await analyzeRequest;
const body = analyzePost.postDataJSON() as { path?: string };
expect(body.path).toBe(dupePaths[1]);
const analyzeResponse = await analyzePost.response();
if (!analyzeResponse) throw new Error('analyze POST received no response');
if (!analyzeResponse.ok()) {
throw new Error(`analyze POST failed with HTTP ${analyzeResponse.status()}`);
}
// Progress is tracked per path identity: only the clicked row spins. Under
// name-keyed tracking (`reanalyzing === repo.name`) BOTH rows would spin.
await expect(inactiveRow.locator('.animate-spin')).toHaveCount(1);
await expect(activeRow.locator('.animate-spin')).toHaveCount(0);
// On completion the app reconnects to the re-analyzed duplicate ITSELF — a
// name-keyed completion would reconnect to the FIRST name match (the
// sibling). Assert the identity of the reconnect at the request level.
//
// Deliberately NOT asserted here: that the reconnect reaches the Ready
// state. A pre-existing storage race (any repo, duplicates or not) can
// leave a freshly re-analyzed database transiently unreadable ("Binder
// exception: Table CodeRelation does not exist") right after completion,
// which would fail this test for reasons unrelated to the #2419 identity
// contract it covers. Tighten to a full Ready assertion once that is fixed.
await expect
.poll(() => connectTargets.filter((t) => t === dupePaths[1]).length, { timeout: 120_000 })
.toBeGreaterThan(0);
expect(connectTargets).not.toContain(dupePaths[0]);
// Re-analyze must not duplicate or replace registry entries.
expect((await listDupes()).sort()).toEqual([...dupePaths].sort());
});
// ── 6. Delete removes exactly the chosen duplicate, not its sibling ──────────
test('deleting one duplicate leaves the same-named sibling registered and loaded', async ({
page,
}) => {
// Delete retries below may wait out a server-side repo lock.
test.setTimeout(120_000);
await connectTo(page, dupePaths[0]);
// Record every DELETE the UI issues — the #2419 contract is that they all
// target exactly the chosen duplicate's path and NEVER the sibling's.
const deleteTargets: string[] = [];
page.on('request', (req) => {
if (req.method() === 'DELETE' && req.url().includes('/api/repo')) {
const target = new URL(req.url()).searchParams.get('repo');
if (target) deleteTargets.push(target);
}
});
await page.locator('[data-testid="repo-switcher-trigger"]').click();
const inactiveRow = page
.locator('[data-testid="repo-switcher-row"][data-active="false"]')
.filter({ hasText: DUPE_NAME });
await expect(inactiveRow).toHaveCount(1);
// Retry the whole click-and-verify block, because two pre-existing server
// races (both unrelated to the #2419 identity contract) can make a single
// click insufficient: a lingering analyze/embed job still holding the repo
// lock 409s the delete, and the registry's validate-prune path can clobber
// a concurrent unregister with its pre-delete snapshot, transiently
// resurrecting the entry after the UI has already dropped the row (in that
// case re-issue the delete by path, off-page, since the row is gone).
await expect(async () => {
if ((await inactiveRow.count()) > 0) {
// Delete icon is revealed on row hover.
await inactiveRow.hover();
await inactiveRow.locator('[data-testid="repo-switcher-delete"]').click();
} else if ((await listDupes()).includes(dupePaths[1])) {
await deleteRepoByPath(dupePaths[1]);
}
// Backend: exactly the inactive sibling is gone, the active one remains.
expect(await listDupes()).toEqual([dupePaths[0]]);
}).toPass({ timeout: 90_000, intervals: [2_000] });
// Identity: the UI's delete requests all targeted the chosen duplicate.
expect(deleteTargets.length).toBeGreaterThan(0);
expect([...new Set(deleteTargets)]).toEqual([dupePaths[1]]);
// Frontend: the active repo is untouched — still Ready on the same path.
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible();
expect(new URL(page.url()).searchParams.get('repo')).toBe(dupePaths[0]);
});

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,5 @@
{
"name": "gitnexus",
"name": "gitnexus-web",
"private": true,
"version": "0.0.0",
"engines": {
@ -20,13 +20,13 @@
"dependencies": {
"@langchain/anthropic": "^1.3.29",
"@langchain/core": "^1.1.49",
"@langchain/google-genai": "^2.1.30",
"@langchain/langgraph": "^1.4.1",
"@langchain/google-genai": "^2.2.0",
"@langchain/langgraph": "^1.4.7",
"@langchain/ollama": "^1.2.7",
"@langchain/openai": "^1.5.0",
"@langchain/openai": "^1.5.3",
"@sigma/edge-curve": "^3.1.0",
"@tailwindcss/vite": "^4.3.0",
"axios": "^1.16.1",
"@tailwindcss/vite": "^4.3.2",
"axios": "^1.18.1",
"d3": "^7.9.0",
"dompurify": "^3.4.11",
"gitnexus-shared": "file:../gitnexus-shared",
@ -40,7 +40,7 @@
"i18next-browser-languagedetector": "^8.2.1",
"langchain": "^1.4.6",
"lru-cache": "^11.5.1",
"lucide-react": "^1.17.0",
"lucide-react": "^1.23.0",
"mermaid": "^11.15.0",
"mnemonist": "^0.40.4",
"pandemonium": "^2.4.0",
@ -53,12 +53,12 @@
"remark-gfm": "^4.0.1",
"sigma": "^3.0.3",
"tailwindcss": "^4.2.4",
"uuid": "^14.0.0",
"uuid": "^14.0.1",
"zod": "^4.4.3"
},
"devDependencies": {
"@babel/types": "^7.29.0",
"@playwright/test": "^1.60.0",
"@playwright/test": "^1.61.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
@ -67,15 +67,15 @@
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/react-syntax-highlighter": "^15.5.13",
"@vercel/node": "^5.8.12",
"@vitejs/plugin-react": "^5.1.4",
"@vercel/node": "^5.8.23",
"@vitejs/plugin-react": "^6.0.2",
"@vitest/coverage-v8": "^4.1.9",
"jsdom": "^29.1.1",
"tree-sitter-wasms": "^0.1.13",
"typescript": "^5.4.5",
"vite": "^8.0.16",
"vite": "^8.1.4",
"vitest": "^4.1.5",
"wait-on": "^9.0.5"
"wait-on": "^9.0.10"
},
"overrides": {
"@vercel/static-config": {

View file

@ -25,6 +25,16 @@ import { parseSkipGraphParam } from './lib/graph-load-decision';
import { formatBackendError } from './i18n/error-messages';
import { useTranslation } from 'react-i18next';
/**
* Restore-param preference for the auto-connect effect: `repo` carries the
* server-resolved path identity (restores the exact repo even when duplicate
* display names exist, #2419), while older `project`-only URLs degrade to a
* name-based restore. Exported for direct unit testing — no test harness
* renders <App/>.
*/
export const pickRestoreRepo = (params: URLSearchParams): string | undefined =>
params.get('repo') ?? params.get('project') ?? undefined;
const AppContent = () => {
const { t } = useTranslation(['common', 'errors']);
const {
@ -66,8 +76,9 @@ const AppContent = () => {
result.repoInfo.name ||
(repoPath || '').replace(/\\/g, '/').split('/').filter(Boolean).pop() ||
'server-project';
const repoIdentity = repoPath || projectName;
setProjectName(projectName);
setCurrentRepo(projectName);
setCurrentRepo(repoIdentity);
// Build KnowledgeGraph from server data for visualization. In chat-only
// mode the graph download was skipped, so the shared builder keeps an
@ -78,9 +89,15 @@ const AppContent = () => {
setGraphMode(built.graphMode);
setChatOnlyNodeCount(built.graphMode === 'chatOnly' ? built.nodeCount : null);
// Persist the active project in the URL for bookmarkability and F5 refresh resilience
// Persist the active project in the URL for bookmarkability and F5 refresh resilience.
// `repo` carries the server-resolved path identity (never the request-side
// string) so a refresh restores this exact repo even when duplicate display
// names exist (#2419); `project` stays as the readable display name.
const urlObj = new URL(window.location.href);
urlObj.searchParams.set('project', projectName);
if (repoPath) {
urlObj.searchParams.set('repo', repoPath);
}
window.history.replaceState(null, '', urlObj.toString());
// Transition directly to exploring view
@ -90,7 +107,7 @@ const AppContent = () => {
// chat-only flag so the agent's prompt matches the loaded/skipped graph (#2178).
try {
if (getActiveProviderConfig()) {
await initializeAgent(projectName, { chatOnly: result.graphSkipped });
await initializeAgent(projectName, { chatOnly: result.graphSkipped, repo: repoIdentity });
}
startEmbeddingsWithFallback();
} catch (err) {
@ -109,7 +126,11 @@ const AppContent = () => {
],
);
// Auto-connect when ?server or ?project query param is present (bookmarkable shortcut)
// Auto-connect when a ?server, ?repo or ?project query param is present
// (bookmarkable shortcut). A failed ?repo= restore (e.g. the bookmarked path
// was deleted) fails visibly via the error overlay → onboarding — it must
// NOT silently fall back to a name-based connect, which could reconnect a
// same-named sibling repo (#2419).
const autoConnectRan = useRef(false);
const tRef = useRef(t);
useEffect(() => {
@ -120,12 +141,12 @@ const AppContent = () => {
if (autoConnectRan.current) return;
const params = new URLSearchParams(window.location.search);
const serverUrlParam = params.get('server');
const projectParam = params.get('project');
const restoreRepoParam = pickRestoreRepo(params);
// `?skipGraph=1` forces chat-only, `?skipGraph=0` forces a full graph;
// absent → auto-detect by node count. Bookmarkable / survives F5 (#2178).
const skipGraphParam = parseSkipGraphParam(params.get('skipGraph'));
if (!serverUrlParam && !projectParam) return;
if (!serverUrlParam && !restoreRepoParam) return;
autoConnectRan.current = true;
setProgress({
@ -169,7 +190,7 @@ const AppContent = () => {
}
},
undefined,
projectParam || undefined,
restoreRepoParam,
{ awaitAnalysis: true, skipGraph: skipGraphParam }, // hold-queue + chat-only control (#2178)
);
};
@ -282,8 +303,11 @@ const AppContent = () => {
setProgress(null);
return;
} catch (err: unknown) {
if (attempt === 0 && err instanceof BackendError && err.status === 404) {
// Server may still be reinitializing — wait and retry
// Server may still be reinitializing after the worker completed:
// that surfaces as a 404 (repo not registered yet) OR a transient
// 5xx/binder error while the freshly-written DB becomes readable.
// Either way, wait and retry once before giving up.
if (attempt === 0 && err instanceof BackendError) {
await new Promise((r) => setTimeout(r, 1500));
continue;
}

View file

@ -57,6 +57,7 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
setSelectedNode,
codeReferenceFocus,
projectName,
currentRepo,
} = useAppState();
const nodeById = useMemo(() => {
@ -226,14 +227,15 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
const isWholeFile = selectedIsFile || startLine === undefined;
const options = isWholeFile
? { repo: projectName }
? {}
: {
startLine: Math.max(0, startLine - CONTEXT_LINES),
endLine: (endLine ?? startLine) + CONTEXT_LINES,
repo: projectName,
};
readFile(selectedFilePath, { ...options, repo: projectName || undefined })
// Prefer the repo path identity over the display name — duplicate display
// names would otherwise resolve to the wrong repository's file (#2420).
readFile(selectedFilePath, { ...options, repo: currentRepo || projectName || undefined })
.then((result) => {
if (!cancelled) {
setFileResult(result);
@ -256,6 +258,7 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
selectedNode?.properties?.endLine,
selectedIsFile,
projectName,
currentRepo,
]);
// Scroll to the selected node's startLine after content loads

View file

@ -15,6 +15,7 @@ import { useAppState } from '../hooks/useAppState';
import {
deleteRepo,
fetchRepos,
repoIdentity,
startAnalyze,
streamAnalyzeProgress,
type BackendRepo,
@ -62,6 +63,7 @@ export const Header = ({
const { t } = useTranslation(['common', 'header', 'errors']);
const {
projectName,
currentRepo,
graph,
graphMode,
openChatPanel,
@ -71,9 +73,10 @@ export const Header = ({
setHelpDialogBoxOpen,
} = useAppState();
const [searchQuery, setSearchQuery] = useState('');
const [repoSearchQuery, setRepoSearchQuery] = useState('');
const [isRepoDropdownOpen, setIsRepoDropdownOpen] = useState(false);
const [showAnalyzer, setShowAnalyzer] = useState(false);
const [reanalyzing, setReanalyzing] = useState<string | null>(null); // repo name being re-analyzed
const [reanalyzing, setReanalyzing] = useState<string | null>(null); // repo identity being re-analyzed
const [deleteError, setDeleteError] = useState<string | null>(null); // surfaced when a delete is rejected (e.g. origin-blocked 403)
const [reanalyzeProgress, setReanalyzeProgress] = useState<JobProgress | null>(null);
const reanalyzeSseRef = useRef<AbortController | null>(null);
@ -96,6 +99,15 @@ export const Header = ({
.slice(0, 10); // Limit to 10 results
}, [graph, searchQuery]);
const filteredRepos = useMemo(() => {
const query = repoSearchQuery.trim().toLowerCase();
if (!query) return availableRepos;
return availableRepos.filter((repo) => repo.name.toLowerCase().includes(query));
}, [availableRepos, repoSearchQuery]);
const activeRepoIdentity = currentRepo ?? projectName;
// Handle clicking outside search or repo dropdown to close them
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
@ -105,6 +117,7 @@ export const Header = ({
if (repoDropdownRef.current && !repoDropdownRef.current.contains(e.target as Node)) {
setIsRepoDropdownOpen(false);
setShowAnalyzer(false);
setRepoSearchQuery('');
}
};
document.addEventListener('mousedown', handleClickOutside);
@ -178,9 +191,12 @@ export const Header = ({
{projectName && (
<div className="relative" ref={repoDropdownRef}>
<button
data-testid="repo-switcher-trigger"
onClick={() => {
setIsRepoDropdownOpen((prev) => !prev);
const nextOpen = !isRepoDropdownOpen;
setIsRepoDropdownOpen(nextOpen);
setShowAnalyzer(false);
if (!nextOpen) setRepoSearchQuery('');
}}
className={`flex cursor-pointer items-center gap-2 rounded-lg border px-3 py-1.5 text-sm transition-all ${
isRepoDropdownOpen
@ -196,145 +212,193 @@ export const Header = ({
</button>
{isRepoDropdownOpen && (
<div className="absolute top-full left-0 z-50 mt-1.5 w-80 animate-slide-up overflow-hidden rounded-xl border border-border-subtle bg-surface shadow-xl">
<div className="absolute top-full left-0 z-50 mt-1.5 flex max-h-[calc(100vh-4.5rem)] w-80 animate-slide-up flex-col overflow-hidden rounded-xl border border-border-subtle bg-surface shadow-xl">
{showAnalyzer ? (
<div className="p-4">
<div className="scrollbar-thin overflow-y-auto p-4">
<RepoAnalyzer
variant="sheet"
onComplete={(repoName) => {
setShowAnalyzer(false);
setIsRepoDropdownOpen(false);
setRepoSearchQuery('');
onAnalyzeComplete?.(repoName);
}}
onCancel={() => setShowAnalyzer(false)}
/>
</div>
) : (
<>
<div className="flex min-h-0 flex-1 flex-col">
{/* Repo list */}
{availableRepos.length > 0 && (
<div>
<div className="px-3 pt-2.5 pb-1.5 text-[10px] font-medium tracking-wider text-text-muted uppercase">
<div className="flex min-h-0 flex-1 flex-col">
<div className="shrink-0 px-3 pt-2.5 pb-1.5 text-[10px] font-medium tracking-wider text-text-muted uppercase">
{t('header:repositories')}
</div>
{availableRepos.map((repo) => (
<div
key={repo.name}
className={`group flex items-center gap-2 px-4 py-2 transition-colors ${
repo.name === projectName
? 'border-l-2 border-accent bg-accent/10'
: 'hover:bg-hover'
}`}
>
<button
onClick={() => {
if (repo.name !== projectName) onSwitchRepo?.(repo.name);
setIsRepoDropdownOpen(false);
}}
className="flex min-w-0 flex-1 cursor-pointer items-center gap-3 text-left"
>
<FolderOpen className="h-3.5 w-3.5 shrink-0 text-node-folder" />
<span className="flex-1 truncate font-mono text-sm text-text-primary">
{repo.name}
</span>
{repo.name === projectName && (
<span className="shrink-0 font-mono text-[10px] text-accent">
{t('header:active')}
</span>
)}
</button>
{/* Re-analyze */}
<button
onClick={async (e) => {
e.stopPropagation();
if (reanalyzing) return; // already running
setReanalyzing(repo.name);
setReanalyzeProgress({
phase: 'queued',
percent: 0,
message: t('common:progress.starting'),
});
try {
const { jobId } = await startAnalyze({
path: repo.path,
force: true,
});
reanalyzeSseRef.current = streamAnalyzeProgress(
jobId,
(p) => setReanalyzeProgress(p),
() => {
setReanalyzing(null);
setReanalyzeProgress(null);
reanalyzeSseRef.current = null;
onAnalyzeComplete?.(repo.name);
},
(errMsg) => {
console.error('Re-analyze failed:', errMsg);
setReanalyzing(null);
setReanalyzeProgress(null);
reanalyzeSseRef.current = null;
},
);
} catch (err) {
console.error('Failed to start re-analysis:', err);
setReanalyzing(null);
setReanalyzeProgress(null);
}
}}
disabled={!!reanalyzing}
className={`cursor-pointer rounded p-1 transition-all ${
reanalyzing === repo.name
? 'text-accent'
: 'text-text-muted/0 group-hover:text-text-muted hover:!text-accent'
}`}
title={
reanalyzing === repo.name
? t('header:reanalyzing')
: t('header:reanalyzeRepo', { repoName: repo.name })
}
>
<RefreshCw
className={`h-3.5 w-3.5 ${reanalyzing === repo.name ? 'animate-spin' : ''}`}
/>
</button>
{/* Delete */}
<button
onClick={async (e) => {
e.stopPropagation();
// Abort any running re-analysis for this repo
if (reanalyzing === repo.name) {
reanalyzeSseRef.current?.abort();
setReanalyzing(null);
setReanalyzeProgress(null);
reanalyzeSseRef.current = null;
}
setDeleteError(null);
try {
await deleteRepo(repo.name);
const updated = await fetchRepos();
onReposChanged?.(updated);
// If we deleted the active repo, switch to first available
if (repo.name === projectName && updated.length > 0) {
onSwitchRepo?.(updated[0].name);
} else if (updated.length === 0) {
// No repos left — go back to onboarding
window.location.reload();
}
} catch (err) {
// Surface the failure instead of silently no-opping —
// e.g. an origin-blocked 403 when driving a local
// backend from the hosted UI.
console.error('Failed to delete repo:', err);
setDeleteError(formatBackendError(err, t));
}
}}
className="cursor-pointer rounded p-1 text-text-muted/0 transition-all group-hover:text-text-muted hover:!text-red-400"
title={t('header:deleteRepo', { repoName: repo.name })}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
<div className="shrink-0 px-3 pb-2">
<div className="flex items-center gap-2 rounded-md border border-border-subtle bg-deep/70 px-2.5 py-1.5 transition-colors focus-within:border-accent/60">
<Search className="h-3.5 w-3.5 shrink-0 text-text-muted" />
<input
type="text"
aria-label={t('header:searchRepositories')}
placeholder={t('header:searchRepositories')}
value={repoSearchQuery}
onChange={(e) => setRepoSearchQuery(e.target.value)}
className="min-w-0 flex-1 border-none bg-transparent text-xs text-text-primary outline-none placeholder:text-text-muted"
/>
</div>
))}
</div>
<div className="scrollbar-thin min-h-0 flex-1 overflow-y-auto pb-1">
{filteredRepos.length === 0 ? (
<div className="px-4 py-3 text-sm text-text-muted">
{t('header:noRepositoriesFound', { query: repoSearchQuery })}
</div>
) : (
filteredRepos.map((repo) => {
const identity = repoIdentity(repo);
const isActive = identity === activeRepoIdentity;
return (
<div
key={identity}
data-testid="repo-switcher-row"
data-active={isActive}
className={`group flex items-center gap-2 px-4 py-2 transition-colors ${
isActive
? 'border-l-2 border-accent bg-accent/10'
: 'hover:bg-hover'
}`}
>
<button
onClick={() => {
if (!isActive) onSwitchRepo?.(identity);
setIsRepoDropdownOpen(false);
setRepoSearchQuery('');
}}
className="flex min-w-0 flex-1 cursor-pointer items-center gap-3 text-left"
>
<FolderOpen className="h-3.5 w-3.5 shrink-0 text-node-folder" />
<span className="flex-1 truncate font-mono text-sm text-text-primary">
{repo.name}
</span>
{isActive && (
<span className="shrink-0 font-mono text-[10px] text-accent">
{t('header:active')}
</span>
)}
</button>
{/* Re-analyze */}
<button
data-testid="repo-switcher-reanalyze"
onClick={async (e) => {
e.stopPropagation();
if (reanalyzing) return; // already running
setReanalyzing(identity);
setReanalyzeProgress({
phase: 'queued',
percent: 0,
message: t('common:progress.starting'),
});
try {
const { jobId } = await startAnalyze({
path: repo.path,
force: true,
});
reanalyzeSseRef.current = streamAnalyzeProgress(
jobId,
(p) => setReanalyzeProgress(p),
() => {
setReanalyzing(null);
setReanalyzeProgress(null);
reanalyzeSseRef.current = null;
onAnalyzeComplete?.(identity);
},
(errMsg) => {
console.error('Re-analyze failed:', errMsg);
setReanalyzing(null);
setReanalyzeProgress(null);
reanalyzeSseRef.current = null;
},
);
} catch (err) {
console.error('Failed to start re-analysis:', err);
setReanalyzing(null);
setReanalyzeProgress(null);
}
}}
disabled={!!reanalyzing}
className={`cursor-pointer rounded p-1 transition-all ${
reanalyzing === identity
? 'text-accent'
: 'text-text-muted/0 group-hover:text-text-muted hover:!text-accent'
}`}
title={
reanalyzing === identity
? t('header:reanalyzing')
: t('header:reanalyzeRepo', { repoName: repo.name })
}
>
<RefreshCw
className={`h-3.5 w-3.5 ${reanalyzing === identity ? 'animate-spin' : ''}`}
/>
</button>
{/* Delete */}
<button
data-testid="repo-switcher-delete"
onClick={async (e) => {
e.stopPropagation();
// Abort any running re-analysis for this repo
if (reanalyzing === identity) {
reanalyzeSseRef.current?.abort();
setReanalyzing(null);
setReanalyzeProgress(null);
reanalyzeSseRef.current = null;
}
setDeleteError(null);
try {
await deleteRepo(identity);
const updated = await fetchRepos();
onReposChanged?.(updated);
// If we deleted the active repo, switch to first available
if (isActive && updated.length > 0) {
// Strip the deleted repo's identity from the URL before
// the fallback switch: the switch success path rewrites
// them, and if it fails nothing stale must remain that
// would restore the deleted repo on refresh (#2419).
const urlObj = new URL(window.location.href);
urlObj.searchParams.delete('repo');
urlObj.searchParams.delete('project');
window.history.replaceState(null, '', urlObj.toString());
onSwitchRepo?.(repoIdentity(updated[0]));
} else if (updated.length === 0) {
// No repos left — go back to onboarding. Strip the
// restore params first so the reload lands on
// onboarding instead of deterministically 404-flashing
// on the just-deleted repo (#2419).
const urlObj = new URL(window.location.href);
urlObj.searchParams.delete('repo');
urlObj.searchParams.delete('project');
urlObj.searchParams.delete('skipGraph');
window.history.replaceState(null, '', urlObj.toString());
window.location.reload();
}
} catch (err) {
// Surface the failure instead of silently no-opping —
// e.g. an origin-blocked 403 when driving a local
// backend from the hosted UI.
console.error('Failed to delete repo:', err);
setDeleteError(formatBackendError(err, t));
}
}}
className="cursor-pointer rounded p-1 text-text-muted/0 transition-all group-hover:text-text-muted hover:!text-red-400"
title={t('header:deleteRepo', { repoName: repo.name })}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
);
})
)}
</div>
</div>
)}
@ -352,7 +416,13 @@ export const Header = ({
<Loader2 className="h-3 w-3 shrink-0 animate-spin text-accent" />
<span className="truncate text-xs text-text-secondary">
{t('header:reanalyzingRepo', {
repoName: reanalyzing,
// `reanalyzing` holds the path identity (#2419) —
// resolve the display name for the label, falling
// back to the path basename.
repoName:
availableRepos.find((r) => repoIdentity(r) === reanalyzing)?.name ??
reanalyzing.split(/[/\\]/).filter(Boolean).at(-1) ??
reanalyzing,
message: translateProgressMessage(reanalyzeProgress.message, t),
})}
</span>
@ -375,7 +445,10 @@ export const Header = ({
}
>
<button
onClick={() => setShowAnalyzer(true)}
onClick={() => {
setRepoSearchQuery('');
setShowAnalyzer(true);
}}
disabled={!!reanalyzing}
className="flex w-full cursor-pointer items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-hover disabled:cursor-not-allowed disabled:opacity-50"
>
@ -385,7 +458,7 @@ export const Header = ({
</span>
</button>
</div>
</>
</div>
)}
</div>
)}

View file

@ -1,5 +1,14 @@
import React, { useState } from 'react';
import { X, GitBranch, Search, Filter, Zap, Keyboard, BarChart2, HelpCircle } from 'lucide-react';
import {
X,
GitBranch,
Search,
Filter,
Zap,
Keyboard,
BarChart2,
HelpCircle,
} from '@/lib/lucide-icons';
import { useTranslation } from 'react-i18next';
interface HelpPanelProps {

View file

@ -6,7 +6,7 @@
import { useEffect, useRef, useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Copy, Focus, ZoomIn, ZoomOut } from 'lucide-react';
import { Copy, Focus, ZoomIn, ZoomOut } from '@/lib/lucide-icons';
import mermaid from 'mermaid';
import DOMPurify from 'dompurify';
import { ProcessData, generateProcessMermaid } from '../lib/mermaid-generator';
@ -18,7 +18,6 @@ interface ProcessFlowModalProps {
isFullScreen?: boolean;
}
// Initialize mermaid with cyan/purple theme matching GitNexus
// Initialize mermaid with cyan/purple theme matching GitNexus
mermaid.initialize({
startOnLoad: false,

View file

@ -18,7 +18,7 @@ import {
Sparkles,
Lightbulb,
Layers,
} from 'lucide-react';
} from '@/lib/lucide-icons';
import { useAppState } from '../hooks/useAppState';
import { ProcessFlowModal } from './ProcessFlowModal';
import type { ProcessData, ProcessStep } from '../lib/mermaid-generator';

View file

@ -183,7 +183,12 @@ type InternalPhase = 'input' | 'starting' | 'analyzing' | 'done' | 'error';
export interface RepoAnalyzerProps {
variant: 'onboarding' | 'sheet';
onComplete: (repoName: string) => void;
/**
* Receives the repo IDENTITY to reconnect with — the analyzed path when the
* server provides one (`repoPath` on the SSE complete event), otherwise the
* display name. Never rendered; the done screen shows the display name.
*/
onComplete: (repoIdentity: string) => void;
onCancel?: () => void;
}
@ -360,19 +365,25 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
jobId,
(p) => setProgress(p),
(data) => {
const name =
// Display vs identity split: the done screen renders the display name
// (never an absolute path), while onComplete receives the identity —
// the analyzed path when the server provides it, so the reconnect
// targets the exact repo even when basenames collide. Old servers omit
// repoPath and degrade to today's name behavior.
const displayName =
data.repoName ??
(fallbackNameSource
? fallbackNameSource.split(/[/\\]/).filter(Boolean).at(-1)
: undefined) ??
t('onboarding:repoAnalyzer.defaultRepoName');
setCompletedRepoName(name);
const identity = data.repoPath ?? displayName;
setCompletedRepoName(displayName);
setGithubToken('');
setPhase('done');
sseControllerRef.current = null;
completeTimerRef.current = setTimeout(() => {
completeTimerRef.current = null;
onComplete(name);
onComplete(identity);
}, 1200);
},
(errMsg) => {

View file

@ -14,7 +14,7 @@
import { Sparkles, ArrowRight, GitBranch, FileCode, Layers } from '@/lib/lucide-icons';
import { RepoAnalyzer } from './RepoAnalyzer';
import type { BackendRepo } from '../services/backend-client';
import { repoIdentity, type BackendRepo } from '../services/backend-client';
import type { TFunction } from 'i18next';
import { useTranslation } from 'react-i18next';
@ -126,7 +126,11 @@ export const RepoLanding = ({ repos, onSelectRepo, onAnalyzeComplete }: RepoLand
{/* Repo list */}
<div className="relative mb-5 space-y-2">
{repos.map((repo) => (
<RepoCard key={repo.name} repo={repo} onClick={() => onSelectRepo(repo.name)} />
<RepoCard
key={repoIdentity(repo)}
repo={repo}
onClick={() => onSelectRepo(repoIdentity(repo))}
/>
))}
</div>

View file

@ -35,6 +35,9 @@ import {
startEmbeddings as backendStartEmbeddings,
streamEmbeddingProgress,
probeBackend,
// Aliased: switchRepo declares a local `let repoIdentity` that would shadow
// a plain named import of this helper.
repoIdentity as repoIdentityOf,
type BackendRepo,
type ConnectResult,
type JobProgress,
@ -52,6 +55,15 @@ export const shouldAutoStartEmbeddings = (): boolean => {
return window.localStorage.getItem(AUTO_START_EMBEDDINGS_STORAGE_KEY) === 'true';
};
// Resolve a human-readable name for a repo path identity: the registry entry's
// display name first, then the path's basename, then the raw identity. State
// keeps holding the path identity (#2419) — user-facing labels and the agent
// prompt must never show an absolute filesystem path.
const displayNameForIdentity = (repos: BackendRepo[], identity: string): string =>
repos.find((r) => repoIdentityOf(r) === identity)?.name ??
identity.split(/[/\\]/).filter(Boolean).at(-1) ??
identity;
export type ViewMode = 'onboarding' | 'loading' | 'exploring';
export type RightPanelTab = 'code' | 'chat';
export type EmbeddingStatus = 'idle' | 'loading' | 'embedding' | 'indexing' | 'ready' | 'error';
@ -162,6 +174,7 @@ interface AppState {
// Project info
projectName: string;
setProjectName: (name: string) => void;
currentRepo: string | undefined;
// Multi-repo switching
serverBaseUrl: string | null;
@ -169,7 +182,7 @@ interface AppState {
availableRepos: BackendRepo[];
setAvailableRepos: (repos: BackendRepo[]) => void;
switchRepo: (repoName: string) => Promise<void>;
setCurrentRepo: (repoName: string) => void;
setCurrentRepo: (repoName: string | undefined) => void;
/** Download the full graph for the current repo after a chat-only connect (#2178). */
loadGraphAnyway: () => Promise<void>;
@ -204,7 +217,10 @@ interface AppState {
// LLM methods
refreshLLMSettings: () => void;
initializeAgent: (overrideProjectName?: string, opts?: { chatOnly?: boolean }) => Promise<void>;
initializeAgent: (
overrideProjectName?: string,
opts?: { chatOnly?: boolean; repo?: string },
) => Promise<void>;
sendChatMessage: (message: string) => Promise<void>;
stopChatResponse: () => void;
clearChat: () => void;
@ -346,6 +362,7 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
// Project info
const [projectName, setProjectName] = useState<string>('');
const [currentRepo, setCurrentRepoState] = useState<string | undefined>(undefined);
// Multi-repo switching
const [serverBaseUrl, setServerBaseUrl] = useState<string | null>(null);
@ -489,8 +506,9 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
// Backend client — direct HTTP calls (no Worker/Comlink)
const repoRef = useRef<string | undefined>(undefined);
const setCurrentRepo = useCallback((repoName: string) => {
const setCurrentRepo = useCallback((repoName: string | undefined) => {
repoRef.current = repoName;
setCurrentRepoState(repoName);
}, []);
const runQuery = useCallback(async (cypher: string): Promise<any[]> => {
@ -613,7 +631,10 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
}, [graphMode]);
const initializeAgent = useCallback(
async (overrideProjectName?: string, opts?: { chatOnly?: boolean }): Promise<void> => {
async (
overrideProjectName?: string,
opts?: { chatOnly?: boolean; repo?: string },
): Promise<void> => {
const config = getActiveProviderConfig();
if (!config) {
setAgentError('Please configure an LLM provider in settings');
@ -632,8 +653,11 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
// Sync repoRef so all agent backend calls target the correct repo.
// initializeAgent can be called from App.tsx (handleServerConnect) which
// never sets repoRef.current directly — without this, queries default to repo[0].
if (overrideProjectName) {
repoRef.current = overrideProjectName;
// Only opts.repo may write the identity: overrideProjectName is a display
// name, and a name-only caller must never clobber the path identity with
// an ambiguous name (#2419).
if (opts?.repo) {
setCurrentRepo(opts.repo);
}
const repo = repoRef.current;
@ -1161,7 +1185,10 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
phase: 'extracting',
percent: 0,
message: i18n.t('common:progress.switchingRepository'),
detail: i18n.t('common:progress.loadingRepository', { repo: repoName }),
detail: i18n.t('common:progress.loadingRepository', {
// `repoName` is a path identity — show the display name, not the path.
repo: displayNameForIdentity(availableRepos, repoName),
}),
});
setViewMode('loading');
setIsAgentReady(false);
@ -1184,7 +1211,10 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
setChatOnlyNodeCount(null);
let connectedRepo: BackendRepo | undefined;
let pNameStr = repoName || 'server-project';
// Bare declarations: both are always assigned on the success path before
// any read, and the catch below returns early (CodeQL alerts 825/826).
let pNameStr: string;
let repoIdentity: string | undefined;
let connectedChatOnly = false;
try {
@ -1225,12 +1255,13 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
const repoPath = result.repoInfo.repoPath ?? result.repoInfo.path;
// Prefer the registry name, then normalize Windows \ and Unix / paths
const pName =
repoName ||
result.repoInfo.name ||
(repoPath || '').replace(/\\/g, '/').split('/').filter(Boolean).pop() ||
repoName ||
'server-project';
repoIdentity = repoName || repoPath || pName;
setProjectName(pName);
repoRef.current = pName;
setCurrentRepo(repoIdentity);
connectedRepo = result.repoInfo;
pNameStr = pName;
@ -1262,11 +1293,19 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
if (pNameStr) {
// Persist the selected project in the URL so a refresh re-opens it.
// `repo` carries the server-resolved path identity (never the
// request-side string) so the refresh restores this exact repo even
// when duplicate display names exist (#2419); `project` stays as the
// readable display name.
// Drop any `?skipGraph` override: a deliberate repo switch should make a
// fresh per-repo decision (auto-detect) on the next refresh rather than
// carry the previous repo's forced mode (#2178).
const urlObj = new URL(window.location.href);
urlObj.searchParams.set('project', pNameStr);
const resolvedRepoPath = connectedRepo?.repoPath ?? connectedRepo?.path;
if (resolvedRepoPath) {
urlObj.searchParams.set('repo', resolvedRepoPath);
}
urlObj.searchParams.delete('skipGraph');
window.history.replaceState(null, '', urlObj.toString());
}
@ -1279,7 +1318,7 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
// Re-initialize agent with the new repo's graph context
try {
if (getActiveProviderConfig()) {
await initializeAgent(pNameStr, { chatOnly: connectedChatOnly });
await initializeAgent(pNameStr, { chatOnly: connectedChatOnly, repo: repoIdentity });
}
setViewMode('exploring');
startEmbeddingsWithFallback();
@ -1295,6 +1334,7 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
},
[
serverBaseUrl,
availableRepos,
setProgress,
setViewMode,
setProjectName,
@ -1313,6 +1353,7 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
setCodePanelOpen,
setCodeReferenceFocus,
setChatMessages,
setCurrentRepo,
],
);
@ -1390,7 +1431,14 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
// the chat-only note (#2178, KTD2). Guarded on a configured provider, like
// switchRepo; runs inside the mounted/stale guard above.
if (getActiveProviderConfig()) {
await initializeAgent(repo, { chatOnly: false });
// Pass the display name explicitly — initializeAgent's empty-deps
// closure traps `projectName` at its initial '', so relying on the
// state fallback would label the prompt the literal 'project'. The
// path identity travels separately via opts.repo.
await initializeAgent(repo ? displayNameForIdentity(availableRepos, repo) : undefined, {
chatOnly: false,
repo,
});
}
} catch (err) {
if (!loadGraphMountedRef.current || repoRef.current !== repo) return;
@ -1404,6 +1452,7 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
}
}, [
serverBaseUrl,
availableRepos,
setProgress,
setViewMode,
setGraph,
@ -1495,6 +1544,7 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
setProgress,
projectName,
setProjectName,
currentRepo,
// Multi-repo switching
serverBaseUrl,
setServerBaseUrl,

View file

@ -47,6 +47,7 @@ export {
ArrowDown,
ArrowRight,
AtSign,
BarChart2,
Brain,
Box,
Braces,
@ -71,6 +72,7 @@ export {
Heart,
HelpCircle,
Home,
Keyboard,
Key,
Layers,
Lightbulb,

View file

@ -6,6 +6,8 @@
"deleteRepo": "Delete {{repoName}}",
"reanalyzingRepo": "Re-analyzing {{repoName}}: {{message}}",
"analyzeNew": "Analyze a new repository...",
"searchRepositories": "Search repositories...",
"noRepositoriesFound": "No repositories found for \"{{query}}\"",
"searchNodes": "Search nodes...",
"noNodesFound": "No nodes found for \"{{query}}\"",
"starIfCool": "Star if cool",

View file

@ -6,6 +6,8 @@
"deleteRepo": "删除 {{repoName}}",
"reanalyzingRepo": "正在重新分析 {{repoName}}:{{message}}",
"analyzeNew": "分析新仓库...",
"searchRepositories": "搜索仓库...",
"noRepositoriesFound": "未找到“{{query}}”相关仓库",
"searchNodes": "搜索节点...",
"noNodesFound": "未找到“{{query}}”相关节点",
"starIfCool": "觉得不错就点星",

View file

@ -28,6 +28,13 @@ export interface BackendRepo {
};
}
/**
* Canonical repo identity: the registry path. The display `name` is ambiguous
* across duplicate repo names (#2419); `repoPath` is the normalized field and
* `path` the legacy list-endpoint field.
*/
export const repoIdentity = (repo: BackendRepo): string => repo.repoPath ?? repo.path ?? repo.name;
export interface EnrichedSearchResult {
filePath: string;
score: number;
@ -535,7 +542,8 @@ export const probeBackend = async (): Promise<boolean> => {
export const fetchRepos = async (): Promise<BackendRepo[]> => {
const response = await fetchWithTimeout(`${_backendUrl}/api/repos`);
await assertOk(response);
return response.json() as Promise<BackendRepo[]>;
const repos = (await response.json()) as BackendRepo[];
return repos.map((r) => ({ ...r, repoPath: r.repoPath ?? r.path }));
};
/** Fetch repo metadata.
@ -891,7 +899,7 @@ export const cancelAnalyze = async (jobId: string): Promise<void> => {
export const streamAnalyzeProgress = (
jobId: string,
onProgress: (progress: JobProgress) => void,
onComplete: (data: { repoName?: string }) => void,
onComplete: (data: { repoName?: string; repoPath?: string }) => void,
onError: (error: string) => void,
): AbortController => {
return streamSSE<JobProgress>(
@ -940,7 +948,7 @@ export const cancelEmbeddings = async (jobId: string): Promise<void> => {
export const streamEmbeddingProgress = (
jobId: string,
onProgress: (progress: JobProgress) => void,
onComplete: (data: { repoName?: string }) => void,
onComplete: (data: { repoName?: string; repoPath?: string }) => void,
onError: (error: string) => void,
): AbortController => {
return streamSSE<JobProgress>(`${_backendUrl}/api/embed/${encodeURIComponent(jobId)}/progress`, {

View file

@ -0,0 +1,108 @@
/**
* Canonical repo identity (#2419).
*
* `repoIdentity` is the single identity helper for the web app: the registry
* path is canonical because the display `name` is ambiguous across duplicate
* repo names. `fetchRepos` must normalize legacy list-endpoint entries
* (`path` only) onto the `repoPath` field, mirroring `fetchRepoInfo`.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { __resetBreakerRegistry__ } from 'gitnexus-shared/test-helpers';
import {
fetchRepos,
repoIdentity,
setBackendUrl,
type BackendRepo,
} from '../../src/services/backend-client';
const BASE = 'http://repo-identity.test:4747';
describe('repoIdentity fallback chain', () => {
it('prefers repoPath when present', () => {
const repo: BackendRepo = {
name: 'reels',
path: '/ws/group-a/reels',
repoPath: '/ws/group-b/reels',
indexedAt: '2026-07-10T00:00:00.000Z',
};
expect(repoIdentity(repo)).toBe('/ws/group-b/reels');
});
it('falls back to path when repoPath is absent', () => {
const repo: BackendRepo = {
name: 'reels',
path: '/ws/group-a/reels',
indexedAt: '2026-07-10T00:00:00.000Z',
};
expect(repoIdentity(repo)).toBe('/ws/group-a/reels');
});
it('falls back to the display name when neither path field is present', () => {
// Legacy payloads can omit both path fields at runtime even though the
// interface marks `path` required — assert the narrow legacy shape to
// exercise the final fallback without weakening the helper's signature.
const legacyRepo = {
name: 'reels',
indexedAt: '2026-07-10T00:00:00.000Z',
} as BackendRepo;
expect(repoIdentity(legacyRepo)).toBe('reels');
});
});
describe('fetchRepos repoPath normalization', () => {
beforeEach(() => {
__resetBreakerRegistry__();
setBackendUrl(BASE);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('maps legacy path-only entries onto repoPath', async () => {
const legacyBody = JSON.stringify([
{ name: 'reels', path: '/ws/group-a/reels', indexedAt: '2026-07-10T00:00:00.000Z' },
{ name: 'docs', path: '/ws/group-b/docs', indexedAt: '2026-07-09T00:00:00.000Z' },
]);
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
expect(url).toContain('/api/repos');
return new Response(legacyBody, {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
});
vi.stubGlobal('fetch', fetchMock);
const repos = await fetchRepos();
expect(repos).toMatchObject([
{ name: 'reels', path: '/ws/group-a/reels', repoPath: '/ws/group-a/reels' },
{ name: 'docs', path: '/ws/group-b/docs', repoPath: '/ws/group-b/docs' },
]);
});
it('keeps a server-provided repoPath over the legacy path field', async () => {
const body = JSON.stringify([
{
name: 'reels',
path: '/ws/group-a/reels',
repoPath: '/ws/group-b/reels',
indexedAt: '2026-07-10T00:00:00.000Z',
},
]);
vi.stubGlobal(
'fetch',
vi.fn(
async () =>
new Response(body, {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
),
);
const repos = await fetchRepos();
expect(repos).toMatchObject([{ name: 'reels', repoPath: '/ws/group-b/reels' }]);
});
});

View file

@ -0,0 +1,73 @@
import { render } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { ReactNode } from 'react';
import type { GraphNode } from 'gitnexus-shared';
import { CodeReferencesPanel } from '../../src/components/CodeReferencesPanel';
import { readFile } from '../../src/services/backend-client';
const fileNode: GraphNode = {
id: 'File:src/foo.ts',
label: 'File',
properties: { name: 'foo.ts', filePath: 'src/foo.ts' },
};
// Mutable mock state: the useAppState factory closes over this object so each
// test can reassign fields (e.g. currentRepo) before rendering.
const appState = {
graph: null,
selectedNode: fileNode,
codeReferences: [],
removeCodeReference: vi.fn(),
clearCodeReferences: vi.fn(),
setSelectedNode: vi.fn(),
codeReferenceFocus: null,
projectName: 'reels',
currentRepo: undefined as string | undefined,
};
vi.mock('../../src/hooks/useAppState', () => ({
useAppState: () => appState,
}));
vi.mock('../../src/services/backend-client', () => ({
readFile: vi.fn(),
}));
vi.mock('react-syntax-highlighter', () => ({
Prism: ({ children }: { children?: ReactNode }) => <pre>{children}</pre>,
}));
vi.mock('react-syntax-highlighter/dist/esm/styles/prism', () => ({
vscDarkPlus: {},
}));
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
describe('CodeReferencesPanel repo identity (#2420)', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(readFile).mockResolvedValue({ content: 'const a = 1;', totalLines: 1 });
});
it('reads the selected file from the active repo path, not the display name', () => {
appState.currentRepo = '/ws/b/reels';
appState.projectName = 'reels';
render(<CodeReferencesPanel onFocusNode={vi.fn()} />);
expect(readFile).toHaveBeenCalledWith('src/foo.ts', { repo: '/ws/b/reels' });
});
it('falls back to the project display name when no repo path is active', () => {
appState.currentRepo = undefined;
appState.projectName = 'reels';
render(<CodeReferencesPanel onFocusNode={vi.fn()} />);
expect(readFile).toHaveBeenCalledWith('src/foo.ts', { repo: 'reels' });
});
});

View file

@ -0,0 +1,327 @@
import { fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { Header } from '../../src/components/Header';
import {
deleteRepo,
fetchRepos,
startAnalyze,
streamAnalyzeProgress,
} from '../../src/services/backend-client';
import type { BackendRepo } from '../../src/services/backend-client';
vi.mock('../../src/hooks/useAppState', () => ({
useAppState: () => ({
projectName: 'reels',
currentRepo: '/workspace/group-b/reels',
graph: null,
graphMode: 'full',
openChatPanel: vi.fn(),
isRightPanelOpen: false,
rightPanelTab: 'chat',
setSettingsPanelOpen: vi.fn(),
setHelpDialogBoxOpen: vi.fn(),
}),
}));
vi.mock('../../src/components/EmbeddingStatus', () => ({
EmbeddingStatus: () => <div data-testid="embedding-status" />,
}));
vi.mock('../../src/components/LanguageSwitcher', () => ({
LanguageSwitcher: () => <div data-testid="language-switcher" />,
}));
vi.mock('../../src/components/RepoAnalyzer', () => ({
RepoAnalyzer: () => <div data-testid="repo-analyzer" />,
}));
vi.mock('../../src/services/backend-client', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/services/backend-client')>();
return {
...actual,
deleteRepo: vi.fn(),
fetchRepos: vi.fn(),
startAnalyze: vi.fn(),
streamAnalyzeProgress: vi.fn(),
};
});
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => {
if (key === 'header:repositories') return 'Repositories';
if (key === 'header:active') return 'Active';
if (key === 'header:reanalyzeRepo') return `Re-analyze ${options?.repoName ?? ''}`;
if (key === 'header:reanalyzingRepo')
return `Re-analyzing ${options?.repoName ?? ''}: ${options?.message ?? ''}`;
if (key === 'header:deleteRepo') return `Delete ${options?.repoName ?? ''}`;
if (key === 'header:analyzeNew') return 'Analyze new';
if (key === 'header:searchRepositories') return 'Search repositories...';
if (key === 'header:noRepositoriesFound')
return `No repositories found for ${options?.query}`;
return key;
},
}),
}));
function makeRepo(index: number): BackendRepo {
return {
name: index === 0 ? 'reels' : `repo-${index}`,
path: `/tmp/repo-${index}`,
stats: {
files: 1,
nodes: 1,
edges: 0,
communities: 0,
processes: 0,
},
};
}
describe('Header', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.unstubAllGlobals();
// Reset the URL mutated by the delete handler's hygiene pass.
window.history.replaceState(null, '', '/');
});
it('keeps a large repository menu scrollable inside the viewport', () => {
render(<Header availableRepos={Array.from({ length: 30 }, (_, index) => makeRepo(index))} />);
fireEvent.click(screen.getByRole('button', { name: /reels/i }));
const menu = screen.getByText('Repositories').closest('.absolute');
expect(menu).not.toBeNull();
expect(menu).toHaveClass('max-h-[calc(100vh-4.5rem)]');
expect(menu).toHaveClass('overflow-hidden');
const scrollableRepoList = screen.getByText('repo-29').closest('.scrollbar-thin');
expect(scrollableRepoList).not.toBeNull();
expect(scrollableRepoList).toHaveClass('overflow-y-auto');
expect(scrollableRepoList).toHaveClass('flex-1');
});
it('filters repositories locally by displayed name', async () => {
const user = userEvent.setup();
render(
<Header
availableRepos={[
{ ...makeRepo(0), name: 'reels', path: '/workspace/apps/reels' },
{ ...makeRepo(1), name: 'gitnexus-web', path: '/workspace/GitNexus/gitnexus-web' },
{ ...makeRepo(2), name: 'api-server', path: '/workspace/gitnexus/api' },
]}
/>,
);
fireEvent.click(screen.getByRole('button', { name: /reels/i }));
const input = screen.getByRole('textbox', { name: 'Search repositories...' });
await user.type(input, 'gitnexus');
expect(screen.getByText('gitnexus-web')).toBeInTheDocument();
expect(screen.queryByText('api-server')).not.toBeInTheDocument();
await user.clear(input);
await user.type(input, 'api');
expect(screen.getByText('api-server')).toBeInTheDocument();
expect(screen.queryByText('gitnexus-web')).not.toBeInTheDocument();
});
it('shows an empty state when no repositories match the local search', async () => {
const user = userEvent.setup();
render(<Header availableRepos={Array.from({ length: 3 }, (_, index) => makeRepo(index))} />);
fireEvent.click(screen.getByRole('button', { name: /reels/i }));
await user.type(screen.getByRole('textbox', { name: 'Search repositories...' }), 'missing');
expect(screen.getByText('No repositories found for missing')).toBeInTheDocument();
expect(screen.queryByText('repo-1')).not.toBeInTheDocument();
});
it('does not leave stale rows when duplicate repository names are filtered', async () => {
const user = userEvent.setup();
render(
<Header
availableRepos={[
{ ...makeRepo(0), name: 'search_sync', path: '/workspace/group-a/search_sync' },
{ ...makeRepo(1), name: 'tab_server', path: '/workspace/group-a/tab_server' },
{ ...makeRepo(2), name: 'feed_sync', path: '/workspace/group-a/feed_sync' },
{ ...makeRepo(3), name: 'search_sync', path: '/workspace/group-b/search_sync' },
{ ...makeRepo(4), name: 'tab_server', path: '/workspace/group-b/tab_server' },
{ ...makeRepo(5), name: 'reels', path: '/workspace/group-b/reels' },
]}
/>,
);
fireEvent.click(screen.getByRole('button', { name: /reels/i }));
await user.type(screen.getByRole('textbox', { name: 'Search repositories...' }), 'tab');
const repoList = screen.getAllByText('tab_server')[0].closest('.scrollbar-thin');
expect(repoList).not.toBeNull();
expect(repoList).toHaveTextContent('tab_server');
expect(repoList).not.toHaveTextContent('search_sync');
expect(repoList).not.toHaveTextContent('feed_sync');
expect(repoList).not.toHaveTextContent('reels');
});
it('uses repository path identity when duplicate display names are present', async () => {
const onSwitchRepo = vi.fn();
render(
<Header
onSwitchRepo={onSwitchRepo}
availableRepos={[
{ ...makeRepo(0), name: 'reels', path: '/workspace/group-a/reels' },
{ ...makeRepo(1), name: 'reels', path: '/workspace/group-b/reels' },
]}
/>,
);
fireEvent.click(screen.getByRole('button', { name: /reels/i }));
expect(screen.getAllByText('Active')).toHaveLength(1);
await userEvent.click(screen.getAllByText('reels')[1]);
expect(onSwitchRepo).toHaveBeenCalledWith('/workspace/group-a/reels');
});
it('deletes and falls back using repository path identity', async () => {
const onSwitchRepo = vi.fn();
const updatedRepos = [{ ...makeRepo(2), name: 'reels', path: '/workspace/group-a/reels' }];
vi.mocked(fetchRepos).mockResolvedValue(updatedRepos);
render(
<Header
onSwitchRepo={onSwitchRepo}
availableRepos={[
{ ...makeRepo(0), name: 'reels', path: '/workspace/group-a/reels' },
{ ...makeRepo(1), name: 'reels', path: '/workspace/group-b/reels' },
]}
/>,
);
fireEvent.click(screen.getByRole('button', { name: /reels/i }));
await userEvent.click(screen.getAllByTitle('Delete reels')[1]);
expect(deleteRepo).toHaveBeenCalledWith('/workspace/group-b/reels');
expect(onSwitchRepo).toHaveBeenCalledWith('/workspace/group-a/reels');
});
it('strips repo, project and skipGraph from the URL before reloading after the last repo is deleted', async () => {
window.history.replaceState(
null,
'',
'/?repo=%2Fworkspace%2Fgroup-b%2Freels&project=reels&skipGraph=1',
);
// jsdom's location.reload is own+non-configurable — replace the whole
// `location` accessor with a stub that delegates URL reads to the real
// Location (kept live by history.replaceState) and mocks reload.
const realLocation = window.location;
const reloadMock = vi.fn();
vi.stubGlobal('location', {
get href() {
return realLocation.href;
},
get search() {
return realLocation.search;
},
reload: reloadMock,
});
vi.mocked(fetchRepos).mockResolvedValue([]);
render(
<Header
availableRepos={[{ ...makeRepo(0), name: 'reels', path: '/workspace/group-b/reels' }]}
/>,
);
fireEvent.click(screen.getByRole('button', { name: /reels/i }));
await userEvent.click(screen.getByTitle('Delete reels'));
expect(reloadMock).toHaveBeenCalledTimes(1);
expect(window.location.search).not.toContain('repo=');
expect(window.location.search).not.toContain('project=');
expect(window.location.search).not.toContain('skipGraph');
});
it('strips repo and project from the URL before falling back after deleting the active repo', async () => {
window.history.replaceState(null, '', '/?repo=%2Fworkspace%2Fgroup-b%2Freels&project=reels');
// Capture the URL at the moment of the fallback switch — the stale
// identity must already be gone so a failed switch leaves nothing that
// restores the deleted repo on refresh (#2419).
const searchAtSwitch: string[] = [];
const onSwitchRepo = vi.fn(() => {
searchAtSwitch.push(window.location.search);
});
vi.mocked(fetchRepos).mockResolvedValue([
{ ...makeRepo(2), name: 'reels', path: '/workspace/group-a/reels' },
]);
render(
<Header
onSwitchRepo={onSwitchRepo}
availableRepos={[
{ ...makeRepo(0), name: 'reels', path: '/workspace/group-a/reels' },
{ ...makeRepo(1), name: 'reels', path: '/workspace/group-b/reels' },
]}
/>,
);
fireEvent.click(screen.getByRole('button', { name: /reels/i }));
await userEvent.click(screen.getAllByTitle('Delete reels')[1]);
expect(deleteRepo).toHaveBeenCalledWith('/workspace/group-b/reels');
expect(onSwitchRepo).toHaveBeenCalledWith('/workspace/group-a/reels');
expect(searchAtSwitch).toEqual(['']);
});
it('shows the display name, not the path identity, in the re-analyze progress label', async () => {
vi.mocked(startAnalyze).mockResolvedValue({ jobId: 'job-1', status: 'running' });
vi.mocked(streamAnalyzeProgress).mockReturnValue(new AbortController());
render(
<Header
availableRepos={[
{ ...makeRepo(0), name: 'reels', path: '/ws/a/reels' },
{ ...makeRepo(1), name: 'reels', path: '/ws/b/reels' },
]}
/>,
);
fireEvent.click(screen.getByRole('button', { name: /reels/i }));
// Re-analyze the second duplicate-name row — `reanalyzing` becomes the
// path identity '/ws/b/reels', but the label must render the name.
await userEvent.click(screen.getAllByTitle('Re-analyze reels')[1]);
const label = screen.getByText(/^Re-analyzing /);
expect(label.textContent).toMatch(/^Re-analyzing reels:/);
expect(label.textContent).not.toContain('/ws/b/reels');
});
it('falls back to the path basename when the re-analyzing identity is no longer listed', async () => {
vi.mocked(startAnalyze).mockResolvedValue({ jobId: 'job-2', status: 'running' });
vi.mocked(streamAnalyzeProgress).mockReturnValue(new AbortController());
const { rerender } = render(
<Header availableRepos={[{ ...makeRepo(0), name: 'reels', path: '/ws/b/reels' }]} />,
);
fireEvent.click(screen.getByRole('button', { name: /reels/i }));
await userEvent.click(screen.getByTitle('Re-analyze reels'));
// The repo list refreshes while the re-analysis is still in flight and the
// identity disappears from it — the label degrades to the path basename.
rerender(<Header availableRepos={[{ ...makeRepo(1), name: 'other', path: '/ws/x/other' }]} />);
const label = screen.getByText(/^Re-analyzing /);
expect(label.textContent).toMatch(/^Re-analyzing reels:/);
expect(label.textContent).not.toContain('/ws/b/reels');
});
});

View file

@ -0,0 +1,79 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { AppStateProvider, useAppState } from '../../src/hooks/useAppState';
import { getActiveProviderConfig } from '../../src/core/llm/settings-service';
import type { CodebaseContext } from '../../src/core/llm/context-builder';
// initializeAgent's heavy dynamic imports are stubbed — these tests only lock
// the identity-write rule, not agent behavior.
vi.mock('../../src/core/llm/context-builder', () => ({
buildCodebaseContext: vi.fn(
async (): Promise<CodebaseContext> => ({
stats: {
projectName: 'stub',
fileCount: 0,
functionCount: 0,
classCount: 0,
interfaceCount: 0,
methodCount: 0,
},
hotspots: [],
folderTree: '',
}),
),
}));
vi.mock('../../src/core/llm/agent', () => ({
createGraphRAGAgent: vi.fn(() => ({})),
}));
vi.mock('../../src/core/llm/settings-service', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/core/llm/settings-service')>();
return { ...actual, getActiveProviderConfig: vi.fn(actual.getActiveProviderConfig) };
});
afterEach(() => {
vi.restoreAllMocks();
});
const withProvider = () => {
vi.mocked(getActiveProviderConfig).mockReturnValue({
provider: 'openai',
model: 'gpt-4o',
apiKey: 'test-key',
});
};
describe('initializeAgent repo-identity writes (#2419)', () => {
it('does not clobber the path identity when called with only a display name', async () => {
withProvider();
const { result } = renderHook(() => useAppState(), { wrapper: AppStateProvider });
act(() => {
result.current.setCurrentRepo('/ws/b/reels');
});
await act(async () => {
await result.current.initializeAgent('reels');
});
// The pre-PR idiom initializeAgent(projectName) must no longer overwrite
// the path identity with an ambiguous display name.
expect(result.current.currentRepo).toBe('/ws/b/reels');
});
it('writes the identity when opts.repo is provided', async () => {
withProvider();
const { result } = renderHook(() => useAppState(), { wrapper: AppStateProvider });
act(() => {
result.current.setCurrentRepo('/ws/a/reels');
});
await act(async () => {
await result.current.initializeAgent('reels', { repo: '/ws/b/reels' });
});
expect(result.current.currentRepo).toBe('/ws/b/reels');
});
});

View file

@ -1,6 +1,38 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { AppStateProvider, useAppState } from '../../src/hooks/useAppState';
import { getActiveProviderConfig } from '../../src/core/llm/settings-service';
import { buildCodebaseContext, type CodebaseContext } from '../../src/core/llm/context-builder';
// Capture initializeAgent's observable seam: buildCodebaseContext receives the
// effective project name that ends up in the agent's system prompt.
vi.mock('../../src/core/llm/context-builder', () => ({
buildCodebaseContext: vi.fn(
async (): Promise<CodebaseContext> => ({
stats: {
projectName: 'stub',
fileCount: 0,
functionCount: 0,
classCount: 0,
interfaceCount: 0,
methodCount: 0,
},
hotspots: [],
folderTree: '',
}),
),
}));
vi.mock('../../src/core/llm/agent', () => ({
createGraphRAGAgent: vi.fn(() => ({})),
}));
vi.mock('../../src/core/llm/settings-service', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/core/llm/settings-service')>();
// Wrap with the real implementation so tests without an explicit override
// keep today's no-provider (null) behavior.
return { ...actual, getActiveProviderConfig: vi.fn(actual.getActiveProviderConfig) };
});
afterEach(() => {
vi.restoreAllMocks();
@ -170,6 +202,81 @@ describe('loadGraphAnyway (chat-only escape hatch, #2178)', () => {
expect(result.current.graphMode).toBe('chatOnly');
});
it('re-initializes the agent with the looked-up display name and the path identity', async () => {
vi.mocked(getActiveProviderConfig).mockReturnValue({
provider: 'openai',
model: 'gpt-4o',
apiKey: 'test-key',
});
const fetchMock = vi.fn((url: string) => {
if (url.includes('/api/repo')) return Promise.resolve(repoInfoResponse());
if (url.includes('/api/graph')) return Promise.resolve(graphNdjsonResponse());
return Promise.resolve(
new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }),
);
});
vi.stubGlobal('fetch', fetchMock);
const { result } = renderHook(() => useAppState(), { wrapper: AppStateProvider });
act(() => {
result.current.setServerBaseUrl('http://localhost:4747');
result.current.setAvailableRepos([
{
name: 'reels-display',
path: '/r/big-repo',
repoPath: '/r/big-repo',
indexedAt: '2026-06-13T00:00:00Z',
},
]);
result.current.setCurrentRepo('/r/big-repo');
result.current.setGraphMode('chatOnly');
});
await act(async () => {
await result.current.loadGraphAnyway();
});
// The agent prompt gets the human-readable display name — never the
// absolute path, and never the 'project' literal that initializeAgent's
// empty-deps closure would fall back to (projectName is trapped at '').
expect(vi.mocked(buildCodebaseContext)).toHaveBeenCalledWith(
expect.any(Function),
'reels-display',
);
// The repo identity itself stays the path (threaded via opts.repo).
expect(result.current.currentRepo).toBe('/r/big-repo');
});
it('falls back to the identity basename for the agent prompt when the repo list misses it', async () => {
vi.mocked(getActiveProviderConfig).mockReturnValue({
provider: 'openai',
model: 'gpt-4o',
apiKey: 'test-key',
});
const fetchMock = vi.fn((url: string) => {
if (url.includes('/api/repo')) return Promise.resolve(repoInfoResponse());
if (url.includes('/api/graph')) return Promise.resolve(graphNdjsonResponse());
return Promise.resolve(
new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }),
);
});
vi.stubGlobal('fetch', fetchMock);
const { result } = renderHook(() => useAppState(), { wrapper: AppStateProvider });
act(() => {
result.current.setServerBaseUrl('http://localhost:4747');
result.current.setCurrentRepo('/r/big-repo');
result.current.setGraphMode('chatOnly');
});
await act(async () => {
await result.current.loadGraphAnyway();
});
expect(vi.mocked(buildCodebaseContext)).toHaveBeenCalledWith(expect.any(Function), 'big-repo');
expect(result.current.currentRepo).toBe('/r/big-repo');
});
it('does not throw or apply state when unmounted mid-load', async () => {
let resolveGraph: (r: Response) => void = () => {};
const graphPromise = new Promise<Response>((res) => {

View file

@ -0,0 +1,103 @@
/**
* Analyze completion: display vs identity split (PR #2420 review R2/R7).
*
* The SSE complete event may carry `repoPath` (the analyzed path). RepoAnalyzer
* must pass that IDENTITY to onComplete — so the post-analyze reconnect targets
* the exact repo even when basenames collide — while the done screen keeps
* rendering the display NAME and never shows an absolute path. Old servers
* omit repoPath; the name fallback must be preserved.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, fireEvent, render, screen } from '@testing-library/react';
import { RepoAnalyzer } from '../../src/components/RepoAnalyzer';
import { i18nReady } from '../../src/i18n';
import {
cancelAnalyze,
streamAnalyzeProgress,
uploadFolder,
} from '../../src/services/backend-client';
vi.mock('../../src/services/backend-client', () => ({
startAnalyze: vi.fn(),
cancelAnalyze: vi.fn(),
streamAnalyzeProgress: vi.fn(),
uploadFolder: vi.fn(),
}));
const JOB = { jobId: 'job-1', status: 'queued' };
type CompleteData = { repoName?: string; repoPath?: string };
beforeEach(async () => {
await i18nReady;
vi.clearAllMocks();
vi.mocked(cancelAnalyze).mockResolvedValue(undefined as never);
vi.mocked(uploadFolder).mockResolvedValue(JOB);
});
afterEach(() => {
vi.useRealTimers();
});
/**
* Render RepoAnalyzer, drive a folder-upload analyze to the SSE stream, and
* return the onComplete spy plus the captured SSE complete callback. Uses fake
* timers because completion holds a ~1200ms timer before firing onComplete.
*/
async function startTrackedJob() {
let sseComplete: ((data: CompleteData) => void) | undefined;
vi.mocked(streamAnalyzeProgress).mockImplementation((_jobId, _onProgress, onComplete) => {
sseComplete = onComplete;
return new AbortController();
});
vi.useFakeTimers();
const onDone = vi.fn<(repoIdentity: string) => void>();
render(<RepoAnalyzer variant="onboarding" onComplete={onDone} />);
fireEvent.click(screen.getByRole('tab', { name: 'Local Folder' }));
fireEvent.change(screen.getByTestId('folder-upload-input'), {
target: { files: [new File(['x'], 'a.ts')] },
});
// Flush the upload promise so trackJob subscribes to the SSE stream.
await act(async () => {});
expect(streamAnalyzeProgress).toHaveBeenCalledTimes(1);
return { onDone, complete: (data: CompleteData) => sseComplete?.(data) };
}
describe('analyze completion identity', () => {
it('passes repoPath to onComplete but renders only the display name', async () => {
const { onDone, complete } = await startTrackedJob();
act(() => {
complete({ repoName: 'reels', repoPath: '/ws/b/reels' });
});
// Done screen shows the display name, never the absolute path.
expect(screen.getByText('reels')).toBeInTheDocument();
expect(screen.queryByText('/ws/b/reels')).toBeNull();
// onComplete fires after the ~1200ms done-screen dwell, with the identity.
expect(onDone).not.toHaveBeenCalled();
act(() => {
vi.advanceTimersByTime(1200);
});
expect(onDone).toHaveBeenCalledTimes(1);
expect(onDone).toHaveBeenCalledWith('/ws/b/reels');
});
it('falls back to the display name when the server omits repoPath', async () => {
const { onDone, complete } = await startTrackedJob();
act(() => {
complete({ repoName: 'reels' });
});
expect(screen.getByText('reels')).toBeInTheDocument();
act(() => {
vi.advanceTimersByTime(1200);
});
expect(onDone).toHaveBeenCalledTimes(1);
expect(onDone).toHaveBeenCalledWith('reels');
});
});

View file

@ -0,0 +1,68 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { AppStateProvider, useAppState } from '../../src/hooks/useAppState';
afterEach(() => {
vi.restoreAllMocks();
// Reset the URL mutated by switchRepo's persistence.
window.history.replaceState(null, '', '/');
});
// Duplicate-display-name repo: `name` alone cannot identify it (#2419), so the
// URL must carry the server-resolved path identity alongside the display name.
const repoInfoResponse = () =>
new Response(
JSON.stringify({
name: 'reels',
path: '/ws/group-b/reels',
repoPath: '/ws/group-b/reels',
indexedAt: '2026-07-10T00:00:00Z',
stats: { nodes: 300_000, edges: 600_000 },
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
);
describe('switchRepo URL persistence (#2419)', () => {
it('writes both the server-resolved repo path and the display name on success', async () => {
const fetchMock = vi.fn((url: string) => {
if (url.includes('/api/repo')) return Promise.resolve(repoInfoResponse());
return Promise.resolve(
new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }),
);
});
vi.stubGlobal('fetch', fetchMock);
const { result } = renderHook(() => useAppState(), { wrapper: AppStateProvider });
act(() => {
result.current.setServerBaseUrl('http://localhost:4747');
});
await act(async () => {
await result.current.switchRepo('/ws/group-b/reels');
});
expect(window.location.search).toContain('repo=%2Fws%2Fgroup-b%2Freels');
expect(window.location.search).toContain('project=reels');
// A deliberate switch drops any per-repo skipGraph override (#2178).
expect(window.location.search).not.toContain('skipGraph');
});
it('leaves the URL unchanged when the connect fails', async () => {
window.history.replaceState(null, '', '/?repo=%2Fws%2Fgroup-a%2Freels&project=reels');
const fetchMock = vi.fn(() => Promise.reject(new Error('connection refused')));
vi.stubGlobal('fetch', fetchMock);
const { result } = renderHook(() => useAppState(), { wrapper: AppStateProvider });
act(() => {
result.current.setServerBaseUrl('http://localhost:4747');
});
await act(async () => {
await result.current.switchRepo('/ws/group-b/reels');
});
// The failed target must not poison the URL — a refresh still restores
// the previously connected repo.
expect(window.location.search).toBe('?repo=%2Fws%2Fgroup-a%2Freels&project=reels');
});
});

View file

@ -0,0 +1,36 @@
import { describe, expect, it, vi } from 'vitest';
// The helper lives in App.tsx, whose import chain pulls in WebGL-backed
// rendering (sigma via GraphCanvas) that jsdom cannot load — stub it out;
// this suite only exercises the pure pickRestoreRepo helper.
vi.mock('../../src/components/GraphCanvas', () => ({
GraphCanvas: () => null,
}));
import { pickRestoreRepo } from '../../src/App';
describe('pickRestoreRepo (URL restore param preference, #2419)', () => {
it('prefers the repo path identity when both params are present', () => {
const params = new URLSearchParams('repo=%2Fws%2Fgroup-b%2Freels&project=reels');
expect(pickRestoreRepo(params)).toBe('/ws/group-b/reels');
});
it('falls back to the project display name for legacy project-only URLs', () => {
const params = new URLSearchParams('project=reels');
expect(pickRestoreRepo(params)).toBe('reels');
});
it('uses the repo path identity when only repo is present', () => {
const params = new URLSearchParams('repo=%2Fws%2Fgroup-b%2Freels');
expect(pickRestoreRepo(params)).toBe('/ws/group-b/reels');
});
it('returns undefined when neither param is present', () => {
const params = new URLSearchParams('server=http%3A%2F%2Flocalhost%3A4747');
expect(pickRestoreRepo(params)).toBeUndefined();
});
});

View file

@ -7,6 +7,9 @@
# GITNEXUS_EMBEDDING_MODEL=BAAI/bge-large-en-v1.5
# GITNEXUS_EMBEDDING_DIMS=1024
# GITNEXUS_EMBEDDING_API_KEY=your-key
# GITNEXUS_EMBEDDING_MAX_ATTEMPTS=3
# GITNEXUS_EMBEDDING_RETRY_CAP_MS=5000
# GITNEXUS_EMBEDDING_MIN_INTERVAL_MS=0
# Works with Infinity, vLLM, TEI, llama.cpp, Ollama, LM Studio, or OpenAI.
# See README for details.

View file

@ -4,6 +4,64 @@ All notable changes to GitNexus will be documented in this file.
## [Unreleased]
## [1.6.9] - 2026-07-04
### Added
- **Flat workspace index follows the checked-out branch** — the default (non-multi-branch) index now tracks `git checkout` instead of staying pinned to the branch it was created on (#2364)
- **Spring DI resolver for `@Autowired List<T>` injection** — collection-typed constructor/field injection resolves to all matching bean implementations (#2200)
- **Opt-in CJK bigram segmentation for FTS search** — improves search relevance over Chinese/Japanese/Korean text (#2339)
- **Compact, description-forward embedding text** — shorter, more targeted embedding input for symbol search (#2333, #2334)
- **`Route` nodes get a `(method, url)` identity** — distinct HTTP verbs on the same URL are no longer merged into one node (#2289, #2302)
- **Nuxt/Nitro auto-imports resolved in the TypeScript scope resolver** (#2026)
- **Doc comments searchable across all languages** via FTS (#2286)
- **Cross-file and inline HTTP handler resolution for `group`** — named handlers across files (#2275, #2277) and inline provider handlers via call-site line (#2276, #2282)
- **Cross-repo call trace using PDG** for `group` (#2269)
- **Java and Python conservative taint source/sink models** (#2267, #2253)
- **Java and Kotlin HTTP consumer extraction expanded**, with Kotlin Spring provider parity (#2268, #2254, #1888)
- **Django route extraction for multi-repo `group`** (#1836)
- **Kilo Code + GitNexus MCP setup guide** (#2259)
### Fixed
- **Index metadata renamed to `gitnexus.json`** with dual-write compatibility for existing indexes (#2363)
- **Java call graph** — cast-wrapped and `this.method()` receivers now resolve call edges (#2357)
- **Icon imports consolidated** — fixes stale refs and a package-name collision (#2343)
- **Embeddings** — CUDA 13 hosts now use a system-matched `onnxruntime-node` build for GPU acceleration (#2341)
- **Ladybug single-writer transaction contention** now retries instead of failing (#2342)
- **`--limit` CLI flag** — i18n-safe, guards 0/negative values, and truncates at the correct path (#2310)
- **Ladybug pinned to 0.18.0**, validating the multi-writer deadlock fix (#2340)
- **Full text file content stays searchable** in the FTS index (#2323)
- **Vector distance threshold made configurable** (#2330)
- **LadybugDB-incompatible multi-label Cypher replaced** in `group` queries (#2325, #2327)
- **Windows `@group` reopen** — read-only bridge handle is cached to fix repeated reopen failures (#2274, #2313)
- **FTS stemmer made configurable** (#2307)
- **FastAPI `APIRouter` constructor prefixes applied** to nested routes (#2312)
- **MCP `api_impact` response shape stabilized** for same-URL multi-verb routes (#2308, #2309)
- **Generator function declarations indexed** (#2305)
- **FTS indexes the `description` field** so doc comments are keyword-searchable (#2300)
- **Spring interface-inherited routes resolved** (#2288, #2290)
- **Spring method-level array-form route mappings recognized** (#2281)
- **MCP `impact` callgraph mode tolerates adapter-materialized `line:0`** (#2279, #2283)
- **Kotlin `fun interface` extraction** via a tree-sitter-kotlin re-vendor (#2271)
- **`--pdg analyze` double-free fixed** — LadybugDB close-destructor crash avoided and connection serialization hardened (#2264)
### Changed
- **Root README restructured and all READMEs fact-checked** (#2360)
- **Bundled skill reference drift fixed** in docs (#2362)
### Performance
- **`group`/HTTP route extraction skips source parsing** for files already covered by the graph (#2138 Part 2, #2265)
### Chore / Dependencies
- **gitnexus runtime** — bump `node-addon-api` 8.8.0 → 8.9.0 (#2366), `commander` 14.0.3 → 15.0.0 (#2322), `onnxruntime-node` (#2321), `onnxruntime-common` (#2320), `uuid` 14.0.0 → 14.0.1 (#2285)
- **gitnexus dev** — bump `@types/node` (#2273)
- **gitnexus-web** — bump `lucide-react` (#2349), `@langchain/langgraph` (#2344), `@playwright/test` (#2346), `@langchain/openai` (#2348, #2291), `@langchain/google-genai` (#2345), `langchain` 1.4.4 → 1.4.6 (#2294), `@vitest/coverage-v8` (#2297), `lru-cache` 11.3.6 → 11.5.1 (#2298), `@langchain/core` (#2293)
- **CI** — bump `softprops/action-gh-release` 3.0.0 → 3.0.1 (#2352), `actions/cache` 5.0.5 → 6.1.0 (#2351), `actions/setup-python` 6.2.0 → 6.3.0 (#2350), `actions/checkout` 6.0.3 → 7.0.0 (#2292), `release-drafter/release-drafter` 7.3.1 → 7.4.0 (#2295)
## [1.6.8] - 2026-06-20
### Added

View file

@ -2,7 +2,7 @@
**Graph-powered code intelligence for AI agents.** Index any codebase into a knowledge graph, then query it via MCP or CLI.
Works with **Cursor**, **Claude Code**, **Antigravity** (Google), **Codex**, **Windsurf**, **Cline**, **OpenCode**, and any MCP-compatible tool.
Works with **Cursor**, **Claude Code**, **Antigravity** (Google), **Codex**, **Windsurf**, **Cline**, **OpenCode**, **CodeBuddy** (Tencent), **Qoder** (Alibaba), and any MCP-compatible tool.
[![npm version](https://img.shields.io/npm/v/gitnexus.svg)](https://www.npmjs.com/package/gitnexus)
[![License: PolyForm Noncommercial](https://img.shields.io/badge/License-PolyForm%20Noncommercial-blue.svg)](https://polyformproject.org/licenses/noncommercial/1.0.0/)
@ -40,14 +40,16 @@ To configure MCP for your editor, run `npx gitnexus setup` once — or set it up
| Editor | MCP | Skills | Hooks (auto-augment) | Support |
| ------------------------ | --- | ------ | ------------------------------------------------------------------------------------------ | ------------ |
| **Claude Code** | Yes | Yes | Yes (PreToolUse) | **Full** |
| **Claude Code** | Yes | Yes | Yes (PreToolUse + PostToolUse) | **Full** |
| **Cursor** | Yes | Yes | Yes (postToolUse, [manual install](../gitnexus-cursor-integration/README.md#hook-install)) | **Full** |
| **Antigravity** (Google) | Yes | Yes | Yes (AfterTool, [Gemini CLI hooks schema](https://geminicli.com/docs/hooks/reference/)) | **Full** |
| **Codex** | Yes | Yes | — | MCP + Skills |
| **Windsurf** | Yes | — | — | MCP |
| **Codex** | Yes | Yes | Yes (PreToolUse + PostToolUse, [Codex hooks](https://developers.openai.com/codex/hooks)) | **Full** |
| **OpenCode** | Yes | Yes | — | MCP + Skills |
| **CodeBuddy** (Tencent) | Yes | Yes | — | MCP + Skills |
| **Qoder** (Alibaba) | Yes | Yes | — | MCP + Skills |
| **Windsurf** | Yes | — | — | MCP |
> **Claude Code** gets the deepest integration: MCP tools + agent skills + PreToolUse hooks that automatically enrich grep/glob/bash calls with knowledge graph context.
> **Claude Code** and **Codex** get the deepest integration: MCP tools + agent skills + PreToolUse hooks that automatically enrich grep/glob/bash calls with knowledge graph context + PostToolUse hooks that detect a stale index after commits and prompt the agent to reindex.
### Community Integrations
@ -69,12 +71,23 @@ claude mcp add gitnexus -- npx -y gitnexus@latest mcp
claude mcp add gitnexus -- cmd /c npx -y gitnexus@latest mcp
```
### Codex (full support — MCP + skills)
### Codex (full support — MCP + skills + hooks)
```bash
codex mcp add gitnexus -- npx -y gitnexus@latest mcp
```
Codex hooks (PreToolUse graph enrichment + PostToolUse stale-index detection in `~/.codex/hooks.json`, [same schema as Claude Code](https://developers.openai.com/codex/hooks)) need the bundled adapter script, so they are installed by `gitnexus setup -c codex` rather than manually.
Alternatively, install everything as a [Codex plugin](https://developers.openai.com/codex/plugins/build) (MCP + skills + hooks in one step):
```bash
codex plugin marketplace add abhigyanpatwari/GitNexus
# then inside Codex: /plugins → install "GitNexus"
```
> **Codex notes:** SessionStart is intentionally not registered — Codex reads [AGENTS.md natively](https://developers.openai.com/codex/guides/agents-md), which already carries the GitNexus context block. Newly installed hooks need a one-time approval in Codex via `/hooks` before they run. Pick **one** install route (`gitnexus setup -c codex` **or** the plugin): plugin hooks load alongside `~/.codex/hooks.json`, so installing both can fire duplicate hooks per tool call.
### Cursor / Windsurf
Add to `~/.cursor/mcp.json` (global — works for all projects):
@ -105,6 +118,36 @@ Add to `~/.config/opencode/config.json`:
}
```
### CodeBuddy
CodeBuddy reads only the **first existing file** in its config priority chain: `~/.codebuddy/.mcp.json` (recommended) → `~/.codebuddy/mcp.json` (deprecated) → `~/.codebuddy.json` (legacy). Edit the first non-empty file that exists — creating a higher-priority file would hide the servers in the ones below it. If none exist, create `~/.codebuddy/.mcp.json`:
```json
{
"mcpServers": {
"gitnexus": {
"command": "npx",
"args": ["-y", "gitnexus@latest", "mcp"]
}
}
}
```
### Qoder
Add to `~/.qoder.json`:
```json
{
"mcpServers": {
"gitnexus": {
"command": "npx",
"args": ["-y", "gitnexus@latest", "mcp"]
}
}
}
```
## How It Works
GitNexus builds a complete knowledge graph of your codebase through a multi-phase indexing pipeline:
@ -120,33 +163,56 @@ GitNexus builds a complete knowledge graph of your codebase through a multi-phas
The result is a **LadybugDB graph database** stored locally in `.gitnexus/` with full-text search and semantic embeddings.
### Experimental community detection engine
Community detection uses the bundled Graphology Leiden implementation by default. To test the #2337 Icebug migration path without changing default analyze behavior, set:
```bash
GITNEXUS_COMMUNITY_ENGINE=icebug npx gitnexus analyze
```
Supported values are `graphology`, `icebug`, and `auto`. The Icebug path is an experimental probe: GitNexus does not bundle an Icebug native package yet, and if a separately resolvable module is unavailable or its API does not match the expected `Graph.fromCSR` / `ParallelLeidenView` shape, analyze falls back to Graphology and reports the fallback in progress output. Today `auto` is behaviorally identical to `icebug`: both try Icebug and fall back to Graphology, while `graphology` skips the Icebug probe entirely.
## MCP Tools
Your AI agent gets these tools automatically:
Your AI agent gets **17 tools** (15 per-repo + 2 group) automatically:
| Tool | What It Does | `repo` Param |
| ---------------- | ---------------------------------------------------------------- | ------------ |
| `list_repos` | Discover all indexed repositories (paginated — `limit`/`offset`) | — |
| `query` | Process-grouped hybrid search (BM25 + semantic + RRF) | Optional |
| `context` | 360-degree symbol view — categorized refs, process participation | Optional |
| `impact` | Blast radius analysis with depth grouping and confidence | Optional |
| `detect_changes` | Git-diff impact — maps changed lines to affected processes | Optional |
| `rename` | Multi-file coordinated rename with graph + text search | Optional |
| `cypher` | Raw Cypher graph queries | Optional |
| Tool | What It Does |
| ---------------- | ---------------------------------------------------------------------- |
| `list_repos` | Discover all indexed repositories (paginated — `limit`/`offset`) |
| `query` | Process-grouped hybrid search (BM25 + semantic + RRF) |
| `context` | 360-degree symbol view — categorized refs, process participation |
| `impact` | Blast radius analysis with depth grouping and confidence |
| `trace` | Shortest directed path between two symbols (call + class-member edges) |
| `detect_changes` | Git-diff impact — maps changed lines to affected processes |
| `check` | Read-only structural checks against the indexed graph |
| `rename` | Multi-file coordinated rename with graph + text search |
| `cypher` | Raw Cypher graph queries |
| `route_map` | API route map — which components fetch which endpoints, and handlers |
| `tool_map` | MCP/RPC tool definitions — where they're defined and handled |
| `shape_check` | Validate API response shapes against consumers' property accesses |
| `api_impact` | Pre-change impact report for an API route handler |
| `explain` | Explain persisted taint findings (source→sink flows, `--pdg` indexes) |
| `pdg_query` | Query control/data dependence at statement level (`--pdg` indexes) |
| `group_list` | List configured repository groups |
| `group_sync` | Rebuild a group's Contract Registry and cross-repo links |
> With one indexed repo, the `repo` param is optional. With multiple, specify which: `query({search_query: "auth", repo: "my-app"})`.
> With one indexed repo, the `repo` param is optional. With multiple, specify which: `query({search_query: "auth", repo: "my-app"})`. Per-repo tools also take an optional `branch` for indexes pinned with `gitnexus analyze --branch`; omitting it queries the workspace index, which follows your checked-out working tree. `explain` and `pdg_query` need an index built with `gitnexus analyze --pdg`.
## MCP Resources
| Resource | Purpose |
| --------------------------------------- | ---------------------------------------------------- |
| `gitnexus://repos` | List all indexed repositories (read first) |
| `gitnexus://setup` | Setup and usage guidance for agents |
| `gitnexus://repo/{name}/context` | Codebase stats, staleness check, and available tools |
| `gitnexus://repo/{name}/clusters` | All functional clusters with cohesion scores |
| `gitnexus://repo/{name}/cluster/{name}` | Cluster members and details |
| `gitnexus://repo/{name}/processes` | All execution flows |
| `gitnexus://repo/{name}/process/{name}` | Full process trace with steps |
| `gitnexus://repo/{name}/schema` | Graph schema for Cypher queries |
| `gitnexus://group/{name}/contracts` | A group's extracted contracts and cross-links |
| `gitnexus://group/{name}/status` | Staleness of repos in a group |
## MCP Prompts
@ -164,7 +230,12 @@ gitnexus analyze [path] # Index a repository (or update stale index)
gitnexus analyze --repair-fts # Fast path: rebuild/verify only FTS indexes on existing index data
gitnexus analyze --force # Full rebuild: re-parse + graph rebuild + FTS rebuild
gitnexus analyze --embeddings # Enable embedding generation (slower, better search)
gitnexus embeddings install # Fetch the optional local embedding stack on demand (--cuda, --force)
gitnexus analyze --skills # Generate repo-specific skill files from detected communities
gitnexus analyze --skip-agents-md # Preserve custom AGENTS.md/CLAUDE.md gitnexus section edits
gitnexus analyze --skip-skills # Skip installing standard .claude/skills/gitnexus-* skill files
gitnexus analyze --skip-git # Index folders that are not Git repositories
gitnexus analyze --workers <n> # Parse worker pool size (>=1; default: cores-1, capped at 16)
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
@ -178,13 +249,18 @@ gitnexus status # Show index status for current repo
gitnexus clean # Delete index for current repo
gitnexus clean --all --force # Delete all indexes
gitnexus wiki [path] # Generate LLM-powered docs from knowledge graph
gitnexus wiki --model <model> # Wiki with custom LLM model (default: gpt-4o-mini)
gitnexus wiki --model <model> # Wiki with custom LLM model (default: minimax/minimax-m2.5)
gitnexus wiki --base-url http://llama-box.local:8080/v1 --allow-insecure-connection llama-box.local
# Allow an exact LAN/self-hosted HTTP LLM host; env: GITNEXUS_ALLOW_INSECURE_CONNECTION
gitnexus doctor # Show runtime platform capabilities and embedding configuration
# Direct graph queries — the same tools the MCP server exposes, no MCP daemon needed
gitnexus query "<concept>" # Process-grouped hybrid search
gitnexus context <symbol> [--uid <uid> | --file <path>] # 360° symbol view; flags disambiguate a shared name
gitnexus impact <symbol> [--uid <uid> | --file <path> | --kind <kind>] # Blast radius; flags disambiguate a shared name
gitnexus trace <from> <to> # Shortest directed path between two symbols
gitnexus detect-changes # Map the working-tree diff to affected symbols and execution flows
gitnexus check # Read-only structural checks against the indexed graph
gitnexus cypher "<query>" # Run a raw Cypher query against the knowledge graph
# Repository groups (multi-repo / monorepo service tracking)
@ -196,6 +272,7 @@ gitnexus group sync <name> # Extract contrac
gitnexus group contracts <name> # Inspect extracted contracts and cross-links
gitnexus group query <name> <q> # Search execution flows across all repos in a group
gitnexus group status <name> # Check staleness of repos in a group
gitnexus group impact <name> --target <symbol> --repo <groupPath> # Cross-repo blast radius
```
### `gitnexus watch`
@ -230,10 +307,13 @@ export GITNEXUS_EMBEDDING_URL=http://your-server:8080/v1
export GITNEXUS_EMBEDDING_MODEL=BAAI/bge-large-en-v1.5
export GITNEXUS_EMBEDDING_DIMS=1024 # optional, default 384
export GITNEXUS_EMBEDDING_API_KEY=your-key # optional, default: "unused"
export GITNEXUS_EMBEDDING_MAX_ATTEMPTS=3 # optional, total attempts (1-20)
export GITNEXUS_EMBEDDING_RETRY_CAP_MS=5000 # optional, maximum retry delay
export GITNEXUS_EMBEDDING_MIN_INTERVAL_MS=0 # optional, minimum request spacing
gitnexus analyze . --embeddings
```
Works with Infinity, vLLM, TEI, llama.cpp, Ollama, LM Studio, or OpenAI. When unset, local embeddings are used unchanged.
Works with Infinity, vLLM, TEI, llama.cpp, Ollama, LM Studio, or OpenAI. Retry and pacing settings are provider-neutral; provider-specific limits should be supplied through configuration. When unset, local embeddings are used unchanged.
## Multi-Repo Support
@ -241,7 +321,7 @@ GitNexus supports indexing multiple repositories. Each `gitnexus analyze` regist
## Supported Languages
TypeScript, JavaScript, Python, Java, C, C++, C#, Go, Rust, PHP, Kotlin, Swift, Ruby
TypeScript, JavaScript, Python, Java, C, C++, C#, Go, Rust, PHP, Kotlin, Swift, Ruby, Dart
### Language Feature Matrix
@ -260,6 +340,7 @@ TypeScript, JavaScript, Python, Java, C, C++, C#, Go, Rust, PHP, Kotlin, Swift,
| Swift | — | — | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| C | — | — | ✓ | — | ✓ | ✓ | — | ✓ | ✓ |
| C++ | — | — | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ |
| Dart | ✓ | — | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ |
**Imports** — cross-file import resolution · **Named Bindings** — `import { X as Y }` / re-export tracking · **Exports** — public/exported symbol detection · **Heritage** — class inheritance, interfaces, mixins · **Type Annotations** — explicit type extraction for receiver resolution · **Constructor Inference** — infer receiver type from constructor calls (`self`/`this` resolution included for all languages) · **Config** — language toolchain config parsing (tsconfig, go.mod, etc.) · **Frameworks** — AST-based framework pattern detection · **Entry Points** — entry point scoring heuristics
@ -271,12 +352,14 @@ GitNexus ships with skill files that teach AI agents how to use the tools effect
- **Debugging** — Trace bugs through call chains
- **Impact Analysis** — Analyze blast radius before changes
- **Refactoring** — Plan safe refactors using dependency mapping
- **Guide** — GitNexus tool/resource/schema reference for the agent
- **CLI** — Run analyze/status/clean/wiki commands on request
Installed automatically by both `gitnexus analyze` (per-repo) and `gitnexus setup` (global).
Installed automatically by both `gitnexus analyze` (per-repo) and `gitnexus setup` (global). Run `gitnexus analyze --skills` to additionally generate each detected functional area as a direct project skill under `.claude/skills/gitnexus-area-<name>/`.
## Requirements
- Node.js >= 18
- Node.js >= 22
- Git repository (uses git for commit tracking)
## Release candidates
@ -362,7 +445,7 @@ gitnexus serve
### Installation fails with native module errors
Some optional language grammars (Dart, Kotlin, Swift) require native compilation. If they fail, GitNexus still works — those languages will be skipped.
Some optional language grammars (Dart, Proto, Swift, Kotlin) require native compilation. If they fail, GitNexus still works — those languages will be skipped. To skip them intentionally (no C++ toolchain needed), set `GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1` before installing.
If `npm install -g gitnexus` fails on native modules:
@ -375,18 +458,41 @@ If `npm install -g gitnexus` fails on native modules:
npm install -g gitnexus
```
### Installation fails behind an HTTP proxy (`onnxruntime-node` postinstall)
`onnxruntime-node`'s postinstall downloads optional CUDA GPU binaries from `api.nuget.org` — outside the npm registry, so registry mirrors don't cover it, and its proxy layer (`global-agent`) ignores the standard `HTTP_PROXY`/`HTTPS_PROXY` variables and rejects 302 redirects ([#2370](https://github.com/abhigyanpatwari/GitNexus/issues/2370)).
Since the packages are optional dependencies, a failed download no longer breaks `npm install -g gitnexus` — npm skips the embedding stack and everything else works. The stack then **self-heals on demand**: the first `gitnexus analyze --embeddings` (or an explicit `gitnexus embeddings install`) fetches it through your configured npm registry — mirrors and proxies apply, no NuGet download involved — into `~/.gitnexus/embedding-runtime`.
```bash
# heal a proxy-degraded install manually (CPU embeddings; registry-only)
gitnexus embeddings install
# reinstall into the prefix even when the stack already resolves
gitnexus embeddings install --force
# CUDA GPU hosts: also fetch GPU binaries (NuGet; set the proxy global-agent reads)
GLOBAL_AGENT_HTTPS_PROXY=<proxy-url> gitnexus embeddings install --cuda
```
The prefix defaults to `~/.gitnexus/embedding-runtime`; set `GITNEXUS_EMBEDDING_RUNTIME_DIR` to install it elsewhere (e.g. a writable path in a container).
> **Node requirement for the on-demand prefix:** the self-heal loads the prefixed packages via `module.registerHooks`, available on Node **≥ 22.15** (on the 22.x line) or **≥ 23.5** (on the 23.x line). On an older Node the packages install but can't be loaded from the prefix — reinstall them into the install itself instead (works on every supported Node): `ONNXRUNTIME_NODE_INSTALL=skip npm install -g gitnexus` (Windows: `set ONNXRUNTIME_NODE_INSTALL=skip && npm install -g gitnexus`). Skipping only the CUDA download keeps full CPU embeddings (CPU embeddings don't need it). Check the result any time with `gitnexus doctor` (Embeddings → Support line).
### 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.
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 (a plain `INSTALL` to download a missing extension, escalating to `FORCE INSTALL` only when the `LOAD` error shows the existing file is broken or truncated, so a permanent non-file failure does not re-download on every run) 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:
Configure the behavior with these 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. |
| `GITNEXUS_FTS_STEMMER` | supported LadybugDB stemmer | `porter` | Stemmer used when rebuilding BM25/FTS indexes. Use `none` for CJK-heavy repositories, or a language stemmer such as `german`, `french`, or `spanish` when that better matches repository comments and identifiers. Re-run `gitnexus analyze --repair-fts` after changing it. |
| `GITNEXUS_WAL_CHECKPOINT_THRESHOLD` | integer `>= -1` | `67108864` (64 MiB) | LadybugDB WAL auto-checkpoint threshold during analyze (bytes). Auto-checkpoint remains enabled; `-1` keeps Ladybug's stock ~16 MiB. Larger thresholds reduce checkpoint frequency but increase the WAL size at rotation time — choose a smaller value on disk-constrained environments. |
| Variable | Values | Default | Effect |
| -------------------------------------------- | ------------------------------ | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GITNEXUS_LBUG_EXTENSION_INSTALL` | `auto`, `load-only`, `never` | `auto` | `auto` runs one bounded install if LOAD fails — a plain `INSTALL`, escalating to `FORCE INSTALL` only when the LOAD error shows the present extension file is broken. `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 extension-install child before it is killed. |
| `GITNEXUS_FTS_STEMMER` | supported LadybugDB stemmer | `porter` | Stemmer used when rebuilding BM25/FTS indexes. Use `none` for CJK-heavy repositories, or a language stemmer such as `german`, `french`, or `spanish` when that better matches repository comments and identifiers. Re-run `gitnexus analyze --repair-fts` after changing it. |
| `GITNEXUS_FTS_CJK_SEGMENTATION` | `none`, `bigram` | `none` | `bigram` inserts overlapping character-bigram boundaries into Chinese/Japanese Han-ideograph spans in `content`/`description` before FTS indexing, so LadybugDB's space-only tokenizer can see sub-phrase word boundaries. Scoped to CJK Unified Ideographs only — Japanese Hiragana/Katakana and Korean Hangul are not currently segmented. Unlike `GITNEXUS_FTS_STEMMER`, this rewrites stored text — enabling it on an already-indexed repo requires a full `gitnexus analyze --force`; neither `--repair-fts` nor a plain incremental `analyze` applies it to previously-indexed files. Set the same value wherever `analyze` and search-serving processes (CLI query, MCP server, web server) run. |
| `GITNEXUS_COMMUNITY_ENGINE` | `graphology`, `icebug`, `auto` | `graphology` | Community-detection engine used during analyze. `graphology` uses the bundled default path. `icebug` and `auto` currently behave identically: both try the experimental Icebug CSR path and fall back to Graphology if the optional native module is unavailable or incompatible. |
| `GITNEXUS_WAL_CHECKPOINT_THRESHOLD` | integer `>= -1` | `67108864` (64 MiB) | LadybugDB WAL auto-checkpoint threshold during analyze (bytes). Auto-checkpoint remains enabled; `-1` keeps Ladybug's stock ~16 MiB. Larger thresholds reduce checkpoint frequency but increase the WAL size at rotation time — choose a smaller value on disk-constrained environments. |
```bash
# Offline/airgapped: never reach the network for extensions
@ -397,6 +503,11 @@ GITNEXUS_LBUG_EXTENSION_INSTALL_TIMEOUT_MS=30000 npx gitnexus analyze
# CJK-heavy codebase: rebuild keyword indexes without English stemming
GITNEXUS_FTS_STEMMER=none npx gitnexus analyze --repair-fts
# CJK-heavy codebase: enable sub-phrase search over Chinese/Japanese Han text.
# On an already-indexed repo, the first run after enabling this MUST be --force —
# --repair-fts and plain incremental `analyze` both leave old files un-segmented.
GITNEXUS_FTS_CJK_SEGMENTATION=bigram npx gitnexus analyze --force
```
### Analysis runs out of memory
@ -446,11 +557,13 @@ For repositories with very large source files, `GITNEXUS_WORKER_SUB_BATCH_MAX_BY
Three env vars expose the pool's resilience layers (respawn budget, cumulative-timeout cap, circuit breaker). Defaults are tuned for typical repos; bump them when an analyze legitimately needs more retries, or lower them to fail-fast on a known-bad shape.
| Variable | Default | Effect |
| ----------------------------------------------- | ----------------------- | --------------------------------------------------------------------------------------------------------------------- |
| `GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT` | `3` | Max replacement spawns per slot before the slot is dropped from the active rotation. |
| `GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS` | `5 × subBatchTimeoutMs` | Total retry wall-time budget per job before quarantining. Bounds exponentially-growing retry waits. |
| `GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD` | `max(3, poolSize)` | Per-slot consecutive deaths before the pool's circuit breaker trips. After tripping, dispatches require a fresh pool. |
| Variable | Default | Effect |
| ----------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT` | `3` | Max replacement spawns per slot before the slot is dropped from the active rotation. |
| `GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS` | `5 × subBatchTimeoutMs` | Total retry wall-time budget per job before quarantining. Bounds exponentially-growing retry waits. |
| `GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD` | `max(3, poolSize)` | Per-slot consecutive deaths before the pool's circuit breaker trips. After tripping, dispatches require a fresh pool. |
| `GITNEXUS_WORKER_SHUTDOWN_DRAIN_MS` | `30000` | Max wait at pool shutdown for a retired worker still inside native code — terminated at its next JS-safe point instead of mid-native-call, which would abort the process (`Napi::Error`, #2432). |
| `GITNEXUS_CPP_CAPTURE_BUDGET_MS` | `20000` | Per-file wall-clock budget for C++ capture extraction; on breach the file keeps partial captures with a warning (#2432). `0` expires immediately. |
### Graph cleanup tuning
@ -462,17 +575,26 @@ After scope resolution, analyze prunes inert block-local value symbols (a functi
Programmatic callers can pass `keepLocalValueSymbols: true` in `PipelineOptions` instead of setting the env var.
### Hook augmentation/notifications are silently skipped
### Hook augmentation and skip diagnostics
The Claude Code / Antigravity hooks intentionally stay **silent** on normal skip
The Claude Code / Antigravity hooks keep their **stderr** silent on normal skip
paths so strict hook runners (e.g. Codex `PreToolUse`) never see unexpected
output. A search may not be augmented — or a stale-index reminder may not appear
on stderr — when the GitNexus MCP server owns the repo DB, when the DB-lock probe
times out and fails closed, or when the index is already current.
diagnostic output.
To see why a hook skipped, set `GITNEXUS_DEBUG=1` and re-run the action — the hook
writes the reason (e.g. `[GitNexus] augment skipped: MCP server owns DB`) and the
stale-index hint to its stderr:
When a GitNexus process holds the repo DB write lock (the common case — the MCP
server is running, or the DB-lock probe timed out and failed closed), the local
CLI `augment` can't run (LadybugDB is single-writer). Rather than drop the
augmentation, the hook hands the agent a short, conditional MCP-query hint on
stdout (the sanctioned `additionalContext` channel) — _"if the GitNexus MCP tools
are live in this session, call `query` …"_ — so an agent that has the tools can
still fetch graph-ranked context. The hint is throttled to at most once per repo
per window (`GITNEXUS_MCP_HINT_THROTTLE_MS`, default 10 min; `0` disables), so an
owner-locked session isn't nudged on every search. A stale-index reminder, or an
already-current index, stays silent.
To see why a hook skipped the CLI augment, set `GITNEXUS_DEBUG=1` and re-run the
action — the hook writes the reason (e.g. `[GitNexus] augment skipped: MCP server
owns DB`) and the stale-index hint to its stderr:
```bash
GITNEXUS_DEBUG=1 <your command> # surfaces hook skip/diagnostic reasons on stderr

View file

@ -13,8 +13,8 @@
"c": {
"fingerprint": "12a196b2d6249c8d86a931b12ecebc2a0cdf8d6f47683acdd0d8e9d8bc7657f5",
"scaling_budget": 1.5,
"_added": "#1956: c added to the scope-capture bench (was UNBENCHED). C has no inheritance — flat scale source. Adding it exposed + fixed a pre-existing O(n^2) findNodeAtRange root-walk in c/captures.ts (threaded c.node, byte-identical over c-* fixtures); scaling 3.475 -> 0.96.",
"_note": "#1983: + c-static-linkage-worker fixture (caller.c/lib.c/lib.h/local.c — worker-path static-linkage side-channel test). Pure fixture-corpus drift: no c/captures.ts or query change branch-vs-main, existing fixtures' captures byte-identical (c-captures.test.ts 45/45), scaling stays linear (~0.97). The baseline was missed when the fixture landed; regenerated here. fingerprint 0de009b->39f3a83.",
"_added": "#1956: c added to the scope-capture bench (was UNBENCHED). C has no inheritance \u2014 flat scale source. Adding it exposed + fixed a pre-existing O(n^2) findNodeAtRange root-walk in c/captures.ts (threaded c.node, byte-identical over c-* fixtures); scaling 3.475 -> 0.96.",
"_note": "#1983: + c-static-linkage-worker fixture (caller.c/lib.c/lib.h/local.c \u2014 worker-path static-linkage side-channel test). Pure fixture-corpus drift: no c/captures.ts or query change branch-vs-main, existing fixtures' captures byte-identical (c-captures.test.ts 45/45), scaling stays linear (~0.97). The baseline was missed when the fixture landed; regenerated here. fingerprint 0de009b->39f3a83.",
"_rebaselined": "#1919 open-language coverage: new lang-resolution fixtures + intended capture additions (F5/F9 c-cpp, F26/F28/F29 dart, F47/F48/F49/F51/F52 kotlin, F75/F79 swift). Fingerprint-only drift; scaling_ratio ~1.0 (linear, no perf regression)."
},
"cpp": {
@ -24,7 +24,7 @@
"_note_1899_followup": "#1899 follow-up: braced-init metadata now carries element count, intentionally changing C++ capture output; CI benchmark scaling remains linear (1.129 < 1.5).",
"_added": "#1956: cpp added to the scope-capture bench (was UNBENCHED). Heritage-bearing scale source (: public Base, public Mixin) drives emitCppInheritanceCaptures at scale. Adding it exposed + fixed a pre-existing O(n^2) findNodeAtRange root-walk in cpp/captures.ts (~12 sites, threaded c.node, byte-identical over 263 cpp-* fixtures); scaling 2.30 -> 1.12.",
"_rebaselined": "#1919 open-language coverage: new lang-resolution fixtures + intended capture additions (F5/F9 c-cpp, F26/F28/F29 dart, F47/F48/F49/F51/F52 kotlin, F75/F79 swift). Fingerprint-only drift; scaling_ratio ~1.0 (linear, no perf regression). #2094: deleted C++ declarations retain @declaration.is-deleted metadata; deleted operator and pointer-return shapes plus the expanded deleted-overload fixture are included. Intended capture drift; scaling remains linear (1.139 < 1.5).",
"_note": "#1975: + cpp-out-of-line-class fixture, fixture_count 263->265. #1990: + cpp-adl-ns-plus-hidden-friend-same-name fixture (ADL hidden-friend + namespace-callable merge parity test). Pure fixture-corpus drift — no scope-extractor change; existing fixtures' captures byte-identical. fixture_count 265->267. #1995: + cpp-union-nested-tail-collision and cpp-anon-ns-tail-collision fixtures — pure fixture-corpus drift; fixture_count 270->272, fingerprint 538e8be->d63ded6. #1993: + cpp-cross-namespace-same-tail fixture — pure fixture-corpus drift; fixture_count 272->273, fingerprint d63ded6->6d6207ae. #2077 review follow-up: cpp-member-lattice adds cross-file, qualified-base, nested-template, inherited-using, this-receiver, and non-virtual-override regressions; fixture_count 274->275. Capture scaling remains linear (1.134 < 1.5). #1899: braced-init call arguments emit a conservative parameter-type capture; fixture_count 277, scaling remains linear (1.141 < 1.5)."
"_note": "#1975: + cpp-out-of-line-class fixture, fixture_count 263->265. #1990: + cpp-adl-ns-plus-hidden-friend-same-name fixture (ADL hidden-friend + namespace-callable merge parity test). Pure fixture-corpus drift \u2014 no scope-extractor change; existing fixtures' captures byte-identical. fixture_count 265->267. #1995: + cpp-union-nested-tail-collision and cpp-anon-ns-tail-collision fixtures \u2014 pure fixture-corpus drift; fixture_count 270->272, fingerprint 538e8be->d63ded6. #1993: + cpp-cross-namespace-same-tail fixture \u2014 pure fixture-corpus drift; fixture_count 272->273, fingerprint d63ded6->6d6207ae. #2077 review follow-up: cpp-member-lattice adds cross-file, qualified-base, nested-template, inherited-using, this-receiver, and non-virtual-override regressions; fixture_count 274->275. Capture scaling remains linear (1.134 < 1.5). #1899: braced-init call arguments emit a conservative parameter-type capture; fixture_count 277, scaling remains linear (1.141 < 1.5)."
},
"csharp": {
"_rebaselined": "#1956 synth-widening: + csharp-qualified-base fixture; the synth now walks record_declaration + struct_declaration base_lists and handles alias_qualified_name (matching the #1940 legacy leg), so record/struct heritage now emits. csharp-record-base gains a record inherits capture. (record->record SAME-namespace EXTENDS is a separate registry resolution gap, tracked as follow-up.) Linear (~1.00). (Earlier #1956: heritage-bearing scale source.) | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged. | #1924 F16: record primary-constructor base bindings now exclude constructor arguments; capture fingerprint changes, scaling remains linear. | #2036 review follow-up: csharp-record-base now exercises primary-constructor base dispatch end to end; +2 capture groups, scaling remains linear.",
@ -35,20 +35,20 @@
"rust": {
"fingerprint": "ac610bbe97666bf285923479dd7b43a2fe4c5354aae8df1bcbafdc04fb220f82",
"scaling_budget": 1.5,
"_rebaselined": "#1956 tri-review U1: rust-qualified-trait fixture (scoped + generic-of-scoped impl trait paths); bareTypeIdentifier now resolves scoped_type_identifier bases by their name: tail (additive, no existing-fixture drift); linear (~1.04). #1975: + rust-scoped-impl fixture (impl a::Inner / b::Inner inherent scoped impls) — legacy @definition.impl scoped arm + findEnclosingClassInfo inherent-impl scoped target; rust scope-extractor captures byte-identical. | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged.",
"_note": "PR #1934: F66/F68 let-binding pattern narrowing; F71 union (Struct-labeled, now materialized via legacy @definition.struct + resolvable); F72 macro FULLY WIRED — @declaration.macro/@reference.macro + MacroRegistry → USES edges to Macro nodes (never a same-named fn). + rust-macro / rust-union fixtures and merged with origin/main #1975 rust-scoped-impl; fingerprint re-baselined (scaling ~0.99, fixture_count 126). #1992: + rust-nested-tail-collision-generic and rust-generic-impl-same-method-name (F3) fixtures — pure fixture-corpus drift, no scope-extractor change; fixture_count 127->129, fingerprint 56ffc1c0->b00aea0f."
"_rebaselined": "#1956 tri-review U1: rust-qualified-trait fixture (scoped + generic-of-scoped impl trait paths); bareTypeIdentifier now resolves scoped_type_identifier bases by their name: tail (additive, no existing-fixture drift); linear (~1.04). #1975: + rust-scoped-impl fixture (impl a::Inner / b::Inner inherent scoped impls) \u2014 legacy @definition.impl scoped arm + findEnclosingClassInfo inherent-impl scoped target; rust scope-extractor captures byte-identical. | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged.",
"_note": "PR #1934: F66/F68 let-binding pattern narrowing; F71 union (Struct-labeled, now materialized via legacy @definition.struct + resolvable); F72 macro FULLY WIRED \u2014 @declaration.macro/@reference.macro + MacroRegistry \u2192 USES edges to Macro nodes (never a same-named fn). + rust-macro / rust-union fixtures and merged with origin/main #1975 rust-scoped-impl; fingerprint re-baselined (scaling ~0.99, fixture_count 126). #1992: + rust-nested-tail-collision-generic and rust-generic-impl-same-method-name (F3) fixtures \u2014 pure fixture-corpus drift, no scope-extractor change; fixture_count 127->129, fingerprint 56ffc1c0->b00aea0f."
},
"php": {
"fingerprint": "bc2c27c5ba26d5aea61142a2a99fb772222f5b969205260eb7a71b4c0bd73cdb",
"fingerprint": "31c9e3f3cb7094a2bf9021cf9db859036e002f8b44605cd993b470fc600e97cb",
"scaling_budget": 1.5,
"_rebaselined": "#1956: heritage-bearing scale source (class extends Base + use trait); both forms gated at scale; linear (~1.04).",
"_note": "PR #1931: F53 import multi-clause, F54 enum_case, F55 anonymous_class — fixture count 138→140, fingerprint drift expected."
"_rebaselined": "#1956: heritage-bearing scale source (class extends Base + use trait); both forms gated at scale; linear (~1.04). | #2481/#2482: PHP imports carry a symbol-kind capture so function/constant imports resolve by declaring file; capture shape changes, scaling remains linear (~1.04).",
"_note": "PR #1931: F53 import multi-clause, F54 enum_case, F55 anonymous_class \u2014 fixture count 138\u2192140, fingerprint drift expected."
},
"ruby": {
"fingerprint": "b5ea93bb3d0469c3821a8c70f5d5991c6f326e41097c119ad691154301dcc753",
"scaling_budget": 1.5,
"_rebaselined": "#1956 synth-widening: + ruby-qualified-base fixture; synth now reduces a scope_resolution superclass (class C < Mod::Super) to its trailing constant (matching the #1940 legacy leg), at parity. Linear (~1.03). (Earlier #1956: heritage-bearing scale source.) | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged.",
"_note": "F62: + scope_resolution class/module declaration captures — fixture count 78→81, fingerprint drift expected. #1975: + ruby-tail-collision fixture (Foo::Bar vs Baz::Bar stay distinct nodes) — pure fixture-corpus drift, scope-extractor captures unchanged; 81→82. #1991: + ruby-nested-mixin-tail-collision fixture (85→86). Recomputed on the #942 merge (fixture-comment rewording shifts capture byte-positions, capture LOGIC unchanged): bf6b13a -> b5ea93bb."
"_note": "F62: + scope_resolution class/module declaration captures \u2014 fixture count 78\u219281, fingerprint drift expected. #1975: + ruby-tail-collision fixture (Foo::Bar vs Baz::Bar stay distinct nodes) \u2014 pure fixture-corpus drift, scope-extractor captures unchanged; 81\u219282. #1991: + ruby-nested-mixin-tail-collision fixture (85\u219286). Recomputed on the #942 merge (fixture-comment rewording shifts capture byte-positions, capture LOGIC unchanged): bf6b13a -> b5ea93bb."
},
"swift": {
"fingerprint": "180ac68e780bdf6f9089d53f51cbb9a66aed3e7774631cc3fcbaae5020213998",
@ -62,16 +62,16 @@
"_rebaselined": "#1919 review CF3 fix: extended kotlin-local-property-owner (init/accessor destructuring) + new dart-accessor-owner fixture (getter/setter ownership). Fingerprint-only corpus drift; scaling ~1.0."
},
"java": {
"fingerprint": "9b29cafe32873b4902bda311bd089ffc04efe08f13557b966d29544be514080a",
"fingerprint": "062d754764aaa8a6772fb90875c710502a63e3e7a300e633942381ed914faada",
"scaling_budget": 1.5,
"_rebaselined": "#1956 synth-widening: + java-iface-extends fixture; synthesizeJavaInheritanceReferences now ALSO walks interface_declaration extends_interfaces (interface IA extends IB, IC<T>), matching the #1940 legacy leg. (Earlier U2+review: java-qualified-base fixture covers 2- AND 3-segment qualified bases guarding the legacy end-anchor; synth tail-resolves scoped bases.) Linear (~1.03). (Earliest: java added to bench, exposed+fixed the O(n^2) findNodeAtRange root-walk; 3.09 -> ~0.99.) | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged.",
"_rebaselined": "#2357 (supersedes #2353): + java-cast-receiver, java-this-field-chain, java-this-dispatch fixtures (cast-wrapped receivers, this.field chains incl. initializer contexts, bare-this dispatch pinning). Drift is purely fixture-additive: with the three new dirs parked, the fingerprint reproduces the prior baseline byte-identically \u2014 no emit/capture change. #1956 synth-widening: + java-iface-extends fixture; synthesizeJavaInheritanceReferences now ALSO walks interface_declaration extends_interfaces (interface IA extends IB, IC<T>), matching the #1940 legacy leg. (Earlier U2+review: java-qualified-base fixture covers 2- AND 3-segment qualified bases guarding the legacy end-anchor; synth tail-resolves scoped bases.) Linear (~1.03). (Earliest: java added to bench, exposed+fixed the O(n^2) findNodeAtRange root-walk; 3.09 -> ~0.99.) | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged.",
"_note": "#1928 / #2045: F35 adds qualified + qualified-generic constructor query captures (`new pkg.Foo()`, `new a.b.Foo()`, `new pkg.Box<T>()`); F38 synthesizes `@reference.call.constructor` on `super(...)`/`this(...)` explicit_constructor_invocation nodes; F41 generic-aware stripQualifier in interpret (type-binding normalization). + java-qualified-constructor and java-explicit-constructor fixtures. Pure capture-additive + fixture-corpus drift; scaling stays linear (~1.06)."
},
"typescript": {
"fingerprint": "3f44a4a6892698df2d145c8ff2812c3b318807648983c88aca28fbd694f172f9",
"scaling_budget": 1.5,
"_rebaselined": "#1962: F44 (class scope@), F85 (enum member declarations), F87 (optional_parameter type annotations) add new captures — fingerprint drift expected.",
"_note": "#1968: F44, F85, F87 — fingerprint drift expected."
"_rebaselined": "#1962: F44 (class scope@), F85 (enum member declarations), F87 (optional_parameter type annotations) add new captures \u2014 fingerprint drift expected.",
"_note": "#1968: F44, F85, F87 \u2014 fingerprint drift expected."
},
"javascript": {
"fingerprint": "d72f03c6c502235d2d4b74d66baa5c7d361f040d7a1b72e84acad61210d05ae8",
@ -84,6 +84,6 @@
"scaling_budget": 1.5,
"_added": "#1951: bench coverage added (was ungated); scale source heritage-bearing (: Base()); js/kotlin O(n^2) findNodeAtRange-per-match fixed to threaded captured node, now linear.",
"_rebaselined": "#1919 review CF3 fix: extended kotlin-local-property-owner (init/accessor destructuring) + new dart-accessor-owner fixture (getter/setter ownership). Fingerprint-only corpus drift; scaling ~1.0.",
"_rebaselined_2271": "PR #2271: re-vendored tree-sitter-kotlin 0.3.8 -> unreleased fwcd main c8ac3d26 for `fun interface` support + new kotlin-fun-interface fixture in the corpus. Drift is both corpus-additive (the fixture) and grammar-driven (the new grammar parses `fun interface` as a class_declaration, not an ERROR node). Baselined to the NEW grammar's fingerprint, so this --check passes only once the regenerated prebuilds land — until then CI loads the committed 0.3.8 binary and the bench is red, same as the kotlin fun-interface integration tests. scaling ~0.83 (linear)."
"_rebaselined_2271": "PR #2271: re-vendored tree-sitter-kotlin 0.3.8 -> unreleased fwcd main c8ac3d26 for `fun interface` support + new kotlin-fun-interface fixture in the corpus. Drift is both corpus-additive (the fixture) and grammar-driven (the new grammar parses `fun interface` as a class_declaration, not an ERROR node). Baselined to the NEW grammar's fingerprint, so this --check passes only once the regenerated prebuilds land \u2014 until then CI loads the committed 0.3.8 binary and the bench is red, same as the kotlin fun-interface integration tests. scaling ~0.83 (linear)."
}
}

View file

@ -40,13 +40,35 @@ function readInput() {
}
function isGlobalRegistryDir(candidate) {
if (fs.existsSync(path.join(candidate, 'meta.json'))) return false;
if (
fs.existsSync(path.join(candidate, 'gitnexus.json')) ||
fs.existsSync(path.join(candidate, 'meta.json'))
) {
return false;
}
return (
fs.existsSync(path.join(candidate, 'registry.json')) ||
fs.existsSync(path.join(candidate, 'repos'))
);
}
/**
* Read the index metadata file, preferring `gitnexus.json` (current format)
* and falling back to the legacy `meta.json` mirror. Returns `null` if
* neither exists or parses.
*/
function readIndexMeta(gitNexusDir) {
try {
return JSON.parse(fs.readFileSync(path.join(gitNexusDir, 'gitnexus.json'), 'utf-8'));
} catch {
try {
return JSON.parse(fs.readFileSync(path.join(gitNexusDir, 'meta.json'), 'utf-8'));
} catch {
return null;
}
}
}
function walkForGitNexusDir(startDir) {
let dir = startDir;
for (let i = 0; i < 5; i++) {
@ -369,6 +391,49 @@ function buildAfterToolContext(input) {
return parts.length > 0 ? parts.join('\n\n') : null;
}
/**
* Fallback augmentation for the #2396 path: when a GitNexus process holds the
* lbug DB write lock the CLI `augment` can't run, so point the agent at the MCP
* `query` tool instead. Phrased conditionally ("if the MCP tools are live") so it
* stays truthful on every owner path — a confirmed MCP owner, a `serve` owner, or
* a fail-closed probe where no server is actually confirmed. `pattern` is embedded
* verbatim; the caller (writeAdditionalContext) JSON-escapes it structurally.
*/
function buildMcpQueryHint(pattern) {
return (
`[GitNexus] Local augment is unavailable (the graph DB is held by another ` +
`GitNexus process). If the GitNexus MCP tools are live in this session, call ` +
`the GitNexus \`query\` MCP tool (e.g. mcp__gitnexus__query) with ` +
`search_query "${pattern}".`
);
}
/**
* #2396 throttle: emit the MCP-query hint at most once per repo per window, so an
* owner-locked session isn't nudged on every search. Window (ms) via
* GITNEXUS_MCP_HINT_THROTTLE_MS (default 10min; 0/invalid disables). Best-effort —
* any fs error falls back to emitting.
* ponytail: per-repo mtime marker, shared across concurrent sessions on the same
* repo; add per-session dedup only if that sharing becomes a problem.
*/
function shouldEmitMcpHint(gitNexusDir) {
const raw = process.env.GITNEXUS_MCP_HINT_THROTTLE_MS;
const windowMs = raw === undefined || raw === '' ? 600000 : Number(raw);
if (!Number.isFinite(windowMs) || windowMs <= 0) return true;
const marker = path.join(gitNexusDir, '.mcp-hint-shown');
try {
if (Date.now() - fs.statSync(marker).mtimeMs < windowMs) return false;
} catch {
/* marker missing/unreadable → emit */
}
try {
fs.writeFileSync(marker, '');
} catch {
/* best-effort; still emit */
}
return true;
}
function runAugment(gitNexusDir, cwd, pattern) {
// Acquire the per-repo slot BEFORE the DB-owner probe (#2163): the probe
// itself spawns lsof/ps, so it must be bounded by the same ≤3-per-repo cap
@ -388,12 +453,16 @@ function runAugment(gitNexusDir, cwd, pattern) {
}
try {
if (hasGitNexusServerOwner(gitNexusDir)) {
// Normal skip path: the MCP server owns the DB. Stay silent for strict
// hook runners (issue #1913); surface the reason only under GITNEXUS_DEBUG.
// #2396: the MCP server holds the DB write lock, so a competing CLI
// `augment` would only contend on it (LadybugDB is single-writer). The
// session has the GitNexus MCP tools live — route the augmentation to the
// agent via additionalContext instead of dropping it. Mirror the skip
// reason to stderr only under GITNEXUS_DEBUG (strict-runner contract,
// #1913); the hint itself rides the sanctioned additionalContext channel.
if (isDebugEnabled()) {
process.stderr.write('[GitNexus] augment skipped: MCP server owns DB\n');
}
return '';
return shouldEmitMcpHint(gitNexusDir) ? buildMcpQueryHint(pattern) : '';
}
const cliPath = resolveCliPath();
const child = runGitNexusCli(cliPath, ['augment', '--', pattern], cwd, 7000);
@ -426,12 +495,10 @@ function buildStaleIndexHint(gitNexusDir, cwd) {
let lastCommit = '';
let hadEmbeddings = false;
try {
const meta = JSON.parse(fs.readFileSync(path.join(gitNexusDir, 'meta.json'), 'utf-8'));
const meta = readIndexMeta(gitNexusDir);
if (meta) {
lastCommit = meta.lastCommit || '';
hadEmbeddings = meta.stats && meta.stats.embeddings > 0;
} catch {
/* no meta — treat as stale */
}
if (currentHead === lastCommit) return '';

View file

@ -38,13 +38,35 @@ function readInput() {
* Returns the path to .gitnexus/ or null if not found.
*/
function isGlobalRegistryDir(candidate) {
if (fs.existsSync(path.join(candidate, 'meta.json'))) return false;
if (
fs.existsSync(path.join(candidate, 'gitnexus.json')) ||
fs.existsSync(path.join(candidate, 'meta.json'))
) {
return false;
}
return (
fs.existsSync(path.join(candidate, 'registry.json')) ||
fs.existsSync(path.join(candidate, 'repos'))
);
}
/**
* Read the index metadata file, preferring `gitnexus.json` (current format)
* and falling back to the legacy `meta.json` mirror. Returns `null` if
* neither exists or parses.
*/
function readIndexMeta(gitNexusDir) {
try {
return JSON.parse(fs.readFileSync(path.join(gitNexusDir, 'gitnexus.json'), 'utf-8'));
} catch {
try {
return JSON.parse(fs.readFileSync(path.join(gitNexusDir, 'meta.json'), 'utf-8'));
} catch {
return null;
}
}
}
/**
* Walk up from `startDir` looking for a non-registry `.gitnexus/` folder.
* Returns the path to `.gitnexus/` or null if not found within 5 levels.
@ -327,6 +349,49 @@ function runGitNexusCli(cliPath, args, cwd, timeout) {
});
}
/**
* Fallback augmentation for the #2396 path: when a GitNexus process holds the
* lbug DB write lock the CLI `augment` can't run, so point the agent at the MCP
* `query` tool instead. Phrased conditionally ("if the MCP tools are live") so it
* stays truthful on every owner path — a confirmed MCP owner, a `serve` owner, or
* a fail-closed probe where no server is actually confirmed. `pattern` is embedded
* verbatim; the caller (sendHookResponse) JSON-escapes it structurally.
*/
function buildMcpQueryHint(pattern) {
return (
`[GitNexus] Local augment is unavailable (the graph DB is held by another ` +
`GitNexus process). If the GitNexus MCP tools are live in this session, call ` +
`the GitNexus \`query\` MCP tool (e.g. mcp__gitnexus__query) with ` +
`search_query "${pattern}".`
);
}
/**
* #2396 throttle: emit the MCP-query hint at most once per repo per window, so an
* owner-locked session isn't nudged on every search. Window (ms) via
* GITNEXUS_MCP_HINT_THROTTLE_MS (default 10min; 0/invalid disables). Best-effort —
* any fs error falls back to emitting.
* ponytail: per-repo mtime marker, shared across concurrent sessions on the same
* repo; add per-session dedup only if that sharing becomes a problem.
*/
function shouldEmitMcpHint(gitNexusDir) {
const raw = process.env.GITNEXUS_MCP_HINT_THROTTLE_MS;
const windowMs = raw === undefined || raw === '' ? 600000 : Number(raw);
if (!Number.isFinite(windowMs) || windowMs <= 0) return true;
const marker = path.join(gitNexusDir, '.mcp-hint-shown');
try {
if (Date.now() - fs.statSync(marker).mtimeMs < windowMs) return false;
} catch {
/* marker missing/unreadable → emit */
}
try {
fs.writeFileSync(marker, '');
} catch {
/* best-effort; still emit */
}
return true;
}
/**
* PreToolUse handler — augment searches with graph context.
*/
@ -363,18 +428,25 @@ function handlePreToolUse(input) {
let result = '';
try {
if (hasGitNexusServerOwner(gitNexusDir)) {
// Normal skip path: the MCP server owns the DB, so the CLI augment would
// contend on the lock. Stay silent for strict hook runners (issue #1913);
// surface the reason only when diagnostics are explicitly requested.
// #2396: the MCP server holds the DB write lock, so a competing CLI
// `augment` would only contend on it (LadybugDB is single-writer). But the
// session that triggered this hook has the GitNexus MCP tools live — route
// the augmentation to the agent via additionalContext instead of silently
// doing nothing. Mirror the skip reason to stderr only under GITNEXUS_DEBUG
// (strict-runner contract, #1913); the hint itself rides the sanctioned
// additionalContext stdout channel the successful augment already uses.
if (isDebugEnabled()) {
process.stderr.write('[GitNexus] augment skipped: MCP server owns DB\n');
}
return;
}
const cliPath = resolveCliPath();
const child = runGitNexusCli(cliPath, ['augment', '--', pattern], cwd, 7000);
if (!child.error && child.status === 0) {
result = extractAugmentContext(child.stderr || '');
if (shouldEmitMcpHint(gitNexusDir)) {
result = buildMcpQueryHint(pattern);
}
} else {
const cliPath = resolveCliPath();
const child = runGitNexusCli(cliPath, ['augment', '--', pattern], cwd, 7000);
if (!child.error && child.status === 0) {
result = extractAugmentContext(child.stderr || '');
}
}
} catch {
/* graceful failure */
@ -442,12 +514,10 @@ function handlePostToolUse(input) {
let lastCommit = '';
let hadEmbeddings = false;
try {
const meta = JSON.parse(fs.readFileSync(path.join(gitNexusDir, 'meta.json'), 'utf-8'));
const meta = readIndexMeta(gitNexusDir);
if (meta) {
lastCommit = meta.lastCommit || '';
hadEmbeddings = meta.stats && meta.stats.embeddings > 0;
} catch {
/* no meta — treat as stale */
}
// If HEAD matches last indexed commit, no reindex needed

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
{
"name": "gitnexus",
"version": "1.6.8",
"version": "1.6.9",
"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",
@ -52,11 +52,11 @@
"postinstall": "node scripts/build-tree-sitter-grammars.cjs",
"assert-publish-coverage": "node scripts/assert-publish-grammar-coverage.cjs",
"prepare": "node scripts/build.js",
"prepack": "node scripts/assert-publish-grammar-coverage.cjs && node scripts/build.js"
"prepack": "node scripts/assert-publish-grammar-coverage.cjs && node scripts/build.js",
"version": "node scripts/sync-plugin-manifests.mjs"
},
"dependencies": {
"@huggingface/transformers": "^4.1.0",
"@ladybugdb/core": "^0.17.0",
"@ladybugdb/core": "^0.18.0",
"@modelcontextprotocol/sdk": "^1.0.0",
"@scarf/scarf": "^1.4.0",
"busboy": "^1.6.0",
@ -76,7 +76,6 @@
"node-addon-api": "^8.0.0",
"node-gyp-build": "^4.8.0",
"onnxruntime-common": "^1.26.0",
"onnxruntime-node": "^1.24.0",
"pandemonium": "^2.4.0",
"pino": "^10.3.1",
"pino-pretty": "^13.1.3",
@ -93,11 +92,15 @@
"tree-sitter-typescript": "^0.23.2",
"uuid": "^14.0.0"
},
"optionalDependencies": {
"@huggingface/transformers": "^4.1.0",
"onnxruntime-node": "^1.24.0"
},
"devDependencies": {
"@babel/generator": "^7.29.7",
"@babel/parser": "^7.29.7",
"@babel/traverse": "^7.29.7",
"@babel/types": "^7.29.7",
"@babel/generator": "^8.0.0",
"@babel/parser": "^8.0.0",
"@babel/traverse": "^8.0.0",
"@babel/types": "^8.0.0",
"@types/busboy": "^1.5.4",
"@types/cli-progress": "^3.11.6",
"@types/cors": "^2.8.17",

View file

@ -30,8 +30,16 @@ const PLATFORM_LOGIC = [
'test/unit/setup-jsonc.test.ts',
'test/unit/setup-codex.test.ts',
'test/unit/setup-antigravity.test.ts',
'test/integration/setup-uninstall-roundtrip.test.ts',
'test/unit/resolve-invocation.test.ts',
// CLI-spawn entry-point resolution; its path-separator assertion (cli[/\\]index)
// must exercise the Windows backslash branch, so run it on the OS matrix (#2394).
'test/unit/cli-entry.test.ts',
'test/unit/platform-capabilities.test.ts',
// getconf page-size probe: explicit process.platform gate (win32 short-circuit)
// plus a live-probe test whose only real non-4K coverage is macos-arm64's
// 16 KiB pages — the exact hardware class #1231 targets (#2424 review).
'test/unit/lbug-config-pagesize.test.ts',
'test/unit/worker-pool-windows-quarantine.test.ts',
'test/unit/lbug-pool-fts-load.test.ts',
'test/unit/repo-manager.test.ts',
@ -42,11 +50,35 @@ const PLATFORM_LOGIC = [
'test/unit/cursor-hook.test.ts',
'test/unit/sidecar-recovery.test.ts',
'test/unit/pool-wal-recovery.test.ts',
'test/unit/lbug-adapter-wal-schema.test.ts',
'test/unit/detect-changes-worktree.test.ts',
'test/unit/eval-server-bind-restriction.test.ts',
'test/unit/ignore-service.test.ts',
'test/unit/group/bridge-db.test.ts',
'test/unit/group/bridge-db-edge.test.ts',
'test/unit/onnxruntime-node-resolver.test.ts',
// Windows cmd.exe arg-quoting + compose-and-spawn for the npm install (#2372):
// the quoting rules and win32 single-string spawn shape are OS-sensitive, so
// exercise them on real windows-latest. The spawn-shape/path tests force their
// platform branch and derive expected paths via the real fns, so they pass on
// any host (see the platform stubs + resolve() in the test file).
'test/unit/embedding-runtime-install.test.ts',
// Real-spawn arg-delivery round-trip: proves the install spawn delivers args
// to the child intact on each platform — win32 via the cmd.exe -> .cmd %* ->
// node chain (real cmd.exe, not just our model), macos/linux via the no-shell
// array form. Runs on every platform (the ubuntu suite covers Linux; this
// registration adds windows + macos).
'test/unit/embedding-install-arg-delivery.test.ts',
// Structural FTS-extension classifier against REAL binaries (#2374): on this
// matrix `process.execPath` / `lbugjs.node` are a real PE (windows) and Mach-O
// (macos), so the header parsing is proven on genuine binaries, not synthetic
// buffers (the ubuntu suite covers the ELF path).
'test/integration/extension-binary-real.test.ts',
// Server repo resolver branches on path shape (path.isAbsolute, backslash
// detection) and canonicalizePath/realpathSync, all of which differ between
// POSIX and Windows — the fail-closed path-claim semantics must hold on the
// real windows-latest path implementation (#2419/#2420).
'test/unit/server-api-repo-resolution.test.ts',
];
// Native LadybugDB integration tests — exercise the @ladybugdb/core
@ -74,6 +106,17 @@ const LBUG_NATIVE = [
'test/integration/fts-description-search.test.ts',
'test/integration/staleness-and-stability.test.ts',
'test/integration/analyze-wal-checkpoint-failure.test.ts',
'test/integration/fts-stemmer-sweep.test.ts',
'test/integration/lbug-multiwriter-deadlock.test.ts',
// #2409 batched incremental writeback: chunked IN-list DETACH DELETEs +
// backslash quote escaping against the REAL native engine — the failing
// environment for #2409 was Windows, so the write pattern must be proven
// on the windows-latest native addon, not just Ubuntu.
'test/integration/lbug-delete-nodes-for-files.test.ts',
// #2409 defect 2: dirty-flag recovery parks lbug.wal/.shadow (rename next
// to a live native DB, rm-then-rename over an existing parked copy) before
// any open — rename semantics are exactly what differs on Windows.
'test/unit/incremental-dirty-recovery.test.ts',
];
// Process spawning and CLI tests — exercise child_process with real
@ -81,8 +124,13 @@ const LBUG_NATIVE = [
// quoting, path resolution, signal handling)
const SPAWN_CLI = [
'test/integration/cli-e2e.test.ts',
'test/integration/cli-limit-e2e.test.ts',
'test/integration/hooks-e2e.test.ts',
'test/integration/skills-e2e.test.ts',
// Spawns the real CLI across hermetic HOME/USERPROFILE homes to exercise the
// FTS extension lifecycle — the #2374 bug was Windows-reported, so this must
// run on the Windows/macOS matrix, not just the Ubuntu full suite.
'test/integration/fts-extension-e2e.test.ts',
'test/integration/server-http-startup.test.ts',
'test/integration/mcp/server-startup.test.ts',
'test/integration/analyze-heap-oom-e2e.test.ts',

View file

@ -0,0 +1,32 @@
/**
* Install the LadybugDB FTS extension into the shared home (~/.lbdb) up front, so
* every test in a sharded CI run finds it regardless of which shard it lands in.
*
* FTS-dependent tests split two ways: the LOAD-path gate (skipUnlessFtsAvailable)
* self-installs on miss, but the FILE-path gate (requireFtsResourceOrSkip, e.g.
* extension-binary-real.test.ts) resolves the extension path at module load and
* cannot self-install. Sharding (and the balancing sequencer) can drop such a
* test into a shard with no installer sibling — this step removes that ordering
* dependency by installing FTS once before vitest starts. `auto` is LOAD-first,
* so a cache-warmed extension costs no network.
*
* Best-effort: exits 0 on failure (offline etc.) — the per-test gates still
* hard-fail under GITNEXUS_REQUIRE_FTS=1 if FTS is genuinely unavailable, which
* is where the loud signal belongs.
*/
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { initLbug, loadFTSExtension, closeLbug } from '../src/core/lbug/lbug-adapter.js';
const dir = mkdtempSync(join(tmpdir(), 'gn-ensure-fts-'));
try {
await initLbug(join(dir, 'ensure-fts.lbug'));
const ok = await loadFTSExtension(undefined, { policy: 'auto' });
console.log(ok ? 'FTS extension ready.' : 'FTS extension unavailable (continuing).');
} catch (err) {
console.warn(`ensure-fts: skipped (${err instanceof Error ? err.message : String(err)})`);
} finally {
await closeLbug();
rmSync(dir, { recursive: true, force: true });
}

View file

@ -3,9 +3,41 @@ import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { createRequire } from 'node:module';
import { pathToFileURL } from 'node:url';
const EXTENSION_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9_]*$/;
// Positive on-disk-corruption signatures. `FORCE INSTALL` re-downloads even when
// a file is already present; we only want that when the LOAD error proves the
// existing file is bad (truncated/wrong-platform, #2374). For everything else —
// a missing file (plain INSTALL downloads it), or a permanent non-file failure a
// re-download can never fix (missing runtime dep: "cannot open shared object") —
// plain INSTALL avoids re-downloading ~2 MB on every analyze run forever.
// Exported so a parity test keeps this byte-identical to the copy in
// src/core/lbug/extension-load-error.ts (this `.mjs` cannot import that `.ts`), #2383 F5b.
export const FILE_CORRUPTION_SIGNATURES = [
/invalid elf/i,
/file too short/i,
/not a valid/i,
/bad magic/i,
/wrong architecture/i,
/mach-o/i,
/truncat/i,
];
/**
* Decide the install verb from the LOAD error that triggered this install.
* `FORCE INSTALL` only when the error positively indicates file-level breakage;
* otherwise plain `INSTALL` (missing file, missing-dependency dlopen failure,
* or unknown/absent error).
*/
export function chooseInstallVerb(loadError) {
if (loadError && FILE_CORRUPTION_SIGNATURES.some((re) => re.test(loadError))) {
return 'FORCE INSTALL';
}
return 'INSTALL';
}
function parseLbugMaxDbSize(raw) {
const parsed = raw ? Number(raw) : NaN;
if (!Number.isFinite(parsed) || parsed <= 0) {
@ -14,28 +46,54 @@ function parseLbugMaxDbSize(raw) {
return Math.floor(parsed);
}
async function installDuckDbExtension(extensionName, verifyOnly = false) {
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;
function resolveMaxDbSize() {
// argv[3] is the optional positional size; ignore it when it is actually a
// flag token (e.g. `--verify-only`) and fall back to the env default.
const sizeArg =
process.argv[3] && !process.argv[3].startsWith('--') ? process.argv[3] : undefined;
const lbugMaxDbSize = parseLbugMaxDbSize(sizeArg ?? process.env.GITNEXUS_LBUG_MAX_DB_SIZE);
return parseLbugMaxDbSize(sizeArg ?? process.env.GITNEXUS_LBUG_MAX_DB_SIZE);
}
/** Open a scratch LadybugDB and return its connection plus a disposer. */
async function defaultConnect(lbugMaxDbSize) {
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;
const db = new lbug.Database(dbPath, 0, false, false, lbugMaxDbSize);
const conn = new lbug.Connection(db);
return {
conn,
dispose: async () => {
await conn.close().catch(() => {});
await db.close().catch(() => {});
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
},
};
}
/**
* Install (or verify) an optional LadybugDB extension in this short-lived process.
*
* @param {string} extensionName
* @param {object} [options]
* @param {boolean} [options.verifyOnly] LOAD-only Docker build gate — no install.
* @param {string} [options.loadError] The parent's LOAD failure; selects the verb.
* @param {(size: number) => Promise<{conn: {query: (sql: string) => Promise<unknown>}, dispose: () => Promise<void>}>} [options.connect]
* Connection factory; injectable for offline unit tests.
*/
export async function installDuckDbExtension(extensionName, options = {}) {
const { verifyOnly = false, loadError, connect } = options;
if (!extensionName || !EXTENSION_NAME_PATTERN.test(extensionName)) {
throw new Error(`Invalid DuckDB extension name: ${extensionName ?? '<missing>'}`);
}
const makeConnection = connect ?? (() => defaultConnect(resolveMaxDbSize()));
const { conn, dispose } = await makeConnection();
try {
db = new lbug.Database(dbPath, 0, false, false, lbugMaxDbSize);
conn = new lbug.Connection(db);
if (verifyOnly) {
// Prove a previously-baked extension is resolvable by a FRESH process
// under the current HOME (the runtime `LOAD EXTENSION` path) — no INSTALL,
@ -46,19 +104,22 @@ async function installDuckDbExtension(extensionName, verifyOnly = false) {
`[install-ext] LOAD-only verify OK for '${extensionName}' (HOME=${process.env.HOME})`,
);
} else {
await conn.query(`INSTALL ${extensionName}`);
// Plain INSTALL is a no-op when the file already exists; escalate to FORCE
// only when the LOAD error proves the on-disk file is broken (#2374).
await conn.query(`${chooseInstallVerb(loadError)} ${extensionName}`);
}
} finally {
if (conn) await conn.close().catch(() => {});
if (db) await db.close().catch(() => {});
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
await dispose();
}
}
installDuckDbExtension(
process.argv[2] ?? process.env.GITNEXUS_LBUG_EXTENSION_NAME,
process.argv.includes('--verify-only'),
).catch((err) => {
console.error(err instanceof Error ? (err.stack ?? err.message) : String(err));
process.exitCode = 1;
});
// Only run when executed directly — imported (e.g. by unit tests) it stays inert.
if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
installDuckDbExtension(process.argv[2] ?? process.env.GITNEXUS_LBUG_EXTENSION_NAME, {
verifyOnly: process.argv.includes('--verify-only'),
loadError: process.env.GITNEXUS_LBUG_EXTENSION_LOAD_ERROR,
}).catch((err) => {
console.error(err instanceof Error ? (err.stack ?? err.message) : String(err));
process.exitCode = 1;
});
}

View file

@ -14,6 +14,7 @@ import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { ALL_CROSS_PLATFORM } from './cross-platform-tests.js';
import { parseShardArg } from './shard-arg.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
@ -27,18 +28,72 @@ if (missing.length > 0) {
process.exit(1);
}
console.log(`Running ${ALL_CROSS_PLATFORM.length} platform-sensitive tests...\n`);
// Optional sharding (CI): `--shard=<i>/<n>` splits the fixed file list across
// parallel matrix shards so each runner processes ~1/n of it. Passed straight
// through to vitest, which partitions the *given* files deterministically. The
// Windows runner is ~5x slower than macOS/Linux on this spawn-heavy suite (~50
// CLI/worker process spawns), so a single shard was creeping past the watchdog
// below; sharding keeps each runner well under it (see ci-tests.yml matrix).
// Fail loud on a malformed --shard arg (mirrors the missing-files check above):
// a silently-dropped shard flag would run the full unsharded suite and re-trip
// the watchdog. Kept outside the execFileSync try/catch below so the message
// isn't swallowed by that catch's watchdog-only branch.
let shardArg: string | undefined;
try {
execFileSync('npx', ['vitest', 'run', ...ALL_CROSS_PLATFORM], {
cwd: ROOT,
stdio: 'inherit',
timeout: 15 * 60 * 1000,
shell: true,
});
} catch (err: any) {
if (err.killed || err.signal) {
console.error('vitest timed out after 15 minutes');
}
shardArg = parseShardArg(process.argv.slice(2));
} catch (err) {
console.error(err instanceof Error ? err.message : String(err));
process.exit(1);
}
// Per-shard watchdog, default 15 min. Sharding splits the file list by COUNT, not
// runtime, so the heaviest spawn suites can cluster on one shard — what this
// bounds is the *busiest* shard, not an even 1/n of wall-clock. The busiest
// Windows shard has grown to the default (14m57s on the v1.6.10-rc.19 green
// run, one observed timeout since — #2449), so CI raises the budget to 20
// minutes via GITNEXUS_CROSS_PLATFORM_TIMEOUT_MINUTES; the default stays 15
// for local runs.
const DEFAULT_TIMEOUT_MIN = 15;
const timeoutMinutes = Number.parseInt(
process.env.GITNEXUS_CROSS_PLATFORM_TIMEOUT_MINUTES ?? String(DEFAULT_TIMEOUT_MIN),
10,
);
const timeoutMs =
Number.isFinite(timeoutMinutes) && timeoutMinutes > 0
? timeoutMinutes * 60 * 1000
: DEFAULT_TIMEOUT_MIN * 60 * 1000;
console.log(
`Running ${ALL_CROSS_PLATFORM.length} platform-sensitive tests` +
`${shardArg ? ` (${shardArg.replace('--shard=', 'shard ')})` : ''}...\n`,
);
const startedAt = Date.now();
try {
execFileSync('npx', ['vitest', 'run', ...ALL_CROSS_PLATFORM, ...(shardArg ? [shardArg] : [])], {
cwd: ROOT,
stdio: 'inherit',
timeout: timeoutMs,
shell: true,
});
} catch (err) {
// execFileSync sets `killed`/`signal` when the watchdog above kills vitest.
const e = err as {
killed?: boolean;
signal?: NodeJS.Signals | null;
status?: number | null;
code?: string;
};
if (e.killed || e.signal) {
console.error(`vitest timed out after ${Math.round(timeoutMs / 60_000)} minutes`);
}
// #2449: Windows shards have died with a bare `status: null`, empty stderr
// and nothing to triage from. Always leave the child's exit facts behind.
const elapsedSec = Math.round((Date.now() - startedAt) / 1000);
console.error(
`vitest exited abnormally: status=${e.status ?? 'null'} signal=${e.signal ?? 'none'} ` +
`killed=${e.killed === true} spawnCode=${e.code ?? 'none'} elapsed=${elapsedSec}s ` +
`budget=${Math.round(timeoutMs / 60_000)}min`,
);
process.exit(1);
}

View file

@ -0,0 +1,30 @@
/**
* Resolves the optional `--shard=<index>/<total>` argument for
* `run-cross-platform.ts`.
*
* Extracted as a pure, side-effect-free function so the branch logic is
* unit-testable without the script's top-level `execFileSync` (see
* `test/unit/shard-arg.test.ts`). Mirrors the `computeSpawnPrefix` extraction
* pattern in `test/helpers/cli-entry.ts`.
*/
const SHARD_RE = /^--shard=\d+\/\d+$/;
/**
* Returns the matched `--shard=<index>/<total>` token (e.g. `--shard=1/3`) to
* pass straight through to vitest, or `undefined` when no shard arg is present.
*
* Fails loud on a shard-shaped-but-malformed arg (e.g. `--shard=1`, `--shard`,
* `--shard=abc`): a silently-ignored malformed arg would drop the shard flag and
* run the full unsharded ~50-spawn suite, re-arming the Windows watchdog timeout
* with no signal. Only `--shard` / `--shard=…` args are inspected, so unrelated
* flags (including a hypothetical `--shardx=…`) pass through untouched.
*/
export function parseShardArg(argv: string[]): string | undefined {
const shardArgs = argv.filter((a) => a === '--shard' || a.startsWith('--shard='));
const malformed = shardArgs.find((a) => !SHARD_RE.test(a));
if (malformed !== undefined) {
throw new Error(`Malformed --shard arg '${malformed}' — expected --shard=<index>/<total>`);
}
return shardArgs[0];
}

View file

@ -0,0 +1,141 @@
#!/usr/bin/env node
/**
* Fail-closed version sync for the plugin manifest surfaces (#2445).
*
* `publish.yml` bumps only `gitnexus/package.json` when it cuts an RC, so
* every RC tag through v1.6.10-rc.28 shipped manifests frozen at the last
* stable version and failed its own unit suite (the cli-commands version
* contract). This script pins all four manifest surfaces to the package
* version:
*
* - gitnexus-claude-plugin/.claude-plugin/plugin.json (top-level version)
* - .claude-plugin/marketplace.json (plugins[gitnexus])
* - gitnexus-claude-plugin/.codex-plugin/plugin.json (top-level version)
* - .agents/plugins/marketplace.json (plugins[gitnexus])
*
* Modes:
* node scripts/sync-plugin-manifests.mjs rewrite stale surfaces
* node scripts/sync-plugin-manifests.mjs --check verify only, exit 1 on drift
*
* Fail-closed: a missing file, unparseable JSON, an absent version field, or
* anything other than exactly one `gitnexus` marketplace entry aborts with a
* non-zero exit rather than letting a release ship a partial sync.
*/
import { readFileSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const MANIFEST_SURFACES = [
{ file: 'gitnexus-claude-plugin/.claude-plugin/plugin.json', kind: 'plugin' },
{ file: '.claude-plugin/marketplace.json', kind: 'marketplace' },
{ file: 'gitnexus-claude-plugin/.codex-plugin/plugin.json', kind: 'plugin' },
{ file: '.agents/plugins/marketplace.json', kind: 'marketplace' },
];
const PLUGIN_NAME = 'gitnexus';
function readJson(filePath) {
let raw;
try {
raw = readFileSync(filePath, 'utf8');
} catch (err) {
throw new Error(`Cannot read manifest surface ${filePath}: ${err.message}`);
}
try {
return { raw, parsed: JSON.parse(raw) };
} catch (err) {
throw new Error(`Manifest surface ${filePath} is not valid JSON: ${err.message}`);
}
}
function versionTarget(manifest, kind, filePath) {
if (kind === 'plugin') {
if (typeof manifest.version !== 'string' || manifest.version.length === 0) {
throw new Error(`Manifest surface ${filePath} has no version field to sync`);
}
return manifest;
}
const entries = (Array.isArray(manifest.plugins) ? manifest.plugins : []).filter(
(plugin) => plugin?.name === PLUGIN_NAME,
);
if (entries.length !== 1) {
throw new Error(
`Manifest surface ${filePath} must contain exactly one "${PLUGIN_NAME}" plugin entry, found ${entries.length}`,
);
}
if (typeof entries[0].version !== 'string' || entries[0].version.length === 0) {
throw new Error(`Manifest surface ${filePath} has no version field to sync`);
}
return entries[0];
}
/**
* Sync (or with `check: true`, only inspect) every manifest surface under
* `rootDir`. Returns `{ version, synced, stale }` where `stale` lists the
* surfaces that did not match the package version when the run started.
*/
export function syncPluginManifests(rootDir, { check = false } = {}) {
const pkgPath = path.join(rootDir, 'gitnexus', 'package.json');
const version = readJson(pkgPath).parsed.version;
if (typeof version !== 'string' || version.length === 0) {
throw new Error(`No version found in ${pkgPath}`);
}
const synced = [];
const stale = [];
for (const { file, kind } of MANIFEST_SURFACES) {
const manifestPath = path.join(rootDir, file);
const { raw, parsed } = readJson(manifestPath);
const target = versionTarget(parsed, kind, manifestPath);
if (target.version === version) continue;
stale.push({ file, from: target.version });
if (check) continue;
// Textual surgery instead of re-serializing: JSON.stringify would refold
// arrays and fight prettier, turning a one-line version bump into
// formatting churn inside the release commit. The needle is built from
// the parsed current version, and anything other than exactly one
// occurrence aborts rather than guessing.
const needle = `"version": "${target.version}"`;
const occurrences = raw.split(needle).length - 1;
if (occurrences !== 1) {
throw new Error(
`Manifest surface ${manifestPath} has ${occurrences} occurrences of ${needle}; ` +
'expected exactly one, refusing to sync',
);
}
writeFileSync(manifestPath, raw.replace(needle, `"version": "${version}"`));
synced.push(file);
}
return { version, synced, stale };
}
const invokedDirectly =
process.argv[1] !== undefined && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (invokedDirectly) {
const check = process.argv.includes('--check');
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
const result = syncPluginManifests(rootDir, { check });
if (check && result.stale.length > 0) {
for (const { file, from } of result.stale) {
console.error(
`::error::${file} is at ${from} but gitnexus/package.json is at ${result.version}. ` +
'Run `node gitnexus/scripts/sync-plugin-manifests.mjs` and commit the result.',
);
}
process.exit(1);
}
for (const file of result.synced) {
console.log(`synced ${file} -> ${result.version}`);
}
console.log(
result.stale.length === 0 && result.synced.length === 0
? `all plugin manifests already at ${result.version}`
: `plugin manifests now at ${result.version}`,
);
}

View file

@ -24,6 +24,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. |
| `--pdg` | Build the program-dependence layers used by `explain` and `pdg_query` (taint, CDG, and REACHING_DEF). |
**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.

View file

@ -42,6 +42,12 @@ For any task involving code understanding, debugging, impact analysis, or refact
| `explain` | Persisted taint findings — source→sink data flows (needs `analyze --pdg`) |
| `pdg_query` | Control/data dependence — what gates X (CDG) / where Y flows (REACHING_DEF); needs `analyze --pdg` |
| `check` | Check graph invariants such as circular imports |
| `route_map` | API route map — which components/hooks fetch which endpoints, and the handler files that serve them |
| `shape_check` | Response-shape drift — keys each route returns vs keys its consumers access (flags MISMATCH) |
| `api_impact` | Pre-change report for an API route — consumers, middleware, shape mismatches, risk level |
| `tool_map` | MCP/RPC tool definitions and the files that handle them |
| `group_list` | List configured multi-repo groups, or one group's config |
| `group_sync` | Rebuild a group's Contract Registry (cross-repo HTTP contract links); run after `group.yaml` changes or member re-index |
| `list_repos` | Discover indexed repos (paginated — `limit`/`offset`) |
### Paginating `list_repos`
@ -77,13 +83,13 @@ Notes: `offset` ≥ `total` returns an empty page (with `total` still reported).
### Taint findings (`explain`)
`explain` returns intra-procedural taint findings (`TAINTED` edges) recorded by `gitnexus analyze --pdg` — each with a sink category (command-injection, code-injection, path-traversal, sql-injection, xss), source/sink lines, and the ordered hop path with the variable carried on each hop.
`explain` returns taint findings recorded by `gitnexus analyze --pdg` — intra-procedural `TAINTED` edges plus cross-function `TAINT_PATH` hops where the interprocedural taint phase found a function-level source→sink chain. Each finding includes a sink category (command-injection, code-injection, path-traversal, sql-injection, xss), source/sink lines, and the ordered hop path with the variable carried on each hop.
- `explain {}` — enumerate all findings for the repo (bounded by `limit`, deterministic order)
- `explain { target: "src/vuln.ts" }` — findings in a file (suffix path match accepted)
- `explain { target: "runUserCommand" }` — findings in a function (resolved like `context`; ambiguous names return ranked candidates)
A repo indexed without `--pdg` returns a clear "no taint layer" note. Caveats: findings are intra-procedural only — cross-function, closure/callback, property/field, and implicit flows are not modeled, so the absence of a finding is **not** proof of safety. `SANITIZES` (sanitizer-kill) edges are queryable via `cypher`.
A repo indexed without `--pdg` returns a clear "no taint layer" note. Caveats: closure/callback, property/field, and implicit flows are not modeled, and interprocedural findings are function-level `TAINT_PATH` hops rather than statement-level path proof, so the absence of a finding is **not** proof of safety. `SANITIZES` (sanitizer-kill) edges are queryable via `cypher`.
### Control & data dependence (`pdg_query`)
@ -104,6 +110,8 @@ A repo indexed without `--pdg` returns a "no PDG layer" note (or "status unknown
Returns ordered `hops` (each `{ name, filePath, startLine }`) and an aligned `edges[]` of `{ relType, confidence }`, so call hops and containment (`HAS_METHOD`) hops stay distinguishable. When no path exists it reports the **furthest** reachable node (where the chain breaks) and sets `truncated: true` if a traversal cap was hit first. Every result carries a `status`: `ok` / `no_path` / `ambiguous` / `not_found` / `error`.
Cross-repo (experimental): pass `repo: "@groupName"` to trace across a group's member repos — the path may cross **one** `ContractLink` boundary (reported as a `CONTRACT_LINK` hop with the bridged contract in `crossings[]`). Omit `to` entirely to follow `from`'s outgoing HTTP call to whatever provider endpoint it lands on. Groups are configured via `group_list` / `group_sync`.
## Resources Reference
Lightweight reads (~100-500 tokens) for navigation:
@ -119,8 +127,10 @@ Lightweight reads (~100-500 tokens) for navigation:
## Graph Schema
**Nodes:** File, Function, Class, Interface, Method, Community, Process
**Edges (via CodeRelation.type):** CALLS, IMPORTS, EXTENDS, IMPLEMENTS, DEFINES, MEMBER_OF, STEP_IN_PROCESS
**Nodes:** File, Folder, Function, Class, Interface, Method, CodeElement, Community, Process, Route, Tool, plus language-specific types (Struct, Enum, Trait, Impl, Namespace, Module, …) and BasicBlock (`--pdg` indexes only). The full node list lives in `gitnexus://repo/{name}/schema`.
**Edges (via CodeRelation.type):** CALLS, IMPORTS, EXTENDS, IMPLEMENTS, DEFINES, CONTAINS, MEMBER_OF, HAS_METHOD, HAS_PROPERTY, ACCESSES, METHOD_OVERRIDES, METHOD_IMPLEMENTS, STEP_IN_PROCESS, HANDLES_ROUTE, FETCHES, HANDLES_TOOL, ENTRY_POINT_OF, WRAPS, QUERIES, INJECTS, plus `--pdg`-only types (CFG, REACHING_DEF, TAINTED, SANITIZES, TAINT_PATH, CDG — zero rows on a default index).
Read `gitnexus://repo/{name}/schema` before writing Cypher — it is the authoritative schema for the indexed repo.
```cypher
MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(f:Function {name: "myFunc"})

View file

@ -36,7 +36,7 @@ All three are `BasicBlock → BasicBlock` edges in the single `CodeRelation` tab
- `pdg_query({ mode: 'controls', target })` — CDG. For the anchored function,
each edge: controlling predicate block → dependent block + branch sense in
`label` (`'T'` = predicate's true/taken arm, `'F'` = false/fall-through). An
`reason` (`'T'` = predicate's true/taken arm, `'F'` = false/fall-through). An
edge into an early-return/throw block is flagged `guard: true`.
- `pdg_query({ mode: 'flows', target, variable? })` — REACHING_DEF def→use
edges; `variable` filters to one binding.

View file

@ -30,7 +30,7 @@ description: "Use when the user wants to rename, extract, split, move, or restru
```
- [ ] rename({symbol_name: "oldName", new_name: "newName", dry_run: true}) — preview all edits
- [ ] Review graph edits (high confidence) and ast_search edits (review carefully)
- [ ] Review graph edits (high confidence) and text_search edits (review carefully)
- [ ] If satisfied: rename({..., dry_run: false}) — apply edits
- [ ] detect_changes() — verify only expected files changed
- [ ] Run tests for affected processes
@ -66,7 +66,7 @@ description: "Use when the user wants to rename, extract, split, move, or restru
```
rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true})
→ 12 edits across 8 files
→ 10 graph edits (high confidence), 2 ast_search edits (review)
→ 10 graph edits (high confidence), 2 text_search edits (review)
→ Changes: [{file_path, edits: [{line, old_text, new_text, confidence}]}]
```
@ -107,10 +107,10 @@ RETURN caller.name, caller.filePath ORDER BY caller.filePath
```
1. rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true})
→ 12 edits: 10 graph (safe), 2 ast_search (review)
→ 12 edits: 10 graph (safe), 2 text_search (review)
→ Files: validator.ts, login.ts, middleware.ts, config.json...
2. Review ast_search edits (config.json: dynamic reference!)
2. Review text_search edits (config.json: dynamic reference!)
3. rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: false})
→ Applied 12 edits across 8 files

View file

@ -2,7 +2,7 @@
* AI Context Generator
*
* Creates AGENTS.md and CLAUDE.md with full inline GitNexus context.
* AGENTS.md is the standard read by Cursor, Windsurf, OpenCode, Codex, Cline, etc.
* AGENTS.md is the standard read by Cursor, Windsurf, OpenCode, Codex, Cline, CodeBuddy, Qoder, etc.
* CLAUDE.md is for Claude Code which only reads that file.
*/
@ -155,7 +155,7 @@ export function generateGitNexusContent(
? generatedSkills
.map(
(s) =>
`| Work in the ${s.label} area (${s.symbolCount} symbols) | \`.claude/skills/generated/${s.name}/SKILL.md\` |`,
`| Work in the ${s.label} area (${s.symbolCount} symbols) | \`.claude/skills/${s.name}/SKILL.md\` |`,
)
.join('\n')
: '';
@ -163,16 +163,16 @@ export function generateGitNexusContent(
// Standard skill rows reference files installed by installSkills(). When
// --skip-skills suppresses that install, these rows must be omitted — else
// AGENTS.md/CLAUDE.md would direct agents to read files that don't exist.
// Community skills (generatedRows) live in .claude/skills/generated/ and
// Community skills (generatedRows) live directly under .claude/skills/ and
// are independent of --skip-skills, so they remain when present.
const standardSkillsRows = skipSkills
? ''
: `| Understand architecture / "How does X work?" | \`.claude/skills/gitnexus/gitnexus-exploring/SKILL.md\` |
| Blast radius / "What breaks if I change X?" | \`.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md\` |
| Trace bugs / "Why is X failing?" | \`.claude/skills/gitnexus/gitnexus-debugging/SKILL.md\` |
| Rename / extract / split / refactor | \`.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md\` |
| Tools, resources, schema reference | \`.claude/skills/gitnexus/gitnexus-guide/SKILL.md\` |
| Index, status, clean, wiki CLI commands | \`.claude/skills/gitnexus/gitnexus-cli/SKILL.md\` |`;
: `| Understand architecture / "How does X work?" | \`.claude/skills/gitnexus-exploring/SKILL.md\` |
| Blast radius / "What breaks if I change X?" | \`.claude/skills/gitnexus-impact-analysis/SKILL.md\` |
| Trace bugs / "Why is X failing?" | \`.claude/skills/gitnexus-debugging/SKILL.md\` |
| Rename / extract / split / refactor | \`.claude/skills/gitnexus-refactoring/SKILL.md\` |
| Tools, resources, schema reference | \`.claude/skills/gitnexus-guide/SKILL.md\` |
| Index, status, clean, wiki CLI commands | \`.claude/skills/gitnexus-cli/SKILL.md\` |`;
const tableBody = [standardSkillsRows, generatedRows].filter(Boolean).join('\n');
const skillsTable = tableBody
@ -364,11 +364,12 @@ async function upsertGitNexusSection(
}
/**
* Install GitNexus skills to .claude/skills/gitnexus/
* Install GitNexus skills as direct children of .claude/skills/
* Works natively with Claude Code, Cursor, and GitHub Copilot
*/
async function installSkills(repoPath: string): Promise<string[]> {
const skillsDir = path.join(repoPath, '.claude', 'skills', 'gitnexus');
const skillsDir = path.join(repoPath, '.claude', 'skills');
const legacySkillsDir = path.join(skillsDir, 'gitnexus');
const installedSkills: string[] = [];
// Skill definitions bundled with the package
@ -436,6 +437,15 @@ Use GitNexus tools to accomplish this task.
await fs.writeFile(skillPath, skillContent, 'utf-8');
installedSkills.push(skill.name);
// Previous releases installed these known standard skills one level too
// deep. Remove only the child owned by this installer; unknown siblings
// under the legacy grouping directory may be user-authored and survive.
try {
await fs.rm(path.join(legacySkillsDir, skill.name), { recursive: true, force: true });
} catch (err) {
logger.warn({ err }, `Warning: Could not remove legacy skill ${skill.name}:`);
}
} catch (err) {
// Skip on error, don't fail the whole process
logger.warn({ err }, `Warning: Could not install skill ${skill.name}:`);
@ -518,14 +528,14 @@ export async function generateAIContextFiles(
createdFiles.push('CLAUDE.md (skipped via --skip-agents-md)');
}
// Install skills to .claude/skills/gitnexus/ (unless --skip-skills)
// Install standard skills directly under .claude/skills/ (unless --skip-skills)
if (!options?.skipSkills) {
const installedSkills = await installSkills(repoPath);
if (installedSkills.length > 0) {
createdFiles.push(`.claude/skills/gitnexus/ (${installedSkills.length} skills)`);
createdFiles.push(`.claude/skills/gitnexus-*/ (${installedSkills.length} skills)`);
}
} else {
createdFiles.push('.claude/skills/gitnexus/ (skipped via --skip-skills)');
createdFiles.push('.claude/skills/gitnexus-*/ (skipped via --skip-skills)');
}
return { files: createdFiles };

View file

@ -13,10 +13,13 @@ import os from 'os';
import { spawn } from 'child_process';
import v8 from 'v8';
import cliProgress from 'cli-progress';
import { isLbugReady } from '../core/lbug/lbug-adapter.js';
import { isLbugReady, LbugWipeError } from '../core/lbug/lbug-adapter.js';
import { boundedCheckpointBeforeExit } from '../core/lbug/shutdown-helpers.js';
import {
getOsPageSize,
isLbugCheckpointIoError,
isLbugPageSizeFrameError,
isPageSizeAwareLadybug,
isWalCorruptionError,
parseWalCheckpointThreshold,
WAL_RECOVERY_SUGGESTION,
@ -37,6 +40,7 @@ import {
GitNexusRcError,
} from './analyze-config.js';
import { runFullAnalysis } from '../core/run-analyze.js';
import { getRuntimeFingerprint } from '../core/platform/capabilities.js';
import { getMaxFileSizeBannerMessage } from '../core/ingestion/utils/max-file-size.js';
import { warnMissingOptionalGrammars, getOptionalGrammarExtensions } from './optional-grammars.js';
import { glob } from 'glob';
@ -45,8 +49,26 @@ import { cliError } from './cli-message.js';
import { EMBEDDING_DIMS_ERROR, normalizeEmbeddingDims } from './embedding-dims.js';
import { formatElapsed } from './format-elapsed.js';
import { isHfDownloadFailure } from '../core/embeddings/hf-env.js';
import { safeUrl } from '../core/embeddings/http-client.js';
import { isLocalEmbeddingRuntimeBlockerMessage } from '../core/embeddings/runtime-support.js';
import {
isHttpEmbeddingDimsError,
isHttpEmbeddingError,
isHttpMode,
safeUrl,
} from '../core/embeddings/http-client.js';
import {
isLocalEmbeddingRuntimeBlockerMessage,
isMissingLocalEmbeddingStackMessage,
localEmbeddingPrefixUnloadableMessage,
localEmbeddingStackMissingMessage,
} from '../core/embeddings/runtime-support.js';
import {
ANALYZE_EMBEDDING_INSTALL_TIMEOUT_MS,
getEmbeddingInstallTimeoutMs,
getEmbeddingRuntimeDir,
installEmbeddingRuntime,
isPrefixRuntimeLoadable,
resolveEmbeddingRuntime,
} from '../core/embeddings/runtime-install.js';
import { warnIfNpm11NpxRisk } from './resolve-invocation.js';
// Capture stderr.write at module load BEFORE anything (LadybugDB native
@ -624,7 +646,7 @@ export interface AnalyzeOptions {
* default-on case.
*/
stats?: boolean;
/** Skip installing standard GitNexus skill files to .claude/skills/gitnexus/. */
/** Skip installing standard GitNexus skill files directly under .claude/skills/. */
skipSkills?: boolean;
/**
* Default branch for the generated regression-compare example (#243). From
@ -1087,6 +1109,60 @@ const analyzeCommandImpl = async (
);
}
// On-demand embedding runtime (#2370): when the optional stack was pruned at
// install time (proxy-blocked NuGet download in onnxruntime-node's
// postinstall), heal it here instead of failing later in the pipeline. The
// install goes through the user's npm registry config (mirrors/proxies
// apply) with --ignore-scripts, so no NuGet download is attempted. Runs
// before bar.start() like the sibling validations above.
if (embeddingsEnabled && !isHttpMode()) {
const resolved = resolveEmbeddingRuntime();
// Resolved-but-unloadable (a populated prefix on a Node with no
// module.registerHooks), or nothing installed on such a Node: fail fast with
// capability guidance instead of dying mid-pipeline over an unusable prefix
// or downloading a runtime the loader can't reach. A package-sourced stack
// never needs the hook, so it is excluded. --embeddings was explicitly
// requested and this failure is deterministic, so fail fast rather than
// silently degrading to BM25 (distinct from a transient install timeout).
if (!isPrefixRuntimeLoadable() && (resolved === null || resolved.source === 'runtime-prefix')) {
cliError(` ${localEmbeddingPrefixUnloadableMessage().replace(/\n/g, '\n ')}\n`, {
recoveryHint: 'local-embedding-stack-missing',
});
process.exitCode = 1;
return;
}
// On-demand embedding runtime (#2370): when the optional stack was pruned at
// install time (proxy-blocked NuGet download in onnxruntime-node's
// postinstall), heal it here instead of failing later in the pipeline. The
// install goes through the user's npm registry config (mirrors/proxies
// apply) with --ignore-scripts, so no NuGet download is attempted.
if (resolved === null) {
console.log(
` Local embedding runtime is not installed (optional packages were skipped at install time).\n` +
` Downloading it now from your npm registry into ${getEmbeddingRuntimeDir()} …\n` +
` (one-time; rerun manually anytime with \`gitnexus embeddings install\`)\n`,
);
try {
// Short deadline (env override still wins): analyze is interactive, so a
// blackholed proxy must not stall the whole index run for the 10-minute
// default — fail over to the guidance below instead.
await installEmbeddingRuntime(
{},
getEmbeddingInstallTimeoutMs(ANALYZE_EMBEDDING_INSTALL_TIMEOUT_MS),
);
console.log(' Embedding runtime installed.\n');
} catch (err) {
cliError(
` Could not install the embedding runtime: ${err instanceof Error ? err.message : String(err)}\n\n` +
` ${localEmbeddingStackMissingMessage().replace(/\n/g, '\n ')}\n`,
{ recoveryHint: 'local-embedding-stack-missing' },
);
process.exitCode = 1;
return;
}
}
}
if (options.repairFts && options.force) {
cliError(
' Cannot combine `--repair-fts` with `--force`. ' +
@ -1317,10 +1393,11 @@ const analyzeCommandImpl = async (
// preserving the rest of the block (incl. --skills community rows). No-op
// when the value already matches, so a routine up-to-date run is silent
// (#1996 tri-review P2).
// Only refresh the repo-root AGENTS.md/CLAUDE.md base_ref for the
// PRIMARY/flat index (#2106 R2). A non-primary branch's up-to-date
// analyze must not churn the committed AGENTS.md — this mirrors the
// in-pipeline `if (!placement.branch)` gate around generateAIContextFiles.
// Only refresh the repo-root AGENTS.md/CLAUDE.md base_ref for the flat
// WORKSPACE index (#2106 R2, #2354). A pinned --branch sub-index's
// up-to-date analyze must not churn the committed AGENTS.md — this
// mirrors the in-pipeline `if (!placement.branch)` gate around
// generateAIContextFiles.
let baseRefRefreshed: string[] = [];
if (result.isPrimaryBranch !== false) {
try {
@ -1546,6 +1623,70 @@ const analyzeCommandImpl = async (
return;
}
// DB-family wipe failure (#2409, tri-review 4669518496 P2-4): the rebuild
// could not verify the LadybugDB file family was removed — usually another
// process (MCP server, serve worker, antivirus) holding the index open.
// Keyed on the error *type* (repo norm from #2385), never message text.
// The message itself is fully self-contained (survivor paths + stop-MCP /
// AV-exclusion / re-run guidance) because the serve worker forwards only
// `err.message` over IPC — this branch just renders it without the
// raw-stack fallback below.
if (err instanceof LbugWipeError) {
cliError(` ${msg.replace(/\n/g, '\n ')}\n`, {
recoveryHint: 'lbug-wipe-failed',
});
process.exitCode = 1;
return;
}
// Buffer-manager frame-release failure on non-4K-page kernels (#1231).
// LadybugDB <= 0.17.x assumed 4 KiB OS pages when releasing evicted
// frames; Raspberry Pi 5 (16 KiB kernel pages) and other arm64 systems
// crash mid-COPY with a raw native message. 0.18.0 detects the page size
// at runtime, so the actionable fix depends on which side of that
// boundary the installed @ladybugdb/core is.
if (isLbugPageSizeFrameError(err)) {
const pageSize = getOsPageSize();
const ladybug = getRuntimeFingerprint().ladybugdb;
const pageLine =
pageSize !== undefined && pageSize !== 4096
? ` Detected OS page size: ${pageSize} bytes (non-4K — e.g. Raspberry Pi 5 16K kernel, Asahi Linux).\n`
: '';
// The upgrade variant must not assert version facts about an unknown
// version — mirror the doctor-side wording rule (#2424 review R2).
const upgradeIntro =
ladybug === undefined
? ` The installed @ladybugdb/core version is unknown — it may predate the\n` +
` runtime OS-page-size detection added in 0.18.0.\n`
: ` The installed @ladybugdb/core (${ladybug}) assumes 4 KiB pages in its buffer\n` +
` manager.\n`;
const guidance = isPageSizeAwareLadybug(ladybug)
? ` The installed @ladybugdb/core (${ladybug}) already detects the OS page size at runtime,\n` +
` so this configuration was expected to work. Please report it:\n` +
` https://github.com/abhigyanpatwari/GitNexus/issues/1231\n` +
` and include: gitnexus --version, node --version, getconf PAGE_SIZE, uname -a,\n` +
` and the full error message above.\n`
: upgradeIntro +
` Upgrade GitNexus to a release that bundles @ladybugdb/core >= 0.18.0\n` +
` (gitnexus >= 1.6.9), which detects the OS page size at runtime:\n` +
` npm install -g gitnexus@latest\n` +
` Last-resort workaround on Raspberry Pi 5: boot the 4 KiB-page kernel\n` +
` (config.txt: kernel=kernel8.img), at the cost of Pi 5 optimizations.\n`;
// Embed the raw native text (indented, no stack) so "the full error
// message above" is fulfillable — same idiom as the LbugWipeError
// branch. The errno suffix and the 0.18.0 guard's frame/granule numbers
// are the discriminating triage content (#2424 review P2).
cliError(
` LadybugDB's buffer manager failed to release frame memory.\n` +
` ${msg.replace(/\n/g, '\n ')}\n` +
pageLine +
guidance,
{ recoveryHint: 'lbug-page-size', pageSize, ladybugVersion: ladybug },
);
process.exitCode = 1;
return;
}
// Local embedding runtime unsupported on this platform (macOS Intel ships no
// darwin/x64 ONNX native binding, #1515). The guard threw before importing
// transformers.js, so this is a clean, actionable GitNexus message. Checked
@ -1560,10 +1701,75 @@ const analyzeCommandImpl = async (
return;
}
// The optional embedding stack (@huggingface/transformers → onnxruntime-node)
// was pruned at install time — usually a proxy-blocked NuGet download during
// onnxruntime-node's postinstall (#2370). Checked before the generic
// module-not-found "installation may be corrupt" hint below, which would
// otherwise misdiagnose a deliberate optional-dependency skip.
if (isMissingLocalEmbeddingStackMessage(msg)) {
cliError(` ${msg.replace(/\n/g, '\n ')}\n`, {
recoveryHint: 'local-embedding-stack-missing',
});
process.exitCode = 1;
return;
}
// Malformed GITNEXUS_EMBEDDING_DIMS env var (#2385). readConfig() throws a
// plain Error (a config mistake, not an endpoint failure), surfacing here from
// httpEmbed()->readConfig() inside the analysis run. Show a clean config
// message rather than a raw stack dump. The --embedding-dims CLI flag is
// validated up front (EMBEDDING_DIMS_ERROR); this covers the env-var path.
// Checked before the endpoint/HF branches: it is a plain Error, so
// isHttpEmbeddingError() is false and the HF network heuristic must not claim it.
if (isHttpEmbeddingDimsError(msg)) {
cliError(` ${msg.replace(/\n/g, '\n ')}\n`, {
recoveryHint: 'embedding-dims-invalid',
});
process.exitCode = 1;
return;
}
// Custom HTTP embedding endpoint failure (#2385). When a `--embedding-base-url`
// is configured, HTTP mode never downloads a model — so a failure talking to
// that endpoint must NOT show the huggingface-download guidance. Keyed on the
// error *type* (HttpEmbeddingError), not its message text, so it stays correct
// regardless of locale or wording. Checked before the HF branch, whose network
// heuristic (`fetch failed` / `ECONNREFUSED`) would otherwise also match a
// wrapped endpoint-connection error. The header is deliberately neutral: this
// type covers both never-reached failures (connection/timeout/DNS) and
// reached-but-failed ones (4xx/5xx, dimension/shape mismatch), so it must not
// assert "unreachable". The thrown `msg` carries the specific reason (and the
// masked URL where one applies), so it is surfaced verbatim.
if (isHttpEmbeddingError(err)) {
cliError(
` The custom embedding endpoint request failed.\n` +
` ${msg.replace(/\n/g, '\n ')}\n` +
` Suggestions:\n` +
` 1. Verify the endpoint URL is reachable and running ` +
`(--embedding-base-url / GITNEXUS_EMBEDDING_URL: host, port, /v1 path).\n` +
` 2. Confirm the model name and embedding dimensions match what the endpoint serves.\n` +
` 3. Re-run without --embeddings to index without vectors.\n`,
{ recoveryHint: 'http-embedding-endpoint-error' },
);
process.exitCode = 1;
return;
}
// isHttpMode() is a pure presence probe (URL+MODEL) that never throws — a
// malformed GITNEXUS_EMBEDDING_DIMS is handled by the dims branch above — so
// no defensive try/catch is needed here (#2385).
const inHttpMode = isHttpMode();
// HF download failure — show clean guidance without the raw stack trace.
// Checked before writeFatalToStderr so the user sees one focused message
// rather than a stack-trace dump followed by a second remediation block.
if (isHfDownloadFailure(msg) || msg.includes('Failed to download embedding model')) {
// Gated on !inHttpMode: with a custom endpoint configured no model download
// is ever attempted, so a network error there is the endpoint's, handled by
// the HttpEmbeddingError branch above — never HF's (#2385).
if (
(isHfDownloadFailure(msg) || msg.includes('Failed to download embedding model')) &&
!inHttpMode
) {
cliError(
` The embedding model could not be downloaded.\n` +
` huggingface.co may be unreachable from your network\n` +

View file

@ -18,9 +18,9 @@ import {
UnsafeStoragePathError,
} from '../storage/repo-manager.js';
import {
cleanQuarantinedMissingShadowWals,
cleanParkedLbugSidecars,
inspectLbugSidecars,
listQuarantinedMissingShadowWals,
listParkedLbugSidecars,
} from '../core/lbug/sidecar-recovery.js';
import { t } from './i18n/index.js';
@ -83,7 +83,13 @@ export const cleanCommand = async (options?: {
const lbugPath = path.join(repo.storagePath, 'lbug');
const state = await inspectLbugSidecars(lbugPath);
const quarantined = await listQuarantinedMissingShadowWals(lbugPath);
// Single roster authority (this shipping review, FIX 5): the aggregate
// covers both parked-sidecar families — the timestamped missing-shadow
// WAL quarantines AND the fixed-name `.dirty-recovery` parks (`.next`
// residues included) left by a dirty-flag recovery rebuild (#2409). The
// previous inline concatenations here were how the `.next` residue
// stayed invisible to this surface.
const quarantined = await listParkedLbugSidecars(lbugPath);
console.log(t('clean.lbugSidecars.state', { state: state.kind }));
if (quarantined.length === 0) {
@ -100,8 +106,16 @@ export const cleanCommand = async (options?: {
return;
}
const deleted = await cleanQuarantinedMissingShadowWals(lbugPath);
const { deleted, failed } = await cleanParkedLbugSidecars(lbugPath);
console.log(t('clean.lbugSidecars.deleted', { count: deleted.length }));
// A locked parked file no longer crashes the clean mid-command (FIX 5)
// — the rest were deleted above; report what remains and why.
if (failed.length > 0) {
console.log(t('clean.lbugSidecars.failed', { count: failed.length }));
for (const file of failed) {
console.log(` - ${file}`);
}
}
return;
}

View file

@ -46,10 +46,15 @@ import { t, type CliMessageKey, type CliMessageVars } from './i18n/index.js';
export type RecoveryHint =
| 'wal-corruption'
| 'wal-checkpoint-threshold'
| 'lbug-wipe-failed'
| 'lbug-page-size'
| 'heap-oom-respawn'
| 'native-worker-abort'
| 'hf-endpoint-unreachable'
| 'http-embedding-endpoint-error'
| 'embedding-dims-invalid'
| 'local-embedding-unsupported'
| 'local-embedding-stack-missing'
| 'large-repo'
| 'npm-resolution'
| 'module-not-found'

View file

@ -57,11 +57,16 @@ export function formatDetectChangesResult(result: unknown): string {
const changed = Array.isArray(payload.changed_symbols) ? payload.changed_symbols : [];
if (changed.length > 0) {
lines.push(t('tool.detectChanges.changedSymbols'));
for (const symbol of changed.slice(0, 15)) {
const shown = changed.slice(0, 15);
for (const symbol of shown) {
lines.push(` ${symbol.type ?? 'Symbol'} ${symbol.name ?? '?'} → ${symbol.filePath ?? '?'}`);
}
if (changed.length > 15) {
lines.push(t('tool.detectChanges.overflowMore', { count: changed.length - 15 }));
// Overflow is measured against the TRUE total (summary.changed_count), not
// the array length — the array may already be `--limit`-sliced, so using its
// length would under-report (or hide) how many symbols are not shown.
const totalChanged = summary.changed_count ?? changed.length;
if (totalChanged > shown.length) {
lines.push(t('tool.detectChanges.overflowMore', { count: totalChanged - shown.length }));
}
lines.push('');
}
@ -69,7 +74,8 @@ export function formatDetectChangesResult(result: unknown): string {
const affected = Array.isArray(payload.affected_processes) ? payload.affected_processes : [];
if (affected.length > 0) {
lines.push(t('tool.detectChanges.affectedExecutionFlows'));
for (const processInfo of affected.slice(0, 10)) {
const shownAffected = affected.slice(0, 10);
for (const processInfo of shownAffected) {
const changedSteps = Array.isArray(processInfo.changed_steps)
? processInfo.changed_steps
: [];
@ -80,6 +86,12 @@ export function formatDetectChangesResult(result: unknown): string {
})}) — ${t('tool.detectChanges.changedSteps', { steps })}`,
);
}
const totalAffected = summary.affected_count ?? affected.length;
if (totalAffected > shownAffected.length) {
lines.push(
t('tool.detectChanges.overflowMore', { count: totalAffected - shownAffected.length }),
);
}
}
return lines.join('\n').trim();

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